Files
WebChecker/utils/ui_helpers.py
T
2026-04-21 17:16:37 -04:00

491 lines
17 KiB
Python

"""
utils/ui_helpers.py — Shared UI constants, theme helpers, and reusable widgets.
Key additions vs previous version:
• ThemeManager — singleton that owns the active palette and reapplies
ttk styles + rebuilds the app shell on toggle
• THEMES — dark and light colour palettes
• DateEntry — Entry + calendar popup (no third-party libs required)
• scrolled_text — now reads colours from ThemeManager so it re-themes
"""
import calendar
import tkinter as tk
from tkinter import ttk, messagebox
from datetime import date
# ─── Font Constants (theme-independent) ───────────────────────────────────────
FONT_FAMILY = "Segoe UI"
FONT = (FONT_FAMILY, 10)
FONT_BOLD = (FONT_FAMILY, 10, "bold")
FONT_TITLE = (FONT_FAMILY, 16, "bold")
FONT_HEADING = (FONT_FAMILY, 12, "bold")
FONT_SMALL = (FONT_FAMILY, 9)
# ─── Colour Palettes ──────────────────────────────────────────────────────────
THEMES = {
"dark": {
"bg": "#1e1e2e",
"surface": "#2a2a3e",
"surface2": "#313150",
"accent": "#7c6af7",
"accent_hover": "#9d8fff",
"danger": "#e05c5c",
"success": "#4caf50",
"warning": "#f0a500",
"text": "#cdd6f4",
"text_dim": "#6c7086",
"border": "#45475a",
"checked": "#4caf50",
"unchecked": "#e05c5c",
"white": "#ffffff",
"cal_header": "#313150",
"cal_weekend": "#e05c5c",
"cal_today": "#7c6af7",
"cal_selected": "#4caf50",
},
"light": {
"bg": "#f5f5fa",
"surface": "#ffffff",
"surface2": "#e8e8f0",
"accent": "#5b4de8",
"accent_hover": "#7c6af7",
"danger": "#d32f2f",
"success": "#388e3c",
"warning": "#f57c00",
"text": "#1a1a2e",
"text_dim": "#6b6b80",
"border": "#c5c5d8",
"checked": "#388e3c",
"unchecked": "#d32f2f",
"white": "#ffffff",
"cal_header": "#e0dff8",
"cal_weekend": "#d32f2f",
"cal_today": "#5b4de8",
"cal_selected": "#388e3c",
},
}
# ─── ThemeManager (singleton) ─────────────────────────────────────────────────
class ThemeManager:
_instance = None
def __init__(self, root, initial="dark"):
ThemeManager._instance = self
self._root = root
self._current = initial
self.apply()
@classmethod
def get(cls):
if cls._instance is None:
return THEMES["dark"]
return THEMES[cls._instance._current]
@property
def is_dark(self):
return self._current == "dark"
def toggle(self, rebuild_callback=None):
self._current = "light" if self._current == "dark" else "dark"
self.apply()
if rebuild_callback:
rebuild_callback()
def apply(self):
C = THEMES[self._current]
style = ttk.Style(self._root)
style.theme_use("clam")
style.configure(".",
background=C["bg"], foreground=C["text"],
fieldbackground=C["surface"], bordercolor=C["border"],
relief="flat", font=FONT)
style.configure("TFrame", background=C["bg"])
style.configure("Surface.TFrame", background=C["surface"])
style.configure("TLabel",
background=C["bg"], foreground=C["text"], font=FONT)
style.configure("Title.TLabel",
font=FONT_TITLE, foreground=C["accent"])
style.configure("Heading.TLabel",
font=FONT_HEADING, foreground=C["text"])
style.configure("Dim.TLabel",
foreground=C["text_dim"], font=FONT_SMALL)
style.configure("TEntry",
fieldbackground=C["surface2"], foreground=C["text"],
insertcolor=C["text"], bordercolor=C["border"],
relief="flat", padding=6)
style.configure("TCombobox",
fieldbackground=C["surface2"], foreground=C["text"],
selectbackground=C["accent"], selectforeground=C["white"])
style.map("TCombobox",
fieldbackground=[("readonly", C["surface2"])],
foreground=[("readonly", C["text"])])
style.configure("TButton",
background=C["accent"], foreground=C["white"],
padding=(12, 6), relief="flat", font=FONT_BOLD)
style.map("TButton",
background=[("active", C["accent_hover"])],
relief=[("active", "flat")])
style.configure("Danger.TButton",
background=C["danger"], foreground=C["white"])
style.map("Danger.TButton",
background=[("active", "#c94444" if self.is_dark else "#b71c1c")])
style.configure("Success.TButton",
background=C["success"], foreground=C["white"])
style.map("Success.TButton",
background=[("active", "#3d9140" if self.is_dark else "#2e7d32")])
style.configure("Ghost.TButton",
background=C["surface"], foreground=C["text"], relief="flat")
style.map("Ghost.TButton",
background=[("active", C["surface2"])])
style.configure("Treeview",
background=C["surface"], foreground=C["text"],
fieldbackground=C["surface"], rowheight=30, font=FONT)
style.configure("Treeview.Heading",
background=C["surface2"], foreground=C["accent"],
font=FONT_BOLD, relief="flat")
style.map("Treeview",
background=[("selected", C["accent"])],
foreground=[("selected", C["white"])])
style.configure("TNotebook",
background=C["bg"], bordercolor=C["border"])
style.configure("TNotebook.Tab",
background=C["surface"], foreground=C["text_dim"],
padding=(16, 8), font=FONT_BOLD)
style.map("TNotebook.Tab",
background=[("selected", C["accent"])],
foreground=[("selected", C["white"])])
style.configure("TScrollbar",
background=C["surface2"], troughcolor=C["bg"],
bordercolor=C["bg"], arrowcolor=C["text_dim"])
style.configure("TCheckbutton",
background=C["bg"], foreground=C["text"], font=FONT)
style.configure("TProgressbar",
troughcolor=C["surface2"], background=C["accent"])
style.configure("TSeparator", background=C["border"])
self._root.configure(bg=C["bg"])
# ─── Colour proxy — keeps `COLOURS["key"]` syntax working everywhere ──────────
class _ColourProxy(dict):
def __getitem__(self, key):
return ThemeManager.get()[key]
def get(self, key, default=None):
return ThemeManager.get().get(key, default)
COLOURS = _ColourProxy()
# ─── Legacy shim ──────────────────────────────────────────────────────────────
def apply_theme(root, mode="dark"):
"""Backwards-compatible shim. Prefer ThemeManager directly."""
ThemeManager(root, initial=mode)
# ─── DateEntry — Entry + calendar popup ───────────────────────────────────────
class DateEntry(tk.Frame):
"""
Themed date-picker: read-only Entry showing YYYY-MM-DD plus a calendar
button that opens a month-grid popup.
de = DateEntry(parent, initial_date="2025-04-20")
de.grid(row=0, column=1, sticky="ew")
value = de.get() # "2025-04-21"
de.set("2025-05-01")
"""
def __init__(self, parent, initial_date="", width=12, **kwargs):
C = ThemeManager.get()
super().__init__(parent, bg=C["bg"], **kwargs)
try:
self._date = date.fromisoformat(initial_date) if initial_date else date.today()
except ValueError:
self._date = date.today()
self._var = tk.StringVar(value=self._date.isoformat())
self._entry = tk.Entry(
self, textvariable=self._var, width=width,
bg=C["surface2"], fg=C["text"],
insertbackground=C["text"],
relief="flat", font=FONT,
state="readonly",
readonlybackground=C["surface2"],
)
self._entry.pack(side="left", ipady=5, padx=(0, 2))
self._btn = tk.Button(
self, text="📅",
command=self._open_popup,
bg=C["surface2"], fg=C["text"],
activebackground=C["accent"],
activeforeground=C["white"],
relief="flat", font=FONT,
cursor="hand2", padx=4,
)
self._btn.pack(side="left")
def get(self):
return self._var.get()
def set(self, value):
try:
self._date = date.fromisoformat(value)
self._var.set(self._date.isoformat())
except (ValueError, TypeError):
pass
def _open_popup(self):
_CalendarPopup(self, self._date, callback=self._on_date_selected)
def _on_date_selected(self, selected):
self._date = selected
self._var.set(selected.isoformat())
class _CalendarPopup(tk.Toplevel):
"""Month-grid calendar popup used by DateEntry."""
def __init__(self, anchor_widget, initial, callback):
super().__init__(anchor_widget)
self.callback = callback
self._year = initial.year
self._month = initial.month
self._selected = initial
self.overrideredirect(True)
self.resizable(False, False)
C = ThemeManager.get()
self.configure(bg=C["border"])
self._build()
self._position(anchor_widget)
self.grab_set()
self.focus_set()
self.bind("<Escape>", lambda _: self.destroy())
def _position(self, anchor):
anchor.update_idletasks()
x = anchor.winfo_rootx()
y = anchor.winfo_rooty() + anchor.winfo_height() + 2
self.geometry(f"+{x}+{y}")
def _build(self):
C = ThemeManager.get()
outer = tk.Frame(self, bg=C["surface"], padx=2, pady=2)
outer.pack(fill="both", expand=True)
# Navigation
nav = tk.Frame(outer, bg=C["cal_header"])
nav.pack(fill="x")
def nav_btn(text, cmd):
return tk.Button(
nav, text=text, command=cmd,
bg=C["cal_header"], fg=C["text"],
activebackground=C["accent"], activeforeground=C["white"],
relief="flat", font=FONT_BOLD, cursor="hand2",
padx=8, pady=4,
)
nav_btn("◀◀", self._prev_year).pack(side="left")
nav_btn("◀", self._prev_month).pack(side="left")
self._header_lbl = tk.Label(
nav, bg=C["cal_header"], fg=C["text"], font=FONT_BOLD)
self._header_lbl.pack(side="left", expand=True, fill="x")
nav_btn("▶", self._next_month).pack(side="right")
nav_btn("▶▶", self._next_year).pack(side="right")
# Day-name headers
hdr = tk.Frame(outer, bg=C["surface"])
hdr.pack(fill="x")
for i, dn in enumerate(["Mo","Tu","We","Th","Fr","Sa","Su"]):
fg = C["cal_weekend"] if i >= 5 else C["text_dim"]
tk.Label(hdr, text=dn, bg=C["surface"], fg=fg,
font=FONT_SMALL, width=4, anchor="center").grid(
row=0, column=i, padx=1, pady=2)
self._grid_frame = tk.Frame(outer, bg=C["surface"])
self._grid_frame.pack(fill="both", expand=True)
tk.Button(
outer, text="Today",
command=self._select_today,
bg=C["cal_today"], fg=C["white"],
activebackground=C["accent_hover"],
activeforeground=C["white"],
relief="flat", font=FONT_SMALL, cursor="hand2", pady=3,
).pack(fill="x", pady=(4, 2))
self._render_month()
def _render_month(self):
C = ThemeManager.get()
for w in self._grid_frame.winfo_children():
w.destroy()
self._header_lbl.config(
text=f"{calendar.month_name[self._month]} {self._year}")
today = date.today()
cal = calendar.monthcalendar(self._year, self._month)
for row_i, week in enumerate(cal):
for col_i, day in enumerate(week):
if day == 0:
tk.Label(self._grid_frame, text="",
bg=C["surface"], width=4).grid(
row=row_i, column=col_i, padx=1, pady=1)
continue
d = date(self._year, self._month, day)
is_today = (d == today)
is_selected = (d == self._selected)
is_weekend = (col_i >= 5)
if is_selected:
bg, fg = C["cal_selected"], C["white"]
elif is_today:
bg, fg = C["cal_today"], C["white"]
else:
bg = C["surface2"] if is_weekend else C["surface"]
fg = C["cal_weekend"] if is_weekend else C["text"]
tk.Button(
self._grid_frame,
text=str(day), bg=bg, fg=fg,
activebackground=C["accent"],
activeforeground=C["white"],
relief="flat", font=FONT_SMALL,
width=3, cursor="hand2",
command=lambda dd=d: self._pick(dd),
).grid(row=row_i, column=col_i, padx=1, pady=1)
def _pick(self, d):
self._selected = d
self.callback(d)
self.destroy()
def _select_today(self):
self._pick(date.today())
def _prev_month(self):
if self._month == 1:
self._month, self._year = 12, self._year - 1
else:
self._month -= 1
self._render_month()
def _next_month(self):
if self._month == 12:
self._month, self._year = 1, self._year + 1
else:
self._month += 1
self._render_month()
def _prev_year(self):
self._year -= 1
self._render_month()
def _next_year(self):
self._year += 1
self._render_month()
# ─── Reusable Widget Factories ────────────────────────────────────────────────
def labelled_entry(parent, label, row, show=None, width=35):
lbl = ttk.Label(parent, text=label)
lbl.grid(row=row, column=0, sticky="w", padx=(0, 12), pady=6)
var = tk.StringVar()
ent = ttk.Entry(parent, textvariable=var, width=width, show=show or "")
ent.grid(row=row, column=1, sticky="ew", pady=6)
return var, ent
def scrolled_text(parent, height=6, width=50):
C = ThemeManager.get()
frame = tk.Frame(parent, bg=C["surface2"])
sb = tk.Scrollbar(frame)
sb.pack(side="right", fill="y")
txt = tk.Text(
frame, height=height, width=width, wrap="word",
yscrollcommand=sb.set,
bg=C["surface2"], fg=C["text"],
insertbackground=C["text"],
relief="flat", font=FONT, padx=8, pady=6,
)
txt.pack(side="left", fill="both", expand=True)
sb.config(command=txt.yview)
return frame, txt
def confirm_delete(item_name):
return messagebox.askyesno(
"Confirm Delete",
f"Are you sure you want to delete '{item_name}'?\nThis action cannot be undone."
)
def show_error(message, title="Error"):
messagebox.showerror(title, message)
def show_info(message, title="Success"):
messagebox.showinfo(title, message)
def make_scrollable_frame(parent):
C = ThemeManager.get()
canvas = tk.Canvas(parent, bg=C["bg"], highlightthickness=0)
vsb = ttk.Scrollbar(parent, orient="vertical", command=canvas.yview)
inner = ttk.Frame(canvas)
inner.bind("<Configure>",
lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
canvas.create_window((0, 0), window=inner, anchor="nw")
canvas.configure(yscrollcommand=vsb.set)
canvas.pack(side="left", fill="both", expand=True)
vsb.pack(side="right", fill="y")
def _safe_scroll(event):
try:
canvas.yview_scroll(int(-1 * (event.delta / 120)), "units")
except Exception:
pass
def _enter(e):
canvas.bind_all("<MouseWheel>", _safe_scroll)
def _leave(e):
try:
canvas.unbind_all("<MouseWheel>")
except Exception:
pass
canvas.bind("<Enter>", _enter)
canvas.bind("<Leave>", _leave)
return canvas, inner