diff --git a/RA3MP3Playback.py b/RA3MP3Playback.py index 1491da9..8555cdf 100644 --- a/RA3MP3Playback.py +++ b/RA3MP3Playback.py @@ -22,7 +22,7 @@ import pygame shutdown_event = threading.Event() # ========================= CONFIG ========================= -DEBUG = True # Set True to enable debug overrides +DEBUG = False # Set True to enable debug overrides # Default paths (used if not in debug mode) DEFAULT_WIN_GAME_EXE = r"./qgame.dll" @@ -93,23 +93,73 @@ ANSI_ESCAPE_RE = re.compile( # ========================================================== # ===================== UTILITY FUNCTIONS ================= +# Strip Quake-style color codes (^ followed by any character) +_QUAKE_COLOR_RE = re.compile(r"\^.") + +def _strip_quake_colors(text): + return _QUAKE_COLOR_RE.sub("", text) + def find_pk3_subfolders(base_path): """ - Returns a list of subfolder names under base_path that contain + Returns a list of strings for each subfolder under base_path that contains at least one .pk3 file. + + The list always begins with: + baseq3\nQuake III Arena + + Each subsequent entry is formatted as: + folder_name + "\n" + description """ base = Path(base_path) - matches = [] + + # Always start with baseq3 + results = ["baseq3\nQuake III Arena"] if not base.is_dir(): - return matches + return results for entry in base.iterdir(): - if entry.is_dir(): - if any(f.is_file() and f.suffix.lower() == ".pk3" for f in entry.iterdir()): - matches.append(entry.name) + if not entry.is_dir(): + continue - return matches + folder_name = entry.name + folder_name_lower = folder_name.lower() + + # Skip baseq3 during scan (already added) + if folder_name_lower == "baseq3": + continue + + # Check for at least one .pk3 file + has_pk3 = any( + f.is_file() and f.suffix.lower() == ".pk3" + for f in entry.iterdir() + ) + + if not has_pk3: + continue + + # Special-case missionpack + if folder_name_lower == "missionpack": + results.append(f"{folder_name}\nQuake III Team Arena") + continue + + # Description.txt handling + description_file = entry / "description.txt" + + if description_file.is_file(): + try: + with description_file.open("r", encoding="utf-8", errors="ignore") as f: + first_line = _strip_quake_colors(f.readline().strip()) + if first_line: + results.append(f"{folder_name}\n{first_line}") + else: + results.append(f"{folder_name}\nNo description available") + except OSError: + results.append(f"{folder_name}\nNo description available") + else: + results.append(f"{folder_name}\nNo description available") + + return results def acquire_single_instance(lockfile_base: str): """ @@ -579,36 +629,35 @@ def main(): watcher_thread = threading.Thread(target=track_watcher, daemon=True) watcher_thread.start() - # # # Launch quake process via subprocess - # pty_proc = subprocess.Popen( - # [game_exe, "+set", "ttycon", "1"] + sys.argv[1:], - # stdin=subprocess.PIPE, - # stdout=subprocess.PIPE, - # stderr=subprocess.STDOUT, - # bufsize=0, # unbuffered - # universal_newlines=False) + chosen_mod = None + run_mod = [] - items = ["choice1","choice2","choice 3.","a","b","c","d"] - menu = TextMenu( - items, - width=30, - height=5, - title="Choose your stink", - border_fg="dark gray", - border_bg="dark red", - inside_fg="dark red", - inside_bg="black", - selected_fg="black", - selected_bg="dark red", - timeout=10 - ) - choice=menu.show() + if "fs_game" not in sys.argv[1:]: + game_path = Path(game_exe) + items = find_pk3_subfolders(game_path.parent) - print(f"You selected: {choice}") + menu = TextMenu( + items, + width=60, + height=20, + title="Quake III Arena mod loader menu", + border_fg="light red", + border_bg="black", + inside_fg="dark red", + inside_bg="black", + selected_fg="black", + selected_bg="light red", + timeout=10 + ) + choice=menu.show() + chosen_mod = choice.split("\n", 1)[0] + + if chosen_mod != None: + run_mod = ["+set", "fs_game", chosen_mod] if os.name == "nt": pty_proc = subprocess.Popen( - [game_exe, "+set", "ttycon", "1"] + sys.argv[1:], + [game_exe, "+set", "ttycon", "1"] + run_mod + sys.argv[1:], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, @@ -620,7 +669,7 @@ def main(): master_fd, slave_fd = pty.openpty() pty_proc = subprocess.Popen( - [game_exe, "+set", "ttycon", "1"] + sys.argv[1:], + [game_exe, "+set", "ttycon", "1"] + run_mod + sys.argv[1:], stdin=slave_fd, stdout=slave_fd, stderr=slave_fd, diff --git a/textmenu.py b/textmenu.py index b7b029d..1ad8340 100644 --- a/textmenu.py +++ b/textmenu.py @@ -83,7 +83,36 @@ class TextMenu: self._timeout_alarm = None 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.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): @@ -92,6 +121,8 @@ class TextMenu: 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) @@ -99,21 +130,39 @@ class TextMenu: # Buttons with proper click handling self.menu_widgets = [] for i, c in enumerate(choices): - btn = SelectableButton(c) + # 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() - # Check if this is a double-click - if now - self.last_click_time < self.double_click_threshold: - # Double-click detected - return the selection + # 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 + self.selected = choice_text # Use original text without prefix/newline raise urwid.ExitMainLoop() else: - # Single click - just focus the item (like keyboard navigation) + # 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 @@ -122,18 +171,56 @@ class TextMenu: 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 + self.selected = choice_text # Use original text without prefix/newline raise urwid.ExitMainLoop() return activate_handler - urwid.connect_signal(btn, 'click', make_click_handler(c, i)) - urwid.connect_signal(btn, 'activate', make_activate_handler(c)) - self.menu_widgets.append(urwid.AttrMap(btn, None, focus_map='selected')) + 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) - # Scrollable ListBox + # 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 = urwid.ListBox(self.list_walker) + 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 @@ -160,7 +247,7 @@ class TextMenu: self.loop = urwid.MainLoop( self.frame, palette=self.palette, - unhandled_input=self._unhandled_input, + #unhandled_input=self._unhandled_input, handle_mouse=True ) @@ -171,33 +258,6 @@ class TextMenu: self._timeout_alarm = None self.linebox.set_title(self.title) # restore original title - # Keyboard or mouse input - handle ALL input types - def _unhandled_input(self, key): - # Cancel timeout on ANY input - self._cancel_timeout() - - # Handle Enter key immediately (like double-click behavior) - if key in ('enter', ' '): - focus_widget, _ = self.listbox.get_focus() - if focus_widget: - # Get the button text from the nested widgets - btn_text = focus_widget.original_widget.base_widget.get_label() - self.selected = btn_text - raise urwid.ExitMainLoop() - elif key == 'esc': - self.selected = None - raise urwid.ExitMainLoop() - # Handle navigation keys that should stop timer but not select - elif key in ('up', 'down', 'page up', 'page down'): - # Timer already cancelled above, just continue - # Navigation already changes focus automatically - pass - # Handle mouse events that should stop timer - elif isinstance(key, tuple) and len(key) >= 3 and key[0].startswith('mouse'): - # This catches mouse events like ('mouse press', 1, x, y) - # Timer already cancelled above, continue with normal processing - pass - # Timeout exit def _timeout_exit(self, loop, user_data=None): self.selected = None @@ -215,7 +275,7 @@ class TextMenu: self.linebox.set_title(f"{self.title} ({math.ceil(remaining)}s)") - # Schedule next update in 0.1s for precise timing + # Schedule next update in 0.1s for precise tracking self._timeout_alarm = loop.set_alarm_in(0.1, self._update_timer) def show(self):