#!/usr/bin/env python3 # -*- coding: utf-8 -*- # Self-voiced Terminal Menu for Apple IIe Disk Launcher import os import sys import time import curses import speechd # Python bindings for Speech Dispatcher import configparser class VoicedDiskMenu: def __init__(self, title="Apple 2e Disk Menu"): self.title = title self.menu_items = [] # List to store (name, path) tuples self.current_index = 0 # Index of current selection self.stdscr = None self.curses_initialized = False # Flag to track if curses has been initialized # Config settings self.config_dir = os.path.expanduser("~/.config/stormux") self.config_file = os.path.join(self.config_dir, "apple2e_menu.conf") self.config = configparser.ConfigParser() # Default settings self.speech_rate = 0 # Normal speech rate (0 is default in speechd) # Load settings self.load_settings() # Initialize speech client self.speech_client = None self.init_speech() def init_speech(self): """Initialize the speech client""" try: self.speech_client = speechd.SSIPClient("apple_menu") self.speech_client.set_priority(speechd.Priority.IMPORTANT) self.speech_client.set_punctuation(speechd.PunctuationMode.SOME) # Apply speech rate from settings self.speech_client.set_rate(self.speech_rate) 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""" # Create default settings if they don't exist if not os.path.exists(self.config_file): self.save_settings() return try: self.config.read(self.config_file) # Load speech settings if 'Speech' in self.config: self.speech_rate = self.config.getint('Speech', 'rate', fallback=0) except Exception as e: print(f"Error loading settings: {e}") # If loading fails, we'll use default values def save_settings(self): """Save settings to config file""" # Ensure config directory exists os.makedirs(self.config_dir, exist_ok=True) # Update config object if 'Speech' not in self.config: self.config['Speech'] = {} self.config['Speech']['rate'] = str(self.speech_rate) # Write to file try: with open(self.config_file, 'w') as f: self.config.write(f) except Exception as e: print(f"Error saving settings: {e}") def increase_speech_rate(self): """Increase speech rate""" self.speech_rate = min(100, self.speech_rate + 10) # Max is 100 if self.speech_client: try: self.speech_client.set_rate(self.speech_rate) self.speak(f"Speech rate: {self.speech_rate}") except Exception as e: print(f"Error adjusting speech rate: {e}") # Save the new setting self.save_settings() def decrease_speech_rate(self): """Decrease speech rate""" self.speech_rate = max(-100, self.speech_rate - 10) # Min is -100 if self.speech_client: try: self.speech_client.set_rate(self.speech_rate) self.speak(f"Speech rate: {self.speech_rate}") except Exception as e: print(f"Error adjusting speech rate: {e}") # Save the new setting self.save_settings() def speak(self, text, interrupt=True): """Speak the given text with option to interrupt existing speech""" if self.speech_client is None: return try: if interrupt: self.stop_speech() self.speech_client.speak(text) except Exception as e: # If speech fails, try to reinitialize and try once more try: self.init_speech() if self.speech_client: self.speech_client.speak(text) except: # If reinitializing fails, just give up silently pass def stop_speech(self): """Stop any ongoing speech""" if self.speech_client is None: return try: self.speech_client.cancel() except Exception as e: # If cancel fails, try to reinitialize self.init_speech() def announce_current_item(self, interrupt=True): """Announce the currently selected menu item""" if self.menu_items and 0 <= self.current_index < len(self.menu_items): name = self.menu_items[self.current_index][0] self.speak(name, interrupt=interrupt) def execute_current_item(self): """Execute the currently selected menu item""" if self.menu_items and 0 <= self.current_index < len(self.menu_items): _, path = self.menu_items[self.current_index] # Clean up resources before executing the command self.cleanup(full_cleanup=True) # Handle special boot options if path == "TEXTALKER_ONLY": # Boot with TextTalker only (original Apple 2e option) os.system('export GAME="Apple 2e" && startx') elif path.startswith("DISK_ONLY:"): # Boot with disk only, no TextTalker os.system(f'export GAME="{path}" && startx') else: # Boot with TextTalker + specified disk (default behavior) os.system(f'export GAME="{path}" && startx') sys.exit(0) # Exit after launching def speak_help(self): """Speak help information""" helpText = """ Navigation controls: Up arrow: Previous disk. Down arrow: Next disk. Enter: Launch selected disk. H key: Hear these instructions again. Left bracket: Decrease speech rate. Right bracket: Increase speech rate. Escape or Q: Exit the menu. 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 = "Up/Down: Navigate | Enter: Select | H: Help | [ ] Rate | Q/Esc: Quit" x = max(0, w // 2 - len(helpText) // 2) self.stdscr.addstr(3, x, helpText) # Check if we have items if not self.menu_items: message = "No disk files found." x = max(0, w // 2 - len(message) // 2) self.stdscr.addstr(5, x, message, curses.A_DIM) else: # Show a limited number of items, centered around the current selection max_display = min(h - 7, len(self.menu_items)) # Max number of items to display # Calculate starting index for display half_display = max_display // 2 if self.current_index < half_display: start_idx = 0 elif self.current_index >= len(self.menu_items) - half_display: start_idx = max(0, len(self.menu_items) - max_display) else: start_idx = self.current_index - half_display # Draw visible menu items for i in range(start_idx, min(start_idx + max_display, len(self.menu_items))): y = (i - start_idx) + 5 # Start items at line 5 # Highlight the selected item name = self.menu_items[i][0] if i == self.current_index: 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"Speech Rate: {self.speech_rate}" self.stdscr.addstr(h-2, 2, rateText) self.stdscr.refresh() def cleanup(self, full_cleanup=False): """Clean up resources before exiting or executing a command Args: full_cleanup: If True, also close curses. Used when exiting or running a command. """ # Stop any speech self.stop_speech() # Close speech client if self.speech_client: try: self.speech_client.close() except: pass self.speech_client = None # Restore terminal settings if curses was initialized if full_cleanup and self.curses_initialized: try: curses.nocbreak() self.stdscr.keypad(False) curses.echo() curses.endwin() except: # If there's an error, just try a simple endwin try: curses.endwin() except: pass # Last resort, just continue def load_disks_from_directory(self, directory_path): """Load disk files from the specified directory""" # Add special boot options first self.menu_items.append(("Boot with TextTalker only", "TEXTALKER_ONLY")) # Expand the path (in case it contains ~) directory_path = os.path.expanduser(directory_path) # Check if directory exists if not os.path.exists(directory_path) or not os.path.isdir(directory_path): print(f"Directory {directory_path} does not exist or is not a directory") return try: # Get all .dsk files in the directory files = [f for f in os.listdir(directory_path) if os.path.isfile(os.path.join(directory_path, f)) and f.lower().endswith(('.dsk', '.do'))] # Sort files alphabetically files.sort() # Create menu items for disk files for file in files: # Get full path to the file file_path = os.path.join(directory_path, file) # Create display name - remove extension display_name = os.path.splitext(file)[0] # Replace underscores with spaces for better readability display_name = display_name.replace('_', ' ') # Add both TextTalker + disk and disk-only options self.menu_items.append((f"{display_name} (with TextTalker)", file_path)) self.menu_items.append((f"{display_name} (disk only)", f"DISK_ONLY:{file_path}")) except Exception as e: print(f"Error loading disk directory: {e}") def run(self): """Run the menu system""" # Check if menu is empty if not self.menu_items: message = "No disk files found. Exiting." print(message) # Speak the message self.init_speech() if self.speech_client: self.speak(message) # Wait for speech to finish (rough estimate) time.sleep(3) # Clean up and exit properly self.cleanup(full_cleanup=True) sys.exit(0) try: # Initialize curses self.stdscr = curses.initscr() self.curses_initialized = True curses.noecho() curses.cbreak() self.stdscr.keypad(True) # Initial draw self.draw_menu() # Welcome message self.speak(self.title) # Wait for initial speech to finish before announcing first item time.sleep(1) 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 self.current_index = (self.current_index - 1) % len(self.menu_items) self.draw_menu() self.announce_current_item() elif key == curses.KEY_DOWN: # Move to next item self.current_index = (self.current_index + 1) % len(self.menu_items) self.draw_menu() self.announce_current_item() elif key == curses.KEY_ENTER or key == 10 or key == 13: # Enter key self.execute_current_item() elif key == ord('h') or key == ord('H'): # Help self.speak_help() 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 == 27 or key == ord('q') or key == ord('Q'): # Esc or Q break except Exception as e: # End curses in case of error if self.curses_initialized: try: curses.endwin() except: pass print(f"An error occurred: {e}") finally: # Clean up - safe to call even if curses wasn't initialized self.cleanup(full_cleanup=True) # Run the menu if __name__ == "__main__": # Create the menu menu = VoicedDiskMenu() # Load disk files from the Apple IIe disks directory menu.load_disks_from_directory("~/.local/games/apple2e/disks/") # Run the menu menu.run()