#!/usr/bin/env python3 # -*- coding: utf-8 -*- # Self-voiced Terminal Menu System for Stormux game image import os import platform import sys import time import threading import signal import curses import subprocess import speechd # Python bindings for Speech Dispatcher import configparser import pathlib import re import simpleaudio as sa import json import shlex from dataclasses import dataclass from pathlib import Path from stormux_speech_settings import load_speech_settings, save_speech_settings INSTALL_GAME_RUNNER = Path("/home/stormux/.local/.functions/install_game.sh") SERVICE_STATE_HELPER = Path("/home/stormux/.local/bin/stormux_service_state.sh") SERVICE_ACTION_LOG = Path.home() / ".cache" / "stormux" / "service-actions.log" @dataclass(frozen=True) class GameEntry: section: str name: str installScript: Path launchScript: Path | None = None @property def isInstalled(self): return self.launchScript is not None @property def defaultAction(self): return "Play" if self.isInstalled else "Install" def find_launch_script(launchRoot, _section, gameName): candidate = Path(launchRoot) / f"{gameName}.sh" if candidate.exists(): return candidate return None def discover_game_entries(installRoot=None, launchRoot=None): installRoot = Path(installRoot or Path.home() / ".local" / ".install") launchRoot = Path(launchRoot or Path.home() / ".local" / ".launch") entries = [] if not installRoot.is_dir(): return entries for sectionRoot in sorted(path for path in installRoot.iterdir() if path.is_dir()): section = sectionRoot.name for installScript in sorted(sectionRoot.glob("*.sh")): gameName = installScript.stem launchScript = find_launch_script(launchRoot, section, gameName) entries.append(GameEntry(section, gameName, installScript, launchScript)) return entries def build_game_command(entry): if entry.launchScript is not None: launchPath = str(entry.launchScript) return f"STORMUX_LAUNCH_SCRIPT={launchPath!r} startx" return shlex.quote(str(entry.installScript)) def build_game_install_command(entry): return f"{shlex.quote(str(INSTALL_GAME_RUNNER))} {shlex.quote(str(entry.installScript))}" def build_game_uninstall_command(entry): if entry.launchScript is None: return "" launchPath = shlex.quote(str(entry.launchScript)) gameName = shlex.quote(entry.name) return f"rm -f {launchPath}; echo {gameName} uninstalled" def build_game_actions(entry): actions = [] if entry.isInstalled: actions.append(("Play", build_game_command(entry))) actions.append(("Uninstall", build_game_uninstall_command(entry))) else: actions.append(("Install", build_game_install_command(entry))) return actions def submenu_command(callback): def wrapped(): return callback() wrapped.opensSubmenu = True return wrapped def run_service_action(action, serviceName, helper=SERVICE_STATE_HELPER, logPath=SERVICE_ACTION_LOG): command = ["sudo", "-n", str(helper), action, serviceName] result = subprocess.run(command, capture_output=True, text=True, check=False) logPath = Path(logPath) logPath.parent.mkdir(parents=True, exist_ok=True) with logPath.open("a", encoding="utf-8") as logFile: logFile.write(f"Service action {' '.join(command)} [{time.ctime()}]\n") logFile.write(f"Return code: {result.returncode}\n") if result.stdout: logFile.write("stdout:\n") logFile.write(result.stdout) if not result.stdout.endswith("\n"): logFile.write("\n") if result.stderr: logFile.write("stderr:\n") logFile.write(result.stderr) if not result.stderr.endswith("\n"): logFile.write("\n") return result class VoicedMenu: def __init__(self, title="Stormux Game Menu"): self.title = title self.menuSections = {} # Dictionary to hold sections and their items self.sectionNames = [] # List to maintain section order self.currentSection = 0 # Index of current section self.currentItemIndices = {} # Current item index for each section self.stdscr = None # Check if system is installed or running from USB self.is_installed = os.path.exists("/home/stormux/.baremetal") # System services dictionary - maps friendly names to service names self.systemMenuServices = { 'Braille': 'brltty.path', 'D L N A Server': 'minidlna.service', 'Fenrir Screen Reader': 'fenrirscreenreader-tty.service', 'Bluetooth': 'bluetooth.service', 'SSH': 'sshd.service', 'Battery Monitoring': 'battery-monitor.service' } # Config settings self.configDir = os.path.expanduser("~/.config/stormux") self.configFile = os.path.join(self.configDir, "game_launcher.conf") self.config = configparser.ConfigParser() # Default settings self.speechRate = 0 # Normal speech rate (0 is default in speechd) self.speechPitch = 0 # Normal speech pitch self.volume = 50 # Default volume level # Load settings self.load_settings() # Initialize speech client self.speechClient = None self.init_speech() # Track playing sound self.currentSound = None # Load downloadable games registry self.downloadable_games = self.load_downloadable_games() self.savedMenuState = None self.menuStateStack = [] self.activeGameEntry = None def load_downloadable_games(self): """Load downloadable games registry from JSON file""" registry_path = "/usr/share/stormux/downloadable_games.json" try: if os.path.exists(registry_path): with open(registry_path, 'r') as f: data = json.load(f) return data.get('downloadable_games', {}) return {} except Exception as e: print(f"Error loading downloadable games registry: {e}") return {} def is_game_installed(self, game_id): """Check if a downloadable game is installed""" if game_id not in self.downloadable_games: return True # Not a downloadable game, assume installed game_info = self.downloadable_games[game_id] game_dir = os.path.expanduser(f"~/.local/games/{game_info['directory']}") return os.path.exists(game_dir) and os.path.exists(os.path.join(game_dir, game_info['executable'])) def get_display_name(self, game_name, game_id=None): """Get display name for menu item, adding (not installed) if needed""" if game_id and game_id in self.downloadable_games and not self.is_game_installed(game_id): return f"{game_name} (not installed)" return game_name def init_speech(self): """Initialize the speech client""" try: self.speechClient = speechd.SSIPClient('stormux_menu') self.speechClient.set_priority(speechd.Priority.IMPORTANT) self.speechClient.set_punctuation(speechd.PunctuationMode.SOME) # Apply speech settings from saved values self.speechClient.set_rate(self.speechRate) self.speechClient.set_pitch(self.speechPitch) except Exception as e: print(f"Could not initialize speech: {e}") # Fallback to None - the speak method will handle this def load_settings(self): """Load settings from config file""" speechSettings = load_speech_settings() self.speechRate = speechSettings.rate self.speechPitch = speechSettings.pitch # Create default settings if they don't exist if not os.path.exists(self.configFile): self.save_settings() return try: self.config.read(self.configFile) # Load volume settings if 'Volume' in self.config: self.volume = self.config.getint('Volume', 'level', fallback=50) except Exception as e: print(f"Error loading settings: {e}") # If loading fails, we'll use default values def save_settings(self): """Save menu-local settings to config file.""" # Ensure config directory exists os.makedirs(self.configDir, exist_ok=True) # Save volume settings if 'Volume' not in self.config: self.config['Volume'] = {} self.config['Volume']['level'] = str(self.volume) # Write to file try: with open(self.configFile, 'w') as f: self.config.write(f) except Exception as e: print(f"Error saving settings: {e}") def save_speech_settings(self): """Save speech settings to the shared Speech Dispatcher config.""" speechSettings = load_speech_settings() speechSettings.rate = self.speechRate speechSettings.pitch = self.speechPitch save_speech_settings(speechSettings) def increase_speech_rate(self): """Increase speech rate""" self.speechRate = min(100, self.speechRate + 10) # Max is 100 if self.speechClient: try: self.speechClient.set_rate(self.speechRate) self.speak(f"System speech rate: {self.speechRate}") except Exception as e: print(f"Error adjusting speech rate: {e}") # Save the new setting self.save_speech_settings() def decrease_speech_rate(self): """Decrease speech rate""" self.speechRate = max(-100, self.speechRate - 10) # Min is -100 if self.speechClient: try: self.speechClient.set_rate(self.speechRate) self.speak(f"System speech rate: {self.speechRate}") except Exception as e: print(f"Error adjusting speech rate: {e}") # Save the new setting self.save_speech_settings() def increase_speech_pitch(self): """Increase speech pitch""" self.speechPitch = min(100, self.speechPitch + 10) # Max is 100 if self.speechClient: try: self.speechClient.set_pitch(self.speechPitch) self.speak(f"System speech pitch: {self.speechPitch}") except Exception as e: print(f"Error adjusting speech pitch: {e}") # Save the new setting self.save_speech_settings() def decrease_speech_pitch(self): """Decrease speech pitch""" self.speechPitch = max(-100, self.speechPitch - 10) # Min is -100 if self.speechClient: try: self.speechClient.set_pitch(self.speechPitch) self.speak(f"System speech pitch: {self.speechPitch}") except Exception as e: print(f"Error adjusting speech pitch: {e}") # Save the new setting self.save_speech_settings() def get_current_volume(self): """Get the current system volume percentage""" try: result = subprocess.run( ['pactl', 'get-sink-volume', '@DEFAULT_SINK@'], capture_output=True, text=True, check=True ) output = result.stdout # Extract percentage from output like "Volume: front-left: 27111 / 41% / -23.00 dB" match = re.search(r'(\d+)%', output) if match: return int(match.group(1)) return self.volume # Return saved volume if parsing fails except Exception as e: print(f"Error getting volume: {e}") return self.volume # Return saved volume if command fails def set_volume(self, volumePercent): """Set the system volume to the specified percentage""" # Ensure volume is between 0 and 150% volumePercent = max(0, min(150, volumePercent)) try: subprocess.run( ['pactl', 'set-sink-volume', '@DEFAULT_SINK@', f'{volumePercent}%'], check=True ) subprocess.run( ['sudo', '-n', '/usr/lib/stormux/stormux_alsa_state.sh', 'store'], check=False ) self.volume = volumePercent self.save_settings() return True except Exception as e: print(f"Error setting volume: {e}") return False def increase_volume(self): """Increase the system volume by 5%""" currentVolume = self.get_current_volume() newVolume = min(150, currentVolume + 5) # Max 150% if self.set_volume(newVolume): self.speak(f"Volume {newVolume} percent") self.draw_menu() def decrease_volume(self): """Decrease the system volume by 5%""" currentVolume = self.get_current_volume() newVolume = max(0, currentVolume - 5) # Min 0% if self.set_volume(newVolume): self.speak(f"Volume {newVolume} percent") self.draw_menu() def play_sound(self, soundName): """Play a sound effect using simpleaudio with ability to cancel previous sounds""" try: # Cancel any currently playing sound self.stop_sound() soundsDir = "/usr/share/sounds/stormux" soundFile = os.path.join(soundsDir, f"{soundName}.wav") # Check if the file exists if not os.path.exists(soundFile): print(f"Sound file not found: {soundFile}") return False # Play the sound and store the play_obj waveObj = sa.WaveObject.from_wave_file(soundFile) self.currentSound = waveObj.play() return True except Exception as e: print(f"Error playing sound {soundName}: {e}") return False def stop_sound(self): """Stop any currently playing sound""" if self.currentSound is not None and self.currentSound.is_playing(): self.currentSound.stop() self.currentSound = None def check_service_status(self, serviceName): """Check if a system service is active""" try: result = subprocess.run( ['systemctl', 'is-active', serviceName], capture_output=True, text=True ) return result.stdout.strip() == 'active' except Exception as e: print(f"Error checking {serviceName} status: {e}") return False def toggle_service(self, friendlyName): """Toggle a system service on/off""" # Get the actual service name from the dictionary serviceName = self.systemMenuServices.get(friendlyName) if not serviceName: print(f"Unknown service: {friendlyName}") return isActive = self.check_service_status(serviceName) # Clean up curses before running the command with sudo curses.endwin() # Clean up speech client if self.speechClient: self.speechClient.close() self.speechClient = None action = "disable" if isActive else "enable" result = None try: result = run_service_action(action, serviceName) if result.returncode == 0: print(f"{friendlyName} {action}d successfully") else: print(f"Error {action}ing {friendlyName}: see {SERVICE_ACTION_LOG}") except Exception as e: print(f"Error {action}ing {friendlyName}: {e}") # Restart speech client self.init_speech() # Restart curses self.stdscr = curses.initscr() curses.noecho() curses.cbreak() self.stdscr.keypad(True) if result is not None and result.returncode == 0: self.speak(f"{friendlyName} {action}d") # Update the menu with the new service status self.update_service_menu_items() # Update Bluetooth menu items if Bluetooth service was toggled if friendlyName == "Bluetooth": self.update_bluetooth_menu_items() # Update Media menu items if DLNA Server was toggled if friendlyName == "D L N A Server": self.update_media_menu_items() else: self.speak(f"Could not {action} {friendlyName}. Check service action log.") # Redraw the menu self.draw_menu() def toggle_screen(self, screenType): """Toggle between screen output and headless mode""" try: # Platform-specific handling is_x86 = platform.machine() == "x86_64" if screenType == "headless": if is_x86: # On x86, remove all 10-* files for best compatibility os.system("sudo rm -f /etc/X11/xorg.conf.d/10-*.conf") else: # On Pi, only remove non-essential files, preserve fbdev os.system("sudo rm -f /etc/X11/xorg.conf.d/10-screen.conf") configFile = "/home/stormux/.local/files/10-headless.conf" message = "Screen disabled." else: # For enabling screen, remove any existing configuration first os.system("sudo rm -f /etc/X11/xorg.conf.d/10-*.conf") configFile = "/home/stormux/.local/files/10-screen.conf" message = "Screen enabled." # Copy the configuration file os.system(f"sudo cp {configFile} /etc/X11/xorg.conf.d/") self.speak(message, interrupt=False) except Exception as e: message = f"Error changing screen configuration: {e}" print(message) self.speak(message) def report_battery_status(self): """Report current battery status""" try: import pathlib power_supply_dir = pathlib.Path('/sys/class/power_supply') # Check if power supply directory exists if not power_supply_dir.exists(): self.speak("No battery found.") return battery_found = False battery_level = None is_charging = False # Look for battery for item in power_supply_dir.iterdir(): type_file = item / 'type' if type_file.exists(): try: if type_file.read_text().strip() == 'Battery': battery_found = True # Get battery level capacity_file = item / 'capacity' if capacity_file.exists(): battery_level = int(capacity_file.read_text().strip()) # Check charging status status_file = item / 'status' if status_file.exists(): status = status_file.read_text().strip() is_charging = status in ['Charging', 'Full'] break except Exception: continue # Check for AC power ac_connected = False for item in power_supply_dir.iterdir(): type_file = item / 'type' online_file = item / 'online' if type_file.exists() and online_file.exists(): try: device_type = type_file.read_text().strip() if device_type in ['ADP', 'Mains', 'AC']: online = int(online_file.read_text().strip()) if online == 1: ac_connected = True break except Exception: continue if not battery_found: self.speak("No battery found.") return # Build status message if battery_level is not None: message = f"Battery level: {battery_level} percent" if is_charging: message += ", charging" elif ac_connected: message += ", plugged in" else: message += ", on battery power" # Add warning context for low levels if battery_level <= 5: message += ". Critical level!" elif battery_level <= 10: message += ". Low battery!" else: message = "Battery detected but unable to read level" self.speak(message) except Exception as e: self.speak(f"Error reading battery status: {e}") def handle_sigint(self, _signum, _frame): """Make Ctrl+C behave like Escape inside the menu.""" self.handle_escape_or_restart() def update_service_menu_items(self): """Update all service-related menu items based on their current status""" if self.sectionNames != ["Services"]: return self.menuSections["Services"] = self.build_services_menu_actions() + [("Back", self.restore_saved_menu)] def build_service_menu_items(self): """Build service toggle items based on current service state.""" items = [] for friendlyName, serviceName in self.systemMenuServices.items(): isActive = self.check_service_status(serviceName) startLabel = "Start" if friendlyName == "Fenrir Screen Reader" else "Enable" stopLabel = "Stop" if friendlyName == "Fenrir Screen Reader" else "Disable" if isActive: items.append((f"{stopLabel} {friendlyName}", lambda fn=friendlyName: self.toggle_service(fn))) else: items.append((f"{startLabel} {friendlyName}", lambda fn=friendlyName: self.toggle_service(fn))) return items def build_services_menu_actions(self): """Build the system services submenu.""" return self.build_service_menu_items() + [ ("Enable Screen", lambda: self.toggle_screen("screen")), ("Disable Screen", lambda: self.toggle_screen("headless")), ("Battery Status", self.report_battery_status), ] def show_services_menu(self): """Show service toggles and related runtime controls.""" self.show_action_menu("Services", self.build_services_menu_actions()) def show_settings_menu(self): """Show system settings and configuration actions.""" actions = [] if platform.machine() == "x86_64" and not os.path.exists("/home/stormux/.baremetal"): actions.append(("Install System to Hard Drive", "GAME='Install to Disk' /home/stormux/.clirc")) actions.extend([ ("Internet Configuration", "GAME=\"Network Configuration\" /home/stormux/.clirc"), ("Set System Speech Settings", "/home/stormux/.local/bin/speechd_rate.py"), ("Set Default Voice", "/home/stormux/.local/bin/set-voice.py"), ("Set Timezone", "GAME='Set Timezone' /home/stormux/.clirc"), ("Download Files", "/home/stormux/.local/download_server.py"), ("Resize to fill empty space on disk", "sudo growpartfs $(df --output='source' / | tail -1)"), ]) self.show_action_menu("Settings", actions) def show_power_menu(self): """Show power and update actions.""" self.show_action_menu("Power", [ ("System Update", "/home/stormux/.local/bin/system-update.sh"), ("Restart: Can Take Several Minutes", "sudo reboot"), ("Power Off: Wait 2 Minutes Before Disconnecting Power", "sudo poweroff"), ]) def install_and_launch(self, executable_name, launch_mode="gui"): """Launch application via xinitrc (which handles installation if needed)""" try: # Launch the application - xinitrc will handle installation if launch_mode == "gui": command = f"GAME='{executable_name}' startx" else: # cli mode command = f"GAME='{executable_name}' /home/stormux/.clirc" # Use the existing execute_current_item infrastructure by temporarily setting command # Save current state original_sections = self.sectionNames.copy() original_current_section = self.currentSection original_items = {} for section in self.menuSections: original_items[section] = self.menuSections[section].copy() # Create temporary item to execute temp_section = "temp_install_launch" self.add_section(temp_section) self.add_item(temp_section, f"Launch {executable_name}", command) # Set to the temporary section and item self.currentSection = len(self.sectionNames) - 1 self.currentItemIndices[temp_section] = 0 # Execute the command using existing infrastructure self.execute_current_item() # Restore original state self.sectionNames = original_sections self.currentSection = original_current_section self.menuSections = original_items except Exception as e: error_msg = f"Error launching {executable_name}: {e}" self.speak(error_msg, interrupt=False) def update_bluetooth_menu_items(self): """Update Bluetooth-related menu items in Accessories section""" if "Accessories" in self.menuSections: # Remove any existing Bluetooth-related items self.menuSections["Accessories"] = [item for item in self.menuSections["Accessories"] if "Bluetooth" not in item[0]] # Add Bluetooth management item only if Bluetooth is enabled if self.check_service_status('bluetooth.service'): self.add_item("Accessories", "Manage Bluetooth Devices", "GAME=blueman-manager startx") def update_media_menu_items(self): """Update Media menu items including DLNA server toggle""" if "Media" in self.menuSections: # Remove any existing DLNA-related items self.menuSections["Media"] = [item for item in self.menuSections["Media"] if "D L N A" not in item[0]] # Check DLNA server status and add appropriate toggle isActive = self.check_service_status('minidlna.service') if isActive: self.add_item("Media", "Disable D L N A Server", lambda: self.toggle_service('D L N A Server')) else: self.add_item("Media", "Enable D L N A Server", lambda: self.toggle_service('D L N A Server')) def scan_documentation_files(self): """Scan Documents directory for .md files and add them to help menu""" docs_dir = os.path.expanduser("~/Documents") if not os.path.exists(docs_dir): return try: # Get all .md files in Documents directory md_files = [] for file in os.listdir(docs_dir): if file.endswith('.md'): file_path = os.path.join(docs_dir, file) if os.path.isfile(file_path): # Create a friendly display name from filename # Remove .md extension and replace underscores with spaces display_name = file[:-3].replace('_', ' ').title() md_files.append((display_name, file)) # Sort files alphabetically by display name md_files.sort(key=lambda x: x[0]) # Add each markdown file to the help section for display_name, filename in md_files: file_path = f"~/Documents/{filename}" self.add_item("Help and Documentation", display_name, f"GAME={file_path} /home/stormux/.clirc") except Exception as e: print(f"Error scanning documentation files: {e}") def add_section(self, sectionName): """Add a new section to the menu""" if sectionName not in self.menuSections: self.menuSections[sectionName] = [] self.sectionNames.append(sectionName) self.currentItemIndices[sectionName] = 0 def add_item(self, sectionName, name, command): """Add a menu item to a specific section""" # Create section if it doesn't exist if sectionName not in self.menuSections: self.add_section(sectionName) self.menuSections[sectionName].append((name, command)) def save_menu_state(self): """Save the current menu state so a temporary submenu can restore it.""" menuState = { "sectionNames": self.sectionNames.copy(), "currentSection": self.currentSection, "currentItemIndices": self.currentItemIndices.copy(), "menuSections": { sectionName: items.copy() for sectionName, items in self.menuSections.items() }, } self.menuStateStack.append(menuState) self.savedMenuState = menuState def restore_saved_menu(self): """Restore the menu state saved before entering a temporary submenu.""" if not self.menuStateStack: return menuState = self.menuStateStack.pop() self.sectionNames = menuState["sectionNames"] self.currentSection = menuState["currentSection"] self.currentItemIndices = menuState["currentItemIndices"] self.menuSections = menuState["menuSections"] self.savedMenuState = self.menuStateStack[-1] if self.menuStateStack else None if not self.menuStateStack: self.activeGameEntry = None self.draw_menu() self.announce_current_section() time.sleep(0.5) self.announce_current_item(interrupt=False) def show_action_menu(self, sectionName, actions): """Replace the current menu with a temporary action submenu.""" self.save_menu_state() self.sectionNames = [sectionName] self.currentSection = 0 self.currentItemIndices = {sectionName: 0} self.menuSections = {sectionName: actions + [("Back", self.restore_saved_menu)]} self.draw_menu() self.announce_current_item() def show_game_actions(self, gameEntry): """Show available actions for a game.""" self.activeGameEntry = gameEntry self.show_action_menu(gameEntry.name, build_game_actions(gameEntry)) def refresh_game_entry(self, gameEntry): """Return a fresh game entry with current launcher state.""" launchRoot = gameEntry.launchScript.parent if gameEntry.launchScript else Path.home() / ".local" / ".launch" if gameEntry.installScript.parent.parent.name == ".install": launchRoot = gameEntry.installScript.parent.parent.parent / ".launch" launchScript = find_launch_script(launchRoot, gameEntry.section, gameEntry.name) return GameEntry(gameEntry.section, gameEntry.name, gameEntry.installScript, launchScript) def refresh_game_action_menu(self): """Refresh the current game action submenu after install state changes.""" if self.activeGameEntry is None: return self.activeGameEntry = self.refresh_game_entry(self.activeGameEntry) sectionName = self.activeGameEntry.name self.sectionNames = [sectionName] self.currentSection = 0 self.currentItemIndices = {sectionName: 0} self.menuSections = {sectionName: build_game_actions(self.activeGameEntry) + [("Back", self.restore_saved_menu)]} self.draw_menu() self.announce_current_item() def handle_escape(self): """Handle Escape based on whether a temporary submenu is active.""" if not self.menuStateStack: return False self.restore_saved_menu() return True def handle_escape_or_restart(self): """Go back from submenus, otherwise restart the menu.""" if self.handle_escape(): return try: # Announce restart first while speech still works self.speak("Restarting menu") # Wait for speech to complete time.sleep(1) # Now clean up resources self.cleanup() # Try to kill the user's speech-dispatcher if needed subprocess.run(["killall", "speech-dispatcher"], check=False) # Restart the application os.execv(sys.argv[0], sys.argv) except Exception as e: print(f"Error during restart: {e}") # If restart fails, we need to recover the UI self.stdscr = curses.initscr() curses.noecho() curses.cbreak() self.stdscr.keypad(True) self.draw_menu() def jump_to_item_by_letter(self, letter): """Move to the next item in the current menu starting with letter.""" if not letter or not self.sectionNames: return False letter = letter.lower() items = self.get_current_items() if not items: return False currentIndex = self.get_current_item_index() for offset in range(1, len(items) + 1): candidateIndex = (currentIndex + offset) % len(items) itemName = items[candidateIndex][0].strip().lower() if itemName.startswith(letter): self.set_current_item_index(candidateIndex) return True return False def jump_to_section_by_letter(self, letter): """Move to the next top-level section starting with letter.""" if not letter or self.menuStateStack or not self.sectionNames: return False letter = letter.lower() for offset in range(1, len(self.sectionNames) + 1): candidateIndex = (self.currentSection + offset) % len(self.sectionNames) sectionName = self.sectionNames[candidateIndex].strip().lower() if sectionName.startswith(letter): self.currentSection = candidateIndex return True return False def handle_control_shortcut(self, key): """Handle reliable control-key shortcuts before printable navigation.""" if key == 2: # Ctrl+B self.report_battery_status() return True if key == 8: # Ctrl+H self.speak_help() return True return False def handle_letter_navigation(self, key): """Handle printable letter navigation.""" if key < 0 or key > 255: return False keyChar = chr(key) if not keyChar.isalpha(): return False moved = False if keyChar.isupper() and not self.menuStateStack: moved = self.jump_to_section_by_letter(keyChar) if moved: self.draw_menu() self.play_sound("menu_category") self.announce_current_section() time.sleep(0.5) self.announce_current_item(interrupt=False) else: moved = self.jump_to_item_by_letter(keyChar) if moved: self.draw_menu() self.play_sound("menu_move") self.announce_current_item() return moved def speak(self, text, interrupt=True): """Speak the given text with option to interrupt existing speech""" if self.speechClient is None: return try: if interrupt: self.stop_speech() self.speechClient.speak(text) except Exception as e: # If speech fails, try to reinitialize and try once more try: self.init_speech() if self.speechClient: self.speechClient.speak(text) except: # If reinitializing fails, just give up silently pass def stop_speech(self): """Stop any ongoing speech""" if self.speechClient is None: return try: self.speechClient.cancel() except Exception as e: # If cancel fails, try to reinitialize self.init_speech() def get_current_items(self): """Get items from the current section""" currentSectionName = self.sectionNames[self.currentSection] return self.menuSections[currentSectionName] def get_current_item_index(self): """Get the current item index in the current section""" currentSectionName = self.sectionNames[self.currentSection] return self.currentItemIndices[currentSectionName] def set_current_item_index(self, index): """Set the current item index for the current section""" currentSectionName = self.sectionNames[self.currentSection] self.currentItemIndices[currentSectionName] = index def announce_current_section(self, interrupt=True): """Announce the currently selected section""" if 0 <= self.currentSection < len(self.sectionNames): sectionName = self.sectionNames[self.currentSection] self.speak(sectionName, interrupt=interrupt) def announce_current_item(self, interrupt=True): """Announce the currently selected menu item""" if len(self.sectionNames) > 0: items = self.get_current_items() index = self.get_current_item_index() if 0 <= index < len(items): name = items[index][0] self.speak(name, interrupt=interrupt) def execute_current_item(self): """Execute the currently selected menu item""" if len(self.sectionNames) > 0: items = self.get_current_items() index = self.get_current_item_index() if 0 <= index < len(items): name, command = items[index] # Check if command is a function (for service toggles and other functions) if callable(command): if not getattr(command, "opensSubmenu", False): self.speak(f"Launching {name}") command() return # Announce we're launching the program self.speak(f"Launching {name}") # Save current terminal state before any changes savedTerminalState = None try: result = subprocess.run(['stty', '-g'], capture_output=True, text=True, check=True) savedTerminalState = result.stdout.strip() except Exception as e: print(f"Warning: Could not save terminal state: {e}") # Cleanup before running the command self.stop_speech() # Close speech client if self.speechClient: try: self.speechClient.close() except: pass self.speechClient = None # Curses cleanup - be thorough if self.stdscr: try: curses.nocbreak() self.stdscr.keypad(False) curses.echo() curses.endwin() except: pass self.stdscr = None # Complete terminal reset to ensure clean state for games try: subprocess.run(['reset'], check=False, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) except: pass # Ensure we're in a new process group to avoid signal conflicts # Execute the command with proper terminal isolation try: # For applications that need dialog/curses, we need to ensure they # become the controlling process with full terminal access # Use os.system with proper terminal restoration as it gives # the subprocess complete control over the terminal # First, save our current process group original_pgrp = os.getpgrp() # Execute using os.system which gives the subprocess complete terminal control exit_code = os.system(command) # Try to regain terminal control after the subprocess exits try: # Get the current terminal file descriptor tty_fd = os.open('/dev/tty', os.O_RDWR) # Try to make our process group the foreground group again os.tcsetpgrp(tty_fd, original_pgrp) os.close(tty_fd) except: # If we can't regain control, that's okay pass except Exception as e: print(f"Error launching {name}: {e}") # Another terminal reset after game exits to clean up any changes try: subprocess.run(['reset'], check=False, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) except: pass # Restore the saved terminal state if we have it if savedTerminalState: try: subprocess.run(['stty', savedTerminalState], check=False) except Exception as e: print(f"Warning: Could not restore terminal state: {e}") # Brief delay to let terminal stabilize time.sleep(0.5) # Reinitialize speech and display self.init_speech() # Reinitialize curses with better error handling try: self.stdscr = curses.initscr() curses.noecho() curses.cbreak() self.stdscr.keypad(True) self.stdscr.clear() self.stdscr.refresh() except Exception as e: print(f"Error reinitializing display: {e}") # Try a more aggressive recovery try: curses.endwin() time.sleep(1) self.stdscr = curses.initscr() curses.noecho() curses.cbreak() self.stdscr.keypad(True) self.stdscr.clear() self.stdscr.refresh() except: print("Could not recover display. Please restart the menu.") sys.exit(1) # Update service menu items and redraw self.update_service_menu_items() if self.activeGameEntry is not None: self.refresh_game_action_menu() else: self.draw_menu() # Let the user know the menu is activated self.speak("Game menu activated") def speak_help(self): """Speak help information""" helpText = """ Navigation controls: Up arrow: Previous menu item. Down arrow: Next menu item. Left arrow: Previous section. Right arrow: Next section. Lowercase letters: Jump to menu items. Uppercase letters: Jump to categories from the main menu. Enter: Launch selected item. Control H: Hear these instructions again. Control B: Report battery status. Left bracket: Decrease system speech rate. Right bracket: Increase system speech rate. Left brace: Decrease system speech pitch. Right brace: Increase system speech pitch. 9 key: Decrease volume. 0 key: Increase volume. Escape: Go back from submenus or refresh the main menu. Q: Exit the menu. Control C behaves like Escape. Any key will interrupt speech. """ self.speak(helpText) def draw_menu(self): """Draw the menu on the screen""" self.stdscr.clear() h, w = self.stdscr.getmaxyx() # Draw title title = f" {self.title} " x = max(0, w // 2 - len(title) // 2) self.stdscr.addstr(1, x, title, curses.A_BOLD) # Draw help line helpText = "Navigate | Letters: Jump | Enter: Select | Ctrl+H: Help | Ctrl+B: Battery | Esc: Back" x = max(0, w // 2 - len(helpText) // 2) self.stdscr.addstr(3, x, helpText) # Draw current section if len(self.sectionNames) > 0: currentSectionName = self.sectionNames[self.currentSection] sectionText = f"== {currentSectionName} ==" x = max(0, w // 2 - len(sectionText) // 2) self.stdscr.addstr(5, x, sectionText, curses.A_BOLD) # Draw menu items for current section items = self.get_current_items() currentItemIndex = self.get_current_item_index() for i, (name, _) in enumerate(items): y = i + 7 # Start items 2 lines below section header if y < h - 1: # Ensure we don't draw outside the window # Highlight the selected item if i == currentItemIndex: text = f" > {name} " attr = curses.A_REVERSE else: text = f" {name} " attr = curses.A_NORMAL x = max(0, w // 2 - len(text) // 2) self.stdscr.addstr(y, x, text, attr) # Draw speech rate indicator rateText = f"System Speech Rate: {self.speechRate}" self.stdscr.addstr(h-2, 2, rateText) # Draw speech pitch indicator pitchText = f"System Pitch: {self.speechPitch}" pitchX = max(0, w // 2 - len(pitchText) // 2) self.stdscr.addstr(h-2, pitchX, pitchText) # Draw volume indicator volumeText = f"Volume: {self.get_current_volume()}%" self.stdscr.addstr(h-2, w-len(volumeText)-2, volumeText) self.stdscr.refresh() def cleanup(self): """Clean up resources before exiting""" # Stop any speech self.stop_speech() # Close speech client if self.speechClient: try: self.speechClient.close() except: pass self.speechClient = None # Restore terminal settings curses.nocbreak() self.stdscr.keypad(False) curses.echo() curses.endwin() def run(self): """Run the menu system""" if not self.sectionNames: print("Menu is empty. Exiting.") return try: # Initialize curses self.stdscr = curses.initscr() curses.noecho() # Don't echo keypresses curses.cbreak() # React to keys instantly self.stdscr.keypad(True) # Enable special keys signal.signal(signal.SIGINT, self.handle_sigint) # Update all service menu items based on current status self.update_service_menu_items() # Update Bluetooth menu items based on current status self.update_bluetooth_menu_items() # Update Media menu items based on current status self.update_media_menu_items() # Initial draw self.draw_menu() # Welcome message - don't interrupt this initial speech boot_source = "internal disk" if self.is_installed else "USB drive" self.speak(f"Welcome to {self.title}, booted from {boot_source}. Use left and right arrows to navigate sections. Up and down for items. Press Control H for help.") # Wait for initial speech to finish before announcing section time.sleep(1) # Announce initial section and item without interrupting welcome speech self.announce_current_section(interrupt=False) time.sleep(0.5) # Wait before announcing first item self.announce_current_item(interrupt=False) # Main loop while True: key = self.stdscr.getch() # Stop any speech when a key is pressed self.stop_speech() # Handle navigation if key == curses.KEY_UP: # Move to previous item in current section items = self.get_current_items() if items: prevIndex = self.get_current_item_index() self.set_current_item_index((prevIndex - 1) % len(items)) newIndex = self.get_current_item_index() self.draw_menu() # Play sound if selection actually changed if newIndex != prevIndex: self.play_sound("menu_move") self.announce_current_item() elif key == curses.KEY_DOWN: # Move to next item in current section items = self.get_current_items() if items: prevIndex = self.get_current_item_index() self.set_current_item_index((prevIndex + 1) % len(items)) newIndex = self.get_current_item_index() self.draw_menu() # Play sound if selection actually changed if newIndex != prevIndex: self.play_sound("menu_move") self.announce_current_item() elif key == curses.KEY_LEFT: # Move to previous section if self.sectionNames: prevSection = self.currentSection self.currentSection = (self.currentSection - 1) % len(self.sectionNames) self.draw_menu() # Play category change sound if section actually changed if prevSection != self.currentSection: self.play_sound("menu_category") # Announce section and current item without interruption between them self.announce_current_section() time.sleep(0.5) # Brief pause between section and item announcement self.announce_current_item(interrupt=False) # Don't interrupt the section announcement elif key == curses.KEY_RIGHT: # Move to next section if self.sectionNames: prevSection = self.currentSection self.currentSection = (self.currentSection + 1) % len(self.sectionNames) self.draw_menu() # Play category change sound if section actually changed if prevSection != self.currentSection: self.play_sound("menu_category") # Announce section and current item without interruption between them self.announce_current_section() time.sleep(0.5) # Brief pause between section and item announcement self.announce_current_item(interrupt=False) # Don't interrupt the section announcement elif key == curses.KEY_ENTER or key == 10 or key == 13: # Enter key self.play_sound("menu_select") self.execute_current_item() elif key == ord('['): # Decrease speech rate self.decrease_speech_rate() self.draw_menu() elif key == ord(']'): # Increase speech rate self.increase_speech_rate() self.draw_menu() elif key == ord('{'): # Decrease speech pitch self.decrease_speech_pitch() self.draw_menu() elif key == ord('}'): # Increase speech pitch self.increase_speech_pitch() self.draw_menu() elif key == ord('9'): # Decrease volume self.decrease_volume() elif key == ord('0'): # Increase volume self.increase_volume() elif key == ord('q') or key == ord('Q'): # Quit self.speak("Exiting menu") time.sleep(0.5) break elif self.handle_control_shortcut(key): pass elif self.handle_letter_navigation(key): pass elif key == 27: # Esc key - go back from submenus, otherwise restart the application self.handle_escape_or_restart() except Exception as e: # End curses in case of error curses.endwin() print(f"An error occurred: {e}") finally: # Clean up self.cleanup() def configure_system_menu(menu): """Add the nested System menu.""" menu.add_section("System") menu.add_item("System", "Settings", submenu_command(menu.show_settings_menu)) menu.add_item("System", "Services", submenu_command(menu.show_services_menu)) menu.add_item("System", "Power", submenu_command(menu.show_power_menu)) # Example usage if __name__ == "__main__": # Create the menu with sections menu = VoicedMenu(title="Stormux Gaming Menu") for gameEntry in discover_game_entries(): menu.add_item( gameEntry.section, gameEntry.name, submenu_command(lambda entry=gameEntry: menu.show_game_actions(entry)), ) menu.add_section("Emulators") menu.add_item("Emulators", "Apple 2e", "/home/stormux/.local/bin/apple_2e.py") menu.add_item("Emulators", "Game Console Menu", "/home/stormux/.local/bin/rom_launcher.py") # Add help and documentation section menu.add_section("Help and Documentation") # Dynamically scan and add all .md files from Documents directory menu.scan_documentation_files() # Add the IRC help item menu.add_item("Help and Documentation", "Get help on IRC", "GAME=IRC /home/stormux/.clirc") # Add media section menu.add_section("Media") menu.add_item("Media", "Music Player", "/home/stormux/.local/bin/music_player.py") menu.add_item("Media", "BookStorm", "GAME=BookStorm startx") menu.add_item("Media", "Upload Files", "/home/stormux/.local/upload_server/uploader.py") # Add accessories section menu.add_section("Accessories") menu.add_item("Accessories", "Local IP Address", "/home/stormux/.local/bin/ip_info.py local") menu.add_item("Accessories", "Remote IP Address", "/home/stormux/.local/bin/ip_info.py remote") menu.add_item("Accessories", "Sound and Volume", "/home/stormux/.local/bin/audio_manager.py") menu.add_item("Accessories", "Web Browser", "GAME=Brave startx") menu.add_item("Accessories", "LibreOffice", lambda: menu.install_and_launch("libreoffice", "gui")) menu.add_item("Accessories", "Thunderbird", lambda: menu.install_and_launch("thunderbird", "gui")) configure_system_menu(menu) # Run the menu menu.run()