import tkinter as tk from tkinter import ttk import time import math import os from typing import List, Optional class TextMenu: def __init__(self, choices, width=500, height=400, 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_id = None # Store color parameters self.border_fg = border_fg self.border_bg = border_bg self.inside_fg = inside_fg self.inside_bg = inside_bg self.selected_fg = selected_fg self.selected_bg = selected_bg # GUI elements self.root = None self.listbox = None self.timer_label = None # Double-click tracking self.last_click_time = 0 self.last_clicked_index = -1 self.double_click_threshold = 0.3 # seconds self.position_threshold = 5 # pixels tolerance for position matching def _cancel_timeout(self): if self._timeout_id is not None: self.root.after_cancel(self._timeout_id) self._timeout_id = None # Hide the timer label if it exists if hasattr(self, 'timer_frame') and self.timer_frame: self.timer_frame.pack_forget() def _on_single_click(self, event): now = time.time() current_index = self.listbox.nearest(event.y) # Get current click position current_pos = (event.x, event.y) # Check if this is a double-click (same item, timing, and position match) is_same_item = (current_index == self.last_clicked_index) is_timely = (now - self.last_click_time < self.double_click_threshold) # For position check, we just need to make sure it's a reasonable click pos_match = True # For simplicity in GUI version, we'll trust the click position if is_same_item and is_timely and pos_match: # Double-click detected on the same item - return the selection # Convert " | " back to "\n" when returning original_choice = self.choices[current_index] self.selected = original_choice self._cancel_timeout() self.root.quit() # Close the GUI else: # Single click - focus the item and update tracking self.listbox.selection_clear(0, tk.END) self.listbox.selection_set(current_index) self.listbox.activate(current_index) self.last_clicked_index = current_index self.last_click_pos = current_pos self._cancel_timeout() # Cancel timeout on single click too self.last_click_time = now def _on_key_press(self, event): if event.keysym in ('Return', 'space'): # Enter or Space pressed - select focused item selection = self.listbox.curselection() if selection: index = selection[0] # Convert " | " back to "\n" when returning original_choice = self.choices[index] self.selected = original_choice self._cancel_timeout() self.root.quit() elif event.keysym == 'Escape': self.selected = None self._cancel_timeout() self.root.quit() elif event.keysym in ('Up', 'Down', 'Prior', 'Next'): # Cancel timeout for navigation keys self._cancel_timeout() def _on_mousewheel(self, event): """Handle mouse wheel scrolling - navigate through items like arrow keys""" # Cancel timeout when scrolling self._cancel_timeout() # Get current selection selection = self.listbox.curselection() if selection: current_index = selection[0] else: # If nothing is selected, start with the first item current_index = 0 self.listbox.selection_set(0) self.listbox.activate(0) return # Determine direction based on wheel delta if event.delta > 0: # Scrolling up new_index = max(0, current_index - 1) else: # Scrolling down new_index = min(len(self.choices) - 1, current_index + 1) # Update selection self.listbox.selection_clear(0, tk.END) self.listbox.selection_set(new_index) self.listbox.activate(new_index) def _update_timer(self): if self._timeout_id is None: return # Timer was canceled elapsed = time.time() - self._start_time remaining = self.timeout - elapsed if remaining <= 0: self.selected = "errmenutimeout" # Return timeout indicator self.root.quit() return # Update the timer label text if self.timer_label: self.timer_label.config(text=f"Time remaining: {math.ceil(remaining)}s") # Schedule next update in 100ms for precise timing self._timeout_id = self.root.after(100, self._update_timer) def _create_gui(self): # Create main window self.root = tk.Tk() self.root.title(self.title) # Get screen dimensions to center the window screen_width = self.root.winfo_screenwidth() screen_height = self.root.winfo_screenheight() # Calculate x and y coordinates for the window to be centered x = (screen_width // 2) - (self.width // 2) y = (screen_height // 2) - (self.height // 2) # Set the geometry with position self.root.geometry(f"{self.width}x{self.height}+{x}+{y}") self.root.minsize(self.width, self.height) # Set minimum size self.root.resizable(True, True) # Allow resizing if needed # Configure main window background color using border_bg self.root.configure(bg=self.border_bg) # Bind the window close button to quit the application properly self.root.protocol("WM_DELETE_WINDOW", lambda: self._on_window_close()) # Create main content frame using border_bg main_frame = tk.Frame(self.root, bg=self.border_bg) main_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) # Create frame for the list using inside_bg list_frame = tk.Frame(main_frame, bg=self.inside_bg) list_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=(5, 0)) # No bottom padding to leave room for timer # Create scrollbar scrollbar = tk.Scrollbar(list_frame) scrollbar.pack(side=tk.RIGHT, fill=tk.Y) # Create listbox with proper colors using the class parameters self.listbox = tk.Listbox( list_frame, yscrollcommand=scrollbar.set, selectbackground=self.selected_bg, # Selected item background selectforeground=self.selected_fg, # Selected item foreground background=self.inside_bg, # Background color foreground=self.inside_fg, # Foreground color font=('TkDefaultFont', 14), # Larger font size borderwidth=2, highlightthickness=4, activestyle='none', justify=tk.CENTER # Center the text ) # Add choices with " | " separator for spacing instead of \n for choice in self.choices: # Replace newlines with " | " for display display_choice = choice.replace('\n', ' | ') self.listbox.insert(tk.END, display_choice) # Pack with fill and expand to take up available space self.listbox.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) scrollbar.config(command=self.listbox.yview) # Create timer frame at the bottom using border_bg self.timer_frame = tk.Frame(main_frame, bg=self.border_bg) self.timer_frame.pack(fill=tk.X, padx=5, pady=(0, 5)) # Top padding 0 to connect with list, bottom padding for spacing # Create timer label using border_fg and border_bg self.timer_label = tk.Label( self.timer_frame, text="", bg=self.border_bg, # Border background fg=self.border_fg, # Border foreground font=('TkDefaultFont', 10), anchor='w' # Left justify ) self.timer_label.pack(side=tk.LEFT) # Bind events - use the proper double-click event self.listbox.bind('', self._on_single_click) self.listbox.bind('', self._on_double_click) # Proper double-click binding self.root.bind('', self._on_key_press) # Bind mouse wheel scrolling - platform specific if os.name == 'nt': # Windows self.root.bind('', self._on_mousewheel) else: # Linux/Unix self.root.bind('', lambda e: self._on_mousewheel_linux(-1)) # Scroll up self.root.bind('', lambda e: self._on_mousewheel_linux(1)) # Scroll down # Set first item as active if self.choices: self.listbox.selection_set(0) self.listbox.activate(0) self.listbox.focus_set() # This ensures the listbox has keyboard focus def _on_mousewheel_linux(self, direction): """Handle Linux mouse wheel events""" self._cancel_timeout() selection = self.listbox.curselection() if not selection: current_index = 0 self.listbox.selection_set(0) self.listbox.activate(0) return current_index = selection[0] if direction == -1: # Button-4 = scroll up new_index = max(0, current_index - 1) else: # Button-5 = scroll down new_index = min(len(self.choices) - 1, current_index + 1) self.listbox.selection_clear(0, tk.END) self.listbox.selection_set(new_index) self.listbox.activate(new_index) def _on_double_click(self, event): """Handle double-click event directly""" now = time.time() current_index = self.listbox.nearest(event.y) # Get current click position current_pos = (event.x, event.y) # Check if this is a double-click (same item, timing, and position match) is_same_item = (current_index == self.last_clicked_index) is_timely = (now - self.last_click_time < self.double_click_threshold) # For position check, we just need to make sure it's a reasonable click pos_match = True # For simplicity in GUI version, we'll trust the click position if is_same_item and is_timely and pos_match: # Double-click detected on the same item - return the selection # Convert " | " back to "\n" when returning original_choice = self.choices[current_index] self.selected = original_choice self._cancel_timeout() self.root.quit() # Close the GUI else: # This is a new double-click sequence, so treat as a single click self.listbox.selection_clear(0, tk.END) self.listbox.selection_set(current_index) self.listbox.activate(current_index) self.last_clicked_index = current_index self.last_click_pos = current_pos self._cancel_timeout() # Cancel timeout on click self.last_click_time = now def _on_window_close(self): """Handle window close button click""" self.selected = None # Return None only when closed without selection self._cancel_timeout() self.root.quit() def show(self): """Show menu and return selected item. Returns None if timeout expires.""" # Create the GUI self._create_gui() # Start countdown immediately if timeout is set if self.timeout is not None: self._start_time = time.time() # Show the initial timer value if self.timer_label: remaining = self.timeout self.timer_label.config(text=f"Time remaining: {math.ceil(remaining)}s") # Start the timer updates self._timeout_id = self.root.after(100, self._update_timer) # Run the GUI event loop self.root.mainloop() # CRITICAL: Destroy the window BEFORE returning try: if self.root: self.root.destroy() self.root = None except tk.TclError: pass return self.selected