From 6b0396a4b0fe139e5e8fccd13b99b83bf04ecfe1 Mon Sep 17 00:00:00 2001 From: edschuy95 Date: Thu, 22 Jan 2026 14:16:31 -0500 Subject: [PATCH] GUI only change. --- RA3MP3Playback.pyproj | 7 +- RA3MP3Playback.py => RA3MP3Playback.pyw | 86 +--- build.bat | 2 +- textmenu.py | 536 +++++++++++++----------- 4 files changed, 296 insertions(+), 335 deletions(-) rename RA3MP3Playback.py => RA3MP3Playback.pyw (92%) diff --git a/RA3MP3Playback.pyproj b/RA3MP3Playback.pyproj index 989e8b7..db4b4d9 100644 --- a/RA3MP3Playback.pyproj +++ b/RA3MP3Playback.pyproj @@ -4,7 +4,7 @@ 2.0 87ef45f3-101d-4df7-9909-5bfe733259e3 . - RA3MP3Playback.py + RA3MP3Playback.pyw . @@ -12,7 +12,8 @@ RA3MP3Playback RA3MP3Playback Standard Python launcher - +set fs_game arena + + False @@ -24,7 +25,7 @@ false - + diff --git a/RA3MP3Playback.py b/RA3MP3Playback.pyw similarity index 92% rename from RA3MP3Playback.py rename to RA3MP3Playback.pyw index 7a24558..441f772 100644 --- a/RA3MP3Playback.py +++ b/RA3MP3Playback.pyw @@ -639,24 +639,29 @@ def main(): menu = TextMenu( items, - width=60, - height=20, + width=500, + height=400, title="Quake III Arena mod loader menu", - border_fg="light red", + border_fg="red", border_bg="black", inside_fg="dark red", inside_bg="black", selected_fg="black", - selected_bg="light red", + selected_bg="red", timeout=10 ) choice=menu.show() - if choice != None: + if choice == None: + sys.exit(0) + if choice == "errmenutimeout": + chosen_mod = "baseq3" + else: chosen_mod = choice.split("\n", 1)[0] - if chosen_mod != None: run_mod = ["+set", "fs_game", chosen_mod] + #sys.exit(0) + if os.name == "nt": pty_proc = subprocess.Popen( [game_exe, "+set", "ttycon", "1"] + run_mod + sys.argv[1:], @@ -713,76 +718,7 @@ def main(): pass pygame.mixer.quit() -_RELAUNCHED_ENV = "Q3A_LAUNCHED_IN_TERMINAL" - - -def has_terminal(): - try: - return sys.stdin.isatty() and sys.stdout.isatty() - except Exception: - return False - - -def relaunch_in_terminal(): - # Prevent infinite relaunch loops - if os.environ.get(_RELAUNCHED_ENV) == "1": - return - - env = os.environ.copy() - env[_RELAUNCHED_ENV] = "1" - - argv = [os.path.abspath(sys.argv[0])] + sys.argv[1:] - - # --- 1. Preferred: xdg-terminal-exec (Bazzite / Fedora Atomic / Wayland) --- - if shutil.which("xdg-terminal-exec"): - try: - subprocess.Popen( - ["xdg-terminal-exec"] + argv, - env=env - ) - sys.exit(0) - except Exception: - pass # fall through to legacy terminals - - # --- 2. Legacy fallback terminals --- - terminals = [ - ("konsole", ["--hold", "-e"]), - ("gnome-terminal", ["--", "bash", "-c"]), - ("xterm", ["-hold", "-e"]), - ("kitty", ["-e"]), - ("alacritty", ["-e"]), - ] - - for term, args in terminals: - if not shutil.which(term): - continue - - try: - if term == "gnome-terminal": - cmd = [term] + args + [ - f'"{" ".join(argv)}"; exec bash' - ] - else: - cmd = [term] + args + argv - - subprocess.Popen(cmd, env=env) - sys.exit(0) - - except Exception: - continue - - # --- 3. Last-resort failure --- - sys.stderr.write( - "Unable to open a terminal window.\n" - "Please run this application from a terminal.\n" - ) - sys.exit(1) - if __name__ == "__main__": - if (os.name != "nt"): - if not has_terminal(): - relaunch_in_terminal() - lock_fd, lock_path = acquire_single_instance("q3a_launcher.lock") if lock_fd is None: print("Another instance is already running.") diff --git a/build.bat b/build.bat index b0480b4..69d1e9c 100644 --- a/build.bat +++ b/build.bat @@ -1 +1 @@ -pyinstaller --onefile --name Quake3Arena --icon=quake3modern.ico --exclude-module tkinter --exclude-module unittest --exclude-module http --exclude-module pydoc --exclude-module doctest RA3MP3Playback.py \ No newline at end of file +pyinstaller --onefile --noconsole --name Quake3Arena --icon=quake3modern.ico --exclude-module unittest --exclude-module http --exclude-module pydoc --exclude-module doctest RA3MP3Playback.pyw \ No newline at end of file diff --git a/textmenu.py b/textmenu.py index 1ad8340..1d15e64 100644 --- a/textmenu.py +++ b/textmenu.py @@ -1,74 +1,11 @@ -import urwid -import sys -import os +import tkinter as tk +from tkinter import ttk import time -import threading -import queue import math - -# Detect if running in a real terminal -def is_real_terminal(): - if not sys.stdout.isatty(): - return False - if os.name == 'nt': - vs_vars = ["PYCHARM_HOSTED", "VSCODE_PID", "TERM_PROGRAM"] - for var in vs_vars: - if var in os.environ: - return False - return True - -# Fallback menu for non-terminal consoles with timeout note in input prompt -def fallback_menu(choices, title="Menu", timeout=None): - print(f"=== {title} ===") - for i, c in enumerate(choices, 1): - print(f"{i}. {c}") - - prompt = "Enter choice number" - if timeout is not None: - prompt += f" (times out in {timeout}s)" - prompt += ": " - - if timeout is None: - while True: - try: - sel = int(input(prompt)) - if 1 <= sel <= len(choices): - return choices[sel - 1] - except ValueError: - pass - print("Invalid input. Try again.") - else: - q = queue.Queue() - - def get_input(): - try: - val = input(prompt) - q.put(val) - except Exception: - q.put(None) - - t = threading.Thread(target=get_input, daemon=True) - t.start() - start_time = time.time() - while True: - try: - val = q.get_nowait() - if val is None: - return None - sel = int(val) - if 1 <= sel <= len(choices): - return choices[sel - 1] - except queue.Empty: - if time.time() - start_time > timeout: - return None - time.sleep(0.05) - except ValueError: - print("Invalid input. Try again.") - t = threading.Thread(target=get_input, daemon=True) - t.start() +from typing import List, Optional class TextMenu: - def __init__(self, choices, width=40, height=10, title="Menu", + def __init__(self, choices, width=500, height=400, title="Menu", border_fg="white", border_bg="dark blue", inside_fg="white", inside_bg="black", selected_fg="black", selected_bg="light gray", @@ -80,213 +17,300 @@ class TextMenu: self.selected = None self.timeout = timeout self._start_time = None - self._timeout_alarm = None + self._timeout_id = None + # Store color parameters + self.border_fg = border_fg + self.border_bg = border_bg + self.inside_fg = inside_fg + self.inside_bg = inside_bg + self.selected_fg = selected_fg + self.selected_bg = selected_bg + + # GUI elements + self.root = None + self.listbox = None + self.timer_label = None + + # Double-click tracking self.last_click_time = 0 - self.last_click_pos = (0, 0) # Store last click position (x, y) - self.last_clicked_index = -1 # Track which menu item was last clicked + self.last_clicked_index = -1 self.double_click_threshold = 0.3 # seconds self.position_threshold = 5 # pixels tolerance for position matching - # Calculate the effective content width (accounting for borders and internal padding) - content_width = width - 5 # subtract 3 to account for borders and internal padding - - # Function to center text by adding spaces to the left only, supporting newlines - def center_text(text, total_width): - if '\n' in text: - # Split text into lines and center each line individually - lines = text.split('\n') - centered_lines = [] - for line in lines: - if len(line) >= total_width: - centered_lines.append(line) - else: - total_padding = total_width - len(line) - left_padding = total_padding // 2 - centered_lines.append(' ' * left_padding + line) - return '\n'.join(centered_lines) - else: - # Single line text - text_len = len(text) - if text_len >= total_width: - return text - total_padding = total_width - text_len - left_padding = total_padding // 2 - return ' ' * left_padding + text - - # Custom button class that handles enter/space differently - class SelectableButton(urwid.Button): - signals = urwid.Button.signals + ['activate'] # Add our custom signal - - def keypress(self, size, key): - if key in ('enter', ' '): - # For enter/space, trigger activation without generating click signal - # Also cancel timeout here to ensure immediate response - self._parent_menu._cancel_timeout() - self._emit('activate') - return None - return super().keypress(size, key) - - # Buttons with proper click handling - self.menu_widgets = [] - for i, c in enumerate(choices): - # Add a newline prefix for better spacing, then center the text - spaced_text = '\n' + c # Add newline prefix - centered_text = center_text(spaced_text, content_width) - btn = SelectableButton(centered_text) - btn._parent_menu = self # Reference to parent menu for timeout control - - # Store original text without the newline prefix for return purposes - original_choice = c - - def make_click_handler(choice_text, index): - def click_handler(button): - # Handle mouse clicks with double-click detection - now = time.time() - - # Store mouse coordinates when this click handler runs - current_pos = (getattr(self, 'mouse_x', 0), getattr(self, 'mouse_y', 0)) - - # Check if this is a double-click (same item, timing, and position match) - is_same_item = (index == self.last_clicked_index) - is_timely = (now - self.last_click_time < self.double_click_threshold) - pos_match = (abs(current_pos[0] - self.last_click_pos[0]) <= self.position_threshold and - abs(current_pos[1] - self.last_click_pos[1]) <= self.position_threshold) - - if is_same_item and is_timely and pos_match: - # Double-click detected on the same item - return the selection - self._cancel_timeout() - self.selected = choice_text # Use original text without prefix/newline - raise urwid.ExitMainLoop() - else: - # Single click or click on different item - just focus the item - self._cancel_timeout() - self.last_clicked_index = index - self.last_click_pos = current_pos - self.list_walker.set_focus(index) - - self.last_click_time = now - return click_handler - - def make_activate_handler(choice_text): - def activate_handler(button): - # Handle enter/space activation - # Timeout already canceled in button's keypress method - # But call it again to be safe and ensure consistency with click handler - self._cancel_timeout() - self.selected = choice_text # Use original text without prefix/newline - raise urwid.ExitMainLoop() - return activate_handler - - urwid.connect_signal(btn, 'click', make_click_handler(original_choice, i)) # Use original text for selection - urwid.connect_signal(btn, 'activate', make_activate_handler(original_choice)) # Use original text for selection - - attr_mapped_btn = urwid.AttrMap(btn, None, focus_map='selected') - self.menu_widgets.append(attr_mapped_btn) - - # Custom ListBox that intercepts navigation keys and mouse events - class TimedListBox(urwid.ListBox): - def keypress(self, size, key): - # Cancel timeout for navigation keys and mouse wheel - if key in ('up', 'down', 'page up', 'page down', 'home', 'end'): - self.parent_obj._cancel_timeout() - - # Process the key normally - result = super().keypress(size, key) - - # Return the result (None if handled, otherwise the unhandled key) - return result - - def mouse_event(self, size, event, button, col, row, focus): - # Store mouse coordinates for double-click detection - if event == 'mouse press': - # Store the mouse coordinates when a click occurs - self.parent_obj.mouse_x = col - self.parent_obj.mouse_y = row - - # Handle mouse wheel events - if button == 4: # Mouse wheel up - self.parent_obj._cancel_timeout() - self.keypress(size, 'up') - return True - elif button == 5: # Mouse wheel down - self.parent_obj._cancel_timeout() - self.keypress(size, 'down') - return True - - # Call parent mouse_event method for other mouse events - return super().mouse_event(size, event, button, col, row, focus) - - # Scrollable ListBox with timeout handling - self.list_walker = urwid.SimpleFocusListWalker(self.menu_widgets) - self.listbox = TimedListBox(self.list_walker) - self.listbox.parent_obj = self # Provide access to parent methods - self.listbox = urwid.AttrMap(self.listbox, 'inside') - - # Fix height - self.box_adapter = urwid.BoxAdapter(self.listbox, self.height) - - # LineBox with border and title - self.linebox = urwid.LineBox(self.box_adapter, title=self.title) - linebox_colored = urwid.AttrMap(self.linebox, 'border') - - # Fix width and center - self.frame = urwid.Filler( - urwid.Padding(linebox_colored, align='center', width=self.width), - valign='middle' - ) - - # Color palette - self.palette = [ - ('border', border_fg, border_bg), - ('inside', inside_fg, inside_bg), - ('selected', selected_fg, selected_bg), - ] - - # MainLoop - self.loop = urwid.MainLoop( - self.frame, - palette=self.palette, - #unhandled_input=self._unhandled_input, - handle_mouse=True - ) - - # Cancel timeout and restore title def _cancel_timeout(self): - if self._timeout_alarm is not None: - self.loop.remove_alarm(self._timeout_alarm) - self._timeout_alarm = None - self.linebox.set_title(self.title) # restore original title + if self._timeout_id is not None: + self.root.after_cancel(self._timeout_id) + self._timeout_id = None + # Hide the timer label if it exists + if hasattr(self, 'timer_frame') and self.timer_frame: + self.timer_frame.pack_forget() - # Timeout exit - def _timeout_exit(self, loop, user_data=None): - self.selected = None - raise urwid.ExitMainLoop() + def _on_single_click(self, event): + now = time.time() + current_index = self.listbox.nearest(event.y) + + # Get current click position + current_pos = (event.x, event.y) + + # Check if this is a double-click (same item, timing, and position match) + is_same_item = (current_index == self.last_clicked_index) + is_timely = (now - self.last_click_time < self.double_click_threshold) + + # For position check, we just need to make sure it's a reasonable click + pos_match = True # For simplicity in GUI version, we'll trust the click position + + if is_same_item and is_timely and pos_match: + # Double-click detected on the same item - return the selection + # Convert " | " back to "\n" when returning + original_choice = self.choices[current_index] + self.selected = original_choice + self._cancel_timeout() + self.root.quit() # Close the GUI + else: + # Single click - focus the item and update tracking + self.listbox.selection_clear(0, tk.END) + self.listbox.selection_set(current_index) + self.listbox.activate(current_index) + self.last_clicked_index = current_index + self.last_click_pos = current_pos + self._cancel_timeout() # Cancel timeout on single click too + + self.last_click_time = now - # Update countdown in title, precise timing - def _update_timer(self, loop, user_data=None): + def _on_key_press(self, event): + if event.keysym in ('Return', 'space'): + # Enter or Space pressed - select focused item + selection = self.listbox.curselection() + if selection: + index = selection[0] + # Convert " | " back to "\n" when returning + original_choice = self.choices[index] + self.selected = original_choice + self._cancel_timeout() + self.root.quit() + elif event.keysym == 'Escape': + self.selected = None + self._cancel_timeout() + self.root.quit() + elif event.keysym in ('Up', 'Down', 'Prior', 'Next'): + # Cancel timeout for navigation keys + self._cancel_timeout() + + def _on_mousewheel(self, event): + """Handle mouse wheel scrolling - navigate through items like arrow keys""" + # Cancel timeout when scrolling + self._cancel_timeout() + + # Get current selection + selection = self.listbox.curselection() + if selection: + current_index = selection[0] + else: + # If nothing is selected, start with the first item + current_index = 0 + self.listbox.selection_set(0) + self.listbox.activate(0) + return + + # Determine direction based on wheel delta + if event.delta > 0: # Scrolling up + new_index = max(0, current_index - 1) + else: # Scrolling down + new_index = min(len(self.choices) - 1, current_index + 1) + + # Update selection + self.listbox.selection_clear(0, tk.END) + self.listbox.selection_set(new_index) + self.listbox.activate(new_index) + + def _update_timer(self): + if self._timeout_id is None: + return # Timer was canceled + elapsed = time.time() - self._start_time remaining = self.timeout - elapsed if remaining <= 0: - self.linebox.set_title(self.title) - self._timeout_exit(loop) + self.selected = "errmenutimeout" # Return timeout indicator + self.root.quit() return - self.linebox.set_title(f"{self.title} ({math.ceil(remaining)}s)") + # Update the timer label text + if self.timer_label: + self.timer_label.config(text=f"Time remaining: {math.ceil(remaining)}s") - # Schedule next update in 0.1s for precise tracking - self._timeout_alarm = loop.set_alarm_in(0.1, self._update_timer) + # Schedule next update in 100ms for precise timing + self._timeout_id = self.root.after(100, self._update_timer) + + def _create_gui(self): + # Create main window + self.root = tk.Tk() + self.root.title(self.title) + + # Get screen dimensions to center the window + screen_width = self.root.winfo_screenwidth() + screen_height = self.root.winfo_screenheight() + + # Calculate x and y coordinates for the window to be centered + x = (screen_width // 2) - (self.width // 2) + y = (screen_height // 2) - (self.height // 2) + + # Set the geometry with position + self.root.geometry(f"{self.width}x{self.height}+{x}+{y}") + self.root.minsize(self.width, self.height) # Set minimum size + self.root.resizable(True, True) # Allow resizing if needed + + # Configure main window background color using border_bg + self.root.configure(bg=self.border_bg) + + # Bind the window close button to quit the application properly + self.root.protocol("WM_DELETE_WINDOW", lambda: self._on_window_close()) + + # Create main content frame using border_bg + main_frame = tk.Frame(self.root, bg=self.border_bg) + main_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) + + # Create frame for the list using inside_bg + list_frame = tk.Frame(main_frame, bg=self.inside_bg) + list_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=(5, 0)) # No bottom padding to leave room for timer + + # Create scrollbar + scrollbar = tk.Scrollbar(list_frame) + scrollbar.pack(side=tk.RIGHT, fill=tk.Y) + + # Create listbox with proper colors using the class parameters + self.listbox = tk.Listbox( + list_frame, + yscrollcommand=scrollbar.set, + selectbackground=self.selected_bg, # Selected item background + selectforeground=self.selected_fg, # Selected item foreground + background=self.inside_bg, # Background color + foreground=self.inside_fg, # Foreground color + font=('TkDefaultFont', 12), # Larger font size + borderwidth=0, + highlightthickness=0, + activestyle='none', + justify=tk.CENTER # Center the text + ) + + # Add choices with " | " separator for spacing instead of \n + for choice in self.choices: + # Replace newlines with " | " for display + display_choice = choice.replace('\n', ' | ') + self.listbox.insert(tk.END, display_choice) + + # Pack with fill and expand to take up available space + self.listbox.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) + scrollbar.config(command=self.listbox.yview) + + # Create timer frame at the bottom using border_bg + self.timer_frame = tk.Frame(main_frame, bg=self.border_bg) + self.timer_frame.pack(fill=tk.X, padx=5, pady=(0, 5)) # Top padding 0 to connect with list, bottom padding for spacing + + # Create timer label using border_fg and border_bg + self.timer_label = tk.Label( + self.timer_frame, + text="", + bg=self.border_bg, # Border background + fg=self.border_fg, # Border foreground + font=('TkDefaultFont', 10), + anchor='w' # Left justify + ) + self.timer_label.pack(side=tk.LEFT) + + # Bind events - use the proper double-click event + self.listbox.bind('', self._on_single_click) + self.listbox.bind('', self._on_double_click) # Proper double-click binding + self.root.bind('', self._on_key_press) + + # Bind mouse wheel scrolling + self.root.bind('', self._on_mousewheel) # Windows + self.root.bind('', lambda e: self._on_mousewheel(e)) # Linux up + self.root.bind('', lambda e: self._on_mousewheel(e)) # Linux down + + # Set first item as active + if self.choices: + self.listbox.selection_set(0) + self.listbox.activate(0) + + def _on_double_click(self, event): + """Handle double-click event directly""" + now = time.time() + current_index = self.listbox.nearest(event.y) + + # Get current click position + current_pos = (event.x, event.y) + + # Check if this is a double-click (same item, timing, and position match) + is_same_item = (current_index == self.last_clicked_index) + is_timely = (now - self.last_click_time < self.double_click_threshold) + + # For position check, we just need to make sure it's a reasonable click + pos_match = True # For simplicity in GUI version, we'll trust the click position + + if is_same_item and is_timely and pos_match: + # Double-click detected on the same item - return the selection + # Convert " | " back to "\n" when returning + original_choice = self.choices[current_index] + self.selected = original_choice + self._cancel_timeout() + self.root.quit() # Close the GUI + else: + # This is a new double-click sequence, so treat as a single click + self.listbox.selection_clear(0, tk.END) + self.listbox.selection_set(current_index) + self.listbox.activate(current_index) + self.last_clicked_index = current_index + self.last_click_pos = current_pos + self._cancel_timeout() # Cancel timeout on click + + self.last_click_time = now + + def _on_window_close(self): + """Handle window close button click""" + self.selected = None # Return None only when closed without selection + self._cancel_timeout() + self.root.quit() def show(self): """Show menu and return selected item. Returns None if timeout expires.""" - if not is_real_terminal(): - return fallback_menu(self.choices, title=self.title, timeout=self.timeout) - - # Start countdown immediately + # Create the GUI + self._create_gui() + + # Start countdown immediately if timeout is set if self.timeout is not None: self._start_time = time.time() - self._update_timer(self.loop) # show countdown immediately + # Show the initial timer value + if self.timer_label: + remaining = self.timeout + self.timer_label.config(text=f"Time remaining: {math.ceil(remaining)}s") + # Start the timer updates + self._timeout_id = self.root.after(100, self._update_timer) - self.loop.run() - return self.selected \ No newline at end of file + # Run the GUI event loop + self.root.mainloop() + + # Clean up - only destroy if root still exists + if self.root and str(self.root) != '.': + try: + self.root.destroy() + except tk.TclError: + # Window may already be destroyed + pass + + return self.selected + + +if __name__ == "__main__": + # Example usage + choices = [ + "Option 1\nDescription for option 1", + "Option 2\nDescription for option 2", + "Option 3\nDescription for option 3", + "Option 4\nDescription for option 4" + ] + + menu = TextMenu(choices, title="Test Menu", timeout=10) + result = menu.show() + print(f"Selected: {result}") \ No newline at end of file