Source code for frontend.ui_dialogs.scrollable_frame

"""Reusable scrollable frame widget with cross-platform mousewheel and keyboard support."""

from __future__ import annotations

import tkinter as tk
from tkinter import ttk

_REFRESH_DELAY_MS = 50


[docs] class ScrollableFrame(ttk.Frame): """A frame that wraps a Canvas + Scrollbar + inner Frame. Children should be packed/gridded inside ``self.inner``. Args: parent: Parent widget. **kwargs: Extra keyword arguments forwarded to the outer ``ttk.Frame``. """ def __init__(self, parent: tk.Widget, **kwargs) -> None: # type: ignore[type-arg] super().__init__(parent, **kwargs) self._canvas = tk.Canvas(self, highlightthickness=0) self._scrollbar = ttk.Scrollbar(self, orient=tk.VERTICAL, command=self._canvas.yview) self.inner = ttk.Frame(self._canvas) self._canvas_window = self._canvas.create_window( (0, 0), window=self.inner, anchor=tk.NW, ) self._canvas.configure(yscrollcommand=self._scrollbar.set) self._scrollbar.pack(side=tk.RIGHT, fill=tk.Y) self._canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) self.inner.bind("<Configure>", self._on_inner_configure) self._canvas.bind("<Configure>", self._on_canvas_configure) self._bind_mousewheel_recursive(self) self._pending_refresh = False
[docs] def apply_bg(self, bg: str) -> None: """Set the canvas background to match the theme. Args: bg: Background colour (hex or Tk colour name). """ self._canvas.configure(bg=bg)
[docs] def refresh_scroll_region(self) -> None: """Force-update the scroll region after dynamic content changes. Call after adding or removing widgets in ``self.inner``. """ self._canvas.update_idletasks() bbox = self._canvas.bbox("all") if bbox: self._canvas.configure(scrollregion=bbox)
def _schedule_refresh(self) -> None: """Coalesce multiple layout events into a single deferred update.""" if not self._pending_refresh: self._pending_refresh = True self._canvas.after(_REFRESH_DELAY_MS, self._do_refresh) def _do_refresh(self) -> None: self._pending_refresh = False if self._canvas.winfo_exists(): bbox = self._canvas.bbox("all") if bbox: self._canvas.configure(scrollregion=bbox) def _on_inner_configure(self, _event: tk.Event) -> None: # type: ignore[type-arg] self._schedule_refresh() def _on_canvas_configure(self, event: tk.Event) -> None: # type: ignore[type-arg] self._canvas.itemconfig(self._canvas_window, width=event.width) self._schedule_refresh() def _on_mousewheel(self, event: tk.Event) -> str: # type: ignore[type-arg] if self._canvas.winfo_exists(): if hasattr(event, "delta") and event.delta != 0: self._canvas.yview_scroll(int(-1 * (event.delta / 120)), "units") elif getattr(event, "num", 0) == 5: self._canvas.yview_scroll(1, "units") elif getattr(event, "num", 0) == 4: self._canvas.yview_scroll(-1, "units") return "break" def _bind_mousewheel_recursive(self, widget: tk.Widget) -> None: widget.bind("<MouseWheel>", self._on_mousewheel) widget.bind("<Button-4>", self._on_mousewheel) widget.bind("<Button-5>", self._on_mousewheel) for child in widget.winfo_children(): self._bind_mousewheel_recursive(child)
[docs] def bind_new_children(self) -> None: """Re-bind mousewheel on all descendants (call after adding widgets).""" self._bind_mousewheel_recursive(self)