GUI only change.

This commit is contained in:
edschuy95 2026-01-22 14:16:31 -05:00
parent 21d6697963
commit 6b0396a4b0
4 changed files with 296 additions and 335 deletions

View file

@ -4,7 +4,7 @@
<SchemaVersion>2.0</SchemaVersion> <SchemaVersion>2.0</SchemaVersion>
<ProjectGuid>87ef45f3-101d-4df7-9909-5bfe733259e3</ProjectGuid> <ProjectGuid>87ef45f3-101d-4df7-9909-5bfe733259e3</ProjectGuid>
<ProjectHome>.</ProjectHome> <ProjectHome>.</ProjectHome>
<StartupFile>RA3MP3Playback.py</StartupFile> <StartupFile>RA3MP3Playback.pyw</StartupFile>
<SearchPath> <SearchPath>
</SearchPath> </SearchPath>
<WorkingDirectory>.</WorkingDirectory> <WorkingDirectory>.</WorkingDirectory>
@ -12,7 +12,8 @@
<Name>RA3MP3Playback</Name> <Name>RA3MP3Playback</Name>
<RootNamespace>RA3MP3Playback</RootNamespace> <RootNamespace>RA3MP3Playback</RootNamespace>
<LaunchProvider>Standard Python launcher</LaunchProvider> <LaunchProvider>Standard Python launcher</LaunchProvider>
<CommandLineArguments>+set fs_game arena</CommandLineArguments> <CommandLineArguments>
</CommandLineArguments>
<EnableNativeCodeDebugging>False</EnableNativeCodeDebugging> <EnableNativeCodeDebugging>False</EnableNativeCodeDebugging>
</PropertyGroup> </PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' "> <PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
@ -24,7 +25,7 @@
<EnableUnmanagedDebugging>false</EnableUnmanagedDebugging> <EnableUnmanagedDebugging>false</EnableUnmanagedDebugging>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<Compile Include="RA3MP3Playback.py" /> <Compile Include="RA3MP3Playback.pyw" />
<Compile Include="textmenu.py" /> <Compile Include="textmenu.py" />
</ItemGroup> </ItemGroup>
<Import Project="$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)\Python Tools\Microsoft.PythonTools.targets" /> <Import Project="$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)\Python Tools\Microsoft.PythonTools.targets" />

View file

@ -639,24 +639,29 @@ def main():
menu = TextMenu( menu = TextMenu(
items, items,
width=60, width=500,
height=20, height=400,
title="Quake III Arena mod loader menu", title="Quake III Arena mod loader menu",
border_fg="light red", border_fg="red",
border_bg="black", border_bg="black",
inside_fg="dark red", inside_fg="dark red",
inside_bg="black", inside_bg="black",
selected_fg="black", selected_fg="black",
selected_bg="light red", selected_bg="red",
timeout=10 timeout=10
) )
choice=menu.show() 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] chosen_mod = choice.split("\n", 1)[0]
if chosen_mod != None:
run_mod = ["+set", "fs_game", chosen_mod] run_mod = ["+set", "fs_game", chosen_mod]
#sys.exit(0)
if os.name == "nt": if os.name == "nt":
pty_proc = subprocess.Popen( pty_proc = subprocess.Popen(
[game_exe, "+set", "ttycon", "1"] + run_mod + sys.argv[1:], [game_exe, "+set", "ttycon", "1"] + run_mod + sys.argv[1:],
@ -713,76 +718,7 @@ def main():
pass pass
pygame.mixer.quit() 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 __name__ == "__main__":
if (os.name != "nt"):
if not has_terminal():
relaunch_in_terminal()
lock_fd, lock_path = acquire_single_instance("q3a_launcher.lock") lock_fd, lock_path = acquire_single_instance("q3a_launcher.lock")
if lock_fd is None: if lock_fd is None:
print("Another instance is already running.") print("Another instance is already running.")

View file

@ -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 pyinstaller --onefile --noconsole --name Quake3Arena --icon=quake3modern.ico --exclude-module unittest --exclude-module http --exclude-module pydoc --exclude-module doctest RA3MP3Playback.pyw

View file

@ -1,74 +1,11 @@
import urwid import tkinter as tk
import sys from tkinter import ttk
import os
import time import time
import threading
import queue
import math import math
from typing import List, Optional
# 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()
class TextMenu: 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", border_fg="white", border_bg="dark blue",
inside_fg="white", inside_bg="black", inside_fg="white", inside_bg="black",
selected_fg="black", selected_bg="light gray", selected_fg="black", selected_bg="light gray",
@ -80,213 +17,300 @@ class TextMenu:
self.selected = None self.selected = None
self.timeout = timeout self.timeout = timeout
self._start_time = None 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_time = 0
self.last_click_pos = (0, 0) # Store last click position (x, y) self.last_clicked_index = -1
self.last_clicked_index = -1 # Track which menu item was last clicked
self.double_click_threshold = 0.3 # seconds self.double_click_threshold = 0.3 # seconds
self.position_threshold = 5 # pixels tolerance for position matching 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): def _cancel_timeout(self):
if self._timeout_alarm is not None: if self._timeout_id is not None:
self.loop.remove_alarm(self._timeout_alarm) self.root.after_cancel(self._timeout_id)
self._timeout_alarm = None self._timeout_id = None
self.linebox.set_title(self.title) # restore original title # Hide the timer label if it exists
if hasattr(self, 'timer_frame') and self.timer_frame:
self.timer_frame.pack_forget()
# Timeout exit def _on_single_click(self, event):
def _timeout_exit(self, loop, user_data=None): now = time.time()
self.selected = None current_index = self.listbox.nearest(event.y)
raise urwid.ExitMainLoop()
# 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
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
# Update countdown in title, precise timing
def _update_timer(self, loop, user_data=None):
elapsed = time.time() - self._start_time elapsed = time.time() - self._start_time
remaining = self.timeout - elapsed remaining = self.timeout - elapsed
if remaining <= 0: if remaining <= 0:
self.linebox.set_title(self.title) self.selected = "errmenutimeout" # Return timeout indicator
self._timeout_exit(loop) self.root.quit()
return 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 # Schedule next update in 100ms for precise timing
self._timeout_alarm = loop.set_alarm_in(0.1, self._update_timer) 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('<Button-1>', self._on_single_click)
self.listbox.bind('<Double-Button-1>', self._on_double_click) # Proper double-click binding
self.root.bind('<Key>', self._on_key_press)
# Bind mouse wheel scrolling
self.root.bind('<MouseWheel>', self._on_mousewheel) # Windows
self.root.bind('<Button-4>', lambda e: self._on_mousewheel(e)) # Linux up
self.root.bind('<Button-5>', 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): def show(self):
"""Show menu and return selected item. Returns None if timeout expires.""" """Show menu and return selected item. Returns None if timeout expires."""
if not is_real_terminal(): # Create the GUI
return fallback_menu(self.choices, title=self.title, timeout=self.timeout) self._create_gui()
# Start countdown immediately # Start countdown immediately if timeout is set
if self.timeout is not None: if self.timeout is not None:
self._start_time = time.time() 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)
# 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
self.loop.run()
return self.selected 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}")