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.
- lightfall_utils/__init__.py +3 -0
- lightfall_utils/_version.py +24 -0
- lightfall_utils/ca/__init__.py +9 -0
- lightfall_utils/ca/context.py +100 -0
- lightfall_utils/ca/pv.py +307 -0
- lightfall_utils/caproto_shutdown.py +89 -0
- lightfall_utils/config/__init__.py +12 -0
- lightfall_utils/config/layers.py +317 -0
- lightfall_utils/config/manager.py +246 -0
- lightfall_utils/log_buffer.py +231 -0
- lightfall_utils/logging.py +235 -0
- lightfall_utils/py.typed +0 -0
- lightfall_utils/qt_affinity.py +92 -0
- lightfall_utils/theming/__init__.py +30 -0
- lightfall_utils/theming/builtin.py +737 -0
- lightfall_utils/theming/manager.py +1030 -0
- lightfall_utils/theming/provider.py +102 -0
- lightfall_utils/theming/registry.py +195 -0
- lightfall_utils/threads.py +1071 -0
- lightfall_utils-0.1.0.dist-info/METADATA +54 -0
- lightfall_utils-0.1.0.dist-info/RECORD +24 -0
- lightfall_utils-0.1.0.dist-info/WHEEL +4 -0
- lightfall_utils-0.1.0.dist-info/licenses/LEGAL.md +13 -0
- lightfall_utils-0.1.0.dist-info/licenses/LICENSE.md +33 -0
|
@@ -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)
|