Files
gaming-image-files/usr/local/bin/apple_2e.py

404 lines
14 KiB
Python
Executable File

#!/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()