lightfall-utils 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1030 @@
1
+ """Application theme manager.
2
+
3
+ Provides application-wide theme control with support for:
4
+ - Light, dark, and system-following modes
5
+ - Plugin-based theme definitions
6
+ - Beamline-specific color accents
7
+ - Theme-aware color utilities
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import threading
13
+ from dataclasses import dataclass, field
14
+ from enum import Enum
15
+ from typing import TYPE_CHECKING, Any, ClassVar
16
+
17
+ from PySide6.QtCore import QObject, Signal
18
+ from PySide6.QtGui import QColor, QPalette
19
+ from PySide6.QtWidgets import QApplication
20
+
21
+ from lightfall_utils.logging import logger
22
+
23
+ if TYPE_CHECKING:
24
+ from collections.abc import Callable
25
+
26
+ from lightfall_utils.theming.provider import ThemeDefinition, ThemeProvider
27
+
28
+
29
+ class Theme(Enum):
30
+ """Available theme modes.
31
+
32
+ Note: This enum is kept for backward compatibility. New code should
33
+ use string-based theme names with ThemeManager.set_theme_by_name().
34
+ """
35
+
36
+ LIGHT = "light"
37
+ DARK = "dark" # Alias for default dark theme (Slate)
38
+ SLATE = "slate" # Neutral gray dark theme
39
+ DARKBLUE = "darkblue" # Blue-gray dark theme
40
+ SYSTEM = "system" # Follow system preference
41
+
42
+
43
+ @dataclass
44
+ class ThemeColors:
45
+ """Color definitions for a theme.
46
+
47
+ Attributes:
48
+ primary: Primary brand/accent color.
49
+ secondary: Secondary accent color.
50
+ success: Success/positive state color.
51
+ warning: Warning state color.
52
+ error: Error/danger state color.
53
+ info: Informational state color.
54
+ background: Main background color.
55
+ surface: Elevated surface color.
56
+ text: Primary text color.
57
+ text_secondary: Secondary/muted text color.
58
+ border: Border/divider color.
59
+ """
60
+
61
+ primary: str = "#2563eb" # Blue
62
+ secondary: str = "#7c3aed" # Purple
63
+ success: str = "#16a34a" # Green
64
+ warning: str = "#d97706" # Amber
65
+ error: str = "#dc2626" # Red
66
+ info: str = "#0891b2" # Cyan
67
+
68
+ background: str = "#ffffff"
69
+ surface: str = "#f3f4f6"
70
+ text: str = "#1f2937"
71
+ text_secondary: str = "#6b7280"
72
+ border: str = "#e5e7eb"
73
+
74
+ # Connection state colors
75
+ connected: str = ""
76
+ disconnected: str = ""
77
+
78
+ # Islands layout: "sea" is the visible gap behind floating panels.
79
+ # When empty, falls back to background (non-Islands themes unchanged).
80
+ sea: str = ""
81
+
82
+ def __post_init__(self) -> None:
83
+ """Set default state colors based on theme."""
84
+ if not self.connected:
85
+ self.connected = self.success
86
+ if not self.disconnected:
87
+ self.disconnected = self.error
88
+ if not self.sea:
89
+ self.sea = self.background
90
+
91
+ @classmethod
92
+ def from_definition(cls, definition: ThemeDefinition) -> ThemeColors:
93
+ """Create ThemeColors from a ThemeDefinition.
94
+
95
+ Args:
96
+ definition: ThemeDefinition from a theme plugin.
97
+
98
+ Returns:
99
+ ThemeColors instance with the same values.
100
+ """
101
+ return cls(
102
+ primary=definition.primary,
103
+ secondary=definition.secondary,
104
+ success=definition.success,
105
+ warning=definition.warning,
106
+ error=definition.error,
107
+ info=definition.info,
108
+ background=definition.background,
109
+ surface=definition.surface,
110
+ text=definition.text,
111
+ text_secondary=definition.text_secondary,
112
+ border=definition.border,
113
+ connected=definition.connected,
114
+ disconnected=definition.disconnected,
115
+ sea=definition.sea,
116
+ )
117
+
118
+
119
+ # Pre-defined theme color schemes (fallback during early init)
120
+ LIGHT_COLORS = ThemeColors(
121
+ primary="#2563eb",
122
+ secondary="#7c3aed",
123
+ success="#16a34a",
124
+ warning="#d97706",
125
+ error="#dc2626",
126
+ info="#0891b2",
127
+ background="#ffffff",
128
+ surface="#f3f4f6",
129
+ text="#1f2937",
130
+ text_secondary="#6b7280",
131
+ border="#e5e7eb",
132
+ disconnected="#ffcccc",
133
+ )
134
+
135
+ SLATE_COLORS = ThemeColors(
136
+ primary="#3b82f6",
137
+ secondary="#8b5cf6",
138
+ success="#22c55e",
139
+ warning="#f59e0b",
140
+ error="#ef4444",
141
+ info="#06b6d4",
142
+ background="#1e1e1e",
143
+ surface="#2d2d2d",
144
+ text="#d4d4d4",
145
+ text_secondary="#808080",
146
+ border="#3e3e3e",
147
+ disconnected="#5c2020",
148
+ )
149
+
150
+ DARKBLUE_COLORS = ThemeColors(
151
+ primary="#3b82f6",
152
+ secondary="#8b5cf6",
153
+ success="#22c55e",
154
+ warning="#f59e0b",
155
+ error="#ef4444",
156
+ info="#06b6d4",
157
+ background="#1f2937",
158
+ surface="#374151",
159
+ text="#f3f4f6",
160
+ text_secondary="#9ca3af",
161
+ border="#4b5563",
162
+ disconnected="#5c2020",
163
+ )
164
+
165
+ # Fallback color schemes for early init (before plugins load)
166
+ _FALLBACK_COLORS: dict[str, ThemeColors] = {
167
+ "light": LIGHT_COLORS,
168
+ "dark": SLATE_COLORS,
169
+ "slate": SLATE_COLORS,
170
+ "darkblue": DARKBLUE_COLORS,
171
+ }
172
+
173
+
174
+ @dataclass
175
+ class BeamlineTheme:
176
+ """Beamline-specific theme customizations.
177
+
178
+ Attributes:
179
+ name: Beamline identifier.
180
+ display_name: Human-readable beamline name.
181
+ accent_color: Custom accent/primary color.
182
+ logo_path: Path to beamline logo.
183
+ custom_colors: Additional custom color overrides.
184
+ """
185
+
186
+ name: str
187
+ display_name: str = ""
188
+ accent_color: str | None = None
189
+ logo_path: str | None = None
190
+ custom_colors: dict[str, str] = field(default_factory=dict)
191
+
192
+ def __post_init__(self) -> None:
193
+ if not self.display_name:
194
+ self.display_name = self.name
195
+
196
+
197
+ class ThemeManager(QObject):
198
+ """
199
+ Application-wide theme manager.
200
+
201
+ ThemeManager provides:
202
+ - Theme mode switching (light/dark/system)
203
+ - Plugin-based theme definitions via ThemeRegistry
204
+ - System theme detection and following
205
+ - Beamline-specific customization
206
+ - Theme-aware color utilities
207
+ - Stylesheet generation
208
+
209
+ Signals:
210
+ theme_changed: Emitted when theme mode changes (passes theme name as str).
211
+ colors_changed: Emitted when colors are updated.
212
+
213
+ Example:
214
+ >>> manager = ThemeManager.get_instance()
215
+ >>> manager.set_theme_by_name("slate")
216
+ >>> manager.colors.background
217
+ '#1e1e1e'
218
+ >>> manager.get_available_themes()
219
+ [{'name': 'light', 'display_name': 'Light', 'is_dark': False}, ...]
220
+ """
221
+
222
+ theme_changed = Signal(str) # Now emits theme name as string
223
+ colors_changed = Signal()
224
+
225
+ # Contributors that apply to every instance (survive reset()); host apps
226
+ # append here to inject QSS (e.g. Lightfall's docking chrome).
227
+ default_stylesheet_contributors: ClassVar[list[Callable[[ThemeColors, bool, int], str]]] = []
228
+
229
+ _instance: ThemeManager | None = None
230
+ _lock = threading.RLock()
231
+
232
+ def __init__(self, parent: QObject | None = None) -> None:
233
+ """Initialize the theme manager."""
234
+ super().__init__(parent)
235
+ # Theme name: "system" or a registered theme name
236
+ self._theme_name: str = "system"
237
+ # The effective (resolved) theme name (never "system")
238
+ self._effective_theme_name: str = "light"
239
+ # Current theme plugin (may be None during early init)
240
+ self._current_theme_plugin: ThemeProvider | None = None
241
+ # CSS overrides from current theme
242
+ self._css_overrides: str = ""
243
+ self._colors = LIGHT_COLORS
244
+ self._beamline_theme: BeamlineTheme | None = None
245
+ self._stylesheet_contributors: list[Callable[[ThemeColors, bool, int], str]] = []
246
+ # Islands layout (rounded floating panel cards + islands aesthetic) is
247
+ # a user preference applied on top of any theme, not tied to the
248
+ # theme's colors. Off by default; host applications sync it from
249
+ # their preferences.
250
+ self._islands_mode: bool = False
251
+ # Base UI font point size. Carried in the generated stylesheet (not
252
+ # just pushed via QApplication.setFont) so a runtime change re-polishes
253
+ # every widget — see set_font_size(). Host applications sync this from
254
+ # their preferences at preload.
255
+ self._base_font_size: int = 10
256
+
257
+ # Detect system theme
258
+ self._update_effective_theme()
259
+
260
+ @classmethod
261
+ def get_instance(cls) -> ThemeManager:
262
+ """Get the singleton ThemeManager instance."""
263
+ if cls._instance is None:
264
+ with cls._lock:
265
+ if cls._instance is None:
266
+ cls._instance = cls()
267
+ return cls._instance
268
+
269
+ @classmethod
270
+ def reset(cls) -> None:
271
+ """Reset the singleton instance (for testing)."""
272
+ with cls._lock:
273
+ if cls._instance is not None:
274
+ cls._instance.deleteLater()
275
+ cls._instance = None
276
+
277
+ @property
278
+ def theme(self) -> Theme:
279
+ """Current theme mode setting (for backward compatibility).
280
+
281
+ Deprecated: Use theme_name instead.
282
+ """
283
+ try:
284
+ return Theme(self._theme_name)
285
+ except ValueError:
286
+ # Custom theme name not in enum
287
+ if self.is_dark:
288
+ return Theme.DARK
289
+ return Theme.LIGHT
290
+
291
+ @property
292
+ def theme_name(self) -> str:
293
+ """Current theme name setting.
294
+
295
+ Returns "system" if following system preference, otherwise the
296
+ theme plugin name (e.g., "light", "slate", "darkblue").
297
+ """
298
+ return self._theme_name
299
+
300
+ @property
301
+ def effective_theme(self) -> Theme:
302
+ """Actual theme being used (for backward compatibility).
303
+
304
+ Deprecated: Use effective_theme_name instead.
305
+ """
306
+ try:
307
+ return Theme(self._effective_theme_name)
308
+ except ValueError:
309
+ # Custom theme name not in enum
310
+ if self.is_dark:
311
+ return Theme.DARK
312
+ return Theme.LIGHT
313
+
314
+ @property
315
+ def effective_theme_name(self) -> str:
316
+ """Actual theme name being used (resolves "system" to actual theme)."""
317
+ return self._effective_theme_name
318
+
319
+ @property
320
+ def is_dark(self) -> bool:
321
+ """Whether the effective theme is dark."""
322
+ if self._current_theme_plugin:
323
+ return self._current_theme_plugin.is_dark
324
+ # Fallback for known themes
325
+ return self._effective_theme_name in ("dark", "slate", "darkblue")
326
+
327
+ @property
328
+ def colors(self) -> ThemeColors:
329
+ """Current theme colors."""
330
+ return self._colors
331
+
332
+ @property
333
+ def islands_mode(self) -> bool:
334
+ """Whether the Islands layout is applied (independent of the theme)."""
335
+ return self._islands_mode
336
+
337
+ def set_islands_mode(self, enabled: bool) -> None:
338
+ """Enable/disable the Islands layout for any theme.
339
+
340
+ Re-applies the stylesheet (regenerated with the new flag) via the
341
+ theme_changed signal, the same path a theme switch uses.
342
+
343
+ Args:
344
+ enabled: True to apply rounded floating panel cards on a sea
345
+ canvas; False for a flat layout.
346
+ """
347
+ if enabled == self._islands_mode:
348
+ return
349
+ self._islands_mode = enabled
350
+ logger.info("Islands layout {}", "enabled" if enabled else "disabled")
351
+ self.theme_changed.emit(self._theme_name)
352
+
353
+ @property
354
+ def base_font_size(self) -> int:
355
+ """Current base UI font point size."""
356
+ return self._base_font_size
357
+
358
+ def set_font_size(self, size: int) -> None:
359
+ """Set the base UI font point size.
360
+
361
+ The size is baked into the generated stylesheet, so this re-applies the
362
+ stylesheet via the theme_changed signal (the same path a theme switch
363
+ uses). That re-polish is what propagates the new size to every widget:
364
+ QApplication.setFont() alone does not restyle widgets already shown
365
+ under an active global stylesheet (only pyqtgraph, which reads the app
366
+ font live, and menus, re-polished on show, picked it up otherwise).
367
+
368
+ Args:
369
+ size: New base font size in points.
370
+ """
371
+ size = int(size)
372
+ if size == self._base_font_size:
373
+ return
374
+ self._base_font_size = size
375
+ logger.info("Base font size changed: {}pt", size)
376
+ self.theme_changed.emit(self._theme_name)
377
+
378
+ def scale_pt(self, pt_at_10pt: float) -> int:
379
+ """Scale a font point size by the base font size.
380
+
381
+ For text whose size is baked into an inline stylesheet or otherwise
382
+ can't ride the cascade, this keeps it proportional to the Appearance
383
+ font size: ``pt_at_10pt`` is the design size at the reference 10pt base.
384
+ At 10pt the value is unchanged.
385
+
386
+ Args:
387
+ pt_at_10pt: The point size at the reference 10pt base.
388
+
389
+ Returns:
390
+ The scaled point size.
391
+ """
392
+ return round(pt_at_10pt * self._base_font_size / 10.0)
393
+
394
+ def scale_px(self, px_at_10pt: float) -> int:
395
+ """Scale a pixel dimension by the base font size.
396
+
397
+ Imperative pixel sizes (icon sizes, fixed button boxes) cannot ride the
398
+ stylesheet cascade, so widgets that want to track the Appearance font
399
+ size scale their design values (defined at the reference 10pt) through
400
+ this helper. At 10pt the value is unchanged.
401
+
402
+ Args:
403
+ px_at_10pt: The dimension in pixels at the reference 10pt size.
404
+
405
+ Returns:
406
+ The scaled dimension in pixels.
407
+ """
408
+ return round(px_at_10pt * self._base_font_size / 10.0)
409
+
410
+ @property
411
+ def beamline_theme(self) -> BeamlineTheme | None:
412
+ """Current beamline-specific theme."""
413
+ return self._beamline_theme
414
+
415
+ def set_theme(self, theme: Theme) -> None:
416
+ """Set the theme mode (for backward compatibility).
417
+
418
+ Args:
419
+ theme: The theme mode to use.
420
+
421
+ Note: Prefer set_theme_by_name() for new code.
422
+ """
423
+ # Map "dark" to "slate" for backward compatibility
424
+ theme_name = theme.value
425
+ if theme_name == "dark":
426
+ theme_name = "slate"
427
+ self.set_theme_by_name(theme_name)
428
+
429
+ def set_theme_by_name(self, theme_name: str) -> None:
430
+ """Set the theme by name.
431
+
432
+ Args:
433
+ theme_name: Theme name ("system" or a registered theme name).
434
+ "dark" is mapped to "slate" for backward compatibility.
435
+ """
436
+ # Map "dark" to "slate" for backward compatibility
437
+ if theme_name == "dark":
438
+ theme_name = "slate"
439
+
440
+ if theme_name == self._theme_name:
441
+ return
442
+
443
+ old_name = self._theme_name
444
+ self._theme_name = theme_name
445
+ self._update_effective_theme()
446
+
447
+ logger.info("Theme changed: {} -> {}", old_name, theme_name)
448
+ self.theme_changed.emit(theme_name)
449
+
450
+ def get_available_themes(self) -> list[dict[str, Any]]:
451
+ """Get all available themes for UI display.
452
+
453
+ Returns:
454
+ List of theme info dicts with 'name', 'display_name', 'is_dark'.
455
+ Includes a "System" option first.
456
+ """
457
+ themes = [
458
+ {
459
+ "name": "system",
460
+ "display_name": "System",
461
+ "is_dark": None, # Follows system
462
+ }
463
+ ]
464
+
465
+ # Get themes from registry
466
+ try:
467
+ from lightfall_utils.theming.registry import ThemeRegistry
468
+
469
+ registry = ThemeRegistry.get_instance()
470
+ for plugin in registry.get_all():
471
+ themes.append(
472
+ {
473
+ "name": plugin.name,
474
+ "display_name": plugin.display_name,
475
+ "is_dark": plugin.is_dark,
476
+ }
477
+ )
478
+ except ImportError:
479
+ # Registry not available, use fallback
480
+ logger.debug("ThemeRegistry not available, using fallback themes")
481
+ themes.extend(
482
+ [
483
+ {"name": "light", "display_name": "Light", "is_dark": False},
484
+ {"name": "slate", "display_name": "Slate (Dark)", "is_dark": True},
485
+ {"name": "darkblue", "display_name": "Dark Blue", "is_dark": True},
486
+ ]
487
+ )
488
+
489
+ return themes
490
+
491
+ def _update_effective_theme(self) -> None:
492
+ """Update the effective theme based on current settings."""
493
+ if self._theme_name == "system":
494
+ self._effective_theme_name = self._get_system_theme_name()
495
+ else:
496
+ self._effective_theme_name = self._theme_name
497
+
498
+ # Try to get theme from registry
499
+ self._current_theme_plugin = None
500
+ self._css_overrides = ""
501
+
502
+ try:
503
+ from lightfall_utils.theming.registry import ThemeRegistry
504
+
505
+ registry = ThemeRegistry.get_instance()
506
+ plugin = registry.get(self._effective_theme_name)
507
+
508
+ if plugin:
509
+ self._current_theme_plugin = plugin
510
+ definition = plugin.get_theme_definition()
511
+ self._colors = ThemeColors.from_definition(definition)
512
+ self._css_overrides = definition.css_overrides
513
+ else:
514
+ # Theme not in registry, use fallback
515
+ self._use_fallback_colors()
516
+ except ImportError:
517
+ # Registry not available, use fallback
518
+ self._use_fallback_colors()
519
+
520
+ # Apply beamline customizations
521
+ if self._beamline_theme:
522
+ self._apply_beamline_colors()
523
+
524
+ self.colors_changed.emit()
525
+
526
+ def _use_fallback_colors(self) -> None:
527
+ """Use fallback colors when registry is not available."""
528
+ fallback = _FALLBACK_COLORS.get(self._effective_theme_name)
529
+ if fallback:
530
+ self._colors = ThemeColors(**vars(fallback))
531
+ else:
532
+ # Unknown theme, default to light
533
+ self._colors = ThemeColors(**vars(LIGHT_COLORS))
534
+
535
+ def _get_system_theme_name(self) -> str:
536
+ """Get theme name based on system preference."""
537
+ is_dark = self._detect_system_is_dark()
538
+
539
+ # Try to get appropriate theme from registry
540
+ try:
541
+ from lightfall_utils.theming.registry import ThemeRegistry
542
+
543
+ registry = ThemeRegistry.get_instance()
544
+ plugin = registry.get_theme_for_system(is_dark)
545
+ if plugin:
546
+ return plugin.name
547
+ except ImportError:
548
+ pass
549
+
550
+ # Fallback
551
+ return "slate" if is_dark else "light"
552
+
553
+ def _detect_system_is_dark(self) -> bool:
554
+ """Detect if the system is using dark mode."""
555
+ app = QApplication.instance()
556
+ if app is None:
557
+ return False
558
+
559
+ palette = app.palette()
560
+ window_color = palette.color(QPalette.ColorRole.Window)
561
+
562
+ # Calculate luminance
563
+ luminance = (
564
+ 0.299 * window_color.redF()
565
+ + 0.587 * window_color.greenF()
566
+ + 0.114 * window_color.blueF()
567
+ )
568
+
569
+ return luminance < 0.5
570
+
571
+ def _detect_system_theme(self) -> Theme:
572
+ """Detect if the system is using dark mode (for backward compatibility)."""
573
+ return Theme.DARK if self._detect_system_is_dark() else Theme.LIGHT
574
+
575
+ def set_beamline_theme(self, theme: BeamlineTheme | None) -> None:
576
+ """Set beamline-specific theme customizations.
577
+
578
+ Args:
579
+ theme: Beamline theme or None to clear.
580
+ """
581
+ self._beamline_theme = theme
582
+ self._update_effective_theme()
583
+
584
+ if theme:
585
+ logger.info("Applied beamline theme: {}", theme.name)
586
+
587
+ def _apply_beamline_colors(self) -> None:
588
+ """Apply beamline color overrides to current colors."""
589
+ if not self._beamline_theme:
590
+ return
591
+
592
+ # Apply accent color as primary
593
+ if self._beamline_theme.accent_color:
594
+ self._colors.primary = self._beamline_theme.accent_color
595
+
596
+ # Apply any custom color overrides
597
+ for key, value in self._beamline_theme.custom_colors.items():
598
+ if hasattr(self._colors, key):
599
+ setattr(self._colors, key, value)
600
+
601
+ def apply_to_application(self) -> None:
602
+ """Apply the current theme to the Qt application."""
603
+ app = QApplication.instance()
604
+ if app is None:
605
+ return
606
+
607
+ # Set application palette
608
+ if self.is_dark:
609
+ self._apply_dark_palette(app)
610
+ else:
611
+ self._apply_light_palette(app)
612
+
613
+ # Apply global stylesheet
614
+ stylesheet = self.generate_stylesheet()
615
+ app.setStyleSheet(stylesheet)
616
+
617
+ logger.debug("Applied {} theme to application", self._effective_theme_name)
618
+
619
+ def _apply_dark_palette(self, app: QApplication) -> None:
620
+ """Apply a dark color palette to the application."""
621
+ palette = QPalette()
622
+
623
+ # Window color = sea (the app background / gaps between panels).
624
+ # QDockWidget paints from this role directly, ignoring QSS.
625
+ palette.setColor(QPalette.ColorRole.Window, QColor(self._colors.sea))
626
+ palette.setColor(QPalette.ColorRole.WindowText, QColor(self._colors.text))
627
+ palette.setColor(QPalette.ColorRole.Base, QColor(self._colors.surface))
628
+ palette.setColor(QPalette.ColorRole.AlternateBase, QColor(self._colors.background))
629
+ palette.setColor(QPalette.ColorRole.ToolTipBase, QColor(self._colors.surface))
630
+ palette.setColor(QPalette.ColorRole.ToolTipText, QColor(self._colors.text))
631
+
632
+ # Text colors
633
+ palette.setColor(QPalette.ColorRole.Text, QColor(self._colors.text))
634
+ palette.setColor(QPalette.ColorRole.PlaceholderText, QColor(self._colors.text_secondary))
635
+ palette.setColor(QPalette.ColorRole.BrightText, QColor("#ffffff"))
636
+
637
+ # Button colors
638
+ palette.setColor(QPalette.ColorRole.Button, QColor(self._colors.surface))
639
+ palette.setColor(QPalette.ColorRole.ButtonText, QColor(self._colors.text))
640
+
641
+ # Selection colors
642
+ palette.setColor(QPalette.ColorRole.Highlight, QColor(self._colors.primary))
643
+ palette.setColor(QPalette.ColorRole.HighlightedText, QColor("#ffffff"))
644
+
645
+ # Links
646
+ palette.setColor(QPalette.ColorRole.Link, QColor(self._colors.primary))
647
+ palette.setColor(QPalette.ColorRole.LinkVisited, QColor(self._colors.secondary))
648
+
649
+ app.setPalette(palette)
650
+
651
+ def _apply_light_palette(self, app: QApplication) -> None:
652
+ """Apply a light color palette to the application."""
653
+ palette = QPalette()
654
+
655
+ # Window colors
656
+ palette.setColor(QPalette.ColorRole.Window, QColor(self._colors.background))
657
+ palette.setColor(QPalette.ColorRole.WindowText, QColor(self._colors.text))
658
+ palette.setColor(QPalette.ColorRole.Base, QColor("#ffffff"))
659
+ palette.setColor(QPalette.ColorRole.AlternateBase, QColor(self._colors.surface))
660
+ palette.setColor(QPalette.ColorRole.ToolTipBase, QColor("#ffffff"))
661
+ palette.setColor(QPalette.ColorRole.ToolTipText, QColor(self._colors.text))
662
+
663
+ # Text colors
664
+ palette.setColor(QPalette.ColorRole.Text, QColor(self._colors.text))
665
+ palette.setColor(QPalette.ColorRole.PlaceholderText, QColor(self._colors.text_secondary))
666
+ palette.setColor(QPalette.ColorRole.BrightText, QColor("#ffffff"))
667
+
668
+ # Button colors
669
+ palette.setColor(QPalette.ColorRole.Button, QColor(self._colors.surface))
670
+ palette.setColor(QPalette.ColorRole.ButtonText, QColor(self._colors.text))
671
+
672
+ # Selection colors
673
+ palette.setColor(QPalette.ColorRole.Highlight, QColor(self._colors.primary))
674
+ palette.setColor(QPalette.ColorRole.HighlightedText, QColor("#ffffff"))
675
+
676
+ # Links
677
+ palette.setColor(QPalette.ColorRole.Link, QColor(self._colors.primary))
678
+ palette.setColor(QPalette.ColorRole.LinkVisited, QColor(self._colors.secondary))
679
+
680
+ app.setPalette(palette)
681
+
682
+ def add_stylesheet_contributor(
683
+ self, contributor: Callable[[ThemeColors, bool, int], str]
684
+ ) -> None:
685
+ """Register a callable that contributes extra QSS to generate_stylesheet().
686
+
687
+ Called as ``contributor(colors, islands_mode, base_font_size)`` and
688
+ must return a QSS string. Registering the same callable twice is a
689
+ no-op.
690
+ """
691
+ if contributor not in self._stylesheet_contributors:
692
+ self._stylesheet_contributors.append(contributor)
693
+
694
+ def remove_stylesheet_contributor(
695
+ self, contributor: Callable[[ThemeColors, bool, int], str]
696
+ ) -> None:
697
+ """Unregister a stylesheet contributor (no-op if not registered)."""
698
+ if contributor in self._stylesheet_contributors:
699
+ self._stylesheet_contributors.remove(contributor)
700
+
701
+ def generate_stylesheet(self) -> str:
702
+ """Generate a global stylesheet for the current theme.
703
+
704
+ Returns:
705
+ CSS stylesheet string.
706
+ """
707
+ c = self._colors
708
+ base_stylesheet = f"""
709
+ /* lightfall-utils Global Theme Stylesheet */
710
+
711
+ /* Base font size (user preference). Carried in the stylesheet so a runtime
712
+ change re-polishes every widget; QApplication.setFont() alone does not
713
+ propagate to widgets already shown while a global stylesheet is active.
714
+ More specific rules below (and per-widget styles) override this. */
715
+ QWidget {{
716
+ font-size: {self._base_font_size}pt;
717
+ }}
718
+
719
+ /* Scrollbars */
720
+ QScrollBar:vertical {{
721
+ background: {c.surface};
722
+ width: 12px;
723
+ margin: 0;
724
+ }}
725
+ QScrollBar::handle:vertical {{
726
+ background: {c.border};
727
+ min-height: 30px;
728
+ border-radius: 6px;
729
+ margin: 2px;
730
+ }}
731
+ QScrollBar::handle:vertical:hover {{
732
+ background: {c.text_secondary};
733
+ }}
734
+ QScrollBar:horizontal {{
735
+ background: {c.surface};
736
+ height: 12px;
737
+ margin: 0;
738
+ }}
739
+ QScrollBar::handle:horizontal {{
740
+ background: {c.border};
741
+ min-width: 30px;
742
+ border-radius: 6px;
743
+ margin: 2px;
744
+ }}
745
+ QScrollBar::handle:horizontal:hover {{
746
+ background: {c.text_secondary};
747
+ }}
748
+ QScrollBar::add-line, QScrollBar::sub-line {{
749
+ border: none;
750
+ background: none;
751
+ }}
752
+
753
+ /* Tool tips */
754
+ QToolTip {{
755
+ background-color: {c.surface};
756
+ color: {c.text};
757
+ border: 1px solid {c.border};
758
+ padding: 4px;
759
+ }}
760
+
761
+ /* Menu */
762
+ QMenu {{
763
+ background-color: {c.background};
764
+ border: 1px solid {c.border};
765
+ }}
766
+ QMenu::item {{
767
+ padding: 6px 24px;
768
+ }}
769
+ QMenu::item:selected {{
770
+ background-color: {c.primary};
771
+ color: white;
772
+ }}
773
+ QMenu::separator {{
774
+ height: 1px;
775
+ background: {c.border};
776
+ margin: 4px 8px;
777
+ }}
778
+
779
+ /* Tab widget */
780
+ QTabWidget::pane {{
781
+ border: 1px solid {c.border};
782
+ background: {c.background};
783
+ }}
784
+ QTabBar::tab {{
785
+ background: {c.surface};
786
+ border: 1px solid {c.border};
787
+ padding: 8px 16px;
788
+ margin-right: 2px;
789
+ }}
790
+ QTabBar::tab:selected {{
791
+ background: {c.background};
792
+ border-bottom-color: {c.background};
793
+ }}
794
+
795
+ /* Dock widgets */
796
+ QDockWidget {{
797
+ titlebar-close-icon: url(close.png);
798
+ titlebar-normal-icon: url(float.png);
799
+ }}
800
+ QDockWidget::title {{
801
+ background: {c.surface};
802
+ padding: 6px;
803
+ }}
804
+
805
+ /* Status bar */
806
+ QStatusBar {{
807
+ background: {c.surface};
808
+ border-top: 1px solid {c.border};
809
+ }}
810
+
811
+ /* Group box */
812
+ QGroupBox {{
813
+ font-weight: bold;
814
+ border: 1px solid {c.border};
815
+ border-radius: 4px;
816
+ margin-top: 8px;
817
+ padding-top: 8px;
818
+ }}
819
+ QGroupBox::title {{
820
+ subcontrol-origin: margin;
821
+ left: 8px;
822
+ padding: 0 4px;
823
+ }}
824
+
825
+ /* Line edit / multi-line text edits */
826
+ QLineEdit, QTextEdit, QPlainTextEdit {{
827
+ border: 1px solid {c.border};
828
+ border-radius: 4px;
829
+ padding: 4px 8px;
830
+ background: {c.background};
831
+ }}
832
+ QLineEdit:focus, QTextEdit:focus, QPlainTextEdit:focus {{
833
+ border-color: {c.primary};
834
+ }}
835
+
836
+ /* Combo box */
837
+ QComboBox {{
838
+ border: 1px solid {c.border};
839
+ border-radius: 4px;
840
+ padding: 4px 8px;
841
+ background: {c.background};
842
+ }}
843
+ QComboBox:focus {{
844
+ border-color: {c.primary};
845
+ }}
846
+
847
+ /* Spin box */
848
+ QSpinBox, QDoubleSpinBox {{
849
+ border: 1px solid {c.border};
850
+ border-radius: 4px;
851
+ padding: 4px;
852
+ background: {c.background};
853
+ }}
854
+ QSpinBox:focus, QDoubleSpinBox:focus {{
855
+ border-color: {c.primary};
856
+ }}
857
+
858
+ /* Push button */
859
+ QPushButton {{
860
+ background: {c.surface};
861
+ border: 1px solid {c.border};
862
+ border-radius: 4px;
863
+ padding: 6px 16px;
864
+ }}
865
+ QPushButton:hover {{
866
+ background: {c.border};
867
+ }}
868
+ QPushButton:pressed {{
869
+ background: {c.text_secondary};
870
+ }}
871
+ QPushButton:disabled {{
872
+ background: {c.surface};
873
+ color: {c.text_secondary};
874
+ }}
875
+
876
+ /* Primary button */
877
+ QPushButton[primary="true"] {{
878
+ background: {c.primary};
879
+ color: white;
880
+ border: none;
881
+ }}
882
+ QPushButton[primary="true"]:hover {{
883
+ background: {self._adjust_color(c.primary, -20)};
884
+ }}
885
+
886
+ /* Progress bar */
887
+ QProgressBar {{
888
+ border: 1px solid {c.border};
889
+ border-radius: 4px;
890
+ text-align: center;
891
+ background: {c.surface};
892
+ }}
893
+ QProgressBar::chunk {{
894
+ background: {c.primary};
895
+ border-radius: 3px;
896
+ }}
897
+
898
+ /* Splitter */
899
+ QSplitter::handle {{
900
+ background: {c.border};
901
+ }}
902
+ QSplitter::handle:horizontal {{
903
+ width: 2px;
904
+ }}
905
+ QSplitter::handle:vertical {{
906
+ height: 2px;
907
+ }}
908
+
909
+ /* Tree and list views */
910
+ QTreeView, QListView, QTableView {{
911
+ border: 1px solid {c.border};
912
+ }}
913
+ QTreeView::item, QListView::item, QTableView::item {{
914
+ background: {c.background};
915
+ }}
916
+ QTreeView::item:alternate, QListView::item:alternate, QTableView::item:alternate {{
917
+ background: {c.surface};
918
+ }}
919
+ QTreeView::item:selected, QListView::item:selected, QTableView::item:selected {{
920
+ background: {c.primary};
921
+ color: white;
922
+ }}
923
+
924
+ /* Header */
925
+ QHeaderView::section {{
926
+ background: {c.surface};
927
+ border: none;
928
+ border-right: 1px solid {c.border};
929
+ border-bottom: 1px solid {c.border};
930
+ padding: 6px;
931
+ }}
932
+ """
933
+ # Islands aesthetic (rounded menus, inputs, scrollbars, tabs, ...) is
934
+ # applied on top of ANY theme when Islands mode is enabled, so the look
935
+ # is consistent across themes rather than baked into specific ones.
936
+ if self._islands_mode:
937
+ try:
938
+ from lightfall_utils.theming.builtin import generate_islands_stylesheet
939
+
940
+ base_stylesheet += f"\n{generate_islands_stylesheet(c)}"
941
+ except ImportError:
942
+ pass
943
+
944
+ # Append theme-specific CSS overrides
945
+ if self._css_overrides:
946
+ base_stylesheet += f"\n/* Theme-specific overrides */\n{self._css_overrides}"
947
+
948
+ # Append QSS from registered contributors (class-level defaults first,
949
+ # then per-instance registrations).
950
+ for contributor in [
951
+ *type(self).default_stylesheet_contributors,
952
+ *self._stylesheet_contributors,
953
+ ]:
954
+ try:
955
+ base_stylesheet += f"\n{contributor(c, self._islands_mode, self._base_font_size)}"
956
+ except Exception:
957
+ logger.exception("Stylesheet contributor {} failed", contributor)
958
+
959
+ return base_stylesheet
960
+
961
+ @staticmethod
962
+ def _adjust_color(color: str, amount: int) -> str:
963
+ """Adjust a hex color's brightness.
964
+
965
+ Args:
966
+ color: Hex color string.
967
+ amount: Amount to adjust (-255 to 255).
968
+
969
+ Returns:
970
+ Adjusted hex color string.
971
+ """
972
+ qcolor = QColor(color)
973
+ h, s, lightness, a = qcolor.getHslF()
974
+ lightness = max(0.0, min(1.0, lightness + amount / 255.0))
975
+ qcolor.setHslF(h, s, lightness, a)
976
+ return qcolor.name()
977
+
978
+ # Color utility methods
979
+
980
+ def get_state_color(self, state: str) -> str:
981
+ """Get color for a named state.
982
+
983
+ Args:
984
+ state: State name (success, warning, error, info, etc.)
985
+
986
+ Returns:
987
+ Hex color string.
988
+ """
989
+ state_colors = {
990
+ "success": self._colors.success,
991
+ "warning": self._colors.warning,
992
+ "error": self._colors.error,
993
+ "info": self._colors.info,
994
+ "connected": self._colors.connected,
995
+ "disconnected": self._colors.disconnected,
996
+ }
997
+ return state_colors.get(state, self._colors.text)
998
+
999
+ def get_background_for_state(self, state: str) -> str:
1000
+ """Get muted background color for a state.
1001
+
1002
+ Args:
1003
+ state: State name.
1004
+
1005
+ Returns:
1006
+ Hex color string suitable for background.
1007
+ """
1008
+ base_color = self.get_state_color(state)
1009
+
1010
+ if self.is_dark:
1011
+ # Darken for dark theme
1012
+ return self._adjust_color(base_color, -100)
1013
+ else:
1014
+ # Lighten for light theme
1015
+ return self._adjust_color(base_color, 100)
1016
+
1017
+
1018
+ def scaled_pt(ref_pt: float) -> int:
1019
+ """Module-level shortcut for ``ThemeManager.scale_pt``.
1020
+
1021
+ For inline stylesheets that bake an absolute font size: pass the design
1022
+ size at the reference 10pt base and the result tracks the Appearance >
1023
+ Font Size setting. Read when the stylesheet is built.
1024
+ """
1025
+ return ThemeManager.get_instance().scale_pt(ref_pt)
1026
+
1027
+
1028
+ def scaled_px(ref_px: float) -> int:
1029
+ """Module-level shortcut for ``ThemeManager.scale_px`` (see ``scaled_pt``)."""
1030
+ return ThemeManager.get_instance().scale_px(ref_px)