nucleuskit-toolkit 2.2.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.
Files changed (59) hide show
  1. nucleuskit_toolkit/__init__.py +3 -0
  2. nucleuskit_toolkit/__main__.py +60 -0
  3. nucleuskit_toolkit/app.py +34 -0
  4. nucleuskit_toolkit/camera/__init__.py +9 -0
  5. nucleuskit_toolkit/camera/processing.py +374 -0
  6. nucleuskit_toolkit/config.py +86 -0
  7. nucleuskit_toolkit/hermes/__init__.py +0 -0
  8. nucleuskit_toolkit/hermes/constants.py +77 -0
  9. nucleuskit_toolkit/hermes/realtime/__init__.py +8 -0
  10. nucleuskit_toolkit/hermes/realtime/proxy.py +652 -0
  11. nucleuskit_toolkit/logging_utils.py +68 -0
  12. nucleuskit_toolkit/mqtt/__init__.py +5 -0
  13. nucleuskit_toolkit/mqtt/mqtt_controller.py +256 -0
  14. nucleuskit_toolkit/pipeline.py +194 -0
  15. nucleuskit_toolkit/position/__init__.py +5 -0
  16. nucleuskit_toolkit/position/gps_processor.py +91 -0
  17. nucleuskit_toolkit/session_job.py +28 -0
  18. nucleuskit_toolkit/shimmer/__init__.py +5 -0
  19. nucleuskit_toolkit/shimmer/constants.py +32 -0
  20. nucleuskit_toolkit/shimmer/realtime/__init__.py +5 -0
  21. nucleuskit_toolkit/shimmer/realtime/proxy.py +279 -0
  22. nucleuskit_toolkit/tools/__init__.py +1 -0
  23. nucleuskit_toolkit/tools/eeg_regression.py +91 -0
  24. nucleuskit_toolkit/tools/recording_merger.py +437 -0
  25. nucleuskit_toolkit/ui/__init__.py +1 -0
  26. nucleuskit_toolkit/ui/icons.py +165 -0
  27. nucleuskit_toolkit/ui/main_window.py +465 -0
  28. nucleuskit_toolkit/ui/offline_job.py +144 -0
  29. nucleuskit_toolkit/ui/pages/__init__.py +1 -0
  30. nucleuskit_toolkit/ui/pages/about_page.py +88 -0
  31. nucleuskit_toolkit/ui/pages/channel_fixer_page.py +205 -0
  32. nucleuskit_toolkit/ui/pages/channel_gain_page.py +362 -0
  33. nucleuskit_toolkit/ui/pages/eeg_regression_page.py +385 -0
  34. nucleuskit_toolkit/ui/pages/mqtt_controller_page.py +394 -0
  35. nucleuskit_toolkit/ui/pages/offline_page.py +754 -0
  36. nucleuskit_toolkit/ui/pages/playback_page.py +1524 -0
  37. nucleuskit_toolkit/ui/pages/ppg_fixer_page.py +199 -0
  38. nucleuskit_toolkit/ui/pages/realtime_viewer_page.py +954 -0
  39. nucleuskit_toolkit/ui/pages/recording_merge_page.py +395 -0
  40. nucleuskit_toolkit/ui/pages/revert_original_page.py +128 -0
  41. nucleuskit_toolkit/ui/pages/settings_page.py +69 -0
  42. nucleuskit_toolkit/ui/pages/tools_menu_page.py +42 -0
  43. nucleuskit_toolkit/ui/plot_style.py +155 -0
  44. nucleuskit_toolkit/ui/realtime_eeg_plot.py +244 -0
  45. nucleuskit_toolkit/ui/realtime_hermes_motion_plot.py +162 -0
  46. nucleuskit_toolkit/ui/realtime_nlms_config_dialog.py +101 -0
  47. nucleuskit_toolkit/ui/realtime_shimmer_plot.py +135 -0
  48. nucleuskit_toolkit/ui/resources/__init__.py +1 -0
  49. nucleuskit_toolkit/ui/resources/branding/__init__.py +1 -0
  50. nucleuskit_toolkit/ui/resources/branding/logo.png +0 -0
  51. nucleuskit_toolkit/ui/session_inspect.py +226 -0
  52. nucleuskit_toolkit/ui/theme.py +330 -0
  53. nucleuskit_toolkit/ui/widgets.py +190 -0
  54. nucleuskit_toolkit-2.2.0.dist-info/METADATA +351 -0
  55. nucleuskit_toolkit-2.2.0.dist-info/RECORD +59 -0
  56. nucleuskit_toolkit-2.2.0.dist-info/WHEEL +5 -0
  57. nucleuskit_toolkit-2.2.0.dist-info/entry_points.txt +2 -0
  58. nucleuskit_toolkit-2.2.0.dist-info/licenses/LICENSE +675 -0
  59. nucleuskit_toolkit-2.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,330 @@
1
+ """Application theming for PySide6: dark and light palettes, one style sheet for every page.
2
+
3
+ Colour roles (dark / light):
4
+
5
+ - Background: page and sidebar.
6
+ - Surface: cards, inputs, pills, secondary buttons.
7
+ - Muted: descriptions, helper text, placeholders, "Not selected".
8
+ - Accent: selection, step numbers, checked states, active navigation, Enabled pills, primary buttons.
9
+ - Run: the Run pipeline action only (``QPushButton[variant="run"]``); nothing else uses it.
10
+
11
+ Widgets opt into the design with dynamic properties: ``card`` (QFrame), ``variant`` (QPushButton: ``primary``,
12
+ ``secondary``, ``run``, ``link``, ``nav``, ``choice``), ``role`` (QLabel: ``title``, ``subtitle``, ``cardTitle``,
13
+ ``hint``, ``section``, ``muted``, ``placeholder``, ``error``, ``ok``, ``badge``) and ``pill`` (QLabel: ``on``, ``off``).
14
+ The legacy ``secondary="true"`` property of the older pages maps to the outlined secondary button.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import dataclass
20
+ from typing import Literal
21
+
22
+ from PySide6.QtCore import Qt
23
+ from PySide6.QtGui import QColor, QFont, QPalette
24
+ from PySide6.QtWidgets import QApplication
25
+
26
+ from nucleuskit_toolkit.ui.icons import icon_file
27
+
28
+ ThemeMode = Literal["light", "dark", "system"]
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class Palette:
33
+ name: Literal["light", "dark"]
34
+ background: str
35
+ surface: str
36
+ surface_hover: str
37
+ border: str
38
+ text: str
39
+ muted: str
40
+ accent: str
41
+ accent_hover: str
42
+ accent_soft: str
43
+ accent_text: str
44
+ run: str
45
+ run_hover: str
46
+ run_pressed: str
47
+ disabled_text: str
48
+ error: str
49
+ ok: str
50
+ log_background: str
51
+
52
+
53
+ DARK = Palette(
54
+ name="dark",
55
+ background="#10131C",
56
+ surface="#1C2230",
57
+ surface_hover="#242C3D",
58
+ border="#2A3245",
59
+ text="#EEF2F8",
60
+ muted="#AAB6CC",
61
+ accent="#4F85EE",
62
+ accent_hover="#6A98F1",
63
+ accent_soft="rgba(79, 133, 238, 0.16)",
64
+ accent_text="#8DB0F5",
65
+ run="#F47735",
66
+ run_hover="#F68A50",
67
+ run_pressed="#DB6529",
68
+ disabled_text="#6E7890",
69
+ error="#F27C7C",
70
+ ok="#5FC48A",
71
+ log_background="#141925",
72
+ )
73
+
74
+ LIGHT = Palette(
75
+ name="light",
76
+ background="#F4F6FA",
77
+ surface="#FFFFFF",
78
+ surface_hover="#EEF2F8",
79
+ border="#DCE2EC",
80
+ text="#10131C",
81
+ muted="#5B6579",
82
+ accent="#4F85EE",
83
+ accent_hover="#3D74E0",
84
+ accent_soft="rgba(79, 133, 238, 0.12)",
85
+ accent_text="#2F64CF",
86
+ run="#F47735",
87
+ run_hover="#F68A50",
88
+ run_pressed="#DB6529",
89
+ disabled_text="#9AA3B5",
90
+ error="#C93C3C",
91
+ ok="#2E8B57",
92
+ log_background="#F8FAFD",
93
+ )
94
+
95
+ _current: Palette = DARK
96
+
97
+
98
+ def current_palette() -> Palette:
99
+ """The palette applied last (dark before the first ``apply_theme``)."""
100
+ return _current
101
+
102
+
103
+ def stylesheet(p: Palette) -> str:
104
+ check = icon_file("check", "#FFFFFF", 3.0)
105
+ chevron = icon_file("chevron-down", p.muted)
106
+ chevron_disabled = icon_file("chevron-down", p.disabled_text)
107
+ chevron_up = icon_file("chevron-up", p.muted)
108
+ return f"""
109
+ * {{ font-size: 13px; }}
110
+ QWidget {{ color: {p.text}; }}
111
+ QMainWindow, QDialog, QMessageBox {{ background-color: {p.background}; }}
112
+ QLabel, QCheckBox, QRadioButton {{ background-color: transparent; }}
113
+ QToolTip {{ background-color: {p.surface}; color: {p.text}; border: 1px solid {p.border}; padding: 6px; }}
114
+
115
+ /* Cards and sidebar */
116
+ QFrame[card="true"] {{ background-color: {p.surface}; border: 1px solid {p.border}; border-radius: 12px; }}
117
+ QFrame[divider="true"] {{ background-color: {p.border}; border: none; max-height: 1px; min-height: 1px; }}
118
+ QFrame#Sidebar {{ background-color: {p.background}; border: none; border-right: 1px solid {p.border}; }}
119
+ QLabel#StepNumber {{
120
+ background-color: {p.accent}; color: #FFFFFF; border-radius: 16px; font-size: 15px; font-weight: 700;
121
+ }}
122
+ QLabel#HeaderIcon {{ background-color: {p.accent_soft}; border-radius: 18px; }}
123
+ QLabel#AppIcon {{ background-color: {p.surface}; border: 1px solid {p.border}; border-radius: 10px; }}
124
+
125
+ /* Text roles */
126
+ QLabel[role="title"] {{ font-size: 26px; font-weight: 700; color: {p.text}; }}
127
+ QLabel[role="subtitle"] {{ font-size: 14px; color: {p.muted}; }}
128
+ QLabel[role="cardTitle"] {{ font-size: 16px; font-weight: 600; color: {p.text}; }}
129
+ QLabel[role="section"] {{ font-size: 14px; font-weight: 600; color: {p.text}; }}
130
+ QLabel[role="strong"] {{ font-weight: 600; color: {p.text}; }}
131
+ QLabel[role="hint"], QLabel[role="muted"] {{ color: {p.muted}; }}
132
+ QLabel[role="placeholder"] {{ color: {p.muted}; font-style: italic; }}
133
+ QLabel[role="error"] {{ color: {p.error}; }}
134
+ QLabel[role="ok"] {{ color: {p.ok}; }}
135
+ QLabel[role="appName"] {{ font-size: 16px; font-weight: 700; color: {p.text}; }}
136
+ QLabel[pill="on"] {{
137
+ background-color: {p.accent}; color: #FFFFFF; border-radius: 8px; padding: 5px 12px; font-weight: 600;
138
+ }}
139
+ QLabel[pill="off"], QLabel[pill="value"] {{
140
+ background-color: {p.surface_hover}; color: {p.text}; border-radius: 8px; padding: 5px 12px;
141
+ }}
142
+ QLabel:disabled {{ color: {p.disabled_text}; }}
143
+
144
+ /* Inputs */
145
+ QLineEdit, QPlainTextEdit, QTextEdit, QComboBox, QSpinBox, QDoubleSpinBox {{
146
+ background-color: {p.surface}; border: 1px solid {p.border}; border-radius: 8px; padding: 8px 10px;
147
+ color: {p.text}; selection-background-color: {p.accent}; selection-color: #FFFFFF;
148
+ }}
149
+ QLineEdit {{ min-height: 22px; }}
150
+ QLineEdit:focus, QPlainTextEdit:focus, QComboBox:focus, QSpinBox:focus, QDoubleSpinBox:focus {{
151
+ border: 1px solid {p.accent};
152
+ }}
153
+ QLineEdit:disabled, QComboBox:disabled, QPlainTextEdit:disabled {{ color: {p.disabled_text}; }}
154
+ QLineEdit[invalid="true"] {{ border: 1px solid {p.error}; }}
155
+ QFrame[card="true"] QLineEdit, QFrame[card="true"] QComboBox {{ background-color: {p.background}; }}
156
+ QComboBox {{ padding-right: 34px; min-height: 20px; }}
157
+ QComboBox::drop-down {{ border: none; width: 32px; }}
158
+ QComboBox::down-arrow {{ image: url("{chevron}"); width: 16px; height: 16px; }}
159
+ QComboBox::down-arrow:disabled {{ image: url("{chevron_disabled}"); }}
160
+ QSpinBox, QDoubleSpinBox {{ padding-right: 26px; }}
161
+ QSpinBox::up-button, QDoubleSpinBox::up-button, QSpinBox::down-button, QDoubleSpinBox::down-button {{
162
+ subcontrol-origin: border; width: 22px; border: none; background: transparent;
163
+ }}
164
+ QSpinBox::up-button, QDoubleSpinBox::up-button {{ subcontrol-position: top right; }}
165
+ QSpinBox::down-button, QDoubleSpinBox::down-button {{ subcontrol-position: bottom right; }}
166
+ QSpinBox::up-arrow, QDoubleSpinBox::up-arrow {{ image: url("{chevron_up}"); width: 12px; height: 12px; }}
167
+ QSpinBox::down-arrow, QDoubleSpinBox::down-arrow {{ image: url("{chevron}"); width: 12px; height: 12px; }}
168
+ QComboBox QAbstractItemView {{
169
+ background-color: {p.surface}; color: {p.text}; border: 1px solid {p.border};
170
+ selection-background-color: {p.accent}; selection-color: #FFFFFF; outline: none; padding: 4px;
171
+ }}
172
+ QPlainTextEdit#Log {{
173
+ background-color: {p.log_background}; font-family: "Cascadia Mono", "Consolas", "Menlo", monospace;
174
+ font-size: 12px; padding: 10px 12px;
175
+ }}
176
+
177
+ /* Check boxes and radio buttons: visible borders when unchecked, accent fill when checked */
178
+ QCheckBox, QRadioButton {{ spacing: 10px; background-color: transparent; }}
179
+ QCheckBox::indicator, QRadioButton::indicator {{
180
+ width: 18px; height: 18px; border: 2px solid {p.muted}; background-color: transparent;
181
+ }}
182
+ QCheckBox::indicator {{ border-radius: 5px; }}
183
+ QRadioButton::indicator {{ border-radius: 11px; }}
184
+ QCheckBox::indicator:hover, QRadioButton::indicator:hover {{ border-color: {p.accent}; }}
185
+ QCheckBox::indicator:checked {{ background-color: {p.accent}; border-color: {p.accent}; image: url("{check}"); }}
186
+ QRadioButton::indicator:checked {{
187
+ border: 2px solid {p.accent}; background-color: qradialgradient(cx:0.5, cy:0.5, radius:0.5, fx:0.5, fy:0.5,
188
+ stop:0 {p.accent}, stop:0.45 {p.accent}, stop:0.5 transparent, stop:1 transparent);
189
+ }}
190
+ QCheckBox::indicator:disabled, QRadioButton::indicator:disabled {{ border-color: {p.disabled_text}; }}
191
+ QCheckBox:disabled, QRadioButton:disabled {{ color: {p.disabled_text}; }}
192
+
193
+ /* Buttons: primary (accent, default), secondary (outlined), run (orange, Run pipeline only), link, nav */
194
+ QPushButton {{
195
+ background-color: {p.accent}; color: #FFFFFF; border: none; border-radius: 8px;
196
+ padding: 9px 18px; font-weight: 600; min-height: 20px;
197
+ }}
198
+ QPushButton:hover {{ background-color: {p.accent_hover}; }}
199
+ QPushButton:disabled {{ background-color: {p.surface_hover}; color: {p.disabled_text}; }}
200
+ QPushButton[secondary="true"], QPushButton[variant="secondary"] {{
201
+ background-color: transparent; color: {p.text}; border: 1px solid {p.border}; font-weight: 500;
202
+ }}
203
+ QPushButton[secondary="true"]:hover, QPushButton[variant="secondary"]:hover {{
204
+ background-color: {p.surface_hover}; border-color: {p.muted};
205
+ }}
206
+ QPushButton[secondary="true"]:disabled, QPushButton[variant="secondary"]:disabled {{
207
+ background-color: transparent; color: {p.disabled_text}; border-color: {p.border};
208
+ }}
209
+ QPushButton[variant="run"] {{
210
+ background-color: {p.run}; color: #FFFFFF; border-radius: 12px; padding: 0; min-height: 56px;
211
+ }}
212
+ QPushButton[variant="run"]:hover {{ background-color: {p.run_hover}; }}
213
+ QPushButton[variant="run"]:pressed {{ background-color: {p.run_pressed}; }}
214
+ QPushButton[variant="run"]:disabled {{ background-color: {p.surface_hover}; color: {p.disabled_text}; }}
215
+ QPushButton[variant="cancel"] {{
216
+ background-color: transparent; color: {p.text}; border: 2px solid {p.muted}; border-radius: 12px;
217
+ padding: 0; min-height: 52px;
218
+ }}
219
+ QPushButton[variant="cancel"]:hover {{ background-color: {p.surface_hover}; }}
220
+ QPushButton[variant="link"] {{
221
+ background-color: transparent; color: {p.muted}; border: none; padding: 4px 6px; font-weight: 500;
222
+ }}
223
+ QPushButton[variant="link"]:hover {{ color: {p.text}; }}
224
+ QPushButton[variant="nav"] {{
225
+ background-color: transparent; color: {p.text}; border: none; border-radius: 10px;
226
+ padding: 10px 14px; text-align: left; font-weight: 500;
227
+ }}
228
+ QPushButton[variant="nav"]:hover {{ background-color: {p.surface}; }}
229
+ QPushButton[variant="nav"]:checked {{
230
+ background-color: {p.accent_soft}; color: {p.accent_text}; font-weight: 600;
231
+ }}
232
+ QPushButton[variant="nav"]:disabled {{ color: {p.disabled_text}; }}
233
+
234
+ /* Selectable mode cards */
235
+ QFrame[choice="true"] {{ background-color: transparent; border: 1px solid {p.border}; border-radius: 10px; }}
236
+ QFrame[choice="true"]:hover {{ border-color: {p.muted}; }}
237
+ QFrame[choice="true"][selected="true"] {{ background-color: {p.accent_soft}; border: 2px solid {p.accent}; }}
238
+ QFrame[choice="true"]:disabled {{ border-color: {p.border}; }}
239
+
240
+ /* Legacy group boxes (older pages) */
241
+ QGroupBox {{
242
+ font-weight: 600; color: {p.text}; background-color: {p.surface}; border: 1px solid {p.border};
243
+ border-radius: 12px; margin-top: 14px; padding: 16px 12px 12px 12px;
244
+ }}
245
+ QGroupBox::title {{ subcontrol-origin: margin; left: 12px; padding: 0 6px; color: {p.text}; }}
246
+ QGroupBox QLineEdit, QGroupBox QComboBox, QGroupBox QPlainTextEdit {{ background-color: {p.background}; }}
247
+
248
+ /* Lists, tables, tabs, progress, scroll bars, splitters */
249
+ QListWidget, QTableWidget, QTableView, QTreeView {{
250
+ background-color: {p.surface}; border: 1px solid {p.border}; border-radius: 8px; color: {p.text};
251
+ alternate-background-color: {p.surface_hover}; gridline-color: {p.border};
252
+ }}
253
+ QListWidget::item {{ padding: 8px 10px; border-radius: 6px; }}
254
+ QListWidget::item:selected, QTableView::item:selected {{ background-color: {p.accent}; color: #FFFFFF; }}
255
+ QListWidget::item:hover:!selected {{ background-color: {p.surface_hover}; }}
256
+ QHeaderView::section {{
257
+ background-color: {p.surface_hover}; color: {p.text}; border: none; padding: 6px 8px; font-weight: 600;
258
+ }}
259
+ QTabWidget::pane {{ border: 1px solid {p.border}; border-radius: 8px; top: -1px; }}
260
+ QTabBar::tab {{
261
+ background-color: transparent; color: {p.muted}; padding: 8px 14px; border-bottom: 2px solid transparent;
262
+ }}
263
+ QTabBar::tab:selected {{ color: {p.text}; border-bottom: 2px solid {p.accent}; }}
264
+ QProgressBar {{
265
+ background-color: {p.surface_hover}; border: none; border-radius: 6px; color: {p.text};
266
+ text-align: center; min-height: 12px;
267
+ }}
268
+ QProgressBar::chunk {{ background-color: {p.accent}; border-radius: 6px; }}
269
+ QScrollBar:vertical {{ background: transparent; width: 10px; margin: 2px; }}
270
+ QScrollBar::handle:vertical {{ background: {p.border}; border-radius: 4px; min-height: 30px; }}
271
+ QScrollBar:horizontal {{ background: transparent; height: 10px; margin: 2px; }}
272
+ QScrollBar::handle:horizontal {{ background: {p.border}; border-radius: 4px; min-width: 30px; }}
273
+ QScrollBar::add-line, QScrollBar::sub-line {{ width: 0; height: 0; }}
274
+ QScrollBar::add-page, QScrollBar::sub-page {{ background: none; }}
275
+ QSplitter::handle {{ background-color: {p.border}; }}
276
+ QMenu {{ background-color: {p.surface}; border: 1px solid {p.border}; padding: 4px; }}
277
+ QMenu::item:selected {{ background-color: {p.accent}; color: #FFFFFF; }}
278
+ QMessageBox QPushButton {{ min-width: 80px; }}
279
+ """
280
+
281
+
282
+ def resolve_effective_theme(mode: ThemeMode) -> Literal["light", "dark"]:
283
+ """Map 'system' to light or dark using Qt style hints."""
284
+ if mode != "system":
285
+ return mode
286
+ scheme = QApplication.styleHints().colorScheme()
287
+ if scheme == Qt.ColorScheme.Light:
288
+ return "light"
289
+ return "dark"
290
+
291
+
292
+ def apply_theme(app: QApplication, mode: ThemeMode) -> Literal["light", "dark"]:
293
+ """Apply Fusion, the palette and the style sheet; returns the resolved light/dark used."""
294
+ global _current
295
+ app.setStyle("Fusion")
296
+ effective = resolve_effective_theme(mode)
297
+ p = DARK if effective == "dark" else LIGHT
298
+ _current = p
299
+
300
+ font = QFont(app.font())
301
+ font.setFamilies(["Segoe UI Variable Text", "Segoe UI", "Inter", "Helvetica Neue", "Arial"])
302
+ app.setFont(font)
303
+
304
+ pal = QPalette()
305
+ pal.setColor(QPalette.ColorRole.Window, QColor(p.background))
306
+ pal.setColor(QPalette.ColorRole.WindowText, QColor(p.text))
307
+ pal.setColor(QPalette.ColorRole.Base, QColor(p.surface))
308
+ pal.setColor(QPalette.ColorRole.AlternateBase, QColor(p.surface_hover))
309
+ pal.setColor(QPalette.ColorRole.Text, QColor(p.text))
310
+ pal.setColor(QPalette.ColorRole.PlaceholderText, QColor(p.muted))
311
+ pal.setColor(QPalette.ColorRole.Button, QColor(p.surface))
312
+ pal.setColor(QPalette.ColorRole.ButtonText, QColor(p.text))
313
+ pal.setColor(QPalette.ColorRole.Highlight, QColor(p.accent))
314
+ pal.setColor(QPalette.ColorRole.HighlightedText, QColor("#FFFFFF"))
315
+ pal.setColor(QPalette.ColorRole.ToolTipBase, QColor(p.surface))
316
+ pal.setColor(QPalette.ColorRole.ToolTipText, QColor(p.text))
317
+ pal.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.Text, QColor(p.disabled_text))
318
+ pal.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.WindowText, QColor(p.disabled_text))
319
+ pal.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.ButtonText, QColor(p.disabled_text))
320
+ app.setPalette(pal)
321
+ app.setStyleSheet(stylesheet(p))
322
+ return effective
323
+
324
+
325
+ def repolish(widget) -> None: # type: ignore[no-untyped-def]
326
+ """Re-apply the style sheet after a dynamic property changed."""
327
+ style = widget.style()
328
+ style.unpolish(widget)
329
+ style.polish(widget)
330
+ widget.update()
@@ -0,0 +1,190 @@
1
+ """Building blocks of the app's visual language: cards, section headers, dividers, pills, choice cards.
2
+
3
+ Every section header follows one pattern: an icon or a step number in a circle, a title and a one-line description.
4
+ Colours come from the theme (``nucleuskit_toolkit.ui.theme``) through dynamic properties; icons are re-rendered when
5
+ the theme changes (``refresh_icons``).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from PySide6.QtCore import Qt, Signal
11
+ from PySide6.QtWidgets import (
12
+ QFrame,
13
+ QHBoxLayout,
14
+ QLabel,
15
+ QSizePolicy,
16
+ QVBoxLayout,
17
+ QWidget,
18
+ )
19
+
20
+ from nucleuskit_toolkit.ui.icons import pixmap
21
+ from nucleuskit_toolkit.ui.theme import current_palette, repolish
22
+
23
+
24
+ def label(text: str = "", role: str | None = None, *, wrap: bool = False) -> QLabel:
25
+ lbl = QLabel(text)
26
+ if role:
27
+ lbl.setProperty("role", role)
28
+ lbl.setWordWrap(wrap)
29
+ return lbl
30
+
31
+
32
+ def set_role(widget: QLabel, role: str) -> None:
33
+ if widget.property("role") != role:
34
+ widget.setProperty("role", role)
35
+ repolish(widget)
36
+
37
+
38
+ class IconLabel(QLabel):
39
+ """A QLabel showing an icon in a theme colour (``muted``, ``accent``, ``text`` or a hex value)."""
40
+
41
+ def __init__(self, name: str, size: int = 18, color: str = "muted", parent: QWidget | None = None) -> None:
42
+ super().__init__(parent)
43
+ self._name = name
44
+ self._size = size
45
+ self._color = color
46
+ self.setFixedSize(size, size)
47
+ self.setStyleSheet("background: transparent;")
48
+ self.refresh()
49
+
50
+ @property
51
+ def icon_name(self) -> str:
52
+ return self._name
53
+
54
+ def set_icon(self, name: str, color: str | None = None) -> None:
55
+ self._name = name
56
+ if color:
57
+ self._color = color
58
+ self.refresh()
59
+
60
+ def refresh(self) -> None:
61
+ p = current_palette()
62
+ color = getattr(p, self._color, self._color)
63
+ self.setPixmap(pixmap(self._name, color, self._size))
64
+
65
+
66
+ def refresh_icons(root: QWidget) -> None:
67
+ """Re-render every IconLabel under ``root`` (after a theme change)."""
68
+ for child in root.findChildren(IconLabel):
69
+ child.refresh()
70
+
71
+
72
+ class Divider(QFrame):
73
+ def __init__(self, parent: QWidget | None = None) -> None:
74
+ super().__init__(parent)
75
+ self.setProperty("divider", True)
76
+ self.setFrameShape(QFrame.Shape.NoFrame)
77
+ self.setFixedHeight(1)
78
+
79
+
80
+ class Pill(QLabel):
81
+ """Status pill: ``on`` (accent), ``off`` (neutral surface) or ``value`` (neutral, free text)."""
82
+
83
+ def __init__(self, text: str = "", state: str = "value", parent: QWidget | None = None) -> None:
84
+ super().__init__(text, parent)
85
+ self.setProperty("pill", state)
86
+ self.setAlignment(Qt.AlignmentFlag.AlignCenter)
87
+
88
+ def set_state(self, text: str, state: str) -> None:
89
+ self.setText(text)
90
+ if self.property("pill") != state:
91
+ self.setProperty("pill", state)
92
+ repolish(self)
93
+
94
+
95
+ class SectionHeader(QWidget):
96
+ """Icon (or step number) in a circle, title, one-line description."""
97
+
98
+ def __init__(
99
+ self,
100
+ title: str,
101
+ description: str = "",
102
+ *,
103
+ icon: str | None = None,
104
+ step: int | None = None,
105
+ parent: QWidget | None = None,
106
+ ) -> None:
107
+ super().__init__(parent)
108
+ row = QHBoxLayout(self)
109
+ row.setContentsMargins(0, 0, 0, 0)
110
+ row.setSpacing(14)
111
+ if step is not None:
112
+ badge = QLabel(str(step))
113
+ badge.setObjectName("StepNumber")
114
+ badge.setFixedSize(32, 32)
115
+ badge.setAlignment(Qt.AlignmentFlag.AlignCenter)
116
+ row.addWidget(badge, 0, Qt.AlignmentFlag.AlignTop)
117
+ elif icon is not None:
118
+ holder = QLabel()
119
+ holder.setObjectName("HeaderIcon")
120
+ holder.setFixedSize(36, 36)
121
+ inner = QHBoxLayout(holder)
122
+ inner.setContentsMargins(0, 0, 0, 0)
123
+ inner.addWidget(IconLabel(icon, 18, "accent"), 0, Qt.AlignmentFlag.AlignCenter)
124
+ row.addWidget(holder, 0, Qt.AlignmentFlag.AlignTop)
125
+ text = QVBoxLayout()
126
+ text.setSpacing(2)
127
+ self.title = label(title, "cardTitle")
128
+ text.addWidget(self.title)
129
+ self.description = label(description, "hint", wrap=True)
130
+ self.description.setVisible(bool(description))
131
+ text.addWidget(self.description)
132
+ row.addLayout(text, 1)
133
+
134
+
135
+ class Card(QFrame):
136
+ """Rounded surface with consistent padding; ``body`` is its vertical layout."""
137
+
138
+ def __init__(self, parent: QWidget | None = None, *, padding: int = 20, spacing: int = 16) -> None:
139
+ super().__init__(parent)
140
+ self.setProperty("card", True)
141
+ self.body = QVBoxLayout(self)
142
+ self.body.setContentsMargins(padding, padding, padding, padding)
143
+ self.body.setSpacing(spacing)
144
+
145
+
146
+ class ChoiceCard(QFrame):
147
+ """Selectable card (radio behaviour handled by the owner): indicator, icon, title, description."""
148
+
149
+ clicked = Signal()
150
+
151
+ def __init__(self, icon: str, title: str, description: str, parent: QWidget | None = None) -> None:
152
+ super().__init__(parent)
153
+ self.setProperty("choice", True)
154
+ self.setProperty("selected", False)
155
+ self.setCursor(Qt.CursorShape.PointingHandCursor)
156
+ self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
157
+ row = QHBoxLayout(self)
158
+ row.setContentsMargins(18, 14, 18, 14)
159
+ row.setSpacing(14)
160
+ self._dot = IconLabel("radio-off", 22, "muted")
161
+ self._icon = IconLabel(icon, 22, "muted")
162
+ row.addWidget(self._dot)
163
+ row.addWidget(self._icon)
164
+ text = QVBoxLayout()
165
+ text.setSpacing(2)
166
+ text.addWidget(label(title, "strong"))
167
+ text.addWidget(label(description, "hint"))
168
+ row.addLayout(text, 1)
169
+ self._update_indicator()
170
+
171
+ def set_indicator_visible(self, visible: bool) -> None:
172
+ self._dot.setVisible(visible)
173
+
174
+ def is_selected(self) -> bool:
175
+ return bool(self.property("selected"))
176
+
177
+ def set_selected(self, selected: bool) -> None:
178
+ self.setProperty("selected", selected)
179
+ repolish(self)
180
+ self._update_indicator()
181
+
182
+ def _update_indicator(self) -> None:
183
+ on = self.is_selected()
184
+ self._dot.set_icon("radio-on" if on else "radio-off", "accent" if on else "muted")
185
+ self._icon.set_icon(self._icon.icon_name, "accent" if on else "muted")
186
+
187
+ def mousePressEvent(self, event) -> None: # type: ignore[no-untyped-def]
188
+ if self.isEnabled() and event.button() == Qt.MouseButton.LeftButton:
189
+ self.clicked.emit()
190
+ super().mousePressEvent(event)