"""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", }