Compare commits

..

2 commits

Author SHA1 Message Date
f564cea776 New day, new build 2026-01-22 01:05:38 -05:00
d32b83f790 better 2026-01-21 23:07:16 -05:00
2 changed files with 219 additions and 66 deletions

View file

@ -93,23 +93,73 @@ ANSI_ESCAPE_RE = re.compile(
# ========================================================== # ==========================================================
# ===================== UTILITY FUNCTIONS ================= # ===================== 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): 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. 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) base = Path(base_path)
matches = []
# Always start with baseq3
results = ["baseq3\nQuake III Arena"]
if not base.is_dir(): if not base.is_dir():
return matches return results
for entry in base.iterdir(): for entry in base.iterdir():
if entry.is_dir(): if not entry.is_dir():
if any(f.is_file() and f.suffix.lower() == ".pk3" for f in entry.iterdir()): continue
matches.append(entry.name)
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): def acquire_single_instance(lockfile_base: str):
""" """
@ -579,36 +629,35 @@ def main():
watcher_thread = threading.Thread(target=track_watcher, daemon=True) watcher_thread = threading.Thread(target=track_watcher, daemon=True)
watcher_thread.start() watcher_thread.start()
# # # Launch quake process via subprocess chosen_mod = None
# pty_proc = subprocess.Popen( run_mod = []
# [game_exe, "+set", "ttycon", "1"] + sys.argv[1:],
# stdin=subprocess.PIPE, if "fs_game" not in sys.argv[1:]:
# stdout=subprocess.PIPE, game_path = Path(game_exe)
# stderr=subprocess.STDOUT, items = find_pk3_subfolders(game_path.parent)
# bufsize=0, # unbuffered
# universal_newlines=False)
items = ["Pee pee","Poo poo","Stinky caca peepee poopoo pants.","a","b","c","d"]
menu = TextMenu( menu = TextMenu(
items, items,
width=30, width=60,
height=5, height=20,
title="Choose your stink", title="Quake III Arena mod loader menu",
border_fg="dark gray", border_fg="light red",
border_bg="dark red", 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="dark red", selected_bg="light red",
timeout=10 timeout=10
) )
choice=menu.show() choice=menu.show()
chosen_mod = choice.split("\n", 1)[0]
print(f"You selected: {choice}") if chosen_mod != None:
run_mod = ["+set", "fs_game", chosen_mod]
if os.name == "nt": if os.name == "nt":
pty_proc = subprocess.Popen( 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, stdin=subprocess.PIPE,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, stderr=subprocess.STDOUT,
@ -620,7 +669,7 @@ def main():
master_fd, slave_fd = pty.openpty() master_fd, slave_fd = pty.openpty()
pty_proc = subprocess.Popen( 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, stdin=slave_fd,
stdout=slave_fd, stdout=slave_fd,
stderr=slave_fd, stderr=slave_fd,

View file

@ -83,18 +83,144 @@ class TextMenu:
self._timeout_alarm = None self._timeout_alarm = None
self.last_click_time = 0 self.last_click_time = 0
self.double_click_threshold = 0.5 # seconds 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
# Buttons with double-click support # 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 = [] self.menu_widgets = []
for c in choices: for i, c in enumerate(choices):
btn = urwid.Button(c) # Add a newline prefix for better spacing, then center the text
urwid.connect_signal(btn, 'click', self._double_click, c) spaced_text = '\n' + c # Add newline prefix
self.menu_widgets.append(urwid.AttrMap(btn, None, focus_map='selected')) centered_text = center_text(spaced_text, content_width)
btn = SelectableButton(centered_text)
btn._parent_menu = self # Reference to parent menu for timeout control
# Scrollable ListBox # 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.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') self.listbox = urwid.AttrMap(self.listbox, 'inside')
# Fix height # Fix height
@ -121,7 +247,7 @@ class TextMenu:
self.loop = urwid.MainLoop( self.loop = urwid.MainLoop(
self.frame, self.frame,
palette=self.palette, palette=self.palette,
unhandled_input=self._unhandled_input, #unhandled_input=self._unhandled_input,
handle_mouse=True handle_mouse=True
) )
@ -132,28 +258,6 @@ class TextMenu:
self._timeout_alarm = None self._timeout_alarm = None
self.linebox.set_title(self.title) # restore original title 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 # Timeout exit
def _timeout_exit(self, loop, user_data=None): def _timeout_exit(self, loop, user_data=None):
self.selected = None self.selected = None
@ -171,7 +275,7 @@ class TextMenu:
self.linebox.set_title(f"{self.title} ({math.ceil(remaining)}s)") 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) self._timeout_alarm = loop.set_alarm_in(0.1, self._update_timer)
def show(self): def show(self):