336 lines
11 KiB
JavaScript
336 lines
11 KiB
JavaScript
"use strict";
|
|
/**
|
|
* Menu Manager - Handles showing menus and submenus on Jibo's screen
|
|
*
|
|
* Supports:
|
|
* - Showing static menu definitions from JSON files
|
|
* - Dynamically scanning the Skills directory for menuEntry.json files
|
|
* - Submenu navigation with automatic back button
|
|
*
|
|
* Usage:
|
|
* const mm = require('./menu/menu-manager');
|
|
* mm.showMenuFromSkills('/opt/jibo/Jibo/Skills'); // Show main menu from skills
|
|
* mm.showSubmenu('/opt/jibo/Jibo/Skills/MySubmenu'); // Show specific submenu
|
|
*/
|
|
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const skillsScanner = require(path.join(__dirname, 'skills-scanner'));
|
|
|
|
// Track menu history for back navigation
|
|
let menuHistory = [];
|
|
let currentSkillsRoot = '/opt/jibo/Jibo/Skills';
|
|
|
|
/**
|
|
* Build a view configuration object from a menu definition
|
|
* @param {object} menuDef - Menu definition with buttons array
|
|
* @param {object} options - Additional options (isSubmenu, parentPath, etc.)
|
|
* @returns {object} View configuration for jibo.face.views.changeView
|
|
*/
|
|
function buildViewConfig(menuDef, options = {}) {
|
|
const viewConfig = {
|
|
viewConfig: {
|
|
type: 'MenuView',
|
|
id: menuDef.id || 'menu',
|
|
title: menuDef.title || null,
|
|
ignoreSwipeDown: menuDef.allowSwipe === false ? true : false,
|
|
listDefault: menuDef.listDefault || { menuButtonType: 'ActionButton', colors: 'default' },
|
|
list: [],
|
|
},
|
|
open: menuDef.open || {},
|
|
defaultClose: menuDef.defaultClose || { remove: true, transitionClose: 'trans_down' },
|
|
defaultSelect: menuDef.defaultSelect || { remove: true, transitionClose: 'trans_down' }
|
|
};
|
|
|
|
// Add back button for submenus
|
|
if (options.isSubmenu || menuDef.isSubmenu) {
|
|
viewConfig.viewConfig.list.push({
|
|
id: '__back__',
|
|
label: '← Back',
|
|
iconSrc: 'core://resources/actionIcons/back.png',
|
|
colors: 'default',
|
|
actions: [{ type: 'be:menu_back' }]
|
|
});
|
|
}
|
|
|
|
// Process each button in the menu
|
|
(menuDef.buttons || []).forEach(b => {
|
|
const item = {
|
|
id: b.id || b.label,
|
|
label: b.label || b.id,
|
|
iconSrc: b.icon || b.iconSrc || skillsScanner.DEFAULT_ICON,
|
|
colors: b.colors || b.color || menuDef.colors || 'default',
|
|
};
|
|
|
|
// Determine actions based on button type
|
|
if (b.actions || b.events) {
|
|
// Explicit actions provided
|
|
item.actions = b.actions || b.events;
|
|
} else if (b.skillId) {
|
|
// Regular skill - launch it
|
|
item.actions = [{ type: 'be:launch', data: { skillId: b.skillId } }];
|
|
} else if (b.submenu && Array.isArray(b.submenu)) {
|
|
// Submenu with embedded buttons - open inline submenu
|
|
item.actions = [{
|
|
type: 'be:open_submenu',
|
|
data: {
|
|
submenu: b.submenu,
|
|
submenuTitle: b.submenuTitle || b.label,
|
|
submenuPath: b.submenuPath
|
|
}
|
|
}];
|
|
} else if (b.submenuPath) {
|
|
// Submenu by path - open from disk
|
|
item.actions = [{
|
|
type: 'be:open_submenu_path',
|
|
data: { submenuPath: b.submenuPath }
|
|
}];
|
|
} else if (b.utterance) {
|
|
// Legacy utterance-based action
|
|
item.actions = [{ type: 'utterance', data: { utterance: b.utterance } }];
|
|
} else {
|
|
// No action defined
|
|
item.actions = [];
|
|
}
|
|
|
|
// Copy hit area polygon if defined (for custom button shapes)
|
|
if (b.hitAreaPolygon) {
|
|
item.hitAreaPolygon = b.hitAreaPolygon;
|
|
}
|
|
|
|
viewConfig.viewConfig.list.push(item);
|
|
});
|
|
|
|
return viewConfig;
|
|
}
|
|
|
|
/**
|
|
* Handle menu item selection events
|
|
* @param {object} selection - The selected menu item
|
|
* @param {function} cb - Callback function
|
|
*/
|
|
function handleMenuSelection(selection, cb) {
|
|
if (!selection || !selection.actions || !selection.actions.length) {
|
|
if (cb) cb(null, selection);
|
|
return;
|
|
}
|
|
|
|
const action = selection.actions[0];
|
|
|
|
switch (action.type) {
|
|
case 'be:menu_back':
|
|
// Go back to previous menu
|
|
goBack(cb);
|
|
break;
|
|
|
|
case 'be:open_submenu':
|
|
// Open inline submenu with embedded data
|
|
if (action.data && action.data.submenu) {
|
|
showInlineSubmenu(action.data.submenu, action.data.submenuTitle, action.data.submenuPath, cb);
|
|
}
|
|
break;
|
|
|
|
case 'be:open_submenu_path':
|
|
// Open submenu from disk path
|
|
if (action.data && action.data.submenuPath) {
|
|
exports.showSubmenu(action.data.submenuPath, cb);
|
|
}
|
|
break;
|
|
|
|
case 'be:launch':
|
|
// Launch skill - let the BE framework handle this
|
|
if (cb) cb(null, selection);
|
|
break;
|
|
|
|
default:
|
|
if (cb) cb(null, selection);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Show an inline submenu with embedded button data
|
|
* @param {Array} submenuButtons - Array of button definitions
|
|
* @param {string} title - Submenu title
|
|
* @param {string} submenuPath - Path for history tracking
|
|
* @param {function} cb - Callback
|
|
*/
|
|
function showInlineSubmenu(submenuButtons, title, submenuPath, cb) {
|
|
// Save current state to history
|
|
menuHistory.push({ type: 'main', path: currentSkillsRoot });
|
|
|
|
const submenuDef = {
|
|
id: 'submenu-' + (title || 'sub').toLowerCase().replace(/\s+/g, '-'),
|
|
title: title || 'Menu',
|
|
isSubmenu: true,
|
|
buttons: submenuButtons.map(b => ({
|
|
id: b.id,
|
|
label: b.label,
|
|
icon: b.icon || b.iconSrc,
|
|
colors: b.colors || b.color,
|
|
skillId: b.skillId,
|
|
description: b.description
|
|
}))
|
|
};
|
|
|
|
const viewConfig = buildViewConfig(submenuDef, { isSubmenu: true });
|
|
|
|
if (typeof jibo === 'undefined' || !jibo.face || !jibo.face.views) {
|
|
if (cb) cb(new Error('jibo not initialized'));
|
|
return;
|
|
}
|
|
|
|
jibo.face.views.changeView(viewConfig, function(err, result) {
|
|
if (err) {
|
|
if (cb) cb(err);
|
|
return;
|
|
}
|
|
// Set up selection handler for submenu
|
|
if (result && result.on) {
|
|
result.on('select', function(selection) {
|
|
handleMenuSelection(selection, cb);
|
|
});
|
|
}
|
|
if (cb) cb(null, result);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Go back to the previous menu in history
|
|
* @param {function} cb - Callback
|
|
*/
|
|
function goBack(cb) {
|
|
const previous = menuHistory.pop();
|
|
|
|
if (!previous) {
|
|
// No history, show main menu
|
|
exports.showMenuFromSkills(currentSkillsRoot, cb);
|
|
return;
|
|
}
|
|
|
|
if (previous.type === 'main') {
|
|
exports.showMenuFromSkills(previous.path || currentSkillsRoot, cb);
|
|
} else if (previous.type === 'submenu' && previous.path) {
|
|
exports.showSubmenu(previous.path, cb);
|
|
} else {
|
|
exports.showMenuFromSkills(currentSkillsRoot, cb);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Show a menu from a static JSON file
|
|
* @param {string} menuPath - Relative path to menu JSON file
|
|
* @param {function} cb - Callback
|
|
*/
|
|
exports.showMenu = function (menuPath, cb) {
|
|
try {
|
|
const menuDef = require(path.join(__dirname, '..', menuPath));
|
|
const viewConfig = buildViewConfig(menuDef);
|
|
if (typeof jibo === 'undefined' || !jibo.face || !jibo.face.views) {
|
|
throw new Error('jibo not initialized');
|
|
}
|
|
jibo.face.views.changeView(viewConfig, cb || function(){});
|
|
}
|
|
catch (e) {
|
|
console.warn('menu-manager.showMenu error', e);
|
|
if (cb) cb(e);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Show the main menu by scanning the Skills directory for menuEntry.json files
|
|
* @param {string} skillsRootPath - Path to Skills directory
|
|
* @param {function} cb - Callback
|
|
*/
|
|
exports.showMenuFromSkills = function (skillsRootPath, cb) {
|
|
try {
|
|
const root = skillsRootPath || '/opt/jibo/Jibo/Skills';
|
|
currentSkillsRoot = root;
|
|
menuHistory = []; // Reset history when showing main menu
|
|
|
|
const menuDef = skillsScanner.scanSkills(root);
|
|
const viewConfig = buildViewConfig(menuDef);
|
|
|
|
if (typeof jibo === 'undefined' || !jibo.face || !jibo.face.views) {
|
|
throw new Error('jibo not initialized');
|
|
}
|
|
|
|
jibo.face.views.changeView(viewConfig, function(err, result) {
|
|
if (err) {
|
|
console.warn('menu-manager.showMenuFromSkills error', err);
|
|
if (cb) cb(err);
|
|
return;
|
|
}
|
|
// Set up selection handler
|
|
if (result && result.on) {
|
|
result.on('select', function(selection) {
|
|
handleMenuSelection(selection, cb);
|
|
});
|
|
}
|
|
if (cb) cb(null, result);
|
|
});
|
|
}
|
|
catch (e) {
|
|
console.warn('menu-manager.showMenuFromSkills error', e);
|
|
if (cb) cb(e);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Show a submenu by scanning a specific directory
|
|
* @param {string} submenuPath - Full path to the submenu directory
|
|
* @param {function} cb - Callback
|
|
*/
|
|
exports.showSubmenu = function (submenuPath, cb) {
|
|
try {
|
|
// Save current state to history
|
|
if (menuHistory.length === 0) {
|
|
menuHistory.push({ type: 'main', path: currentSkillsRoot });
|
|
}
|
|
|
|
const menuDef = skillsScanner.getSubmenu(submenuPath);
|
|
const viewConfig = buildViewConfig(menuDef, { isSubmenu: true });
|
|
|
|
if (typeof jibo === 'undefined' || !jibo.face || !jibo.face.views) {
|
|
throw new Error('jibo not initialized');
|
|
}
|
|
|
|
jibo.face.views.changeView(viewConfig, function(err, result) {
|
|
if (err) {
|
|
console.warn('menu-manager.showSubmenu error', err);
|
|
if (cb) cb(err);
|
|
return;
|
|
}
|
|
// Set up selection handler
|
|
if (result && result.on) {
|
|
result.on('select', function(selection) {
|
|
handleMenuSelection(selection, cb);
|
|
});
|
|
}
|
|
if (cb) cb(null, result);
|
|
});
|
|
}
|
|
catch (e) {
|
|
console.warn('menu-manager.showSubmenu error', e);
|
|
if (cb) cb(e);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Clear menu history (useful when closing menu completely)
|
|
*/
|
|
exports.clearHistory = function() {
|
|
menuHistory = [];
|
|
};
|
|
|
|
/**
|
|
* Get current menu history (for debugging)
|
|
*/
|
|
exports.getHistory = function() {
|
|
return menuHistory.slice();
|
|
};
|
|
|
|
/**
|
|
* Get the skills scanner module for direct access
|
|
*/
|
|
exports.skillsScanner = skillsScanner;
|