Initial commit
This commit is contained in:
Executable
+390
@@ -0,0 +1,390 @@
|
||||
#!/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)
|
||||
|
||||
# Execute the command to launch the disk file
|
||||
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"""
|
||||
# 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 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 to menu items list
|
||||
self.menu_items.append((display_name, 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()
|
||||
Reference in New Issue
Block a user