saving before having gemini take a crack at fixing things.
This commit is contained in:
parent
a9ac39357f
commit
aa782f3080
3 changed files with 224 additions and 12 deletions
188
textmenu.py
Normal file
188
textmenu.py
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
import urwid
|
||||
import sys
|
||||
import os
|
||||
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()
|
||||
|
||||
class TextMenu:
|
||||
def __init__(self, choices, width=40, height=10, title="Menu",
|
||||
border_fg="white", border_bg="dark blue",
|
||||
inside_fg="white", inside_bg="black",
|
||||
selected_fg="black", selected_bg="light gray",
|
||||
timeout=None):
|
||||
self.choices = choices
|
||||
self.width = width
|
||||
self.height = height
|
||||
self.title = title
|
||||
self.selected = None
|
||||
self.timeout = timeout
|
||||
self._start_time = None
|
||||
self._timeout_alarm = None
|
||||
|
||||
self.last_click_time = 0
|
||||
self.double_click_threshold = 0.5 # seconds
|
||||
|
||||
# Buttons with double-click support
|
||||
self.menu_widgets = []
|
||||
for c in choices:
|
||||
btn = urwid.Button(c)
|
||||
urwid.connect_signal(btn, 'click', self._double_click, c)
|
||||
self.menu_widgets.append(urwid.AttrMap(btn, None, focus_map='selected'))
|
||||
|
||||
# Scrollable ListBox
|
||||
self.list_walker = urwid.SimpleFocusListWalker(self.menu_widgets)
|
||||
self.listbox = urwid.ListBox(self.list_walker)
|
||||
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
|
||||
|
||||
# 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
|
||||
def _timeout_exit(self, loop, user_data=None):
|
||||
self.selected = None
|
||||
raise urwid.ExitMainLoop()
|
||||
|
||||
# Update countdown in title, precise timing
|
||||
def _update_timer(self, loop, user_data=None):
|
||||
elapsed = time.time() - self._start_time
|
||||
remaining = self.timeout - elapsed
|
||||
|
||||
if remaining <= 0:
|
||||
self.linebox.set_title(self.title)
|
||||
self._timeout_exit(loop)
|
||||
return
|
||||
|
||||
self.linebox.set_title(f"{self.title} ({math.ceil(remaining)}s)")
|
||||
|
||||
# Schedule next update in 0.1s for precise timing
|
||||
self._timeout_alarm = loop.set_alarm_in(0.1, self._update_timer)
|
||||
|
||||
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
|
||||
if self.timeout is not None:
|
||||
self._start_time = time.time()
|
||||
self._update_timer(self.loop) # show countdown immediately
|
||||
|
||||
self.loop.run()
|
||||
return self.selected
|
||||
Loading…
Add table
Add a link
Reference in a new issue