491 lines
17 KiB
Python
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
|