04/21 Fisrt commit
This commit is contained in:
@@ -0,0 +1,490 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user