2026-01-22 14:16:31 -05:00
|
|
|
import tkinter as tk
|
|
|
|
|
from tkinter import ttk
|
2026-01-21 22:06:07 -05:00
|
|
|
import time
|
|
|
|
|
import math
|
2026-01-22 19:37:54 -05:00
|
|
|
import os
|
2026-01-22 14:16:31 -05:00
|
|
|
from typing import List, Optional
|
2026-01-21 22:06:07 -05:00
|
|
|
|
|
|
|
|
class TextMenu:
|
2026-01-22 14:16:31 -05:00
|
|
|
def __init__(self, choices, width=500, height=400, title="Menu",
|
2026-01-21 22:06:07 -05:00
|
|
|
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
|
2026-01-22 14:16:31 -05:00
|
|
|
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
|
2026-01-21 22:06:07 -05:00
|
|
|
|
2026-01-22 14:16:31 -05:00
|
|
|
# Double-click tracking
|
2026-01-21 22:06:07 -05:00
|
|
|
self.last_click_time = 0
|
2026-01-22 14:16:31 -05:00
|
|
|
self.last_clicked_index = -1
|
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
|
|
|
|
|
|
2026-01-22 14:16:31 -05:00
|
|
|
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()
|
2026-01-21 23:07:16 -05:00
|
|
|
|
2026-01-22 14:16:31 -05:00
|
|
|
def _on_single_click(self, event):
|
|
|
|
|
now = time.time()
|
|
|
|
|
current_index = self.listbox.nearest(event.y)
|
2026-01-21 23:07:16 -05:00
|
|
|
|
2026-01-22 14:16:31 -05:00
|
|
|
# Get current click position
|
|
|
|
|
current_pos = (event.x, event.y)
|
2026-01-22 01:05:38 -05:00
|
|
|
|
2026-01-22 14:16:31 -05:00
|
|
|
# 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
|
2026-01-21 22:06:07 -05:00
|
|
|
|
2026-01-22 14:16:31 -05:00
|
|
|
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()
|
2026-01-21 22:06:07 -05:00
|
|
|
|
2026-01-22 14:16:31 -05:00
|
|
|
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
|
2026-01-21 22:06:07 -05:00
|
|
|
|
2026-01-22 14:16:31 -05:00
|
|
|
# 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)
|
2026-01-21 22:06:07 -05:00
|
|
|
|
2026-01-22 14:16:31 -05:00
|
|
|
def _update_timer(self):
|
|
|
|
|
if self._timeout_id is None:
|
|
|
|
|
return # Timer was canceled
|
|
|
|
|
|
2026-01-21 22:06:07 -05:00
|
|
|
elapsed = time.time() - self._start_time
|
|
|
|
|
remaining = self.timeout - elapsed
|
|
|
|
|
|
|
|
|
|
if remaining <= 0:
|
2026-01-22 14:16:31 -05:00
|
|
|
self.selected = "errmenutimeout" # Return timeout indicator
|
|
|
|
|
self.root.quit()
|
2026-01-21 22:06:07 -05:00
|
|
|
return
|
|
|
|
|
|
2026-01-22 14:16:31 -05:00
|
|
|
# Update the timer label text
|
|
|
|
|
if self.timer_label:
|
|
|
|
|
self.timer_label.config(text=f"Time remaining: {math.ceil(remaining)}s")
|
2026-01-21 22:06:07 -05:00
|
|
|
|
2026-01-22 14:16:31 -05:00
|
|
|
# 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
|
2026-01-22 16:25:27 -05:00
|
|
|
font=('TkDefaultFont', 14), # Larger font size
|
|
|
|
|
borderwidth=2,
|
|
|
|
|
highlightthickness=4,
|
2026-01-22 14:16:31 -05:00
|
|
|
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)
|
2026-01-22 22:05:31 -05:00
|
|
|
|
|
|
|
|
# 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
|
2026-01-22 14:16:31 -05:00
|
|
|
|
|
|
|
|
# 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('<Button-1>', self._on_single_click)
|
|
|
|
|
self.listbox.bind('<Double-Button-1>', self._on_double_click) # Proper double-click binding
|
|
|
|
|
self.root.bind('<Key>', self._on_key_press)
|
|
|
|
|
|
2026-01-22 19:37:54 -05:00
|
|
|
# Bind mouse wheel scrolling - platform specific
|
|
|
|
|
if os.name == 'nt': # Windows
|
|
|
|
|
self.root.bind('<MouseWheel>', self._on_mousewheel)
|
|
|
|
|
else: # Linux/Unix
|
|
|
|
|
self.root.bind('<Button-4>', lambda e: self._on_mousewheel_linux(-1)) # Scroll up
|
|
|
|
|
self.root.bind('<Button-5>', lambda e: self._on_mousewheel_linux(1)) # Scroll down
|
2026-01-22 14:16:31 -05:00
|
|
|
|
2026-01-22 19:37:54 -05:00
|
|
|
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)
|
2026-01-22 14:16:31 -05:00
|
|
|
|
|
|
|
|
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()
|
2026-01-21 22:06:07 -05:00
|
|
|
|
|
|
|
|
def show(self):
|
|
|
|
|
"""Show menu and return selected item. Returns None if timeout expires."""
|
2026-01-22 14:16:31 -05:00
|
|
|
# Create the GUI
|
|
|
|
|
self._create_gui()
|
|
|
|
|
|
|
|
|
|
# Start countdown immediately if timeout is set
|
2026-01-21 22:06:07 -05:00
|
|
|
if self.timeout is not None:
|
|
|
|
|
self._start_time = time.time()
|
2026-01-22 14:16:31 -05:00
|
|
|
# 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()
|
|
|
|
|
|
2026-01-22 19:37:54 -05:00
|
|
|
# CRITICAL: Destroy the window BEFORE returning
|
2026-01-22 15:06:25 -05:00
|
|
|
try:
|
2026-01-22 19:37:54 -05:00
|
|
|
if self.root:
|
2026-01-22 14:16:31 -05:00
|
|
|
self.root.destroy()
|
2026-01-22 19:37:54 -05:00
|
|
|
self.root = None
|
2026-01-22 15:06:25 -05:00
|
|
|
except tk.TclError:
|
|
|
|
|
pass
|
2026-01-22 14:16:31 -05:00
|
|
|
|
2026-01-22 15:06:25 -05:00
|
|
|
return self.selected
|