Compare commits
No commits in common. "f564cea776b9c63dec68f4ca39aab7583e04949f" and "aa782f3080f1c4129c838a31ee04237027d33256" have entirely different histories.
f564cea776
...
aa782f3080
2 changed files with 66 additions and 219 deletions
|
|
@ -93,73 +93,23 @@ 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 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.
|
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 results
|
return matches
|
||||||
|
|
||||||
for entry in base.iterdir():
|
for entry in base.iterdir():
|
||||||
if not entry.is_dir():
|
if entry.is_dir():
|
||||||
continue
|
if any(f.is_file() and f.suffix.lower() == ".pk3" for f in entry.iterdir()):
|
||||||
|
matches.append(entry.name)
|
||||||
|
|
||||||
folder_name = entry.name
|
return matches
|
||||||
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):
|
||||||
"""
|
"""
|
||||||
|
|
@ -629,35 +579,36 @@ 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()
|
||||||
|
|
||||||
chosen_mod = None
|
# # # Launch quake process via subprocess
|
||||||
run_mod = []
|
# pty_proc = subprocess.Popen(
|
||||||
|
# [game_exe, "+set", "ttycon", "1"] + sys.argv[1:],
|
||||||
if "fs_game" not in sys.argv[1:]:
|
# stdin=subprocess.PIPE,
|
||||||
game_path = Path(game_exe)
|
# stdout=subprocess.PIPE,
|
||||||
items = find_pk3_subfolders(game_path.parent)
|
# stderr=subprocess.STDOUT,
|
||||||
|
# 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=60,
|
width=30,
|
||||||
height=20,
|
height=5,
|
||||||
title="Quake III Arena mod loader menu",
|
title="Choose your stink",
|
||||||
border_fg="light red",
|
border_fg="dark gray",
|
||||||
border_bg="black",
|
border_bg="dark red",
|
||||||
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="dark red",
|
||||||
timeout=10
|
timeout=10
|
||||||
)
|
)
|
||||||
choice=menu.show()
|
choice=menu.show()
|
||||||
chosen_mod = choice.split("\n", 1)[0]
|
|
||||||
|
|
||||||
if chosen_mod != None:
|
print(f"You selected: {choice}")
|
||||||
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"] + run_mod + sys.argv[1:],
|
[game_exe, "+set", "ttycon", "1"] + sys.argv[1:],
|
||||||
stdin=subprocess.PIPE,
|
stdin=subprocess.PIPE,
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.STDOUT,
|
stderr=subprocess.STDOUT,
|
||||||
|
|
@ -669,7 +620,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"] + run_mod + sys.argv[1:],
|
[game_exe, "+set", "ttycon", "1"] + sys.argv[1:],
|
||||||
stdin=slave_fd,
|
stdin=slave_fd,
|
||||||
stdout=slave_fd,
|
stdout=slave_fd,
|
||||||
stderr=slave_fd,
|
stderr=slave_fd,
|
||||||
|
|
|
||||||
168
textmenu.py
168
textmenu.py
|
|
@ -83,144 +83,18 @@ class TextMenu:
|
||||||
self._timeout_alarm = None
|
self._timeout_alarm = None
|
||||||
|
|
||||||
self.last_click_time = 0
|
self.last_click_time = 0
|
||||||
self.last_click_pos = (0, 0) # Store last click position (x, y)
|
self.double_click_threshold = 0.5 # seconds
|
||||||
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)
|
# Buttons with double-click support
|
||||||
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 i, c in enumerate(choices):
|
for c in choices:
|
||||||
# Add a newline prefix for better spacing, then center the text
|
btn = urwid.Button(c)
|
||||||
spaced_text = '\n' + c # Add newline prefix
|
urwid.connect_signal(btn, 'click', self._double_click, c)
|
||||||
centered_text = center_text(spaced_text, content_width)
|
self.menu_widgets.append(urwid.AttrMap(btn, None, focus_map='selected'))
|
||||||
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
|
# Scrollable ListBox
|
||||||
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 = TimedListBox(self.list_walker)
|
self.listbox = urwid.ListBox(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
|
||||||
|
|
@ -247,7 +121,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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -258,6 +132,28 @@ 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
|
||||||
|
|
@ -275,7 +171,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 tracking
|
# Schedule next update in 0.1s for precise timing
|
||||||
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):
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue