Spaces:
Paused
Paused
| """ | |
| GUI Launcher Utilities | |
| Helper functions and widgets for the CustomTkinter GUI. | |
| """ | |
| import platform | |
| import time | |
| from typing import Any, Callable, List, Optional | |
| import customtkinter as ctk | |
| from .config import COLORS, DIMENSIONS, FONTS | |
| from .i18n import get_text | |
| # ============================================================================= | |
| # Mouse Wheel Scrolling Support | |
| # ============================================================================= | |
| def bind_mousewheel(widget: Any, target_scrollable: Optional[Any] = None) -> None: | |
| """ | |
| Bind mouse wheel events to a widget for scrolling. | |
| This enables scrolling with mouse wheel and trackpad gestures on all platforms. | |
| Args: | |
| widget: The widget to bind events to | |
| target_scrollable: The scrollable frame to scroll (if None, uses widget) | |
| """ | |
| target = target_scrollable or widget | |
| def _on_mousewheel(event): | |
| """Handle mouse wheel on Windows/macOS.""" | |
| if hasattr(target, "_parent_canvas"): | |
| canvas = target._parent_canvas | |
| if platform.system() == "Darwin": | |
| # macOS - trackpad and mouse wheel | |
| canvas.yview_scroll(int(-1 * event.delta), "units") | |
| else: | |
| # Windows | |
| canvas.yview_scroll(int(-1 * (event.delta / 120)), "units") | |
| def _on_mousewheel_linux(event): | |
| """Handle mouse wheel on Linux.""" | |
| if hasattr(target, "_parent_canvas"): | |
| canvas = target._parent_canvas | |
| if event.num == 4: | |
| canvas.yview_scroll(-1, "units") | |
| elif event.num == 5: | |
| canvas.yview_scroll(1, "units") | |
| # Bind based on platform | |
| if platform.system() == "Linux": | |
| widget.bind("<Button-4>", _on_mousewheel_linux, add="+") | |
| widget.bind("<Button-5>", _on_mousewheel_linux, add="+") | |
| else: | |
| widget.bind("<MouseWheel>", _on_mousewheel, add="+") | |
| # Also bind to children | |
| _bind_children_mousewheel(widget, target) | |
| def _bind_children_mousewheel(widget: Any, target: Any) -> None: | |
| """Recursively bind mouse wheel events to all children.""" | |
| for child in widget.winfo_children(): | |
| if platform.system() == "Linux": | |
| child.bind( | |
| "<Button-4>", lambda e, t=target: _scroll_linux(e, t, -1), add="+" | |
| ) | |
| child.bind( | |
| "<Button-5>", lambda e, t=target: _scroll_linux(e, t, 1), add="+" | |
| ) | |
| else: | |
| child.bind("<MouseWheel>", lambda e, t=target: _scroll_other(e, t), add="+") | |
| _bind_children_mousewheel(child, target) | |
| def _scroll_linux(event, target, direction): | |
| """Scroll on Linux.""" | |
| if hasattr(target, "_parent_canvas"): | |
| target._parent_canvas.yview_scroll(direction, "units") | |
| def _scroll_other(event, target): | |
| """Scroll on Windows/macOS.""" | |
| if hasattr(target, "_parent_canvas"): | |
| if platform.system() == "Darwin": | |
| target._parent_canvas.yview_scroll(int(-1 * event.delta), "units") | |
| else: | |
| target._parent_canvas.yview_scroll(int(-1 * (event.delta / 120)), "units") | |
| class CTkTooltip: | |
| """ | |
| Modern tooltip widget for CustomTkinter. | |
| Shows a tooltip on hover with smooth appearance. | |
| Usage: | |
| CTkTooltip(widget, "tooltip_key") | |
| """ | |
| def __init__(self, widget: ctk.CTkBaseClass, text_key: str): | |
| self.widget = widget | |
| self.text_key = text_key | |
| self.tooltip_window: Optional[ctk.CTkToplevel] = None | |
| self._scheduled_id = None | |
| widget.bind("<Enter>", self._schedule_show) | |
| widget.bind("<Leave>", self._hide) | |
| widget.bind("<ButtonPress>", self._hide) | |
| def _schedule_show(self, event=None) -> None: | |
| """Schedule tooltip to appear after delay.""" | |
| self._cancel_schedule() | |
| self._scheduled_id = self.widget.after(500, self._show) | |
| def _cancel_schedule(self) -> None: | |
| """Cancel scheduled tooltip.""" | |
| if self._scheduled_id: | |
| self.widget.after_cancel(self._scheduled_id) | |
| self._scheduled_id = None | |
| def _show(self) -> None: | |
| """Show the tooltip.""" | |
| if self.tooltip_window: | |
| return | |
| x = self.widget.winfo_rootx() + 20 | |
| y = self.widget.winfo_rooty() + self.widget.winfo_height() + 5 | |
| self.tooltip_window = ctk.CTkToplevel(self.widget) | |
| self.tooltip_window.wm_overrideredirect(True) | |
| self.tooltip_window.wm_geometry(f"+{x}+{y}") | |
| # Remove window decorations and make it float | |
| self.tooltip_window.attributes("-topmost", True) | |
| # Tooltip frame with rounded corners | |
| frame = ctk.CTkFrame( | |
| self.tooltip_window, | |
| fg_color=COLORS["bg_medium"], | |
| corner_radius=DIMENSIONS["corner_radius_small"], | |
| border_width=1, | |
| border_color=COLORS["border"], | |
| ) | |
| frame.pack(fill="both", expand=True) | |
| label = ctk.CTkLabel( | |
| frame, | |
| text=get_text(self.text_key), | |
| font=ctk.CTkFont(family=FONTS["family"], size=FONTS["size_small"]), | |
| text_color=COLORS["text_primary"], | |
| wraplength=300, | |
| ) | |
| label.pack(padx=10, pady=6) | |
| def _hide(self, event=None) -> None: | |
| """Hide the tooltip.""" | |
| self._cancel_schedule() | |
| if self.tooltip_window: | |
| self.tooltip_window.destroy() | |
| self.tooltip_window = None | |
| # Alias for backwards compatibility | |
| Tooltip = CTkTooltip | |
| class CTkScrollableList(ctk.CTkScrollableFrame): | |
| """ | |
| A scrollable list widget with selectable items. | |
| Replaces the old tk.Listbox with a modern CTk implementation. | |
| Usage: | |
| sl = CTkScrollableList(parent) | |
| sl.add_item("Item 1") | |
| sl.add_item("Item 2", icon="📧") | |
| sl.bind_select(callback) | |
| """ | |
| def __init__(self, parent, height: int = 300, **kwargs): | |
| super().__init__( | |
| parent, | |
| height=height, | |
| fg_color=COLORS["bg_light"], | |
| corner_radius=DIMENSIONS["corner_radius"], | |
| border_width=1, | |
| border_color=COLORS["border"], | |
| **kwargs, | |
| ) | |
| self.items: List[ctk.CTkButton] = [] | |
| self.selected_index: Optional[int] = None | |
| self._on_select: Optional[Callable] = None | |
| self._on_double_click: Optional[Callable] = None | |
| # Enable mouse wheel scrolling | |
| self._bind_scroll_events() | |
| def add_item(self, text: str, icon: str = "") -> None: | |
| """Add an item to the list.""" | |
| display_text = f"{icon} {text}" if icon else text | |
| item = ctk.CTkButton( | |
| self, | |
| text=display_text, | |
| anchor="w", | |
| height=36, | |
| corner_radius=DIMENSIONS["corner_radius_small"], | |
| fg_color="transparent", | |
| hover_color=COLORS["bg_medium"], | |
| text_color=COLORS["text_primary"], | |
| font=ctk.CTkFont(family=FONTS["family"], size=FONTS["size_normal"]), | |
| command=lambda idx=len(self.items): self._select(idx), | |
| ) | |
| item.pack(fill="x", padx=4, pady=2) | |
| item.bind("<Double-1>", lambda e, idx=len(self.items): self._double_click(idx)) | |
| # Bind scroll events to new item | |
| self._bind_scroll_to_widget(item) | |
| self.items.append(item) | |
| def _bind_scroll_events(self) -> None: | |
| """Bind mouse wheel scroll events to this scrollable frame.""" | |
| self._bind_scroll_to_widget(self) | |
| def _bind_scroll_to_widget(self, widget) -> None: | |
| """Bind scroll events to a specific widget.""" | |
| if platform.system() == "Linux": | |
| widget.bind("<Button-4>", self._on_scroll_up, add="+") | |
| widget.bind("<Button-5>", self._on_scroll_down, add="+") | |
| else: | |
| widget.bind("<MouseWheel>", self._on_mousewheel, add="+") | |
| def _on_mousewheel(self, event) -> None: | |
| """Handle mouse wheel on Windows/macOS.""" | |
| if hasattr(self, "_parent_canvas") and self._parent_canvas: | |
| if platform.system() == "Darwin": | |
| self._parent_canvas.yview_scroll(int(-1 * event.delta), "units") | |
| else: | |
| self._parent_canvas.yview_scroll(int(-1 * (event.delta / 120)), "units") | |
| def _on_scroll_up(self, event) -> None: | |
| """Handle scroll up on Linux.""" | |
| if hasattr(self, "_parent_canvas") and self._parent_canvas: | |
| self._parent_canvas.yview_scroll(-3, "units") | |
| def _on_scroll_down(self, event) -> None: | |
| """Handle scroll down on Linux.""" | |
| if hasattr(self, "_parent_canvas") and self._parent_canvas: | |
| self._parent_canvas.yview_scroll(3, "units") | |
| def clear(self) -> None: | |
| """Clear all items.""" | |
| for item in self.items: | |
| item.destroy() | |
| self.items.clear() | |
| self.selected_index = None | |
| def _select(self, index: int) -> None: | |
| """Handle item selection.""" | |
| # Deselect previous | |
| if self.selected_index is not None and self.selected_index < len(self.items): | |
| self.items[self.selected_index].configure( | |
| fg_color="transparent", | |
| text_color=COLORS["text_primary"], | |
| ) | |
| # Select new | |
| self.selected_index = index | |
| if index < len(self.items): | |
| self.items[index].configure( | |
| fg_color=COLORS["accent"], | |
| text_color=COLORS["text_on_color"], # White text on colored background | |
| ) | |
| if self._on_select: | |
| self._on_select(index) | |
| def _double_click(self, index: int) -> None: | |
| """Handle double click.""" | |
| self._select(index) | |
| if self._on_double_click: | |
| self._on_double_click(index) | |
| def bind_select(self, callback: Callable[[int], None]) -> None: | |
| """Bind selection callback.""" | |
| self._on_select = callback | |
| def bind_double_click(self, callback: Callable[[int], None]) -> None: | |
| """Bind double-click callback.""" | |
| self._on_double_click = callback | |
| def get_selected(self) -> Optional[str]: | |
| """Get selected item text.""" | |
| if self.selected_index is not None and self.selected_index < len(self.items): | |
| text = self.items[self.selected_index].cget("text") | |
| # Remove icon prefix if present | |
| if " " in text: | |
| return text.split(" ", 1)[1] | |
| return text | |
| return None | |
| def get_selected_index(self) -> Optional[int]: | |
| """Get selected index.""" | |
| return self.selected_index | |
| def select(self, index: int) -> None: | |
| """Select item by index.""" | |
| if 0 <= index < len(self.items): | |
| self._select(index) | |
| def get_items(self) -> List[str]: | |
| """Get all item texts (without icons).""" | |
| result = [] | |
| for item in self.items: | |
| text = item.cget("text") | |
| if " " in text: | |
| result.append(text.split(" ", 1)[1]) | |
| else: | |
| result.append(text) | |
| return result | |
| # Backwards compatibility alias | |
| ScrollableListbox = CTkScrollableList | |
| class CTkStatusBar(ctk.CTkFrame): | |
| """ | |
| Modern status bar widget for the bottom of the window. | |
| Features: | |
| - Status text | |
| - Port display | |
| - Uptime counter | |
| Usage: | |
| sb = CTkStatusBar(root) | |
| sb.set_status("Ready") | |
| sb.set_port(2048) | |
| sb.start_uptime() | |
| """ | |
| def __init__(self, parent): | |
| super().__init__( | |
| parent, | |
| height=32, | |
| fg_color=COLORS["bg_medium"], | |
| corner_radius=0, | |
| ) | |
| # Prevent frame from shrinking | |
| self.pack_propagate(False) | |
| # Left - status text | |
| self.status_label = ctk.CTkLabel( | |
| self, | |
| text=get_text("statusbar_ready"), | |
| font=ctk.CTkFont(family=FONTS["family"], size=FONTS["size_small"]), | |
| text_color=COLORS["text_secondary"], | |
| ) | |
| self.status_label.pack(side="left", padx=15) | |
| # Right - port info | |
| self.port_label = ctk.CTkLabel( | |
| self, | |
| text="", | |
| font=ctk.CTkFont(family=FONTS["family"], size=FONTS["size_small"]), | |
| text_color=COLORS["text_secondary"], | |
| ) | |
| self.port_label.pack(side="right", padx=15) | |
| # Uptime (next to port) | |
| self.uptime_label = ctk.CTkLabel( | |
| self, | |
| text="", | |
| font=ctk.CTkFont(family=FONTS["family_mono"], size=FONTS["size_small"]), | |
| text_color=COLORS["success"], | |
| ) | |
| self.uptime_label.pack(side="right", padx=(0, 20)) | |
| # Uptime tracking | |
| self._start_time: Optional[float] = None | |
| self._uptime_job = None | |
| def set_status(self, status: str) -> None: | |
| """Set the status text.""" | |
| self.status_label.configure(text=status) | |
| def set_port(self, port: int) -> None: | |
| """Set the port display.""" | |
| self.port_label.configure(text=f"{get_text('statusbar_port')} {port}") | |
| def start_uptime(self) -> None: | |
| """Start the uptime counter.""" | |
| self._start_time = time.time() | |
| self._update_uptime() | |
| def stop_uptime(self) -> None: | |
| """Stop the uptime counter.""" | |
| if self._uptime_job: | |
| self.after_cancel(self._uptime_job) | |
| self._uptime_job = None | |
| self._start_time = None | |
| self.uptime_label.configure(text="") | |
| def _update_uptime(self) -> None: | |
| """Update the uptime display.""" | |
| if self._start_time: | |
| elapsed = int(time.time() - self._start_time) | |
| self.uptime_label.configure( | |
| text=f"{get_text('statusbar_uptime')} {format_uptime(elapsed)}" | |
| ) | |
| self._uptime_job = self.after(1000, self._update_uptime) | |
| # Backwards compatibility alias | |
| StatusBar = CTkStatusBar | |
| # ============================================================================= | |
| # Pure Utility Functions | |
| # ============================================================================= | |
| def validate_port(port_str: str) -> bool: | |
| """Validate that a string is a valid port number (1-65535).""" | |
| try: | |
| port = int(port_str) | |
| return 1 <= port <= 65535 | |
| except ValueError: | |
| return False | |
| def format_uptime(seconds: int) -> str: | |
| """Format seconds into HH:MM:SS or MM:SS.""" | |
| hours, remainder = divmod(seconds, 3600) | |
| minutes, secs = divmod(remainder, 60) | |
| if hours > 0: | |
| return f"{hours:02d}:{minutes:02d}:{secs:02d}" | |
| return f"{minutes:02d}:{secs:02d}" | |
| def copy_to_clipboard(root, text: str) -> None: | |
| """Copy text to system clipboard.""" | |
| root.clipboard_clear() | |
| root.clipboard_append(text) | |
| root.update() # Required for clipboard to persist | |