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.3 # seconds # 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 self._emit('activate') return None return super().keypress(size, key) # Buttons with proper click handling self.menu_widgets = [] for i, c in enumerate(choices): btn = SelectableButton(c) def make_click_handler(choice_text, index): def click_handler(button): # Handle mouse clicks with double-click detection now = time.time() # Check if this is a double-click if now - self.last_click_time < self.double_click_threshold: # Double-click detected - return the selection self._cancel_timeout() self.selected = choice_text raise urwid.ExitMainLoop() else: # Single click - just focus the item (like keyboard navigation) 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 self._cancel_timeout() self.selected = choice_text raise urwid.ExitMainLoop() return activate_handler urwid.connect_signal(btn, 'click', make_click_handler(c, i)) urwid.connect_signal(btn, 'activate', make_activate_handler(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 # Keyboard or mouse input - handle ALL input types def _unhandled_input(self, key): # Cancel timeout on ANY input self._cancel_timeout() # Handle Enter key immediately (like double-click behavior) if key in ('enter', ' '): focus_widget, _ = self.listbox.get_focus() if focus_widget: # Get the button text from the nested widgets btn_text = focus_widget.original_widget.base_widget.get_label() self.selected = btn_text raise urwid.ExitMainLoop() elif key == 'esc': self.selected = None raise urwid.ExitMainLoop() # Handle navigation keys that should stop timer but not select elif key in ('up', 'down', 'page up', 'page down'): # Timer already cancelled above, just continue # Navigation already changes focus automatically pass # Handle mouse events that should stop timer elif isinstance(key, tuple) and len(key) >= 3 and key[0].startswith('mouse'): # This catches mouse events like ('mouse press', 1, x, y) # Timer already cancelled above, continue with normal processing pass # 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