diff --git a/RA3MP3Playback.py b/RA3MP3Playback.py index 8555cdf..9b96512 100644 --- a/RA3MP3Playback.py +++ b/RA3MP3Playback.py @@ -93,73 +93,23 @@ 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 strings for each subfolder under base_path that contains + Returns a list of subfolder names under base_path that contain 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) - - # Always start with baseq3 - results = ["baseq3\nQuake III Arena"] + matches = [] if not base.is_dir(): - return results + return matches for entry in base.iterdir(): - if not entry.is_dir(): - continue + if entry.is_dir(): + if any(f.is_file() and f.suffix.lower() == ".pk3" for f in entry.iterdir()): + matches.append(entry.name) - 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 + return matches def acquire_single_instance(lockfile_base: str): """ @@ -629,35 +579,36 @@ def main(): watcher_thread = threading.Thread(target=track_watcher, daemon=True) watcher_thread.start() - chosen_mod = None - run_mod = [] + # # # 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) - if "fs_game" not in sys.argv[1:]: - game_path = Path(game_exe) - items = find_pk3_subfolders(game_path.parent) + items = ["Pee pee","Poo poo","Stinky caca peepee poopoo pants.","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() - 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] + print(f"You selected: {choice}") if os.name == "nt": pty_proc = subprocess.Popen( - [game_exe, "+set", "ttycon", "1"] + run_mod + sys.argv[1:], + [game_exe, "+set", "ttycon", "1"] + sys.argv[1:], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, @@ -669,7 +620,7 @@ def main(): master_fd, slave_fd = pty.openpty() pty_proc = subprocess.Popen( - [game_exe, "+set", "ttycon", "1"] + run_mod + sys.argv[1:], + [game_exe, "+set", "ttycon", "1"] + sys.argv[1:], stdin=slave_fd, stdout=slave_fd, stderr=slave_fd, diff --git a/textmenu.py b/textmenu.py index 1ad8340..63e6e85 100644 --- a/textmenu.py +++ b/textmenu.py @@ -83,144 +83,18 @@ 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 + self.double_click_threshold = 0.5 # seconds - # 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 + # Buttons with double-click support 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) + for c in choices: + btn = urwid.Button(c) + urwid.connect_signal(btn, 'click', self._double_click, c) + self.menu_widgets.append(urwid.AttrMap(btn, None, focus_map='selected')) - # 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 + # Scrollable ListBox 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.ListBox(self.list_walker) self.listbox = urwid.AttrMap(self.listbox, 'inside') # Fix height @@ -247,7 +121,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 ) @@ -258,6 +132,28 @@ class TextMenu: self._timeout_alarm = None self.linebox.set_title(self.title) # restore original title + # Mouse double-click + def _double_click(self, button, choice_text): + self._cancel_timeout() + now = time.time() + if now - self.last_click_time < self.double_click_threshold: + self.selected = choice_text + raise urwid.ExitMainLoop() + self.last_click_time = now + + # Keyboard or mouse input + def _unhandled_input(self, key): + # Cancel timeout on any input + self._cancel_timeout() + + if key == 'enter': + focus_widget, _ = self.listbox.get_focus() + if focus_widget: + self.selected = focus_widget.base_widget.get_label() + raise urwid.ExitMainLoop() + # Scroll events also count: 'up', 'down', 'page up', 'page down', 'mouse press', 'mouse drag', 'mouse wheel' + # urwid passes these to unhandled_input automatically, so we cancel timeout above + # Timeout exit def _timeout_exit(self, loop, user_data=None): self.selected = None @@ -275,7 +171,7 @@ class TextMenu: self.linebox.set_title(f"{self.title} ({math.ceil(remaining)}s)") - # Schedule next update in 0.1s for precise tracking + # Schedule next update in 0.1s for precise timing self._timeout_alarm = loop.set_alarm_in(0.1, self._update_timer) def show(self): @@ -289,4 +185,4 @@ class TextMenu: self._update_timer(self.loop) # show countdown immediately self.loop.run() - return self.selected \ No newline at end of file + return self.selected