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.
- nucleuskit_toolkit/__init__.py +3 -0
- nucleuskit_toolkit/__main__.py +60 -0
- nucleuskit_toolkit/app.py +34 -0
- nucleuskit_toolkit/camera/__init__.py +9 -0
- nucleuskit_toolkit/camera/processing.py +374 -0
- nucleuskit_toolkit/config.py +86 -0
- nucleuskit_toolkit/hermes/__init__.py +0 -0
- nucleuskit_toolkit/hermes/constants.py +77 -0
- nucleuskit_toolkit/hermes/realtime/__init__.py +8 -0
- nucleuskit_toolkit/hermes/realtime/proxy.py +652 -0
- nucleuskit_toolkit/logging_utils.py +68 -0
- nucleuskit_toolkit/mqtt/__init__.py +5 -0
- nucleuskit_toolkit/mqtt/mqtt_controller.py +256 -0
- nucleuskit_toolkit/pipeline.py +194 -0
- nucleuskit_toolkit/position/__init__.py +5 -0
- nucleuskit_toolkit/position/gps_processor.py +91 -0
- nucleuskit_toolkit/session_job.py +28 -0
- nucleuskit_toolkit/shimmer/__init__.py +5 -0
- nucleuskit_toolkit/shimmer/constants.py +32 -0
- nucleuskit_toolkit/shimmer/realtime/__init__.py +5 -0
- nucleuskit_toolkit/shimmer/realtime/proxy.py +279 -0
- nucleuskit_toolkit/tools/__init__.py +1 -0
- nucleuskit_toolkit/tools/eeg_regression.py +91 -0
- nucleuskit_toolkit/tools/recording_merger.py +437 -0
- nucleuskit_toolkit/ui/__init__.py +1 -0
- nucleuskit_toolkit/ui/icons.py +165 -0
- nucleuskit_toolkit/ui/main_window.py +465 -0
- nucleuskit_toolkit/ui/offline_job.py +144 -0
- nucleuskit_toolkit/ui/pages/__init__.py +1 -0
- nucleuskit_toolkit/ui/pages/about_page.py +88 -0
- nucleuskit_toolkit/ui/pages/channel_fixer_page.py +205 -0
- nucleuskit_toolkit/ui/pages/channel_gain_page.py +362 -0
- nucleuskit_toolkit/ui/pages/eeg_regression_page.py +385 -0
- nucleuskit_toolkit/ui/pages/mqtt_controller_page.py +394 -0
- nucleuskit_toolkit/ui/pages/offline_page.py +754 -0
- nucleuskit_toolkit/ui/pages/playback_page.py +1524 -0
- nucleuskit_toolkit/ui/pages/ppg_fixer_page.py +199 -0
- nucleuskit_toolkit/ui/pages/realtime_viewer_page.py +954 -0
- nucleuskit_toolkit/ui/pages/recording_merge_page.py +395 -0
- nucleuskit_toolkit/ui/pages/revert_original_page.py +128 -0
- nucleuskit_toolkit/ui/pages/settings_page.py +69 -0
- nucleuskit_toolkit/ui/pages/tools_menu_page.py +42 -0
- nucleuskit_toolkit/ui/plot_style.py +155 -0
- nucleuskit_toolkit/ui/realtime_eeg_plot.py +244 -0
- nucleuskit_toolkit/ui/realtime_hermes_motion_plot.py +162 -0
- nucleuskit_toolkit/ui/realtime_nlms_config_dialog.py +101 -0
- nucleuskit_toolkit/ui/realtime_shimmer_plot.py +135 -0
- nucleuskit_toolkit/ui/resources/__init__.py +1 -0
- nucleuskit_toolkit/ui/resources/branding/__init__.py +1 -0
- nucleuskit_toolkit/ui/resources/branding/logo.png +0 -0
- nucleuskit_toolkit/ui/session_inspect.py +226 -0
- nucleuskit_toolkit/ui/theme.py +330 -0
- nucleuskit_toolkit/ui/widgets.py +190 -0
- nucleuskit_toolkit-2.2.0.dist-info/METADATA +351 -0
- nucleuskit_toolkit-2.2.0.dist-info/RECORD +59 -0
- nucleuskit_toolkit-2.2.0.dist-info/WHEEL +5 -0
- nucleuskit_toolkit-2.2.0.dist-info/entry_points.txt +2 -0
- nucleuskit_toolkit-2.2.0.dist-info/licenses/LICENSE +675 -0
- 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)
|