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.last_click_pos = (0, 0) # Store last click position (x, y) self.last_clicked_index = -1 # Track which menu item was last clicked self.double_click_threshold = 0.3 # seconds 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 # 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() # 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 self._cancel_timeout() self.selected = choice_text # Use original text without prefix/newline raise urwid.ExitMainLoop() else: # 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 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 # Timeout already canceled in button's keypress method # But call it again to be safe and ensure consistency with click handler self._cancel_timeout() self.selected = choice_text # Use original text without prefix/newline raise urwid.ExitMainLoop() 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 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 tracking 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