GUI only change.
This commit is contained in:
parent
21d6697963
commit
6b0396a4b0
4 changed files with 296 additions and 335 deletions
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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.")
|
||||||
|
|
@ -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
|
||||||
502
textmenu.py
502
textmenu.py
|
|
@ -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)
|
def _cancel_timeout(self):
|
||||||
content_width = width - 5 # subtract 3 to account for borders and internal padding
|
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()
|
||||||
|
|
||||||
# Function to center text by adding spaces to the left only, supporting newlines
|
def _on_single_click(self, event):
|
||||||
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()
|
now = time.time()
|
||||||
|
current_index = self.listbox.nearest(event.y)
|
||||||
|
|
||||||
# Store mouse coordinates when this click handler runs
|
# Get current click position
|
||||||
current_pos = (getattr(self, 'mouse_x', 0), getattr(self, 'mouse_y', 0))
|
current_pos = (event.x, event.y)
|
||||||
|
|
||||||
# Check if this is a double-click (same item, timing, and position match)
|
# Check if this is a double-click (same item, timing, and position match)
|
||||||
is_same_item = (index == self.last_clicked_index)
|
is_same_item = (current_index == self.last_clicked_index)
|
||||||
is_timely = (now - self.last_click_time < self.double_click_threshold)
|
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)
|
# 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:
|
if is_same_item and is_timely and pos_match:
|
||||||
# Double-click detected on the same item - return the selection
|
# 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._cancel_timeout()
|
||||||
self.selected = choice_text # Use original text without prefix/newline
|
self.root.quit() # Close the GUI
|
||||||
raise urwid.ExitMainLoop()
|
|
||||||
else:
|
else:
|
||||||
# Single click or click on different item - just focus the item
|
# Single click - focus the item and update tracking
|
||||||
self._cancel_timeout()
|
self.listbox.selection_clear(0, tk.END)
|
||||||
self.last_clicked_index = index
|
self.listbox.selection_set(current_index)
|
||||||
|
self.listbox.activate(current_index)
|
||||||
|
self.last_clicked_index = current_index
|
||||||
self.last_click_pos = current_pos
|
self.last_click_pos = current_pos
|
||||||
self.list_walker.set_focus(index)
|
self._cancel_timeout() # Cancel timeout on single click too
|
||||||
|
|
||||||
self.last_click_time = now
|
self.last_click_time = now
|
||||||
return click_handler
|
|
||||||
|
|
||||||
def make_activate_handler(choice_text):
|
def _on_key_press(self, event):
|
||||||
def activate_handler(button):
|
if event.keysym in ('Return', 'space'):
|
||||||
# Handle enter/space activation
|
# Enter or Space pressed - select focused item
|
||||||
# Timeout already canceled in button's keypress method
|
selection = self.listbox.curselection()
|
||||||
# But call it again to be safe and ensure consistency with click handler
|
if selection:
|
||||||
|
index = selection[0]
|
||||||
|
# Convert " | " back to "\n" when returning
|
||||||
|
original_choice = self.choices[index]
|
||||||
|
self.selected = original_choice
|
||||||
self._cancel_timeout()
|
self._cancel_timeout()
|
||||||
self.selected = choice_text # Use original text without prefix/newline
|
self.root.quit()
|
||||||
raise urwid.ExitMainLoop()
|
elif event.keysym == 'Escape':
|
||||||
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
|
|
||||||
|
|
||||||
# Timeout exit
|
|
||||||
def _timeout_exit(self, loop, user_data=None):
|
|
||||||
self.selected = None
|
self.selected = None
|
||||||
raise urwid.ExitMainLoop()
|
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}")
|
||||||
Loading…
Add table
Add a link
Reference in a new issue