"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;