pykrait 0.2.4__tar.gz → 0.3.1__tar.gz

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 (33) hide show
  1. {pykrait-0.2.4 → pykrait-0.3.1}/PKG-INFO +3 -1
  2. {pykrait-0.2.4 → pykrait-0.3.1}/pyproject.toml +15 -2
  3. {pykrait-0.2.4 → pykrait-0.3.1}/src/pykrait/__main__.py +1 -0
  4. {pykrait-0.2.4 → pykrait-0.3.1}/src/pykrait/gui/app.py +29 -4
  5. pykrait-0.3.1/src/pykrait/gui/async_workers.py +323 -0
  6. pykrait-0.3.1/src/pykrait/gui/custom_widgets.py +159 -0
  7. {pykrait-0.2.4 → pykrait-0.3.1}/src/pykrait/gui/fileselection.py +224 -80
  8. pykrait-0.3.1/src/pykrait/gui/mainwindow.py +1114 -0
  9. pykrait-0.3.1/src/pykrait/io/files.py +191 -0
  10. pykrait-0.3.1/src/pykrait/io/images.py +263 -0
  11. pykrait-0.3.1/src/pykrait/pipeline/pipeline.py +804 -0
  12. {pykrait-0.2.4 → pykrait-0.3.1}/src/pykrait/preprocessing/segmentation.py +57 -32
  13. {pykrait-0.2.4 → pykrait-0.3.1}/src/pykrait/preprocessing/timeseries_extraction.py +175 -63
  14. {pykrait-0.2.4 → pykrait-0.3.1}/src/pykrait/trace_analysis/filtering.py +26 -14
  15. {pykrait-0.2.4 → pykrait-0.3.1}/src/pykrait/trace_analysis/oscillations.py +79 -19
  16. pykrait-0.3.1/src/pykrait/trace_analysis/peak_analysis.py +176 -0
  17. pykrait-0.3.1/src/pykrait/trace_analysis/synchronicity.py +204 -0
  18. pykrait-0.3.1/src/pykrait/visualization/image_overlays.py +236 -0
  19. pykrait-0.2.4/src/pykrait/gui/async_workers.py +0 -218
  20. pykrait-0.2.4/src/pykrait/gui/mainwindow.py +0 -614
  21. pykrait-0.2.4/src/pykrait/io/io.py +0 -248
  22. pykrait-0.2.4/src/pykrait/pipeline/pipeline.py +0 -407
  23. pykrait-0.2.4/src/pykrait/trace_analysis/peak_analysis.py +0 -45
  24. {pykrait-0.2.4 → pykrait-0.3.1}/.gitignore +0 -0
  25. {pykrait-0.2.4 → pykrait-0.3.1}/LICENSE +0 -0
  26. {pykrait-0.2.4 → pykrait-0.3.1}/README.md +0 -0
  27. {pykrait-0.2.4 → pykrait-0.3.1}/src/.python-version +0 -0
  28. {pykrait-0.2.4 → pykrait-0.3.1}/src/pykrait/__init__.py +0 -0
  29. {pykrait-0.2.4 → pykrait-0.3.1}/src/pykrait/gui/resources/logo.png +0 -0
  30. {pykrait-0.2.4 → pykrait-0.3.1}/src/pykrait/io/__init__.py +0 -0
  31. {pykrait-0.2.4 → pykrait-0.3.1}/src/pykrait/pipeline/__init__.py +0 -0
  32. {pykrait-0.2.4 → pykrait-0.3.1}/src/pykrait/preprocessing/__init__.py +0 -0
  33. {pykrait-0.2.4 → pykrait-0.3.1}/src/pykrait/trace_analysis/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pykrait
3
- Version: 0.2.4
3
+ Version: 0.3.1
4
4
  Summary: Python Calcium Recording Analysis and Inference Toolbox
5
5
  Author-email: phaleanopsis <22663502+phaleanopsis@users.noreply.github.com>
6
6
  License: MIT
@@ -28,8 +28,10 @@ Requires-Dist: pydantic<2
28
28
  Requires-Dist: pyyaml>=6.0.2
29
29
  Requires-Dist: resource-backed-dask-array>=0.1.0
30
30
  Requires-Dist: scikit-image>=0.25.2
31
+ Requires-Dist: scikit-learn>=1.7.2
31
32
  Requires-Dist: scipy>=1.15.3
32
33
  Requires-Dist: xarray>2025
34
+ Requires-Dist: zstandard>=0.23.0
33
35
  Provides-Extra: dev
34
36
  Requires-Dist: build>=1.2.2.post1; extra == 'dev'
35
37
  Requires-Dist: hatch>=1.14.1; extra == 'dev'
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pykrait"
3
- version = "0.2.4"
3
+ version = "0.3.1"
4
4
  description = "Python Calcium Recording Analysis and Inference Toolbox"
5
5
  readme = { file = "README.md", content-type = "text/markdown" }
6
6
  license = { text = "MIT" }
@@ -33,6 +33,8 @@ dependencies = [
33
33
  "scipy>=1.15.3",
34
34
  "xarray>2025",
35
35
  "pandas>=2.3.1",
36
+ "zstandard>=0.23.0",
37
+ "scikit-learn>=1.7.2",
36
38
  ]
37
39
 
38
40
  [project.optional-dependencies]
@@ -68,6 +70,17 @@ packages = ["src/pykrait"]
68
70
  [tool.hatch.build.targets.sdist]
69
71
  include = ["src/**", "README.md", "pyproject.toml"]
70
72
 
73
+ [tool.hatch.envs.tests]
74
+ dependencies = [
75
+ "pytest",
76
+ "pytest-cov",
77
+ ]
78
+
79
+ [tool.hatch.envs.coverage]
80
+ dependencies = [
81
+ "coverage[toml]",
82
+ ]
83
+
71
84
  [tool.pytest.ini_options]
72
85
  pythonpath = ["src"]
73
86
  testpaths = ["tests"]
@@ -85,4 +98,4 @@ skip_covered = true
85
98
  [tool.ruff]
86
99
  src = ["src"]
87
100
  line-length = 88
88
- target-version = "py311"
101
+ target-version = "py311"
@@ -1,6 +1,7 @@
1
1
  """
2
2
  Copyright © 2025 Pentabyteman
3
3
  """
4
+
4
5
  # src/pykrait/__main__.py
5
6
  from pykrait.gui.app import AppController
6
7
 
@@ -1,23 +1,41 @@
1
1
  import sys
2
2
  import os
3
+ import warnings
3
4
  from PySide6.QtCore import QSize
4
5
  from PySide6.QtGui import QIcon
5
6
  from PySide6.QtWidgets import QApplication
6
7
  from pykrait.gui.fileselection import FileSelectionWindow
7
8
  from pykrait.gui.mainwindow import MainWindow
9
+ from pykrait.io.files import get_pykrait_version
10
+
11
+ def warning_no_source_line(message, category, filename, lineno, file=None, line=None):
12
+ return f"{filename}:{lineno}: {category.__name__}: {message}\n"
8
13
 
9
14
  class AppController:
10
15
  def __init__(self):
16
+
17
+ # format warnings to not print the source line every time
18
+ warnings.formatwarning = warning_no_source_line
19
+
11
20
  self.app = QApplication(sys.argv)
12
21
 
13
22
  # Loading icon from resources
14
23
  appIcon = QIcon()
15
24
  try:
16
25
  from importlib.resources import files
26
+
17
27
  icon_path = files("pykrait.gui.resources").joinpath("logo.png")
18
28
  os.path.exists(icon_path)
19
29
  except Exception:
20
- icon_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "pykrait/gui/resources", "logo.png"))
30
+ icon_path = os.path.abspath(
31
+ os.path.join(
32
+ os.path.dirname(__file__),
33
+ "..",
34
+ "..",
35
+ "pykrait/gui/resources",
36
+ "logo.png",
37
+ )
38
+ )
21
39
  os.path.exists(icon_path)
22
40
  if os.path.exists(icon_path):
23
41
  appIcon.addFile(str(icon_path), QSize(16, 16))
@@ -32,10 +50,17 @@ class AppController:
32
50
 
33
51
  # Set application name and version
34
52
  self.app.setApplicationName("pyKrait")
35
- self.app.setApplicationVersion("0.2.1")
53
+ version = get_pykrait_version()
54
+ if version is not None:
55
+ self.app.setApplicationVersion(version)
36
56
  self.file_selection = FileSelectionWindow()
37
57
  self.file_selection.analysis_complete.connect(self.on_analysis_complete)
38
58
  self.file_selection.show()
59
+
60
+ #updating the style
61
+ self.app.setStyle("Fusion")
62
+ light_palette = self.app.style().standardPalette()
63
+ self.app.setPalette(light_palette)
39
64
 
40
65
  # Keep references to windows
41
66
  self.main_window = None
@@ -52,7 +77,7 @@ class AppController:
52
77
  mean_intensities=results["mean_intensities"],
53
78
  analysis_output=results["analysis_output"],
54
79
  analysis_params=results["analysis_parameters"],
55
- app_controller=self
80
+ app_controller=self,
56
81
  )
57
82
  self.main_window.show()
58
83
 
@@ -66,4 +91,4 @@ class AppController:
66
91
 
67
92
  self.file_selection = FileSelectionWindow()
68
93
  self.file_selection.analysis_complete.connect(self.on_analysis_complete)
69
- self.file_selection.show()
94
+ self.file_selection.show()
@@ -0,0 +1,323 @@
1
+ from PySide6.QtCore import QObject, Signal, Slot
2
+ import traceback
3
+ import numpy as np
4
+ from pykrait.io.images import read_label_image, get_pixel_contour_from_label_img
5
+ from pykrait.io.files import read_Txnrois
6
+ from pykrait.preprocessing.segmentation import timelapse_projection
7
+ import tifffile
8
+ from pykrait.preprocessing.timeseries_extraction import extract_mean_intensities, get_adjacency_matrix
9
+ from pykrait.trace_analysis.filtering import detrend_with_sinc_filter
10
+ import os
11
+
12
+ from pykrait.pipeline.pipeline import (
13
+ AnalysisParameters,
14
+ AnalysisOutput,
15
+ create_analysis_folder,
16
+ load_timelapse,
17
+ create_masks,
18
+ do_peak_analysis,
19
+ calculate_periodic_cells,
20
+ calculate_synchronicity,
21
+ save_analysis_results,
22
+ save_params
23
+ )
24
+
25
+ class ExtractIntensitiesWorker(QObject):
26
+ """
27
+ Worker for extracting mean intensities from a timelapse dataset, using either Cellpose segmentation or a label image.
28
+ """
29
+
30
+ progress_changed = Signal(int, str) # progress percent, status message
31
+ finished = Signal(dict) # analysis results
32
+ error = Signal(str) # error message
33
+
34
+ def __init__(
35
+ self,
36
+ analysis_params: AnalysisParameters,
37
+ output_params: AnalysisOutput,
38
+ mode: str,
39
+ mean_intensities_path: str = None,
40
+ ):
41
+ super().__init__()
42
+ self.analysis_parameters = analysis_params
43
+ self.analysis_output = output_params
44
+ self.mode = mode
45
+ self.mean_intensities_path = mean_intensities_path
46
+
47
+ @Slot()
48
+ def run(self):
49
+ try:
50
+ self.analysis_output = create_analysis_folder(self.analysis_output)
51
+ print(f"Running analysis on {self.analysis_output.filename}")
52
+ # loading the timelapse data and analysing frame interval
53
+ self.timelapse_data, self.analysis_output = load_timelapse(
54
+ self.analysis_output, self.analysis_parameters
55
+ )
56
+
57
+ if self.mode == "cellpose":
58
+ # perform the tproj
59
+ self.progress_changed.emit(
60
+ 10, "Performing T Projection and Cellpose Segmentation..."
61
+ )
62
+ # creating the masks and saving them to the analysis folder
63
+ _, _, self.masks, self.analysis_output = create_masks(
64
+ self.timelapse_data, self.analysis_output, self.analysis_parameters
65
+ )
66
+ # extracts the mean intensities of the masks
67
+ self.progress_changed.emit(60, "Extracting intensities...")
68
+ mean_intensities = extract_mean_intensities(
69
+ self.timelapse_data, self.masks
70
+ )
71
+ elif self.mode == "label_image":
72
+ if self.analysis_output.tproj_path is None:
73
+ print("Performing T Projection...")
74
+ tproj = timelapse_projection(
75
+ self.timelapse_data,
76
+ method="std",
77
+ normalize=True,
78
+ verbose=True,
79
+ )
80
+
81
+ filename_wo_ext = os.path.splitext(self.analysis_output.filename)[0]
82
+ self.analysis_output.tproj_path = os.path.join(
83
+ self.analysis_output.analysis_folder, f"{filename_wo_ext}_tproj.ome.tif"
84
+ )
85
+ tifffile.imwrite(
86
+ self.analysis_output.tproj_path,
87
+ tproj.astype(np.uint16),
88
+ metadata={"axes": "CYX"},
89
+ compression="zlib",
90
+ )
91
+ self.progress_changed.emit(20, "Loading label image...")
92
+ self.masks = read_label_image(self.analysis_output.masks_path)
93
+ # extracts the mean intensities of the masks
94
+ self.progress_changed.emit(60, "Extracting intensities...")
95
+ mean_intensities = extract_mean_intensities(
96
+ self.timelapse_data, self.masks
97
+ )
98
+ elif self.mode == "csv":
99
+ self.progress_changed.emit(20, "Loading label image...")
100
+ self.masks = read_label_image(self.analysis_output.masks_path)
101
+ self.progress_changed.emit(60, "Reading intensities...")
102
+ mean_intensities = read_Txnrois(
103
+ self.mean_intensities_path,
104
+ n_frames=self.timelapse_data.shape[0],
105
+ n_rois=self.masks.max(),
106
+ )
107
+ else:
108
+ raise ValueError("Unknown mode.")
109
+
110
+ (
111
+ self.analysis_output.number_of_frames,
112
+ self.analysis_output.number_of_cells,
113
+ ) = mean_intensities.shape
114
+
115
+ # All done
116
+ self.progress_changed.emit(90, "Calculating ROI boundaries...")
117
+ rois = get_pixel_contour_from_label_img(
118
+ self.masks, orig_shape=self.masks.shape, target_shape=(512, 512)
119
+ )
120
+
121
+ self.progress_changed.emit(100, "Done.")
122
+
123
+ results = {
124
+ "frames": self.timelapse_data,
125
+ "analysis_output": self.analysis_output,
126
+ "analysis_parameters": self.analysis_parameters,
127
+ "masks": self.masks,
128
+ "rois": rois,
129
+ "mean_intensities": mean_intensities,
130
+ }
131
+ save_params(self.analysis_output, self.analysis_parameters, overwrite=True)
132
+ self.finished.emit(results)
133
+
134
+ except Exception as e:
135
+ tb = traceback.format_exc()
136
+ print(f"Error during analysis: {e}\n{tb}")
137
+ self.error.emit(str(e))
138
+
139
+
140
+ class DetrendWorker(QObject):
141
+ """
142
+ _summary_
143
+
144
+ :param QObject: _description_
145
+ :type QObject: _type_
146
+ """
147
+
148
+ finished = Signal(np.ndarray)
149
+ error = Signal(Exception)
150
+
151
+ def __init__(self, intensities, sinc_window, frame_interval):
152
+ super().__init__()
153
+ self.intensities = intensities
154
+ self.sinc_window = sinc_window
155
+ self.frame_interval = frame_interval
156
+
157
+ def run(self):
158
+ try:
159
+ # Call your actual function here
160
+ detrended = detrend_with_sinc_filter(
161
+ signals=self.intensities,
162
+ cutoff_period=self.sinc_window,
163
+ sampling_interval=self.frame_interval,
164
+ )
165
+ self.finished.emit(detrended)
166
+ except Exception as e:
167
+ self.error.emit(e)
168
+
169
+
170
+ class PeakDetectionWorker(QObject):
171
+ """
172
+ Worker for detecting peaks in detrended traces, wraps the `find_peaks` function.
173
+ """
174
+
175
+ finished = Signal(np.ndarray)
176
+ error = Signal(Exception)
177
+
178
+ def __init__(self, detrended_traces, min_width, max_width, analysis_params, analysis_output):
179
+ super().__init__()
180
+ self.traces = detrended_traces
181
+ self.min_width = min_width
182
+ self.max_width = max_width
183
+ self.analysis_params = analysis_params
184
+ self.analysis_output = analysis_output
185
+
186
+ def run(self):
187
+ try:
188
+ peak_series, peak_properties_df, peak_frame_nonzero, analysis_output = do_peak_analysis(self.traces, self.min_width, self.max_width, self.analysis_params, self.analysis_output)
189
+ result = [peak_series, analysis_output]
190
+ self.finished.emit(result)
191
+ except Exception as e:
192
+ self.error.emit(e)
193
+
194
+
195
+ class PeriodicCellWorker(QObject):
196
+ """
197
+ Worker for finding oscillating ROIs in the peak series, wraps the `find_oscillating_rois` function.
198
+ """
199
+
200
+ finished = Signal(AnalysisOutput, np.ndarray, np.ndarray)
201
+ error = Signal(Exception)
202
+
203
+ def __init__(self, peaks: np.ndarray, analysis_params, analysis_output):
204
+ super().__init__()
205
+ self.peaks = peaks
206
+ self.analysis_output = analysis_output
207
+ self.analysis_params = analysis_params
208
+
209
+ def run(self):
210
+ try:
211
+ analysis_output, stds, covs = calculate_periodic_cells(
212
+ self.peaks, self.analysis_params, self.analysis_output
213
+ )
214
+ self.finished.emit(analysis_output, stds, covs)
215
+ except Exception as e:
216
+ self.error.emit(e)
217
+
218
+
219
+ class AdjacencyMatrixWorker(QObject):
220
+ """
221
+ Worker for calculating the adjacency matrix from the masks, wraps the `get_adjacency_matrix` function.
222
+ """
223
+
224
+ finished = Signal(np.ndarray, np.ndarray)
225
+ error = Signal(Exception)
226
+
227
+ def __init__(self, masks: np.ndarray, neighbour_tolerance_pixels: int = 1):
228
+ super().__init__()
229
+ self.masks = masks
230
+ self.neighbour_tolerance_pixels = neighbour_tolerance_pixels
231
+
232
+ def run(self):
233
+ try:
234
+ adjacency_matrix, shortest_path_matrix = get_adjacency_matrix(
235
+ self.masks, neighbour_tolerance=self.neighbour_tolerance_pixels
236
+ )
237
+ self.finished.emit(adjacency_matrix, shortest_path_matrix)
238
+ except Exception as e:
239
+ self.error.emit(e)
240
+
241
+
242
+ class SynchronicityWorker(QObject):
243
+ """
244
+ Worker for calculating the synchronicity matrix from the detrended intensities, wraps the `calculate_synchronicity_matrix` function.
245
+ """
246
+
247
+ finished = Signal(np.ndarray)
248
+ error = Signal(Exception)
249
+
250
+ def __init__(self, analysis_output:AnalysisOutput, analysis_parameters:AnalysisParameters, peak_series: np.ndarray, shortest_path_matrix: np.ndarray):
251
+ super().__init__()
252
+ self.peak_series = peak_series
253
+ self.shortest_path_matrix = shortest_path_matrix
254
+ self.analysis_output = analysis_output
255
+ self.analysis_parameters = analysis_parameters
256
+
257
+ def run(self):
258
+ try:
259
+ results = calculate_synchronicity(
260
+ self.shortest_path_matrix,
261
+ self.peak_series,
262
+ self.analysis_parameters,
263
+ self.analysis_output
264
+ )
265
+ self.finished.emit(results)
266
+ except Exception as e:
267
+ self.error.emit(e)
268
+
269
+
270
+ class AnalysisSaveWorker(QObject):
271
+ """
272
+ Worker for saving the analysis results, wraps the `save_analysis_results` function.
273
+ """
274
+
275
+ finished = Signal(str) # emit output file path or summary message
276
+ error = Signal(Exception)
277
+
278
+ def __init__(
279
+ self,
280
+ tproj:np.ndarray,
281
+ masks:np.ndarray,
282
+ synchronous_peaks_matrix:np.ndarray,
283
+ analysis_output,
284
+ analysis_params,
285
+ peaks: np.ndarray,
286
+ detrended_intensities: np.ndarray,
287
+ intensities: np.ndarray,
288
+ stds: np.ndarray = None,
289
+ covs: np.ndarray = None,
290
+ overwrite: bool = False,
291
+ ):
292
+ super().__init__()
293
+ self.tproj = tproj
294
+ self.masks = masks
295
+ self.synchronous_peaks_matrix = synchronous_peaks_matrix
296
+ self.analysis_output = analysis_output
297
+ self.analysis_params = analysis_params
298
+ self.peaks = peaks
299
+ self.detrended_intensities = detrended_intensities
300
+ self.intensities = intensities
301
+ self.overwrite = overwrite
302
+ self.stds = stds
303
+ self.covs = covs
304
+
305
+ def run(self):
306
+ try:
307
+ save_analysis_results(
308
+ tproj=self.tproj,
309
+ masks=self.masks,
310
+ synchronous_peaks_matrix=self.synchronous_peaks_matrix,
311
+ analysis_output=self.analysis_output,
312
+ analysis_params=self.analysis_params,
313
+ detrended_intensities=self.detrended_intensities,
314
+ peaks=self.peaks,
315
+ intensities=self.intensities,
316
+ stds=self.stds,
317
+ covs=self.covs,
318
+ overwrite=self.overwrite,
319
+ )
320
+ self.finished.emit(f"Saved to {self.analysis_output.analysis_folder}")
321
+
322
+ except Exception as e:
323
+ self.error.emit(e)
@@ -0,0 +1,159 @@
1
+ from PySide6.QtWidgets import (
2
+ QWidget, QLabel, QHBoxLayout, QVBoxLayout, QToolButton, QFrame
3
+ )
4
+ from PySide6.QtCore import Qt, Signal, QRectF, QSize
5
+ from PySide6.QtGui import QPainter, QColor
6
+
7
+
8
+ class Slider(QWidget):
9
+ """Inner slider that paints the track and knob."""
10
+ toggled = Signal(bool)
11
+
12
+ def __init__(self, width=50, height=20, parent=None):
13
+ super().__init__(parent)
14
+ self._checked = False
15
+ self.setFixedSize(width, height)
16
+ self.setCursor(Qt.PointingHandCursor)
17
+
18
+ def paintEvent(self, event):
19
+ palette = self.palette()
20
+ painter = QPainter(self)
21
+ painter.setRenderHint(QPainter.Antialiasing)
22
+
23
+ # Theme colors
24
+ color_on = QColor("#DC5801") # ON highlight
25
+ color_off = QColor("#8D0F70") # OFF highlight
26
+
27
+ base = palette.base().color()
28
+ knob_color = palette.window().color() if base.lightness() < 128 else QColor("white")
29
+
30
+ # Track
31
+ track_rect = QRectF(0, 0, self.width(), self.height())
32
+ track_color = color_on if self._checked else color_off
33
+ painter.setBrush(track_color)
34
+ painter.setPen(Qt.NoPen)
35
+ painter.drawRoundedRect(track_rect, self.height() / 2, self.height() / 2)
36
+
37
+ # Knob
38
+ knob_size = self.height() - 4
39
+ knob_x = self.width() - knob_size - 2 if self._checked else 2
40
+ knob_rect = QRectF(knob_x, 2, knob_size, knob_size)
41
+ painter.setBrush(knob_color)
42
+ painter.drawEllipse(knob_rect)
43
+
44
+ def mousePressEvent(self, event):
45
+ self._checked = not self._checked
46
+ self.toggled.emit(self._checked)
47
+ self.update()
48
+
49
+ def is_checked(self):
50
+ return self._checked
51
+
52
+ def set_checked(self, value: bool):
53
+ self._checked = value
54
+ self.update()
55
+
56
+
57
+ class ToggleSwitch(QWidget):
58
+ """Boolean toggle with labels outside the slider."""
59
+ toggled = Signal(bool)
60
+
61
+ def __init__(self, label_off="STD", label_on="CoV", parent=None):
62
+ super().__init__(parent)
63
+ self.label_off = label_off
64
+ self.label_on = label_on
65
+
66
+ layout = QHBoxLayout(self)
67
+ layout.setContentsMargins(5, 0, 5, 0)
68
+ layout.setSpacing(5)
69
+
70
+ self.left_label = QLabel(self.label_off)
71
+ self.left_label.setAlignment(Qt.AlignVCenter | Qt.AlignRight)
72
+ layout.addWidget(self.left_label)
73
+
74
+ self.slider = Slider()
75
+ self.slider.toggled.connect(self._emit_signal)
76
+ layout.addWidget(self.slider)
77
+
78
+ self.right_label = QLabel(self.label_on)
79
+ self.right_label.setAlignment(Qt.AlignVCenter | Qt.AlignLeft)
80
+ layout.addWidget(self.right_label)
81
+
82
+ def _emit_signal(self, state):
83
+ self.toggled.emit(state)
84
+
85
+ def is_checked(self):
86
+ return self.slider.is_checked()
87
+
88
+ def set_checked(self, value: bool):
89
+ self.slider.set_checked(value)
90
+
91
+ def value(self) -> str:
92
+ return self.label_on if self.slider.is_checked() else self.label_off
93
+
94
+ def set_labels(self, label_on: str, label_off: str):
95
+ self.label_on = label_on
96
+ self.label_off = label_off
97
+ self.left_label.setText(label_off)
98
+ self.right_label.setText(label_on)
99
+
100
+
101
+ class CollapsibleBox(QWidget):
102
+ """Collapsible box that adapts to theme colors."""
103
+ def __init__(self, title="", parent=None, title_font_size=12, arrow_size=10):
104
+ super().__init__(parent)
105
+
106
+ self.toggle_button = QToolButton(text=title, checkable=True, checked=True)
107
+ self.toggle_button.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
108
+ self.toggle_button.setArrowType(Qt.DownArrow)
109
+ self.toggle_button.setIconSize(QSize(arrow_size, arrow_size))
110
+ self.toggle_button.setStyleSheet(f"""
111
+ QToolButton {{
112
+ border: none;
113
+ font-weight: bold;
114
+ font-size: {title_font_size}pt;
115
+ padding: 5px;
116
+ }}
117
+ """)
118
+ self.toggle_button.clicked.connect(self.on_toggle)
119
+
120
+ # Background frame uses palette colors
121
+ self.bg_frame = QFrame()
122
+ self.bg_frame.setAutoFillBackground(True)
123
+ self.update_bg_color()
124
+
125
+ self.content_container = QWidget(self.bg_frame)
126
+ content_layout = QVBoxLayout(self.content_container)
127
+ content_layout.setContentsMargins(10, 10, 10, 10)
128
+ content_layout.setSpacing(5)
129
+ self.content_layout = content_layout
130
+
131
+ frame_layout = QVBoxLayout(self.bg_frame)
132
+ frame_layout.setContentsMargins(0, 0, 0, 0)
133
+ frame_layout.addWidget(self.content_container)
134
+
135
+ main_layout = QVBoxLayout(self)
136
+ main_layout.setContentsMargins(0, 0, 0, 0)
137
+ main_layout.addWidget(self.toggle_button)
138
+ main_layout.addWidget(self.bg_frame)
139
+
140
+ self._expanded = True
141
+
142
+ def update_bg_color(self):
143
+ """Set background color dynamically based on theme."""
144
+ palette = self.palette()
145
+ bg = palette.alternateBase().color()
146
+ self.bg_frame.setStyleSheet(f"""
147
+ QFrame {{
148
+ background-color: {bg.name()};
149
+ border-radius: 8px;
150
+ }}
151
+ """)
152
+
153
+ def on_toggle(self):
154
+ self._expanded = not self._expanded
155
+ self.bg_frame.setVisible(self._expanded)
156
+ self.toggle_button.setArrowType(Qt.DownArrow if self._expanded else Qt.RightArrow)
157
+
158
+ def add_widget(self, widget):
159
+ self.content_layout.addWidget(widget)