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