446 lines
16 KiB
Python
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",
|
|
}
|