misleep 0.3.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 (94) hide show
  1. misleep/__init__.py +111 -0
  2. misleep/__main__.py +16 -0
  3. misleep/analysis/__init__.py +23 -0
  4. misleep/analysis/auto_stage.py +158 -0
  5. misleep/analysis/detection.py +198 -0
  6. misleep/analysis/features.py +131 -0
  7. misleep/analysis/models/P30_EEG_F_lightgbm.pkl +0 -0
  8. misleep/analysis/models/P30_EEG_P_lightgbm.pkl +0 -0
  9. misleep/analysis/models/__init__.py +0 -0
  10. misleep/analysis/models/ado_EEG_F_lightgbm.pkl +0 -0
  11. misleep/analysis/models/ado_EEG_P_lightgbm.pkl +0 -0
  12. misleep/analysis/models/adult_EEG_F_lightgbm.pkl +0 -0
  13. misleep/analysis/models/adult_EEG_P_lightgbm.pkl +0 -0
  14. misleep/analysis/transformer/__init__.py +46 -0
  15. misleep/analysis/transformer/checkpoints/CausalTransformer_best.pt +0 -0
  16. misleep/analysis/transformer/checkpoints/__init__.py +0 -0
  17. misleep/analysis/transformer/configs.py +52 -0
  18. misleep/analysis/transformer/data.py +362 -0
  19. misleep/analysis/transformer/inference.py +328 -0
  20. misleep/analysis/transformer/models.py +220 -0
  21. misleep/analysis/transformer/preprocessing.py +105 -0
  22. misleep/config/__init__.py +105 -0
  23. misleep/config/default_config.ini +38 -0
  24. misleep/data/__init__.py +13 -0
  25. misleep/data/annotation.py +114 -0
  26. misleep/data/midata.py +387 -0
  27. misleep/gui/__init__.py +23 -0
  28. misleep/gui/app.py +183 -0
  29. misleep/gui/config_dialog.py +429 -0
  30. misleep/gui/dialogs.py +1107 -0
  31. misleep/gui/event_filters.py +26 -0
  32. misleep/gui/main_window.py +2699 -0
  33. misleep/gui/qt_utils.py +277 -0
  34. misleep/gui/resources/__init__.py +9 -0
  35. misleep/gui/resources/entire_logo.png +0 -0
  36. misleep/gui/resources/logo.png +0 -0
  37. misleep/gui/resources/misleep.ico +0 -0
  38. misleep/gui/resources/misleep.qrc +7 -0
  39. misleep/gui/resources/misleep_rc.py +1324 -0
  40. misleep/gui/spec_window.py +187 -0
  41. misleep/gui/style.py +994 -0
  42. misleep/gui/uis/SWA_detect_dialog.ui +181 -0
  43. misleep/gui/uis/SWA_detect_dialog_ui.py +176 -0
  44. misleep/gui/uis/__init__.py +6 -0
  45. misleep/gui/uis/about.ui +88 -0
  46. misleep/gui/uis/about_ui.py +80 -0
  47. misleep/gui/uis/auto_stage_causalTransformer_dialog.ui +77 -0
  48. misleep/gui/uis/auto_stage_causalTransformer_dialog_ui.py +90 -0
  49. misleep/gui/uis/auto_stage_lightGBM_dialog.ui +124 -0
  50. misleep/gui/uis/auto_stage_lightGBM_dialog_ui.py +124 -0
  51. misleep/gui/uis/horizontal_line_dialog.ui +166 -0
  52. misleep/gui/uis/horizontal_line_dialog_ui.py +157 -0
  53. misleep/gui/uis/label_dialog.ui +65 -0
  54. misleep/gui/uis/label_dialog_ui.py +72 -0
  55. misleep/gui/uis/main_window.ui +871 -0
  56. misleep/gui/uis/main_window_ui.py +616 -0
  57. misleep/gui/uis/save_data_dialog.ui +95 -0
  58. misleep/gui/uis/save_data_dialog_ui.py +98 -0
  59. misleep/gui/uis/spec_window.ui +109 -0
  60. misleep/gui/uis/spec_window_ui.py +103 -0
  61. misleep/gui/uis/spindle_detect_dialog.ui +202 -0
  62. misleep/gui/uis/spindle_detect_dialog_ui.py +194 -0
  63. misleep/gui/uis/state_spectral_dialog.ui +240 -0
  64. misleep/gui/uis/state_spectral_dialog_ui.py +204 -0
  65. misleep/gui/uis/transfer_result_dialog.ui +104 -0
  66. misleep/gui/uis/transfer_result_dialog_ui.py +94 -0
  67. misleep/gui/workers.py +69 -0
  68. misleep/io/__init__.py +68 -0
  69. misleep/io/annotation.py +367 -0
  70. misleep/io/array.py +209 -0
  71. misleep/io/base.py +174 -0
  72. misleep/io/edf.py +109 -0
  73. misleep/io/mat.py +189 -0
  74. misleep/logger.py +79 -0
  75. misleep/preprocessing/__init__.py +23 -0
  76. misleep/preprocessing/artifacts.py +76 -0
  77. misleep/preprocessing/filtering.py +94 -0
  78. misleep/preprocessing/segment.py +59 -0
  79. misleep/preprocessing/spectral.py +169 -0
  80. misleep/utils/__init__.py +45 -0
  81. misleep/utils/annotation.py +130 -0
  82. misleep/utils/entropy.py +135 -0
  83. misleep/utils/misc.py +69 -0
  84. misleep/utils/time_utils.py +61 -0
  85. misleep/viz/__init__.py +12 -0
  86. misleep/viz/hypnogram.py +48 -0
  87. misleep/viz/signals.py +51 -0
  88. misleep/viz/spectral.py +98 -0
  89. misleep-0.3.0.dist-info/METADATA +249 -0
  90. misleep-0.3.0.dist-info/RECORD +94 -0
  91. misleep-0.3.0.dist-info/WHEEL +5 -0
  92. misleep-0.3.0.dist-info/entry_points.txt +15 -0
  93. misleep-0.3.0.dist-info/licenses/LICENSE +29 -0
  94. misleep-0.3.0.dist-info/top_level.txt +1 -0
misleep/gui/style.py ADDED
@@ -0,0 +1,994 @@
1
+ # -*- coding: UTF-8 -*-
2
+ """Theme system for the MiSleep GUI: light & dark Qt styling + matplotlib theming.
3
+
4
+ Everything visual lives here so the look of MiSleep is:
5
+
6
+ * **consistent** -- one palette drives the Qt widgets *and* the matplotlib
7
+ canvases, so the plot area and the surrounding chrome feel like a single
8
+ designed surface,
9
+ * **precise** -- every control has an explicit hover / pressed / focus /
10
+ disabled state, a calm rounded geometry and a clear accent colour,
11
+ * **portable** -- sizes are expressed in ``pt`` / logical pixels (Qt scales
12
+ them with the display DPI), fonts are picked per platform, and the same
13
+ stylesheet renders on Windows, macOS and Linux (a ``Fusion`` base style is
14
+ used so the QSS result is identical everywhere),
15
+ * **data friendly** -- the matplotlib theme keeps the plots on high-contrast
16
+ surfaces with soft grids and legible ticks.
17
+
18
+ The public API is small::
19
+
20
+ apply_theme(app, "light") # full theme: style + fonts + matplotlib
21
+ apply_theme(app, "dark")
22
+ retheme_figures("dark") # restyle every open figure after a toggle
23
+ apply_app_style(app) # backward compatible alias -> light theme
24
+
25
+ The previous behaviour of :func:`apply_app_style` (a fixed light stylesheet)
26
+ is preserved for compatibility.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import copy
32
+ import sys
33
+ from string import Template
34
+
35
+ from PySide6.QtGui import QFont
36
+ from PySide6.QtWidgets import QApplication
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Palettes
40
+ # ---------------------------------------------------------------------------
41
+
42
+ THEMES = {
43
+ "light": {
44
+ "name": "Light",
45
+ # surfaces
46
+ "window": "#d8dee8",
47
+ "surface": "#f2f4f7",
48
+ "surface_alt": "#cfd7e3",
49
+ "input": "#f8f9fb",
50
+ "disabled_bg": "#d9dfe8",
51
+ # lines
52
+ "border": "#aeb9c8",
53
+ "border_strong": "#75859b",
54
+ "button_top": "#f8f9fb",
55
+ "button_bottom": "#cbd4e0",
56
+ # text
57
+ "text": "#141d2b",
58
+ "text_secondary": "#4a5a70",
59
+ "text_disabled": "#97a3b3",
60
+ # accent (indigo)
61
+ "accent": "#4b56d9",
62
+ "accent_hover": "#3d47c2",
63
+ "accent_pressed": "#333ba6",
64
+ "accent_soft": "#e2e7fc",
65
+ "accent_soft_2": "#d4dbfa",
66
+ # semantic
67
+ "danger": "#dc3d43",
68
+ "danger_hover": "#c43339",
69
+ "success": "#148a5e",
70
+ # misc
71
+ "tooltip_bg": "#1c2736",
72
+ "tooltip_text": "#f4f7fb",
73
+ "scroll_handle": "#aeb9cb",
74
+ "scroll_handle_hover": "#8b9ab2",
75
+ "slider_groove": "#cfd8e6",
76
+ # matplotlib / plot area
77
+ "plot": {"trace": "#0d1524", "grid": "#2f9d74", "bg": "#ffffff"},
78
+ "mpl": {
79
+ "figure.facecolor": "#d8dee8",
80
+ "axes.facecolor": "#ffffff",
81
+ "axes.edgecolor": "#a9b6c9",
82
+ "axes.labelcolor": "#141d2b",
83
+ "xtick.color": "#4a5a70",
84
+ "ytick.color": "#4a5a70",
85
+ "text.color": "#141d2b",
86
+ "grid.color": "#dbe2ec",
87
+ "savefig.facecolor": "#ffffff",
88
+ },
89
+ },
90
+ "dark": {
91
+ "name": "Dark",
92
+ # surfaces
93
+ "window": "#10151f",
94
+ "surface": "#171e2b",
95
+ "surface_alt": "#1e2736",
96
+ "input": "#131a25",
97
+ "disabled_bg": "#171e2b",
98
+ # lines
99
+ "border": "#2c374a",
100
+ "border_strong": "#596984",
101
+ "button_top": "#273247",
102
+ "button_bottom": "#192230",
103
+ # text
104
+ "text": "#eef2f9",
105
+ "text_secondary": "#9aa8bc",
106
+ "text_disabled": "#5b6a80",
107
+ # accent (indigo)
108
+ "accent": "#7a84f0",
109
+ "accent_hover": "#959df5",
110
+ "accent_pressed": "#616bd9",
111
+ "accent_soft": "#28304f",
112
+ "accent_soft_2": "#313b60",
113
+ # semantic
114
+ "danger": "#f0606b",
115
+ "danger_hover": "#f27c85",
116
+ "success": "#2bc48a",
117
+ # misc
118
+ "tooltip_bg": "#2c374a",
119
+ "tooltip_text": "#eef2f9",
120
+ "scroll_handle": "#414e68",
121
+ "scroll_handle_hover": "#5a6a8a",
122
+ "slider_groove": "#2c374a",
123
+ # matplotlib / plot area
124
+ "plot": {"trace": "#d9e3f2", "grid": "#3fae85", "bg": "#101824"},
125
+ "mpl": {
126
+ "figure.facecolor": "#10151f",
127
+ "axes.facecolor": "#101824",
128
+ "axes.edgecolor": "#414e68",
129
+ "axes.labelcolor": "#eef2f9",
130
+ "xtick.color": "#9aa8bc",
131
+ "ytick.color": "#9aa8bc",
132
+ "text.color": "#eef2f9",
133
+ "grid.color": "#232f41",
134
+ "savefig.facecolor": "#101824",
135
+ },
136
+ },
137
+ }
138
+
139
+ # Color-tone presets are independent from light/dark mode. They change the
140
+ # application chrome and accent colors while keeping data/state colors under
141
+ # the user's explicit control in Settings.
142
+ COLOR_TONES = {
143
+ "black": {
144
+ "name": "Black / Gray",
145
+ "light": {
146
+ "window": "#d6d6d6", "surface": "#f0f0f0",
147
+ "surface_alt": "#c9c9c9", "input": "#f8f8f8",
148
+ "disabled_bg": "#d9d9d9", "border": "#aaaaaa",
149
+ "border_strong": "#666a70", "button_top": "#fafafa",
150
+ "button_bottom": "#c8c8c8", "text": "#151515",
151
+ "text_secondary": "#414141", "text_disabled": "#858585",
152
+ "accent": "#3c3f44", "accent_hover": "#24272b",
153
+ "accent_pressed": "#111315", "accent_soft": "#d3d3d3",
154
+ "accent_soft_2": "#bdbdbd", "scroll_handle": "#969696",
155
+ "scroll_handle_hover": "#707070", "slider_groove": "#bcbcbc",
156
+ "plot": {"grid": "#737373"},
157
+ "mpl": {"figure.facecolor": "#d6d6d6",
158
+ "axes.edgecolor": "#8a8a8a", "xtick.color": "#414141",
159
+ "ytick.color": "#414141", "grid.color": "#d0d0d0"},
160
+ },
161
+ "dark": {
162
+ "accent": "#aeb2b8", "accent_hover": "#d0d2d5",
163
+ "accent_pressed": "#858a91", "accent_soft": "#303236",
164
+ "accent_soft_2": "#3c3f44", "text_secondary": "#b4b4b4",
165
+ "plot": {"grid": "#777b80"},
166
+ },
167
+ },
168
+ "pink": {
169
+ "name": "Pink",
170
+ "light": {
171
+ "window": "#ded4d8", "surface": "#f5f0f2",
172
+ "surface_alt": "#d8c6cd", "input": "#fbf8f9",
173
+ "disabled_bg": "#e2dadd", "border": "#bca5ae",
174
+ "border_strong": "#8d6f7b", "button_top": "#fffafb",
175
+ "button_bottom": "#d9c4cc", "text": "#241b1f",
176
+ "text_secondary": "#604b54", "text_disabled": "#9d8991",
177
+ "accent": "#a63f69", "accent_hover": "#862d51",
178
+ "accent_pressed": "#68213e", "accent_soft": "#ead1db",
179
+ "accent_soft_2": "#ddb8c7", "scroll_handle": "#b88b9d",
180
+ "scroll_handle_hover": "#996a7d", "slider_groove": "#d6bbc6",
181
+ "plot": {"grid": "#a85777"},
182
+ "mpl": {"figure.facecolor": "#ded4d8",
183
+ "axes.edgecolor": "#aa8b98", "xtick.color": "#604b54",
184
+ "ytick.color": "#604b54", "grid.color": "#eadde2"},
185
+ },
186
+ "dark": {"accent": "#df7da4", "accent_hover": "#ee9fbe",
187
+ "accent_pressed": "#bd5f85", "accent_soft": "#482a37",
188
+ "accent_soft_2": "#593344"},
189
+ },
190
+ "blue": {
191
+ "name": "Blue",
192
+ "light": {},
193
+ "dark": {},
194
+ },
195
+ "khaki": {
196
+ "name": "Khaki",
197
+ "light": {
198
+ "window": "#ddd9c9", "surface": "#f3f1e8",
199
+ "surface_alt": "#d4cfb8", "input": "#faf9f3",
200
+ "disabled_bg": "#e0ddcf", "border": "#b8af8e",
201
+ "border_strong": "#82785b", "button_top": "#fbfaf3",
202
+ "button_bottom": "#d2ccb3", "text": "#242117",
203
+ "text_secondary": "#5d5742", "text_disabled": "#99927b",
204
+ "accent": "#71663a", "accent_hover": "#5a512d",
205
+ "accent_pressed": "#40391e", "accent_soft": "#e2ddc4",
206
+ "accent_soft_2": "#d2c9a6", "scroll_handle": "#aaa17f",
207
+ "scroll_handle_hover": "#8c825f", "slider_groove": "#cbc4a7",
208
+ "plot": {"grid": "#847a49"},
209
+ "mpl": {"figure.facecolor": "#ddd9c9",
210
+ "axes.edgecolor": "#9f9676", "xtick.color": "#5d5742",
211
+ "ytick.color": "#5d5742", "grid.color": "#e3dfd0"},
212
+ },
213
+ "dark": {"accent": "#c4b76f", "accent_hover": "#d8cd8d",
214
+ "accent_pressed": "#9f944f", "accent_soft": "#3d3928",
215
+ "accent_soft_2": "#4b4630"},
216
+ },
217
+ "blue": {
218
+ "name": "Blue",
219
+ "light": {
220
+ "window": "#d6dde8", "surface": "#eef3f9",
221
+ "surface_alt": "#c9d6e6", "input": "#f7fafd",
222
+ "disabled_bg": "#dde4ee", "border": "#a9bcd4",
223
+ "border_strong": "#6f87a5", "button_top": "#fafcfe",
224
+ "button_bottom": "#c2d2e6", "text": "#16202e",
225
+ "text_secondary": "#41536b", "text_disabled": "#8b9ab0",
226
+ "accent": "#2f5f9e", "accent_hover": "#274f85",
227
+ "accent_pressed": "#1e3f6b", "accent_soft": "#d4e2f3",
228
+ "accent_soft_2": "#c0d4ec", "scroll_handle": "#8fa6c2",
229
+ "scroll_handle_hover": "#6d87a8", "slider_groove": "#b9cbe2",
230
+ "plot": {"grid": "#3e76b0"},
231
+ "mpl": {"figure.facecolor": "#d6dde8",
232
+ "axes.edgecolor": "#7f96b4", "xtick.color": "#41536b",
233
+ "ytick.color": "#41536b", "grid.color": "#dfe7f1"},
234
+ },
235
+ "dark": {"accent": "#6d9fd8", "accent_hover": "#8cb6e8",
236
+ "accent_pressed": "#5182bd", "accent_soft": "#23344d",
237
+ "accent_soft_2": "#2b405f"},
238
+ },
239
+ }
240
+
241
+
242
+ def resolved_theme(theme_name: str = "light", tone_name: str = "black") -> dict:
243
+ """Return a theme with the selected color-tone overrides applied."""
244
+ theme_name = theme_name if theme_name in THEMES else "light"
245
+ tone_name = tone_name if tone_name in COLOR_TONES else "black"
246
+ result = copy.deepcopy(THEMES[theme_name])
247
+ for key, value in COLOR_TONES[tone_name][theme_name].items():
248
+ if isinstance(value, dict):
249
+ result[key].update(value)
250
+ else:
251
+ result[key] = value
252
+ return result
253
+
254
+ #: Buttons that act as *primary* actions get the accent colour.
255
+ _PRIMARY_BUTTONS = (
256
+ "SaveLabelBt", "FilterConfirmBt", "MultipleScalerConfirmBt",
257
+ "PlotSpecBt", "LabelBt",
258
+ )
259
+
260
+ #: Buttons that destroy things get the danger colour.
261
+ _DANGER_BUTTONS = ("DeleteChBt",)
262
+
263
+ _PRIMARY_SELECTOR = ", ".join(f"#{name}" for name in _PRIMARY_BUTTONS)
264
+ _DANGER_SELECTOR = ", ".join(f"#{name}" for name in _DANGER_BUTTONS)
265
+
266
+
267
+ def _state_selector(names, state):
268
+ """Apply a pseudo-state to every selector in a selector group."""
269
+ return ", ".join(f"#{name}:{state}" for name in names)
270
+
271
+
272
+ def font_families() -> list[str]:
273
+ """Return the recommended font stack for the current platform."""
274
+ if sys.platform == "win32":
275
+ return ["Segoe UI", "Microsoft YaHei UI", "PingFang SC",
276
+ "Noto Sans CJK SC", "Arial"]
277
+ if sys.platform == "darwin":
278
+ return ["SF Pro Text", "PingFang SC", "Helvetica Neue", "Arial"]
279
+ return ["Noto Sans", "DejaVu Sans", "Liberation Sans", "sans-serif"]
280
+
281
+
282
+ # ---------------------------------------------------------------------------
283
+ # Qt stylesheet
284
+ # ---------------------------------------------------------------------------
285
+
286
+ _STYLESHEET_TEMPLATE = Template("""
287
+ /* ================= MiSleep theme ================= */
288
+
289
+ QMainWindow, QDialog, QMessageBox, QInputDialog,
290
+ QColorDialog, QFileDialog, QFontDialog {
291
+ background-color: $window;
292
+ }
293
+
294
+ QWidget {
295
+ font-size: 9.5pt;
296
+ color: $text;
297
+ }
298
+
299
+ /* ---------------- menu bar & menus ---------------- */
300
+ QMenuBar {
301
+ background-color: $window;
302
+ border-bottom: 1px solid $border;
303
+ padding: 2px 4px;
304
+ }
305
+ QMenuBar::item {
306
+ background: transparent;
307
+ padding: 4px 10px;
308
+ border-radius: 0px;
309
+ color: $text;
310
+ }
311
+ QMenuBar::item:selected {
312
+ background: $accent_soft;
313
+ color: $text;
314
+ }
315
+ QMenuBar::item:pressed {
316
+ background: $accent_soft_2;
317
+ }
318
+
319
+ QMenu {
320
+ background-color: $surface;
321
+ border: 1px solid $border;
322
+ border-radius: 0px;
323
+ padding: 5px;
324
+ }
325
+ QMenu::item {
326
+ padding: 5px 26px 5px 10px;
327
+ border-radius: 0px;
328
+ color: $text;
329
+ }
330
+ QMenu::item:selected {
331
+ background: $accent_soft;
332
+ color: $text;
333
+ }
334
+ QMenu::item:disabled {
335
+ color: $text_disabled;
336
+ }
337
+ QMenu::separator {
338
+ height: 1px;
339
+ background: $border;
340
+ margin: 5px 8px;
341
+ }
342
+ QMenu::indicator {
343
+ width: 14px;
344
+ height: 14px;
345
+ }
346
+
347
+ /* ---------------- toolbar ---------------- */
348
+ QToolBar {
349
+ background: $surface;
350
+ border-bottom: 1px solid $border;
351
+ spacing: 3px;
352
+ padding: 4px 8px;
353
+ }
354
+ QToolBar::separator {
355
+ background: $border;
356
+ width: 1px;
357
+ margin: 5px 8px;
358
+ }
359
+ QToolButton {
360
+ background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
361
+ stop:0 $button_top, stop:1 $button_bottom);
362
+ border: 1px solid $border_strong;
363
+ border-radius: 4px;
364
+ padding: 3px 8px;
365
+ color: $text;
366
+ }
367
+ QToolButton:hover {
368
+ background: $accent_soft;
369
+ }
370
+ QToolButton:pressed, QToolButton:checked {
371
+ background: $accent_soft_2;
372
+ color: $accent;
373
+ }
374
+ QToolButton:disabled {
375
+ color: $text_disabled;
376
+ }
377
+
378
+ /* ---------------- buttons ---------------- */
379
+ QPushButton {
380
+ background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
381
+ stop:0 $button_top, stop:1 $button_bottom);
382
+ border: 1px solid $border_strong;
383
+ border-radius: 4px;
384
+ padding: 2px 9px;
385
+ color: $text;
386
+ min-height: 20px;
387
+ max-height: 24px;
388
+ }
389
+ QPushButton:hover {
390
+ background: $surface_alt;
391
+ border-color: $accent;
392
+ }
393
+ QPushButton:pressed {
394
+ background: $accent_soft_2;
395
+ }
396
+ QPushButton:focus {
397
+ border-color: $accent;
398
+ }
399
+ QPushButton:disabled {
400
+ color: $text_disabled;
401
+ background: $disabled_bg;
402
+ border-color: $border;
403
+ }
404
+ QPushButton:checked {
405
+ background: $accent_soft;
406
+ border-color: $accent;
407
+ color: $accent;
408
+ }
409
+ QPushButton[default="true"] {
410
+ background: $accent;
411
+ border-color: $accent;
412
+ color: #ffffff;
413
+ }
414
+ QPushButton[default="true"]:hover {
415
+ background: $accent_hover;
416
+ border-color: $accent_hover;
417
+ }
418
+ QPushButton[default="true"]:pressed {
419
+ background: $accent_pressed;
420
+ border-color: $accent_pressed;
421
+ }
422
+
423
+ /* channel-move arrows share a row and expand like Show/Hide/Delete */
424
+ #MoveUpBt, #MoveDownBt {
425
+ min-height: 20px;
426
+ max-height: 24px;
427
+ min-width: 52px;
428
+ padding: 0px;
429
+ font-size: 10pt;
430
+ }
431
+
432
+ /* primary actions */
433
+ $_primary {
434
+ background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
435
+ stop:0 $accent, stop:1 $accent_pressed);
436
+ border-color: $accent;
437
+ color: #ffffff;
438
+ font-weight: 600;
439
+ }
440
+ $_primary_hover {
441
+ background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
442
+ stop:0 $accent_hover, stop:1 $accent);
443
+ border-color: $accent_hover;
444
+ }
445
+ $_primary_pressed {
446
+ background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
447
+ stop:0 $accent_pressed, stop:1 $accent_pressed);
448
+ border-color: $accent_pressed;
449
+ }
450
+ $_primary_disabled {
451
+ background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
452
+ stop:0 $disabled_bg, stop:1 $disabled_bg);
453
+ border-color: $border;
454
+ color: $text_disabled;
455
+ font-weight: 400;
456
+ }
457
+
458
+ /* destructive actions */
459
+ $_danger {
460
+ background: $surface;
461
+ border-color: $danger;
462
+ color: $danger;
463
+ }
464
+ $_danger_hover {
465
+ background: $danger_hover;
466
+ border-color: $danger_hover;
467
+ color: #ffffff;
468
+ }
469
+ $_danger_pressed {
470
+ background: $danger;
471
+ border-color: $danger;
472
+ color: #ffffff;
473
+ }
474
+
475
+ /* ---------------- inputs ---------------- */
476
+ QComboBox, QSpinBox, QDoubleSpinBox, QLineEdit, QDateTimeEdit,
477
+ QTimeEdit, QDateEdit, QPlainTextEdit, QTextEdit {
478
+ background-color: $input;
479
+ border: 1px solid $border_strong;
480
+ border-radius: 4px;
481
+ padding: 2px 7px;
482
+ selection-background-color: $accent;
483
+ selection-color: #ffffff;
484
+ color: $text;
485
+ }
486
+ QComboBox, QSpinBox, QDoubleSpinBox, QLineEdit, QDateTimeEdit,
487
+ QTimeEdit, QDateEdit {
488
+ min-height: 20px;
489
+ max-height: 24px;
490
+ }
491
+ QComboBox:focus, QSpinBox:focus, QDoubleSpinBox:focus, QLineEdit:focus,
492
+ QDateTimeEdit:focus, QTimeEdit:focus, QDateEdit:focus,
493
+ QPlainTextEdit:focus, QTextEdit:focus {
494
+ border-color: $accent;
495
+ background-color: $surface;
496
+ }
497
+ QComboBox:disabled, QSpinBox:disabled, QDoubleSpinBox:disabled,
498
+ QLineEdit:disabled, QDateTimeEdit:disabled, QPlainTextEdit:disabled {
499
+ color: $text_disabled;
500
+ background-color: $disabled_bg;
501
+ border-color: $border;
502
+ }
503
+ QComboBox::drop-down {
504
+ subcontrol-origin: padding;
505
+ subcontrol-position: top right;
506
+ border-left: 1px solid $border_strong;
507
+ border-top-right-radius: 3px;
508
+ border-bottom-right-radius: 3px;
509
+ background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
510
+ stop:0 $button_top, stop:1 $button_bottom);
511
+ width: 24px;
512
+ }
513
+ QComboBox::down-arrow {
514
+ width: 9px;
515
+ height: 9px;
516
+ }
517
+ QComboBox QAbstractItemView {
518
+ background-color: $surface;
519
+ border: 1px solid $border;
520
+ border-radius: 6px;
521
+ padding: 4px;
522
+ selection-background-color: $accent_soft;
523
+ selection-color: $text;
524
+ outline: none;
525
+ }
526
+ QSpinBox::up-button, QDoubleSpinBox::up-button, QDateTimeEdit::up-button,
527
+ QSpinBox::down-button, QDoubleSpinBox::down-button, QDateTimeEdit::down-button {
528
+ subcontrol-origin: border;
529
+ border-left: 1px solid $border_strong;
530
+ background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
531
+ stop:0 $button_top, stop:1 $button_bottom);
532
+ width: 20px;
533
+ }
534
+ QSpinBox::up-button, QDoubleSpinBox::up-button, QDateTimeEdit::up-button {
535
+ subcontrol-position: top right;
536
+ border-bottom: 1px solid $border;
537
+ border-top-right-radius: 3px;
538
+ }
539
+ QSpinBox::down-button, QDoubleSpinBox::down-button, QDateTimeEdit::down-button {
540
+ subcontrol-position: bottom right;
541
+ border-bottom-right-radius: 3px;
542
+ }
543
+ QSpinBox::up-arrow, QDoubleSpinBox::up-arrow, QDateTimeEdit::up-arrow,
544
+ QSpinBox::down-arrow, QDoubleSpinBox::down-arrow, QDateTimeEdit::down-arrow {
545
+ width: 8px;
546
+ height: 6px;
547
+ }
548
+
549
+ /* ---------------- lists, trees, tables ---------------- */
550
+ QListView, QListWidget, QTreeView, QTableView, QTreeWidget {
551
+ background-color: $surface;
552
+ border: 1px solid $border;
553
+ border-radius: 6px;
554
+ alternate-background-color: $surface_alt;
555
+ outline: none;
556
+ }
557
+ QListView::item, QListWidget::item, QTreeView::item, QTableView::item {
558
+ padding: 4px 6px;
559
+ border-radius: 0px;
560
+ }
561
+ QListView::item:selected, QListWidget::item:selected,
562
+ QTreeView::item:selected, QTableView::item:selected {
563
+ background: $accent_soft;
564
+ color: $text;
565
+ }
566
+ QListView::item:hover, QListWidget::item:hover,
567
+ QTreeView::item:hover, QTableView::item:hover {
568
+ background: $surface_alt;
569
+ }
570
+ QHeaderView::section {
571
+ background: $surface_alt;
572
+ color: $text_secondary;
573
+ padding: 5px 8px;
574
+ border: none;
575
+ border-bottom: 1px solid $border;
576
+ font-weight: 600;
577
+ }
578
+
579
+ /* ---------------- scroll areas & bars ---------------- */
580
+ QScrollArea {
581
+ border: none;
582
+ background: transparent;
583
+ }
584
+ QScrollBar:horizontal {
585
+ background: transparent;
586
+ height: 12px;
587
+ border-radius: 0px;
588
+ margin: 0;
589
+ }
590
+ QScrollBar::handle:horizontal {
591
+ background: $scroll_handle;
592
+ border-radius: 6px;
593
+ min-width: 32px;
594
+ }
595
+ QScrollBar::handle:horizontal:hover {
596
+ background: $scroll_handle_hover;
597
+ }
598
+ QScrollBar:vertical {
599
+ background: transparent;
600
+ width: 12px;
601
+ border-radius: 0px;
602
+ margin: 0;
603
+ }
604
+ QScrollBar::handle:vertical {
605
+ background: $scroll_handle;
606
+ border-radius: 6px;
607
+ min-height: 32px;
608
+ }
609
+ QScrollBar::handle:vertical:hover {
610
+ background: $scroll_handle_hover;
611
+ }
612
+ QScrollBar::add-line, QScrollBar::sub-line {
613
+ width: 0;
614
+ height: 0;
615
+ }
616
+ QScrollBar::add-page, QScrollBar::sub-page {
617
+ background: transparent;
618
+ }
619
+
620
+ /* ---------------- sidebar ---------------- */
621
+ #Sidebar, #SidebarScroll, #SidebarScroll > QWidget {
622
+ background: $surface;
623
+ }
624
+ QToolButton#SidebarSectionHeader {
625
+ background: transparent;
626
+ border: none;
627
+ border-bottom: 1px solid $border;
628
+ border-radius: 0;
629
+ text-align: left;
630
+ padding: 7px 8px;
631
+ font-weight: 600;
632
+ font-size: 9pt;
633
+ color: $text_secondary;
634
+ }
635
+ QToolButton#SidebarSectionHeader:hover {
636
+ background: $accent_soft;
637
+ color: $text;
638
+ }
639
+ QToolButton#SidebarSectionHeader:pressed {
640
+ background: $accent_soft_2;
641
+ }
642
+ QToolButton#SidebarSectionHeader:checked {
643
+ color: $accent;
644
+ }
645
+ QMainWindow::separator {
646
+ background: $border;
647
+ width: 1px;
648
+ height: 1px;
649
+ }
650
+ QSplitter::handle {
651
+ background: $border;
652
+ }
653
+
654
+ /* ---------------- groups & tabs ---------------- */
655
+ QGroupBox {
656
+ border: 1px solid $border;
657
+ border-radius: 8px;
658
+ margin-top: 10px;
659
+ padding-top: 8px;
660
+ background: transparent;
661
+ }
662
+ QGroupBox::title {
663
+ subcontrol-origin: margin;
664
+ left: 10px;
665
+ padding: 0 6px;
666
+ color: $accent;
667
+ font-weight: 600;
668
+ }
669
+ QTabWidget::pane {
670
+ border: 1px solid $border;
671
+ border-radius: 8px;
672
+ background: $surface;
673
+ top: -1px;
674
+ }
675
+ QTabBar::tab {
676
+ background: transparent;
677
+ border: none;
678
+ border-bottom: 2px solid transparent;
679
+ padding: 6px 16px;
680
+ color: $text_secondary;
681
+ font-weight: 500;
682
+ }
683
+ QTabBar::tab:selected {
684
+ color: $accent;
685
+ border-bottom: 2px solid $accent;
686
+ font-weight: 600;
687
+ }
688
+ QTabBar::tab:hover:!selected {
689
+ color: $text;
690
+ }
691
+
692
+ /* ---------------- sliders ---------------- */
693
+ QSlider::groove:horizontal {
694
+ height: 4px;
695
+ background: $slider_groove;
696
+ border-radius: 0px;
697
+ }
698
+ QSlider::sub-page:horizontal {
699
+ background: $accent;
700
+ border-radius: 0px;
701
+ }
702
+ QSlider::handle:horizontal {
703
+ width: 14px;
704
+ margin: -5px 0;
705
+ border-radius: 0px;
706
+ background: $surface;
707
+ border: 2px solid $accent;
708
+ }
709
+ QSlider::handle:horizontal:hover {
710
+ background: $accent;
711
+ }
712
+ QSlider::groove:vertical {
713
+ width: 4px;
714
+ background: $slider_groove;
715
+ border-radius: 0px;
716
+ }
717
+ QSlider::sub-page:vertical {
718
+ background: $accent;
719
+ border-radius: 0px;
720
+ }
721
+ QSlider::handle:vertical {
722
+ height: 14px;
723
+ margin: 0 -5px;
724
+ border-radius: 0px;
725
+ background: $surface;
726
+ border: 2px solid $accent;
727
+ }
728
+ QSlider::handle:vertical:hover {
729
+ background: $accent;
730
+ }
731
+
732
+ /* ---------------- checkboxes & radios ---------------- */
733
+ QCheckBox, QRadioButton {
734
+ spacing: 6px;
735
+ }
736
+ /* Keep QCheckBox native: Fusion draws a real check mark (✓). Painting the
737
+ whole checked indicator with QSS hides that glyph on Windows. */
738
+ QRadioButton::indicator {
739
+ width: 16px;
740
+ height: 16px;
741
+ border: 1px solid $border_strong;
742
+ background: $surface;
743
+ }
744
+ QRadioButton::indicator {
745
+ border-radius: 0px;
746
+ }
747
+ QRadioButton::indicator:hover {
748
+ border-color: $accent;
749
+ }
750
+ QRadioButton::indicator:checked {
751
+ background: $accent;
752
+ border-color: $accent;
753
+ }
754
+ QRadioButton::indicator:disabled {
755
+ border-color: $border;
756
+ background: $disabled_bg;
757
+ }
758
+
759
+ /* ---------------- misc ---------------- */
760
+ QStatusBar {
761
+ background: $surface;
762
+ border-top: 1px solid $border;
763
+ }
764
+ QStatusBar::item {
765
+ border: none;
766
+ }
767
+ QToolTip {
768
+ background: $tooltip_bg;
769
+ color: $tooltip_text;
770
+ border: none;
771
+ padding: 4px 8px;
772
+ border-radius: 0px;
773
+ font-size: 9pt;
774
+ }
775
+ QProgressBar {
776
+ border: 1px solid $border;
777
+ border-radius: 6px;
778
+ text-align: center;
779
+ background: $surface;
780
+ color: $text;
781
+ }
782
+ QProgressBar::chunk {
783
+ background: $accent;
784
+ border-radius: 5px;
785
+ }
786
+
787
+ /* ---------------- calendar popup ---------------- */
788
+ QCalendarWidget QWidget#qt_calendar_navigationbar {
789
+ background: $surface_alt;
790
+ }
791
+ QCalendarWidget QToolButton {
792
+ background: transparent;
793
+ border: none;
794
+ border-radius: 0px;
795
+ padding: 2px 6px;
796
+ color: $text;
797
+ }
798
+ QCalendarWidget QToolButton:hover {
799
+ background: $accent_soft;
800
+ }
801
+ QCalendarWidget QAbstractItemView {
802
+ background: $surface;
803
+ color: $text;
804
+ selection-background-color: $accent;
805
+ selection-color: #ffffff;
806
+ outline: none;
807
+ }
808
+ QCalendarWidget QSpinBox {
809
+ background: $input;
810
+ }
811
+ """)
812
+
813
+
814
+ def build_stylesheet(theme_name: str = "light", tone_name: str = "black") -> str:
815
+ """Return the Qt stylesheet for the given theme name."""
816
+ theme_name = theme_name if theme_name in THEMES else "light"
817
+ palette = resolved_theme(theme_name, tone_name)
818
+ palette["_primary"] = _PRIMARY_SELECTOR
819
+ palette["_primary_hover"] = _state_selector(_PRIMARY_BUTTONS, "hover")
820
+ palette["_primary_pressed"] = _state_selector(_PRIMARY_BUTTONS, "pressed")
821
+ palette["_primary_disabled"] = _state_selector(_PRIMARY_BUTTONS, "disabled")
822
+ palette["_danger"] = _DANGER_SELECTOR
823
+ palette["_danger_hover"] = _state_selector(_DANGER_BUTTONS, "hover")
824
+ palette["_danger_pressed"] = _state_selector(_DANGER_BUTTONS, "pressed")
825
+ return _STYLESHEET_TEMPLATE.substitute(palette)
826
+
827
+
828
+ # ---------------------------------------------------------------------------
829
+ # Fonts
830
+ # ---------------------------------------------------------------------------
831
+
832
+ def _setup_app_font(app: QApplication) -> None:
833
+ """Set a per-platform font family so text scales with the display DPI."""
834
+ font = QFont()
835
+ font.setFamilies(font_families())
836
+ font.setPointSizeF(9.5)
837
+ app.setFont(font)
838
+
839
+
840
+ # ---------------------------------------------------------------------------
841
+ # Matplotlib theming
842
+ # ---------------------------------------------------------------------------
843
+
844
+ _MPL_SHARED = {
845
+ "figure.dpi": 100,
846
+ "savefig.dpi": 200,
847
+ "font.size": 9.0,
848
+ "axes.titlesize": 10.0,
849
+ "axes.labelsize": 9.0,
850
+ "xtick.labelsize": 8.0,
851
+ "ytick.labelsize": 8.0,
852
+ "legend.fontsize": 8.5,
853
+ "axes.linewidth": 0.8,
854
+ "axes.spines.top": False,
855
+ "axes.spines.right": False,
856
+ "lines.linewidth": 1.0,
857
+ "grid.linewidth": 0.8,
858
+ "image.cmap": "jet",
859
+ }
860
+
861
+
862
+ def build_palette(theme_name: str = "light", tone_name: str = "black"):
863
+ """Return a QPalette matching the theme.
864
+
865
+ The stylesheet covers most widgets, but a few things are drawn by the
866
+ style itself (combo/spin arrows, check indicators in menus, separators,
867
+ the calendar popup, ...) and pick their colors from the palette - so
868
+ both themes need a consistent palette or those elements stay light (and
869
+ nearly invisible) in dark mode.
870
+ """
871
+ from PySide6.QtGui import QPalette
872
+ from PySide6.QtGui import QColor
873
+
874
+ theme_name = theme_name if theme_name in THEMES else "light"
875
+ p = resolved_theme(theme_name, tone_name)
876
+
877
+ pal = QPalette()
878
+ pal.setColor(QPalette.ColorRole.Window, QColor(p["window"]))
879
+ pal.setColor(QPalette.ColorRole.WindowText, QColor(p["text"]))
880
+ pal.setColor(QPalette.ColorRole.Base, QColor(p["input"]))
881
+ pal.setColor(QPalette.ColorRole.AlternateBase, QColor(p["surface_alt"]))
882
+ pal.setColor(QPalette.ColorRole.ToolTipBase, QColor(p["tooltip_bg"]))
883
+ pal.setColor(QPalette.ColorRole.ToolTipText, QColor(p["tooltip_text"]))
884
+ pal.setColor(QPalette.ColorRole.Text, QColor(p["text"]))
885
+ pal.setColor(QPalette.ColorRole.PlaceholderText, QColor(p["text_disabled"]))
886
+ pal.setColor(QPalette.ColorRole.Button, QColor(p["surface"]))
887
+ pal.setColor(QPalette.ColorRole.ButtonText, QColor(p["text"]))
888
+ pal.setColor(QPalette.ColorRole.BrightText, QColor("#ffffff"))
889
+ pal.setColor(QPalette.ColorRole.Highlight, QColor(p["accent"]))
890
+ pal.setColor(QPalette.ColorRole.HighlightedText, QColor("#ffffff"))
891
+ pal.setColor(QPalette.ColorRole.Link, QColor(p["accent"]))
892
+ pal.setColor(QPalette.ColorRole.Mid, QColor(p["border"]))
893
+ pal.setColor(QPalette.ColorRole.Midlight, QColor(p["border"]))
894
+ pal.setColor(QPalette.ColorRole.Dark, QColor(p["border_strong"]))
895
+ pal.setColor(QPalette.ColorRole.Shadow, QColor(p["border_strong"]))
896
+ pal.setColor(QPalette.ColorRole.Light, QColor(p["surface"]))
897
+
898
+ disabled = QPalette.ColorGroup.Disabled
899
+ pal.setColor(disabled, QPalette.ColorRole.WindowText, QColor(p["text_disabled"]))
900
+ pal.setColor(disabled, QPalette.ColorRole.Text, QColor(p["text_disabled"]))
901
+ pal.setColor(disabled, QPalette.ColorRole.ButtonText, QColor(p["text_disabled"]))
902
+ pal.setColor(disabled, QPalette.ColorRole.Base, QColor(p["disabled_bg"]))
903
+ pal.setColor(disabled, QPalette.ColorRole.Button, QColor(p["disabled_bg"]))
904
+ return pal
905
+
906
+
907
+ def apply_matplotlib_theme(theme_name: str = "light", tone_name: str = "black") -> None:
908
+ """Apply the theme colors to the matplotlib defaults (affects new figures)."""
909
+ import matplotlib as mpl
910
+ from matplotlib import font_manager
911
+
912
+ theme_name = theme_name if theme_name in THEMES else "light"
913
+ mpl.rcParams.update(_MPL_SHARED)
914
+ # Use the first font family that is actually installed, so matplotlib
915
+ # does not warn about every missing CJK fallback on each text draw.
916
+ for family in font_families():
917
+ try:
918
+ font_manager.findfont(family, fallback_to_default=False)
919
+ mpl.rcParams["font.family"] = family
920
+ break
921
+ except Exception:
922
+ continue
923
+ mpl.rcParams.update(resolved_theme(theme_name, tone_name)["mpl"])
924
+
925
+
926
+ def retheme_figures(theme_name: str = "light", tone_name: str = "black") -> None:
927
+ """Restyle every currently open matplotlib figure for ``theme_name``.
928
+
929
+ Used after a live theme toggle so existing canvases (signal area,
930
+ hypnogram, spectrum window, ...) follow the new colors without
931
+ recreating them.
932
+ """
933
+ import matplotlib.pyplot as plt
934
+
935
+ theme_name = theme_name if theme_name in THEMES else "light"
936
+ mpl_colors = resolved_theme(theme_name, tone_name)["mpl"]
937
+ for num in plt.get_fignums():
938
+ fig = plt.figure(num)
939
+ try:
940
+ fig.patch.set_facecolor(mpl_colors["figure.facecolor"])
941
+ for ax in fig.axes:
942
+ ax.set_facecolor(mpl_colors["axes.facecolor"])
943
+ for spine in ax.spines.values():
944
+ spine.set_edgecolor(mpl_colors["axes.edgecolor"])
945
+ ax.tick_params(colors=mpl_colors["xtick.color"], which="both")
946
+ if ax.xaxis.label is not None:
947
+ ax.xaxis.label.set_color(mpl_colors["axes.labelcolor"])
948
+ if ax.yaxis.label is not None:
949
+ ax.yaxis.label.set_color(mpl_colors["axes.labelcolor"])
950
+ fig.canvas.draw_idle()
951
+ except Exception: # pragma: no cover - a figure may be mid-close
952
+ continue
953
+
954
+
955
+ # ---------------------------------------------------------------------------
956
+ # Public API
957
+ # ---------------------------------------------------------------------------
958
+
959
+ def apply_theme(app: QApplication, theme_name: str = "light",
960
+ tone_name: str = "black") -> str:
961
+ """Apply a full MiSleep theme (style + fonts + matplotlib) to ``app``.
962
+
963
+ Parameters
964
+ ----------
965
+ app : QApplication
966
+ The application to style.
967
+ theme_name : str
968
+ ``"light"`` or ``"dark"``. Unknown names fall back to ``"light"``.
969
+
970
+ Returns
971
+ -------
972
+ str
973
+ The theme name that was actually applied.
974
+ """
975
+ theme_name = theme_name if theme_name in THEMES else "light"
976
+
977
+ # Fusion renders identically on every platform, so the QSS look is
978
+ # predictable across Windows / macOS / Linux (native styles would
979
+ # override parts of the stylesheet).
980
+ try:
981
+ app.setStyle("Fusion")
982
+ except Exception: # pragma: no cover
983
+ pass
984
+
985
+ _setup_app_font(app)
986
+ app.setPalette(build_palette(theme_name, tone_name))
987
+ app.setStyleSheet(build_stylesheet(theme_name, tone_name))
988
+ apply_matplotlib_theme(theme_name, tone_name)
989
+ return theme_name
990
+
991
+
992
+ def apply_app_style(app: QApplication) -> None:
993
+ """Backward-compatible alias: apply the default (light) theme."""
994
+ apply_theme(app, "light")