eemkit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. eemkit/__init__.py +51 -0
  2. eemkit/absorbance.py +216 -0
  3. eemkit/campaigns.py +276 -0
  4. eemkit/core/__init__.py +1 -0
  5. eemkit/core/dataset.py +91 -0
  6. eemkit/core/grid.py +100 -0
  7. eemkit/core/history.py +78 -0
  8. eemkit/corrections/__init__.py +44 -0
  9. eemkit/corrections/_common.py +58 -0
  10. eemkit/corrections/blank.py +66 -0
  11. eemkit/corrections/crop.py +33 -0
  12. eemkit/corrections/inner_filter.py +123 -0
  13. eemkit/corrections/raman.py +107 -0
  14. eemkit/corrections/scatter.py +191 -0
  15. eemkit/corrections/spectral.py +117 -0
  16. eemkit/errors.py +37 -0
  17. eemkit/gui/__init__.py +30 -0
  18. eemkit/gui/__main__.py +5 -0
  19. eemkit/gui/campaigns_tab.py +275 -0
  20. eemkit/gui/common.py +395 -0
  21. eemkit/gui/corrections_tab.py +334 -0
  22. eemkit/gui/export_tab.py +137 -0
  23. eemkit/gui/indices_tab.py +228 -0
  24. eemkit/gui/main_window.py +258 -0
  25. eemkit/gui/parafac_tab.py +399 -0
  26. eemkit/gui/project_tab.py +332 -0
  27. eemkit/gui/state.py +149 -0
  28. eemkit/gui/theme.py +125 -0
  29. eemkit/indices.py +256 -0
  30. eemkit/io/__init__.py +1 -0
  31. eemkit/io/_cary_csv.py +174 -0
  32. eemkit/io/cary60.py +120 -0
  33. eemkit/io/cary_eclipse.py +140 -0
  34. eemkit/io/discover.py +176 -0
  35. eemkit/io/loader.py +158 -0
  36. eemkit/io/pairing.py +115 -0
  37. eemkit/parafac/__init__.py +28 -0
  38. eemkit/parafac/_cp.py +125 -0
  39. eemkit/parafac/diagnostics.py +56 -0
  40. eemkit/parafac/export.py +54 -0
  41. eemkit/parafac/model.py +191 -0
  42. eemkit/parafac/validation.py +111 -0
  43. eemkit/pipeline.py +560 -0
  44. eemkit/plot.py +437 -0
  45. eemkit-0.1.0.dist-info/METADATA +195 -0
  46. eemkit-0.1.0.dist-info/RECORD +49 -0
  47. eemkit-0.1.0.dist-info/WHEEL +4 -0
  48. eemkit-0.1.0.dist-info/entry_points.txt +2 -0
  49. eemkit-0.1.0.dist-info/licenses/LICENSE +674 -0
@@ -0,0 +1,332 @@
1
+ """Tab 1: data folder, file inventory and the pairing table."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import warnings
6
+ from pathlib import Path
7
+
8
+ import pandas as pd
9
+ from PySide6.QtCore import QSortFilterProxyModel, Qt
10
+ from PySide6.QtWidgets import (
11
+ QAbstractItemView,
12
+ QFileDialog,
13
+ QFormLayout,
14
+ QGroupBox,
15
+ QHBoxLayout,
16
+ QLineEdit,
17
+ QMessageBox,
18
+ QPlainTextEdit,
19
+ QPushButton,
20
+ QSplitter,
21
+ QTableView,
22
+ QTabWidget,
23
+ QVBoxLayout,
24
+ QWidget,
25
+ )
26
+
27
+ from eemkit import pipeline
28
+ from eemkit.errors import EEMKitWarning
29
+ from eemkit.gui.common import (
30
+ EXCLUDED,
31
+ SUGGESTED,
32
+ ChoiceDelegate,
33
+ DataFrameModel,
34
+ TaskBar,
35
+ note,
36
+ pair,
37
+ primary,
38
+ )
39
+ from eemkit.gui.state import Project
40
+ from eemkit.io.discover import scan_folder, suggest_pairing
41
+ from eemkit.io.pairing import read_pairing
42
+
43
+ FILE_KINDS = {"eem_file": "eem", "eem_blank_file": "eem", "abs_file": "absorbance",
44
+ "abs_blank_file": "absorbance"}
45
+
46
+
47
+ def _pairing_colour(row: pd.Series):
48
+ if str(row.get("include", "")).lower() not in ("yes", "y", "true", "1", "si", "sì"):
49
+ return EXCLUDED
50
+ if row.get("pairing_status", "") == "suggested":
51
+ return SUGGESTED
52
+ return None
53
+
54
+
55
+ class ProjectTab(QWidget):
56
+ def __init__(self, project: Project, tasks: TaskBar, parent=None):
57
+ super().__init__(parent)
58
+ self.project, self.tasks = project, tasks
59
+
60
+ self.data_root = QLineEdit()
61
+ self.pairing_path = QLineEdit()
62
+ self.output_dir = QLineEdit()
63
+ for edit in (self.data_root, self.pairing_path, self.output_dir):
64
+ edit.setReadOnly(True)
65
+ b_root = QPushButton("Choose…")
66
+ b_root.clicked.connect(self.choose_data_root)
67
+ b_pair = QPushButton("Open…")
68
+ b_pair.clicked.connect(self.open_pairing)
69
+ b_out = QPushButton("Choose…")
70
+ b_out.clicked.connect(self.choose_output)
71
+ paths = QGroupBox("Project")
72
+ form = QFormLayout(paths)
73
+ form.addRow("Data folder", pair(self.data_root, b_root))
74
+ form.addRow("", note("Read only: eemkit never writes, renames or moves anything in the "
75
+ "data folder."))
76
+ form.addRow("Pairing table", pair(self.pairing_path, b_pair))
77
+ form.addRow("Output folder", pair(self.output_dir, b_out))
78
+
79
+ self.b_scan = QPushButton("1. Scan data folder")
80
+ self.b_scan.clicked.connect(self.scan)
81
+ self.b_suggest = QPushButton("2. Draft pairing from scan")
82
+ self.b_suggest.clicked.connect(self.suggest)
83
+ self.b_include = QPushButton("Include selected rows")
84
+ self.b_include.clicked.connect(lambda: self.set_include(True))
85
+ self.b_exclude = QPushButton("Exclude selected rows")
86
+ self.b_exclude.clicked.connect(lambda: self.set_include(False))
87
+ self.b_save = QPushButton("Save pairing table")
88
+ self.b_save.clicked.connect(self.save_pairing)
89
+ self.b_load = primary(QPushButton("3. Check and load data"))
90
+ self.b_load.clicked.connect(self.load_data)
91
+ self.b_load.setDefault(True)
92
+ buttons = QHBoxLayout()
93
+ for b in (self.b_scan, self.b_suggest, self.b_include, self.b_exclude, self.b_save,
94
+ self.b_load):
95
+ buttons.addWidget(b)
96
+ buttons.addStretch(1)
97
+
98
+ self.pairing_model = DataFrameModel(row_colour=_pairing_colour)
99
+ self.pairing_model.edited.connect(self._pairing_edited)
100
+ self.proxy = QSortFilterProxyModel()
101
+ self.proxy.setSourceModel(self.pairing_model)
102
+ self.proxy.setFilterCaseSensitivity(Qt.CaseInsensitive)
103
+ self.proxy.setFilterKeyColumn(-1)
104
+ self.filter = QLineEdit()
105
+ self.filter.setPlaceholderText("Filter rows…")
106
+ self.filter.textChanged.connect(self.proxy.setFilterFixedString)
107
+ self.pairing_view = QTableView()
108
+ self.pairing_view.setModel(self.proxy)
109
+ self.pairing_view.setSelectionBehavior(QAbstractItemView.SelectRows)
110
+ self.pairing_view.setSortingEnabled(True)
111
+ self.pairing_view.sortByColumn(-1, Qt.AscendingOrder) # file order until a click
112
+ pairing_box = QWidget()
113
+ pl = QVBoxLayout(pairing_box)
114
+ pl.setContentsMargins(0, 0, 0, 0)
115
+ pl.addWidget(note("One row per sample. Rows drafted from the scan are yellow and start "
116
+ "excluded: check each pairing, then include it. Grey rows are not "
117
+ "used. Double-click a cell to edit it."))
118
+ pl.addWidget(self.filter)
119
+ pl.addWidget(self.pairing_view)
120
+
121
+ self.files_model = DataFrameModel()
122
+ files_view = QTableView()
123
+ files_view.setModel(self.files_model)
124
+ files_view.setSortingEnabled(True)
125
+
126
+ inner = QTabWidget()
127
+ inner.addTab(pairing_box, "Pairing table")
128
+ inner.addTab(files_view, "Files found")
129
+ self.messages = QPlainTextEdit()
130
+ self.messages.setReadOnly(True)
131
+ self.messages.setPlaceholderText("Problems and warnings appear here.")
132
+ split = QSplitter(Qt.Vertical)
133
+ split.addWidget(inner)
134
+ split.addWidget(self.messages)
135
+ split.setStretchFactor(0, 4)
136
+ split.setStretchFactor(1, 1)
137
+
138
+ lay = QVBoxLayout(self)
139
+ lay.addWidget(paths)
140
+ lay.addLayout(buttons)
141
+ lay.addWidget(split, 1)
142
+ project.changed.connect(self._on_changed)
143
+ tasks.busy_changed.connect(self._busy)
144
+ self.refresh_paths()
145
+
146
+ # ------------------------------------------------------------------ #
147
+
148
+ def _busy(self, busy: bool) -> None:
149
+ for b in (self.b_scan, self.b_suggest, self.b_load):
150
+ b.setEnabled(not busy)
151
+
152
+ def _on_changed(self, what: str) -> None:
153
+ if what == "settings":
154
+ self.refresh_paths()
155
+ path = self.project.resolve("pairing")
156
+ if self.project.pairing is None and path is not None and path.is_file():
157
+ self._read_pairing(path)
158
+ if what == "pairing" and self.project.pairing is None:
159
+ self.pairing_model.set_frame(pd.DataFrame())
160
+
161
+ def refresh_paths(self) -> None:
162
+ for key, edit in (("data_root", self.data_root), ("pairing", self.pairing_path),
163
+ ("output_dir", self.output_dir)):
164
+ p = self.project.resolve(key)
165
+ edit.setText(str(p) if p else "")
166
+
167
+ def say(self, text: str) -> None:
168
+ self.messages.appendPlainText(text)
169
+
170
+ # ------------------------------------------------------------------ #
171
+
172
+ def choose_data_root(self) -> None:
173
+ d = QFileDialog.getExistingDirectory(self, "Data folder (read only)",
174
+ self.data_root.text())
175
+ if d:
176
+ self.project.set_path_setting("data_root", d)
177
+ self.project.invalidate("pairing")
178
+ self.project.changed.emit("settings")
179
+
180
+ def choose_output(self) -> None:
181
+ d = QFileDialog.getExistingDirectory(self, "Output folder", self.output_dir.text())
182
+ if d:
183
+ if self.project.in_data_root(d):
184
+ QMessageBox.warning(self, "eemkit", "The output folder must not be inside the "
185
+ "data folder, which is read only.")
186
+ return
187
+ self.project.set_path_setting("output_dir", d)
188
+ self.project.changed.emit("settings")
189
+
190
+ def open_pairing(self) -> None:
191
+ path, _ = QFileDialog.getOpenFileName(self, "Pairing table", self.pairing_path.text(),
192
+ "CSV (*.csv)")
193
+ if path and self._read_pairing(Path(path)):
194
+ self.project.set_path_setting("pairing", path)
195
+ self.project.changed.emit("settings")
196
+
197
+ def _read_pairing(self, path: Path) -> bool:
198
+ try:
199
+ df = read_pairing(path, check_files=False)
200
+ except Exception as exc: # noqa: BLE001 - shown to the user
201
+ QMessageBox.warning(self, "eemkit", str(exc))
202
+ return False
203
+ df["include"] = df["include"].map({True: "yes", False: "no"})
204
+ self._set_pairing(df)
205
+ self.say(f"Opened {path}: {len(df)} rows, {int((df['include'] == 'yes').sum())} "
206
+ "included.")
207
+ return True
208
+
209
+ def _set_pairing(self, df: pd.DataFrame) -> None:
210
+ self.project.pairing = df
211
+ self.pairing_model.set_frame(df)
212
+ self.pairing_model.editable = set(df.columns)
213
+ self.pairing_view.setItemDelegateForColumn(
214
+ list(df.columns).index("include"), ChoiceDelegate(lambda: ["yes", "no"], self))
215
+ for col, kind in FILE_KINDS.items():
216
+ if col in df.columns:
217
+ self.pairing_view.setItemDelegateForColumn(
218
+ list(df.columns).index(col),
219
+ ChoiceDelegate(lambda k=kind: self._files_of(k), self))
220
+ self.pairing_view.resizeColumnsToContents()
221
+ self.project.invalidate("raw")
222
+
223
+ def _files_of(self, kind: str) -> list[str]:
224
+ inv = self.project.inventory
225
+ if inv is None:
226
+ return []
227
+ return list(inv.loc[inv["kind"] == kind, "file"])
228
+
229
+ def _pairing_edited(self) -> None:
230
+ self.project.dirty = True
231
+ self.project.invalidate("raw")
232
+
233
+ def scan(self) -> None:
234
+ root = self.project.resolve("data_root")
235
+ if root is None or not root.is_dir():
236
+ QMessageBox.information(self, "eemkit", "Choose the data folder first.")
237
+ return
238
+
239
+ def work(progress, cancelled):
240
+ return scan_folder(root, progress=lambda i, n: progress("Scanning files", i, n))
241
+
242
+ def done(inv):
243
+ self.project.inventory = inv
244
+ self.files_model.set_frame(inv)
245
+ counts = inv["kind"].value_counts().to_dict()
246
+ self.say(f"Scanned {root}: {len(inv)} CSV files, {counts.get('eem', 0)} EEM, "
247
+ f"{counts.get('absorbance', 0)} absorbance, {counts.get('unknown', 0)} not "
248
+ f"recognised; {int(inv['is_blank'].sum())} look like blanks.")
249
+ for _, r in inv[inv["kind"] == "unknown"].iterrows():
250
+ self.say(f" not used: {r['file']}: {r['problem']}")
251
+
252
+ self.tasks.run(work, done, "Scanning data folder…")
253
+
254
+ def suggest(self) -> None:
255
+ if self.project.inventory is None:
256
+ QMessageBox.information(self, "eemkit", "Scan the data folder first.")
257
+ return
258
+ if self.project.pairing is not None and len(self.project.pairing):
259
+ ok = QMessageBox.question(self, "eemkit", "Replace the current pairing table with a "
260
+ "new draft?")
261
+ if ok != QMessageBox.Yes:
262
+ return
263
+ draft = suggest_pairing(self.project.inventory)
264
+ self._set_pairing(draft)
265
+ self.project.dirty = True
266
+ n_abs = int((draft["abs_file"] != "").sum())
267
+ self.say(f"Drafted {len(draft)} rows ({n_abs} with an absorbance match). All rows are "
268
+ "excluded until you check and include them.")
269
+
270
+ def set_include(self, include: bool) -> None:
271
+ df = self.project.pairing
272
+ rows = {self.proxy.mapToSource(i).row() for i in
273
+ self.pairing_view.selectionModel().selectedRows()}
274
+ if df is None or not rows:
275
+ return
276
+ idx = df.index[sorted(rows)]
277
+ df.loc[idx, "include"] = "yes" if include else "no"
278
+ if include and "pairing_status" in df.columns:
279
+ df.loc[idx, "pairing_status"] = "checked"
280
+ self.pairing_model.set_frame(df)
281
+ self._pairing_edited()
282
+
283
+ def save_pairing(self) -> bool:
284
+ df = self.project.pairing
285
+ if df is None:
286
+ return False
287
+ path = self.project.resolve("pairing")
288
+ if path is None:
289
+ name, _ = QFileDialog.getSaveFileName(self, "Save pairing table", "pairing.csv",
290
+ "CSV (*.csv)")
291
+ if not name:
292
+ return False
293
+ path = Path(name)
294
+ self.project.set_path_setting("pairing", path)
295
+ self.project.changed.emit("settings")
296
+ if self.project.in_data_root(path):
297
+ QMessageBox.warning(self, "eemkit", "The pairing table cannot be saved inside the "
298
+ "read-only data folder.")
299
+ return False
300
+ path.parent.mkdir(parents=True, exist_ok=True)
301
+ df.to_csv(path, index=False)
302
+ self.say(f"Saved {path}")
303
+ return True
304
+
305
+ def load_data(self) -> None:
306
+ if self.project.pairing is not None and not self.save_pairing():
307
+ return
308
+ settings, base = self.project.settings, self.project.base
309
+
310
+ def work(progress, cancelled):
311
+ progress("Reading files", 0, 0)
312
+ with warnings.catch_warnings(record=True) as caught:
313
+ warnings.simplefilter("always", EEMKitWarning)
314
+ ds = pipeline.load(settings, base)
315
+ return ds, [str(w.message) for w in caught]
316
+
317
+ def done(result):
318
+ ds, warns = result
319
+ self.project.raw = ds
320
+ self.project.changed.emit("raw")
321
+ n = ds.sizes["sample"]
322
+ nb = int(ds["blank_intensity"].notnull().any(("ex", "em")).sum()) \
323
+ if "blank_intensity" in ds else 0
324
+ na = int(ds["absorbance"].notnull().any("wavelength").sum()) \
325
+ if "absorbance" in ds else 0
326
+ self.say(f"Loaded {n} samples (ex {ds.ex.min().item():g}-{ds.ex.max().item():g} nm, "
327
+ f"em {ds.em.min().item():g}-{ds.em.max().item():g} nm); {nb} with a blank "
328
+ f"EEM, {na} with absorbance.")
329
+ for w in warns:
330
+ self.say(f" warning: {w}")
331
+
332
+ self.tasks.run(work, done, "Loading data…")
eemkit/gui/state.py ADDED
@@ -0,0 +1,149 @@
1
+ """The open project: settings, pairing table and the results of each step."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+ import pandas as pd
9
+ import xarray as xr
10
+ from PySide6.QtCore import QObject, Signal
11
+
12
+ from eemkit import pipeline
13
+
14
+ STAGES = ("pairing", "raw", "corrected", "indices", "exploration", "final", "campaigns")
15
+ #: Settings that hold file paths, as (section, key).
16
+ PATH_SETTINGS = (("project", "data_root"), ("project", "pairing"), ("project", "output_dir"),
17
+ ("spectral", "ex_factors"), ("spectral", "em_factors"),
18
+ ("campaigns", "sites_table"), ("campaigns", "covariates_table"))
19
+
20
+
21
+ class Project(QObject):
22
+ """Holds everything the tabs share and announces what changed.
23
+
24
+ A result is dropped (and ``changed`` emitted) whenever something it
25
+ depends on changes, so a tab never shows results computed from settings
26
+ or data that are no longer current.
27
+ """
28
+
29
+ changed = Signal(str) # one of STAGES, "settings", or "display" (redraw only)
30
+
31
+ def __init__(self):
32
+ super().__init__()
33
+ self.path: Path | None = None # the project's settings TOML
34
+ self.settings: dict = pipeline.default_settings()
35
+ self.inventory: pd.DataFrame | None = None
36
+ self.pairing: pd.DataFrame | None = None
37
+ self.raw: xr.Dataset | None = None
38
+ self.after_raman: xr.Dataset | None = None
39
+ self.corrected: xr.Dataset | None = None
40
+ self.indices: pd.DataFrame | None = None
41
+ self.exploration: pipeline.Exploration | None = None
42
+ self.model: xr.Dataset | None = None
43
+ self.split: xr.Dataset | None = None
44
+ self.campaign: pipeline.CampaignAnalysis | None = None
45
+ self.dirty = False
46
+
47
+ # paths -------------------------------------------------------------- #
48
+
49
+ @property
50
+ def base(self) -> Path:
51
+ """Folder relative paths in the settings are resolved against."""
52
+ return self.path.parent if self.path else Path.cwd()
53
+
54
+ def resolve(self, key: str, section: str = "project") -> Path | None:
55
+ v = self.settings[section].get(key, "")
56
+ if not v:
57
+ return None
58
+ p = Path(v)
59
+ # normpath, not resolve(): folds "config/../data" without following drive mappings
60
+ return p if p.is_absolute() else Path(os.path.normpath(self.base / p))
61
+
62
+ def in_data_root(self, path: str | Path) -> bool:
63
+ """True if ``path`` lies in the (read-only) data folder."""
64
+ root = self.resolve("data_root")
65
+ return root is not None and Path(path).resolve().is_relative_to(root.resolve())
66
+
67
+ def relative(self, path: str | Path) -> str:
68
+ """``path`` relative to the project file when it lies below it; '' stays ''."""
69
+ if not str(path):
70
+ return ""
71
+ p = Path(path)
72
+ if self.path is not None:
73
+ try:
74
+ p = p.resolve().relative_to(self.base.resolve())
75
+ except ValueError:
76
+ pass
77
+ return p.as_posix()
78
+
79
+ def set_path_setting(self, key: str, path: str | Path, section: str = "project") -> None:
80
+ """Store ``path`` relative to the project file when it lies below it."""
81
+ self.settings[section][key] = self.relative(path)
82
+ self.dirty = True
83
+
84
+ # invalidation ------------------------------------------------------- #
85
+
86
+ def invalidate(self, stage: str) -> None:
87
+ """Drop the results of ``stage`` and of every later stage."""
88
+ later = STAGES[STAGES.index(stage):]
89
+ if "raw" in later:
90
+ self.raw = None
91
+ if "corrected" in later:
92
+ self.after_raman = self.corrected = None
93
+ if "indices" in later:
94
+ self.indices = None
95
+ if "exploration" in later:
96
+ self.exploration = None
97
+ if "final" in later:
98
+ self.model = self.split = None
99
+ self.campaign = None # last stage: depends on everything before it
100
+ for s in later:
101
+ self.changed.emit(s)
102
+
103
+ def set_settings(self, section: str, values: dict, invalidates: str | None) -> None:
104
+ """Update one settings section; drop results that depended on it."""
105
+ old = self.settings.get(section, {})
106
+ new = {**old, **values}
107
+ if new == old:
108
+ return
109
+ self.settings[section] = new
110
+ self.dirty = True
111
+ self.changed.emit("settings")
112
+ if invalidates:
113
+ self.invalidate(invalidates)
114
+
115
+ @property
116
+ def eem_colours(self) -> str:
117
+ """Name of the EEM colour map (a key of :data:`eemkit.plot.COLORMAPS`)."""
118
+ return self.settings["display"]["eem_colours"]
119
+
120
+ def set_eem_colours(self, name: str) -> None:
121
+ if name != self.eem_colours:
122
+ self.set_settings("display", {"eem_colours": name}, None)
123
+ self.changed.emit("display")
124
+
125
+ # files -------------------------------------------------------------- #
126
+
127
+ def save(self, path: str | Path | None = None) -> Path:
128
+ if path is not None:
129
+ old_base = self.base
130
+ self.path = Path(path)
131
+ for section, key in PATH_SETTINGS: # re-express paths against the new folder
132
+ v = self.settings[section].get(key, "")
133
+ if v:
134
+ self.set_path_setting(key, v if Path(v).is_absolute() else old_base / v,
135
+ section)
136
+ if self.path is None:
137
+ raise ValueError("no project file chosen")
138
+ pipeline.save_settings(self.settings, self.path)
139
+ self.dirty = False
140
+ return self.path
141
+
142
+ def open(self, path: str | Path) -> None:
143
+ self.settings = pipeline.load_settings(path)
144
+ self.path = Path(path)
145
+ self.inventory = self.pairing = None
146
+ self.invalidate("pairing")
147
+ self.dirty = False
148
+ self.changed.emit("settings")
149
+ self.changed.emit("display")
eemkit/gui/theme.py ADDED
@@ -0,0 +1,125 @@
1
+ """Light and dark themes of the application (Qt Fusion style, palette and style sheet).
2
+
3
+ The theme is a preference of the user, not of the project: it is kept in
4
+ ``QSettings`` and never written to the project file. Widgets opt into
5
+ special looks through dynamic properties: ``primary`` for the main action of
6
+ a step, ``invalid`` for a field that needs a value, ``role="note"`` for help
7
+ text and ``role="warning"`` for warnings.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from PySide6.QtCore import QSettings, Qt
13
+ from PySide6.QtGui import QColor, QGuiApplication, QPalette
14
+ from PySide6.QtWidgets import QApplication, QStyleFactory, QWidget
15
+
16
+ THEMES = ("system", "light", "dark")
17
+
18
+ COLOURS = {
19
+ "light": {"bg": "#f4f6f8", "surface": "#ffffff", "border": "#d6dbe1", "text": "#1d232b",
20
+ "muted": "#5b6573", "accent": "#1f5fa8", "accent_hover": "#184c86",
21
+ "accent_soft": "#dce8f7", "nav": "#e9edf2", "warn": "#8a5a00",
22
+ "warn_bg": "#fff4d6", "bad_bg": "#fbe3e3", "on_accent": "#ffffff"},
23
+ "dark": {"bg": "#16191d", "surface": "#1f2328", "border": "#353c45", "text": "#e6e9ed",
24
+ "muted": "#9aa4b0", "accent": "#4c8fdb", "accent_hover": "#6aa3e3",
25
+ "accent_soft": "#22364d", "nav": "#1b1e23", "warn": "#f0b54a",
26
+ "warn_bg": "#3a2f17", "bad_bg": "#4a2326", "on_accent": "#0b1220"},
27
+ }
28
+
29
+ STYLE = """
30
+ QWidget {{ color: {text}; font-size: 10pt; }}
31
+ QMainWindow, QDialog {{ background: {bg}; }}
32
+ QMenuBar {{ background: {bg}; }}
33
+ QGroupBox {{ background: {surface}; border: 1px solid {border}; border-radius: 8px;
34
+ margin-top: 14px; padding: 10px 8px 8px 8px; }}
35
+ QGroupBox::title {{ subcontrol-origin: margin; left: 10px; padding: 0 4px;
36
+ color: {muted}; font-weight: 600; }}
37
+ QPushButton {{ background: {surface}; border: 1px solid {border}; border-radius: 6px;
38
+ padding: 6px 14px; }}
39
+ QPushButton:hover {{ border-color: {accent}; }}
40
+ QPushButton:pressed {{ background: {accent_soft}; }}
41
+ QPushButton:disabled {{ color: {muted}; }}
42
+ QPushButton[primary="true"] {{ background: {accent}; color: {on_accent}; border: none;
43
+ font-weight: 600; padding: 8px 18px; }}
44
+ QPushButton[primary="true"]:hover {{ background: {accent_hover}; }}
45
+ QPushButton[primary="true"]:disabled {{ background: {border}; color: {muted}; }}
46
+ QPushButton[role="warning"] {{ color: {warn}; border: none; background: transparent; }}
47
+ QLineEdit, QSpinBox, QDoubleSpinBox, QComboBox, QPlainTextEdit {{
48
+ background: {surface}; border: 1px solid {border}; border-radius: 6px; padding: 4px 6px; }}
49
+ QLineEdit:focus, QSpinBox:focus, QDoubleSpinBox:focus, QComboBox:focus {{
50
+ border-color: {accent}; }}
51
+ QLineEdit[invalid="true"] {{ background: {bad_bg}; }}
52
+ QLabel[role="note"] {{ color: {muted}; }}
53
+ QLabel[role="warning"] {{ color: {warn}; background: {warn_bg}; border-radius: 6px;
54
+ padding: 8px; }}
55
+ QTableView, QListWidget {{ background: {surface}; border: 1px solid {border};
56
+ border-radius: 6px; gridline-color: {border};
57
+ selection-background-color: {accent_soft}; selection-color: {text}; }}
58
+ QHeaderView::section {{ background: {bg}; color: {muted}; border: none;
59
+ border-bottom: 1px solid {border}; padding: 4px 6px; font-weight: 600; }}
60
+ QTabWidget::pane {{ border: 1px solid {border}; border-radius: 6px; background: {surface}; }}
61
+ QTabBar::tab {{ background: transparent; padding: 6px 12px; color: {muted};
62
+ border-bottom: 2px solid transparent; }}
63
+ QTabBar::tab:selected {{ color: {text}; border-bottom: 2px solid {accent}; }}
64
+ QProgressBar {{ border: 1px solid {border}; border-radius: 6px; background: {surface};
65
+ text-align: center; height: 14px; }}
66
+ QProgressBar::chunk {{ background: {accent}; border-radius: 5px; }}
67
+ QSplitter::handle {{ background: {bg}; }}
68
+ QListWidget#nav {{ background: {nav}; border: none; border-radius: 0; padding: 10px 6px;
69
+ font-size: 10.5pt; outline: 0; }}
70
+ QListWidget#nav::item {{ padding: 10px 10px; border-radius: 6px; margin: 1px 0; }}
71
+ QListWidget#nav::item:selected {{ background: {accent_soft}; color: {text};
72
+ border-left: 3px solid {accent}; }}
73
+ QListWidget#nav::item:hover:!selected {{ background: {bg}; }}
74
+ QLabel#brand {{ font-size: 15pt; font-weight: 700; padding: 14px 14px 2px 14px; }}
75
+ QLabel#tagline {{ color: {muted}; padding: 0 14px 8px 14px; }}
76
+ QLabel#summary {{ color: {muted}; padding: 10px 14px; }}
77
+ QWidget#sidebar {{ background: {nav}; border-right: 1px solid {border}; }}
78
+ """
79
+
80
+
81
+ def saved_theme() -> str:
82
+ t = QSettings("eemkit", "eemkit").value("theme", "system")
83
+ return t if t in THEMES else "system"
84
+
85
+
86
+ def save_theme(name: str) -> None:
87
+ QSettings("eemkit", "eemkit").setValue("theme", name)
88
+
89
+
90
+ def resolved(name: str) -> str:
91
+ """'light' or 'dark' ('system' follows the operating system where Qt can tell)."""
92
+ if name != "system":
93
+ return name
94
+ hints = QGuiApplication.styleHints()
95
+ scheme = getattr(hints, "colorScheme", lambda: None)()
96
+ return "dark" if scheme == Qt.ColorScheme.Dark else "light"
97
+
98
+
99
+ def apply_theme(app: QApplication, name: str = "system") -> str:
100
+ """Apply a theme to the whole application; returns 'light' or 'dark'."""
101
+ mode = resolved(name)
102
+ c = COLOURS[mode]
103
+ app.setStyle(QStyleFactory.create("Fusion"))
104
+ pal = QPalette()
105
+ for role, key in ((QPalette.Window, "bg"), (QPalette.Base, "surface"),
106
+ (QPalette.AlternateBase, "bg"), (QPalette.Button, "surface"),
107
+ (QPalette.Text, "text"), (QPalette.WindowText, "text"),
108
+ (QPalette.ButtonText, "text"), (QPalette.ToolTipBase, "surface"),
109
+ (QPalette.ToolTipText, "text"), (QPalette.Highlight, "accent"),
110
+ (QPalette.HighlightedText, "on_accent"), (QPalette.PlaceholderText, "muted"),
111
+ (QPalette.Link, "accent")):
112
+ pal.setColor(role, QColor(c[key]))
113
+ app.setPalette(pal)
114
+ app.setStyleSheet(STYLE.format(**c))
115
+ app.setProperty("eemkit_theme", mode)
116
+ return mode
117
+
118
+
119
+ def mark(widget: QWidget, **props) -> QWidget:
120
+ """Set dynamic properties (e.g. ``primary=True``) and refresh the widget's style."""
121
+ for k, v in props.items():
122
+ widget.setProperty(k, v)
123
+ widget.style().unpolish(widget)
124
+ widget.style().polish(widget)
125
+ return widget