Source code for frontend.theme
"""TTK theme configuration built from environment variables."""
import tkinter as tk
from tkinter import ttk
from config import get_env_from_schema
# -----------------------------------------------------------------------------
# Color conversion and transformation helpers (RegressionLab-style)
# -----------------------------------------------------------------------------
def _color_to_rgb(color: str) -> tuple[int, int, int] | None:
"""Convert color name or hex to RGB tuple (0-255).
Args:
color: Color name (e.g. 'steel blue', 'lime green') or hex (#rrggbb).
Returns:
Tuple of (r, g, b) in 0-255 range, or None if conversion fails.
"""
if not isinstance(color, str) or not color.strip():
return None
color = color.strip().strip('"').strip("'")
if not color:
return None
# Parse hex directly
if color.startswith("#"):
try:
hex_part = color.lstrip("#").strip()
if len(hex_part) == 6:
return (
int(hex_part[0:2], 16),
int(hex_part[2:4], 16),
int(hex_part[4:6], 16),
)
if len(hex_part) == 3:
return (
int(hex_part[0] * 2, 16),
int(hex_part[1] * 2, 16),
int(hex_part[2] * 2, 16),
)
except ValueError:
pass
return None
# Use tkinter for named colors — reuse existing root when available
try:
existing_root = tk._default_root # type: ignore[attr-defined]
if existing_root is not None and existing_root.winfo_exists():
r, g, b = existing_root.winfo_rgb(color)
return (r // 256, g // 256, b // 256)
tmp = tk.Tk()
tmp.withdraw()
r, g, b = tmp.winfo_rgb(color)
tmp.destroy()
return (r // 256, g // 256, b // 256)
except Exception:
pass
# Fallback: matplotlib (handles many color names)
try:
from matplotlib.colors import to_rgb
r, g, b = to_rgb(color)
return (int(r * 255), int(g * 255), int(b * 255))
except (ValueError, TypeError, ImportError):
pass
return None
def _lighten_color(color: str, factor: float = 0.20) -> str:
"""Return a lighter shade by moving toward white.
Args:
color: Source color (name or hex).
factor: Fraction to move toward white (0.20 = 20% lighter).
Returns:
Hex color string (#rrggbb).
"""
rgb = _color_to_rgb(color)
if rgb is None:
rgb = _color_to_rgb(get_env_from_schema("UI_FOREGROUND"))
if rgb is None:
return "#ffffff"
r, g, b = rgb
r = min(255, int(r + (255 - r) * factor))
g = min(255, int(g + (255 - g) * factor))
b = min(255, int(b + (255 - b) * factor))
return f"#{r:02x}{g:02x}{b:02x}"
def _darken_color(color: str, factor: float = 0.25) -> str:
"""Return a darker shade by reducing each channel.
Args:
color: Source color (name or hex).
factor: Fraction to darken (0.25 = 25% darker).
Returns:
Hex color string (#rrggbb).
"""
rgb = _color_to_rgb(color)
if rgb is None:
rgb = _color_to_rgb(get_env_from_schema("UI_BACKGROUND"))
if rgb is None:
return "#1e1e1e"
r, g, b = rgb
mult = 1.0 - factor
r = int(r * mult)
g = int(g * mult)
b = int(b * mult)
return f"#{r:02x}{g:02x}{b:02x}"
[docs]
def get_contrast_foreground(bg_color: str) -> str:
"""Return a foreground color with high contrast against the given background.
Uses luminance to choose black or white for maximum readability.
Args:
bg_color: Background color (name or hex).
Returns:
Hex color string (#000000 or #ffffff).
"""
rgb = _color_to_rgb(bg_color)
if rgb is None:
rgb = _color_to_rgb(get_env_from_schema("UI_BACKGROUND"))
if rgb is None:
return "#ffffff"
r, g, b = rgb
luminance = 0.299 * r + 0.587 * g + 0.114 * b
return "#000000" if luminance > 128 else "#ffffff"
[docs]
def get_select_colors(
element_bg: str,
text_fg: str,
) -> tuple[str, str]:
"""Compute select background and foreground from element colors.
Selected text: 20% lighter than unselected text.
Selected background: 25% darker than the element's background.
Args:
element_bg: Background color of the element (e.g. UI_BUTTON_BG).
text_fg: Foreground/text color (e.g. UI_FOREGROUND).
Returns:
Tuple of (select_background, select_foreground) as hex strings.
"""
select_bg = _darken_color(element_bg, 0.25)
select_fg = _lighten_color(text_fg, 0.20)
return (select_bg, select_fg)
[docs]
def get_font() -> tuple[str, int]:
"""Return the configured ``(family, size)`` font tuple.
Returns:
Tuple of font family name and size.
"""
family: str = get_env_from_schema("UI_FONT_FAMILY")
size: int = get_env_from_schema("UI_FONT_SIZE")
return (family, size)
[docs]
def configure_ttk_styles(root: tk.Tk) -> None:
"""Apply the dark theme to all ttk widgets based on env configuration.
Args:
root: The root Tk window.
"""
bg: str = get_env_from_schema("UI_BACKGROUND")
fg: str = get_env_from_schema("UI_FOREGROUND")
btn_bg: str = get_env_from_schema("UI_BUTTON_BG")
btn_fg: str = get_env_from_schema("UI_BUTTON_FG")
btn_fg_cancel: str = get_env_from_schema("UI_BUTTON_FG_CANCEL")
btn_fg_accent2: str = get_env_from_schema("UI_BUTTON_FG_ACCENT2")
select_bg, select_fg = get_select_colors(element_bg=btn_bg, text_fg=fg)
padding: int = get_env_from_schema("UI_PADDING")
font_family: str = get_env_from_schema("UI_FONT_FAMILY")
font_size: int = get_env_from_schema("UI_FONT_SIZE")
focus_bg = select_bg
focus_field_bg = _lighten_color(btn_bg, 0.1)
font = (font_family, font_size)
font_bold = (font_family, font_size, "bold")
font_small = (font_family, max(10, font_size - 4))
font_large = (font_family, font_size + 6, "bold")
font_desc = (font_family, max(9, int(font_size * 0.72)))
root.configure(bg=bg)
style = ttk.Style(root)
style.theme_use("clam")
style.configure(
".", background=bg, foreground=fg, font=font, borderwidth=0, focuscolor=focus_bg
)
style.configure("TFrame", background=bg)
style.configure("TLabel", background=bg, foreground=fg, font=font)
style.configure("TLabelframe", background=bg, foreground=fg, font=font)
style.configure("TLabelframe.Label", background=bg, foreground=fg, font=font_bold)
# --- Buttons ---
style.configure(
"TButton",
background=btn_bg,
foreground=btn_fg,
font=font,
padding=padding,
borderwidth=1,
relief="raised",
justify=tk.CENTER,
)
style.map(
"TButton",
background=[("pressed", focus_bg), ("focus", focus_bg), ("active", focus_bg)],
foreground=[("active", btn_fg), ("focus", btn_fg)],
)
style.configure("Cancel.TButton", foreground=btn_fg_cancel, justify=tk.CENTER)
style.map(
"Cancel.TButton",
background=[("pressed", focus_bg), ("focus", focus_bg), ("active", focus_bg)],
foreground=[("active", btn_fg_cancel), ("focus", btn_fg_cancel)],
)
style.configure("Accent2.TButton", foreground=btn_fg_accent2, justify=tk.CENTER)
style.map(
"Accent2.TButton",
background=[("pressed", focus_bg), ("focus", focus_bg), ("active", focus_bg)],
foreground=[("active", btn_fg_accent2), ("focus", btn_fg_accent2)],
)
style.configure(
"Small.TButton",
background=btn_bg,
foreground=btn_fg,
font=font,
padding=(4, 2),
borderwidth=1,
relief="raised",
justify=tk.CENTER,
)
style.map(
"Small.TButton",
background=[("pressed", focus_bg), ("focus", focus_bg), ("active", focus_bg)],
foreground=[("active", btn_fg), ("focus", btn_fg)],
)
# Small menu buttons (Configuration, Quit) - smaller font and padding
style.configure(
"SmallMenu.TButton",
background=btn_bg,
foreground=btn_fg,
font=font_small,
padding=(4, 2),
borderwidth=1,
relief="raised",
justify=tk.CENTER,
)
style.map(
"SmallMenu.TButton",
background=[("pressed", focus_bg), ("focus", focus_bg), ("active", focus_bg)],
foreground=[("active", btn_fg), ("focus", btn_fg)],
)
style.configure("SmallMenu.Accent2.TButton", foreground=btn_fg_accent2, justify=tk.CENTER)
style.map(
"SmallMenu.Accent2.TButton",
background=[("pressed", focus_bg), ("focus", focus_bg), ("active", focus_bg)],
foreground=[("active", btn_fg_accent2), ("focus", btn_fg_accent2)],
)
style.configure(
"SmallMenu.Cancel.TButton", foreground=btn_fg_cancel, justify=tk.CENTER, padding=(50, 6)
)
style.map(
"SmallMenu.Cancel.TButton",
background=[("pressed", focus_bg), ("focus", focus_bg), ("active", focus_bg)],
foreground=[("active", btn_fg_cancel), ("focus", btn_fg_cancel)],
)
# --- Labels ---
style.configure("Title.TLabel", font=font_large, foreground=btn_fg)
style.configure("Subtitle.TLabel", font=font_bold, foreground=fg)
style.configure("Small.TLabel", font=font_small, foreground=fg)
style.configure("ConfigDesc.TLabel", font=font_desc, foreground=fg)
# Collapsible-section header style
style.configure("SectionHeader.TFrame", background=btn_bg)
style.configure("SectionHeader.TLabel", background=btn_bg, foreground=btn_fg, font=font_bold)
# --- Entry (larger font + focus highlight) ---
style.configure(
"TEntry",
fieldbackground=btn_bg,
foreground=fg,
insertcolor=fg,
selectbackground=select_bg,
selectforeground=select_fg,
padding=6,
font=font,
)
style.map("TEntry", fieldbackground=[("focus", focus_field_bg)])
# --- Spinbox ---
style.configure(
"TSpinbox",
fieldbackground=btn_bg,
foreground=fg,
arrowcolor=fg,
insertcolor=fg,
selectbackground=select_bg,
selectforeground=select_fg,
padding=6,
font=font,
arrowsize=font[1] + padding * 2,
)
style.map("TSpinbox", fieldbackground=[("focus", focus_field_bg)])
# --- Combobox (arrowsize scales with font for visibility) ---
style.configure(
"TCombobox",
fieldbackground=btn_bg,
foreground=fg,
selectbackground=select_bg,
selectforeground=select_fg,
padding=6,
font=font,
arrowsize=font_size + padding * 2,
)
style.map(
"TCombobox",
fieldbackground=[("readonly", btn_bg), ("focus", focus_field_bg)],
foreground=[("readonly", fg)],
)
# --- Checkbutton (indicator size matches font for better visibility) ---
indicator_size = max(14, font_size)
style.configure(
"TCheckbutton",
background=bg,
foreground=fg,
font=font,
indicatorcolor=btn_bg,
indicatorsize=indicator_size,
)
style.map(
"TCheckbutton",
background=[("active", bg), ("focus", focus_field_bg)],
indicatorcolor=[("selected", btn_fg)],
)
# --- Radiobutton (ODE, Vector ODE, etc.) — hover slightly darker than background ---
hover_bg = _lighten_color(bg, 0.15)
style.configure("TRadiobutton", background=bg, foreground=fg, font=font, indicatorcolor=btn_bg)
style.map(
"TRadiobutton",
background=[("active", hover_bg), ("focus", hover_bg)],
indicatorcolor=[("selected", btn_fg)],
)
# --- Treeview ---
style.configure(
"Treeview",
background=btn_bg,
foreground=fg,
fieldbackground=btn_bg,
font=font_small,
rowheight=int(font_size * 1.8),
)
style.configure("Treeview.Heading", background=bg, foreground=fg, font=font_bold)
style.map(
"Treeview", background=[("selected", select_bg)], foreground=[("selected", select_fg)]
)
# --- Scrollbar ---
style.configure("TScrollbar", background=btn_bg, troughcolor=bg, arrowcolor=fg, borderwidth=0)
# --- Notebook ---
style.configure("TNotebook", background=bg, borderwidth=0)
style.configure(
"TNotebook.Tab",
background=btn_bg,
foreground=fg,
font=font,
padding=[padding, padding // 2],
)
style.map("TNotebook.Tab", background=[("selected", bg)], foreground=[("selected", btn_fg)])
# --- Separator ---
style.configure("TSeparator", background=btn_bg)
# --- PanedWindow ---
style.configure("TPanedwindow", background=bg)
root.option_add("*TCombobox*Listbox*Background", btn_bg)
root.option_add("*TCombobox*Listbox*Foreground", fg)
root.option_add("*TCombobox*Listbox*selectBackground", select_bg)
root.option_add("*TCombobox*Listbox*selectForeground", select_fg)
root.option_add("*TCombobox*Listbox*Font", font)