2026-01-21 22:06:07 -05:00
|
|
|
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
|
2026-01-22 01:05:38 -05:00
|
|
|
self.last_click_pos = (0, 0) # Store last click position (x, y)
|
|
|
|
|
self.last_clicked_index = -1 # Track which menu item was last clicked
|
2026-01-21 23:07:16 -05:00
|
|
|
self.double_click_threshold = 0.3 # seconds
|
2026-01-22 01:05:38 -05:00
|
|
|
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
|
2026-01-21 23:07:16 -05:00
|
|
|
|
|
|
|
|
# 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
|
2026-01-22 01:05:38 -05:00
|
|
|
# Also cancel timeout here to ensure immediate response
|
|
|
|
|
self._parent_menu._cancel_timeout()
|
2026-01-21 23:07:16 -05:00
|
|
|
self._emit('activate')
|
|
|
|
|
return None
|
|
|
|
|
return super().keypress(size, key)
|
|
|
|
|
|
|
|
|
|
# Buttons with proper click handling
|
2026-01-21 22:06:07 -05:00
|
|
|
self.menu_widgets = []
|
2026-01-21 23:07:16 -05:00
|
|
|
for i, c in enumerate(choices):
|
2026-01-22 01:05:38 -05:00
|
|
|
# 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
|
2026-01-21 23:07:16 -05:00
|
|
|
|
|
|
|
|
def make_click_handler(choice_text, index):
|
|
|
|
|
def click_handler(button):
|
|
|
|
|
# Handle mouse clicks with double-click detection
|
|
|
|
|
now = time.time()
|
|
|
|
|
|
2026-01-22 01:05:38 -05:00
|
|
|
# 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
|
2026-01-21 23:07:16 -05:00
|
|
|
self._cancel_timeout()
|
2026-01-22 01:05:38 -05:00
|
|
|
self.selected = choice_text # Use original text without prefix/newline
|
2026-01-21 23:07:16 -05:00
|
|
|
raise urwid.ExitMainLoop()
|
|
|
|
|
else:
|
2026-01-22 01:05:38 -05:00
|
|
|
# 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
|
2026-01-21 23:07:16 -05:00
|
|
|
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
|
2026-01-22 01:05:38 -05:00
|
|
|
# Timeout already canceled in button's keypress method
|
|
|
|
|
# But call it again to be safe and ensure consistency with click handler
|
2026-01-21 23:07:16 -05:00
|
|
|
self._cancel_timeout()
|
2026-01-22 01:05:38 -05:00
|
|
|
self.selected = choice_text # Use original text without prefix/newline
|
2026-01-21 23:07:16 -05:00
|
|
|
raise urwid.ExitMainLoop()
|
|
|
|
|
return activate_handler
|
|
|
|
|
|
2026-01-22 01:05:38 -05:00
|
|
|
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)
|
2026-01-21 22:06:07 -05:00
|
|
|
|
2026-01-22 01:05:38 -05:00
|
|
|
# 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
|
2026-01-21 22:06:07 -05:00
|
|
|
self.list_walker = urwid.SimpleFocusListWalker(self.menu_widgets)
|
2026-01-22 01:05:38 -05:00
|
|
|
self.listbox = TimedListBox(self.list_walker)
|
|
|
|
|
self.listbox.parent_obj = self # Provide access to parent methods
|
2026-01-21 22:06:07 -05:00
|
|
|
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,
|
2026-01-22 01:05:38 -05:00
|
|
|
#unhandled_input=self._unhandled_input,
|
2026-01-21 22:06:07 -05:00
|
|
|
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
|
|
|
|
|
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)")
|
|
|
|
|
|
2026-01-22 01:05:38 -05:00
|
|
|
# Schedule next update in 0.1s for precise tracking
|
2026-01-21 22:06:07 -05:00
|
|
|
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()
|
2026-01-21 23:07:16 -05:00
|
|
|
return self.selected
|