ICRA/app/gui/theme.py

123 lines
4.0 KiB
Python

"""Theme and window helpers."""
from __future__ import annotations
import platform
from tkinter import ttk
try:
import ttkbootstrap as tb # type: ignore
HAS_TTKBOOTSTRAP = True
except Exception: # pragma: no cover - optional dependency
tb = None
HAS_TTKBOOTSTRAP = False
try:
import winreg
except Exception: # pragma: no cover - platform-specific
winreg = None # type: ignore
class ThemeMixin:
"""Provides theme handling utilities for the main application."""
theme: str
style: ttk.Style
using_tb: bool
scale_style: str
def init_theme(self) -> None:
"""Initialise ttk style handling and apply the detected theme."""
if HAS_TTKBOOTSTRAP:
try:
# Ensure msgcat package is available before ttkbootstrap initialises translations
self.root.tk.call("package", "require", "msgcat") # type: ignore[attr-defined]
except Exception:
pass
self.style = tb.Style()
self.using_tb = True
else:
self.style = ttk.Style()
self.style.theme_use("clam")
self.using_tb = False
self.theme = "light"
self.apply_theme(self.detect_system_theme())
def apply_theme(self, mode: str) -> None:
"""Apply light/dark theme including widget palette."""
mode = (mode or "light").lower()
self.theme = "dark" if mode == "dark" else "light"
if HAS_TTKBOOTSTRAP:
try:
theme_name = "darkly" if self.theme == "dark" else "flatly"
self.style.theme_use(theme_name)
except Exception:
pass
self.scale_style = (
"info.Horizontal.TScale" if self.theme == "dark" else "primary.Horizontal.TScale"
)
else:
self.scale_style = "Horizontal.TScale"
if self.theme == "dark":
bg, fg = "#0f0f10", "#f1f1f1"
status_fg = "#f5f5f5"
else:
bg, fg = "#ededf2", "#202020"
status_fg = "#1c1c1c"
self.root.configure(bg=bg) # type: ignore[attr-defined]
s = self.style
s.configure("TFrame", background=bg)
s.configure("TLabel", background=bg, foreground=fg, font=("Segoe UI", 10))
if not HAS_TTKBOOTSTRAP:
s.configure(
"TButton", padding=8, relief="flat", background="#e0e0e0", foreground=fg, font=("Segoe UI", 10)
)
s.map("TButton", background=[("active", "#d0d0d0")])
button_refresher = getattr(self, "_refresh_toolbar_buttons_theme", None)
if callable(button_refresher):
button_refresher()
status_refresher = getattr(self, "_refresh_status_palette", None)
if callable(status_refresher) and hasattr(self, "status"):
status_refresher(status_fg)
def detect_system_theme(self) -> str:
"""Best-effort detection of the OS theme preference."""
try:
if platform.system() == "Windows" and winreg is not None:
key = winreg.OpenKey(
winreg.HKEY_CURRENT_USER,
r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize",
)
value, _ = winreg.QueryValueEx(key, "AppsUseLightTheme")
return "light" if int(value) == 1 else "dark"
except Exception:
pass
return "light"
def bring_to_front(self) -> None:
"""Try to focus the window and raise it to the foreground."""
try:
self.root.lift()
self.root.focus_force()
self.root.attributes("-topmost", True)
self.root.update()
self.root.attributes("-topmost", False)
except Exception:
pass
def toggle_theme(self) -> None:
"""Toggle between light and dark themes."""
next_mode = "dark" if self.theme == "light" else "light"
self.apply_theme(next_mode)
self.update_preview() # type: ignore[attr-defined]
__all__ = ["ThemeMixin", "HAS_TTKBOOTSTRAP"]