""" 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("", 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("", 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("", _safe_scroll) def _leave(e): try: canvas.unbind_all("") except Exception: pass canvas.bind("", _enter) canvas.bind("", _leave) return canvas, inner