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