Files
thechart/src/theme_manager.py
T

446 lines
16 KiB
Python

"""Theme manager for the application using ttkthemes."""
import logging
import tkinter as tk
from tkinter import ttk
from ttkthemes import ThemedStyle
class ThemeManager:
"""Manages application themes and styling."""
def __init__(self, root: tk.Tk, logger: logging.Logger) -> None:
self.root = root
self.logger = logger
self.style: ThemedStyle | None = None
self.current_theme: str = "arc" # Default theme
# Available themes - these are some of the best looking ones
self.available_themes = [
"arc",
"equilux",
"adapta",
"yaru",
"ubuntu",
"plastik",
"breeze",
"elegance",
]
self.initialize_theme()
def initialize_theme(self) -> None:
"""Initialize the themed style."""
try:
self.style = ThemedStyle(self.root)
self.apply_theme(self.current_theme)
self._configure_custom_styles()
self.logger.info(
f"Theme manager initialized with theme: {self.current_theme}"
)
except Exception as e:
self.logger.error(f"Failed to initialize theme manager: {e}")
# Fallback to default ttk styling
self.style = ttk.Style()
def apply_theme(self, theme_name: str) -> bool:
"""Apply a specific theme."""
try:
if self.style and theme_name in self.get_available_themes():
self.style.set_theme(theme_name)
self.current_theme = theme_name
self._configure_custom_styles()
self.logger.info(f"Applied theme: {theme_name}")
return True
else:
self.logger.warning(f"Theme '{theme_name}' not available")
return False
except Exception as e:
self.logger.error(f"Failed to apply theme '{theme_name}': {e}")
return False
def get_available_themes(self) -> list[str]:
"""Get list of available themes."""
if self.style:
try:
# Get all available themes from ttkthemes
all_themes = self.style.theme_names()
# Filter to only include our curated list
return [theme for theme in self.available_themes if theme in all_themes]
except Exception as e:
self.logger.error(f"Failed to get available themes: {e}")
return self.available_themes
return self.available_themes
def get_current_theme(self) -> str:
"""Get the currently active theme."""
return self.current_theme
def _get_contrasting_colors(self, colors: dict[str, str]) -> dict[str, str]:
"""Get contrasting colors for headers with improved visibility."""
def get_luminance(color_str: str) -> float:
"""Calculate relative luminance of a color."""
if not color_str or not color_str.startswith("#"):
return 0.5
try:
rgb = tuple(int(color_str[i : i + 2], 16) for i in (1, 3, 5))
# Calculate relative luminance
return (0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]) / 255
except (ValueError, IndexError):
return 0.5
def get_contrast_ratio(bg: str, fg: str) -> float:
"""Calculate contrast ratio between two colors."""
bg_lum = get_luminance(bg)
fg_lum = get_luminance(fg)
lighter = max(bg_lum, fg_lum)
darker = min(bg_lum, fg_lum)
return (lighter + 0.05) / (darker + 0.05)
# Start with the provided select colors
header_bg = colors["select_bg"]
header_fg = colors["select_fg"]
# Calculate contrast ratio
contrast = get_contrast_ratio(header_bg, header_fg)
# If contrast is poor (less than 3:1), use high-contrast alternatives
if contrast < 3.0:
bg_luminance = get_luminance(colors["bg"])
if bg_luminance > 0.5: # Light theme
header_bg = "#1e1e1e" # Very dark gray background for maximum contrast
header_fg = "#ffffff" # Pure white for maximum contrast
else: # Dark theme - use dark background with light text
header_bg = "#1e1e1e" # Very dark gray for consistency
header_fg = "#ffffff" # Pure white for maximum contrast
self.logger.debug(
f"Poor header contrast ({contrast:.2f}), using fallback colors: "
f"bg={header_bg}, fg={header_fg}"
)
return {
"header_bg": header_bg,
"header_fg": header_fg,
}
def _configure_custom_styles(self) -> None:
"""Configure custom styles for better appearance."""
if not self.style:
return
try:
# Get current theme colors for consistent styling
colors = self.get_theme_colors()
# Get improved header colors with better contrast
header_colors = self._get_contrasting_colors(colors)
# Configure frame styles with better padding and borders
self.style.configure(
"Card.TFrame",
relief="flat",
borderwidth=0,
background=colors["bg"],
)
# Configure label frame styles with modern appearance
self.style.configure(
"Card.TLabelframe",
relief="solid",
borderwidth=1,
background=colors["bg"],
foreground=colors["fg"],
padding=(10, 5, 10, 10),
)
self.style.configure(
"Card.TLabelframe.Label",
background=colors["bg"],
foreground=colors["fg"],
font=("TkDefaultFont", 10, "bold"),
)
# Configure button styles for better appearance
self.style.configure(
"Action.TButton",
padding=(15, 8),
font=("TkDefaultFont", 9, "normal"),
)
# Configure entry styles with modern look
self.style.configure(
"Modern.TEntry",
padding=(8, 5),
borderwidth=1,
relief="solid",
)
# Configure scale styles for pathology inputs
self.style.configure(
"Modern.Horizontal.TScale",
borderwidth=0,
background=colors["bg"],
troughcolor="#e0e0e0",
lightcolor=colors["select_bg"],
darkcolor=colors["select_bg"],
focuscolor=colors["select_bg"],
)
# Configure treeview for better data display
self.style.configure(
"Modern.Treeview",
rowheight=28,
borderwidth=1,
relief="solid",
background=colors["bg"],
foreground=colors["fg"],
fieldbackground=colors["bg"],
selectbackground=colors["select_bg"],
selectforeground=colors["select_fg"],
)
self.style.configure(
"Modern.Treeview.Heading",
padding=(8, 6),
relief="flat",
borderwidth=1,
background=header_colors["header_bg"],
foreground=header_colors["header_fg"],
font=("TkDefaultFont", 9, "bold"),
)
# Ensure header style mapping to override theme defaults
self.style.map(
"Modern.Treeview.Heading",
background=[
("active", header_colors["header_bg"]),
("pressed", header_colors["header_bg"]),
("", header_colors["header_bg"]),
],
foreground=[
("active", header_colors["header_fg"]),
("pressed", header_colors["header_fg"]),
("", header_colors["header_fg"]),
],
)
# Configure comprehensive row selection colors for better visibility
self.style.map(
"Modern.Treeview",
background=[
("selected", colors["select_bg"]),
("active", colors["select_bg"]),
("focus", colors["select_bg"]),
("", colors["bg"]),
],
foreground=[
("selected", colors["select_fg"]),
("active", colors["select_fg"]),
("focus", colors["select_fg"]),
("", colors["fg"]),
],
selectbackground=[
("focus", colors["select_bg"]),
("", colors["select_bg"]),
],
selectforeground=[
("focus", colors["select_fg"]),
("", colors["select_fg"]),
],
)
# Configure notebook tabs with modern styling
self.style.configure(
"Modern.TNotebook.Tab",
padding=(15, 8),
borderwidth=1,
relief="flat",
)
self.style.map(
"Modern.TNotebook.Tab",
background=[("selected", colors["select_bg"])],
foreground=[("selected", colors["select_fg"])],
)
# Configure checkbutton for medicine selection
self.style.configure(
"Modern.TCheckbutton",
padding=(8, 4),
background=colors["bg"],
foreground=colors["fg"],
focuscolor=colors["select_bg"],
)
self.logger.debug("Enhanced custom styles configured")
except Exception as e:
self.logger.error(f"Failed to configure custom styles: {e}")
def get_menu_colors(self) -> dict[str, str]:
"""Get colors specifically for menu theming."""
colors = self.get_theme_colors()
# Use slightly different colors for menus to make them stand out
try:
# For menu background, use a slightly darker/lighter shade
if colors["bg"].startswith("#"):
rgb = tuple(int(colors["bg"][i : i + 2], 16) for i in (1, 3, 5))
if sum(rgb) > 384: # Light theme - make menu slightly darker
menu_bg = (
f"#{max(0, rgb[0] - 8):02x}"
f"{max(0, rgb[1] - 8):02x}"
f"{max(0, rgb[2] - 8):02x}"
)
else: # Dark theme - make menu slightly lighter
menu_bg = (
f"#{min(255, rgb[0] + 15):02x}"
f"{min(255, rgb[1] + 15):02x}"
f"{min(255, rgb[2] + 15):02x}"
)
else:
menu_bg = colors["bg"]
except (ValueError, IndexError):
menu_bg = colors["bg"]
return {
"bg": menu_bg,
"fg": colors["fg"],
"active_bg": colors["select_bg"],
"active_fg": colors["select_fg"],
"disabled_fg": colors.get("disabled_fg", "#888888"),
}
def configure_menu(self, menu: "tk.Menu") -> None:
"""Apply theme colors to a menu widget."""
try:
menu_colors = self.get_menu_colors()
menu.configure(
background=menu_colors["bg"],
foreground=menu_colors["fg"],
activebackground=menu_colors["active_bg"],
activeforeground=menu_colors["active_fg"],
disabledforeground=menu_colors["disabled_fg"],
relief="flat",
borderwidth=1,
)
self.logger.debug(f"Applied theme to menu: {menu_colors}")
except Exception as e:
self.logger.error(f"Failed to configure menu theme: {e}")
def create_themed_menu(self, parent: "tk.Widget", **kwargs) -> "tk.Menu":
"""Create a new menu with theme colors already applied."""
try:
menu = tk.Menu(parent, **kwargs)
self.configure_menu(menu)
return menu
except Exception as e:
self.logger.error(f"Failed to create themed menu: {e}")
# Fallback to a minimally constructed menu without theming
try:
return tk.Menu(parent)
except Exception:
# As a last resort, return a dummy object that quacks like a Menu
class _DummyMenu:
def __init__(self) -> None:
self._options = {}
def __getitem__(self, key): # support menu['tearoff'] tests
return self._options.get(key, 0)
def configure(self, **_kw):
self._options.update(_kw)
return _DummyMenu()
def configure_widget_style(self, widget: tk.Widget, style_name: str) -> None:
"""Apply a specific style to a widget."""
try:
if hasattr(widget, "configure") and self.style:
widget.configure(style=style_name)
except Exception as e:
self.logger.error(f"Failed to configure widget style '{style_name}': {e}")
def get_theme_colors(self) -> dict[str, str]:
"""Get current theme colors for custom widgets."""
if not self.style:
return {
"bg": "#ffffff",
"fg": "#000000",
"select_bg": "#3584e4",
"select_fg": "#ffffff",
"alt_bg": "#f5f5f5",
}
try:
# Get colors from current theme and convert to strings
bg = str(self.style.lookup("TFrame", "background") or "#ffffff")
fg = str(self.style.lookup("TLabel", "foreground") or "#000000")
# Try to get better selection colors from different widget states
select_bg = str(
self.style.lookup("TButton", "background", ["pressed"])
or self.style.lookup("TButton", "background", ["active"])
or self.style.lookup("Treeview", "selectbackground")
or "#0078d4" # Modern blue fallback
)
select_fg = str(
self.style.lookup("TButton", "foreground", ["pressed"])
or self.style.lookup("TButton", "foreground", ["active"])
or self.style.lookup("Treeview", "selectforeground")
or "#ffffff" # White fallback
)
# Ensure contrast - if selection colors are too similar to background,
# use fallbacks
if select_bg == bg or select_bg.lower() == bg.lower():
select_bg = "#0078d4" if bg != "#0078d4" else "#0066cc"
if select_fg == fg or select_fg.lower() == fg.lower():
select_fg = "#ffffff" if fg != "#ffffff" else "#000000"
# Calculate alternating row color
if bg.startswith("#"):
try:
rgb = tuple(int(bg[i : i + 2], 16) for i in (1, 3, 5))
if sum(rgb) > 384: # Light theme
alt_bg = (
f"#{max(0, rgb[0] - 10):02x}"
f"{max(0, rgb[1] - 10):02x}"
f"{max(0, rgb[2] - 10):02x}"
)
else: # Dark theme
alt_bg = (
f"#{min(255, rgb[0] + 10):02x}"
f"{min(255, rgb[1] + 10):02x}"
f"{min(255, rgb[2] + 10):02x}"
)
except ValueError:
alt_bg = "#f5f5f5"
else:
alt_bg = "#f5f5f5"
return {
"bg": bg,
"fg": fg,
"select_bg": select_bg,
"select_fg": select_fg,
"alt_bg": alt_bg, # Add alternating background color
}
except Exception as e:
self.logger.error(f"Failed to get theme colors: {e}")
return {
"bg": "#ffffff",
"fg": "#000000",
"select_bg": "#3584e4",
"select_fg": "#ffffff",
"alt_bg": "#f5f5f5",
}