senoquant 1.0.0b1__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.
- senoquant/__init__.py +6 -0
- senoquant/_reader.py +7 -0
- senoquant/_widget.py +33 -0
- senoquant/napari.yaml +83 -0
- senoquant/reader/__init__.py +5 -0
- senoquant/reader/core.py +369 -0
- senoquant/tabs/__init__.py +15 -0
- senoquant/tabs/batch/__init__.py +10 -0
- senoquant/tabs/batch/backend.py +641 -0
- senoquant/tabs/batch/config.py +270 -0
- senoquant/tabs/batch/frontend.py +1283 -0
- senoquant/tabs/batch/io.py +326 -0
- senoquant/tabs/batch/layers.py +86 -0
- senoquant/tabs/quantification/__init__.py +1 -0
- senoquant/tabs/quantification/backend.py +228 -0
- senoquant/tabs/quantification/features/__init__.py +80 -0
- senoquant/tabs/quantification/features/base.py +142 -0
- senoquant/tabs/quantification/features/marker/__init__.py +5 -0
- senoquant/tabs/quantification/features/marker/config.py +69 -0
- senoquant/tabs/quantification/features/marker/dialog.py +437 -0
- senoquant/tabs/quantification/features/marker/export.py +879 -0
- senoquant/tabs/quantification/features/marker/feature.py +119 -0
- senoquant/tabs/quantification/features/marker/morphology.py +285 -0
- senoquant/tabs/quantification/features/marker/rows.py +654 -0
- senoquant/tabs/quantification/features/marker/thresholding.py +46 -0
- senoquant/tabs/quantification/features/roi.py +346 -0
- senoquant/tabs/quantification/features/spots/__init__.py +5 -0
- senoquant/tabs/quantification/features/spots/config.py +62 -0
- senoquant/tabs/quantification/features/spots/dialog.py +477 -0
- senoquant/tabs/quantification/features/spots/export.py +1292 -0
- senoquant/tabs/quantification/features/spots/feature.py +112 -0
- senoquant/tabs/quantification/features/spots/morphology.py +279 -0
- senoquant/tabs/quantification/features/spots/rows.py +241 -0
- senoquant/tabs/quantification/frontend.py +815 -0
- senoquant/tabs/segmentation/__init__.py +1 -0
- senoquant/tabs/segmentation/backend.py +131 -0
- senoquant/tabs/segmentation/frontend.py +1009 -0
- senoquant/tabs/segmentation/models/__init__.py +5 -0
- senoquant/tabs/segmentation/models/base.py +146 -0
- senoquant/tabs/segmentation/models/cpsam/details.json +65 -0
- senoquant/tabs/segmentation/models/cpsam/model.py +150 -0
- senoquant/tabs/segmentation/models/default_2d/details.json +69 -0
- senoquant/tabs/segmentation/models/default_2d/model.py +664 -0
- senoquant/tabs/segmentation/models/default_3d/details.json +69 -0
- senoquant/tabs/segmentation/models/default_3d/model.py +682 -0
- senoquant/tabs/segmentation/models/hf.py +71 -0
- senoquant/tabs/segmentation/models/nuclear_dilation/__init__.py +1 -0
- senoquant/tabs/segmentation/models/nuclear_dilation/details.json +26 -0
- senoquant/tabs/segmentation/models/nuclear_dilation/model.py +96 -0
- senoquant/tabs/segmentation/models/perinuclear_rings/__init__.py +1 -0
- senoquant/tabs/segmentation/models/perinuclear_rings/details.json +34 -0
- senoquant/tabs/segmentation/models/perinuclear_rings/model.py +132 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/__init__.py +2 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/__init__.py +3 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/data/__init__.py +6 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/data/generate.py +470 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/data/prepare.py +273 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/data/rawdata.py +112 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/data/transform.py +384 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/internals/__init__.py +0 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/internals/blocks.py +184 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/internals/losses.py +79 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/internals/nets.py +165 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/internals/predict.py +467 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/internals/probability.py +67 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/internals/train.py +148 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/io/__init__.py +163 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/models/__init__.py +52 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/models/base_model.py +329 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/models/care_isotropic.py +160 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/models/care_projection.py +178 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/models/care_standard.py +446 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/models/care_upsampling.py +54 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/models/config.py +254 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/models/pretrained.py +119 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/scripts/__init__.py +0 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/scripts/care_predict.py +180 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/utils/__init__.py +5 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/utils/plot_utils.py +159 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/utils/six.py +18 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/utils/tf.py +644 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/utils/utils.py +272 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/version.py +1 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/docs/source/conf.py +368 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/setup.py +68 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/tests/test_datagen.py +169 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/tests/test_models.py +462 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/tests/test_utils.py +166 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/tools/create_zip_contents.py +34 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/__init__.py +30 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/big.py +624 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/bioimageio_utils.py +494 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/data/__init__.py +39 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/geometry/__init__.py +10 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/geometry/geom2d.py +215 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/geometry/geom3d.py +349 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/matching.py +483 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/models/__init__.py +28 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/models/base.py +1217 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/models/model2d.py +594 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/models/model3d.py +696 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/nms.py +384 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/plot/__init__.py +2 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/plot/plot.py +74 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/plot/render.py +298 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/rays3d.py +373 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/sample_patches.py +65 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/scripts/__init__.py +0 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/scripts/predict2d.py +90 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/scripts/predict3d.py +93 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/utils.py +408 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/version.py +1 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/__init__.py +45 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/convert/__init__.py +17 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/convert/cli.py +55 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/convert/core.py +285 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/inspect/__init__.py +15 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/inspect/cli.py +36 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/inspect/divisibility.py +193 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/inspect/probe.py +100 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/inspect/receptive_field.py +182 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/inspect/rf_cli.py +48 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/inspect/valid_sizes.py +278 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/post/__init__.py +8 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/post/core.py +157 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/pre/__init__.py +17 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/pre/core.py +226 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/predict/__init__.py +5 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/predict/core.py +401 -0
- senoquant/tabs/settings/__init__.py +1 -0
- senoquant/tabs/settings/backend.py +29 -0
- senoquant/tabs/settings/frontend.py +19 -0
- senoquant/tabs/spots/__init__.py +1 -0
- senoquant/tabs/spots/backend.py +139 -0
- senoquant/tabs/spots/frontend.py +800 -0
- senoquant/tabs/spots/models/__init__.py +5 -0
- senoquant/tabs/spots/models/base.py +94 -0
- senoquant/tabs/spots/models/rmp/details.json +61 -0
- senoquant/tabs/spots/models/rmp/model.py +499 -0
- senoquant/tabs/spots/models/udwt/details.json +103 -0
- senoquant/tabs/spots/models/udwt/model.py +482 -0
- senoquant/utils.py +25 -0
- senoquant-1.0.0b1.dist-info/METADATA +193 -0
- senoquant-1.0.0b1.dist-info/RECORD +148 -0
- senoquant-1.0.0b1.dist-info/WHEEL +5 -0
- senoquant-1.0.0b1.dist-info/entry_points.txt +2 -0
- senoquant-1.0.0b1.dist-info/licenses/LICENSE +28 -0
- senoquant-1.0.0b1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,815 @@
|
|
|
1
|
+
"""Frontend widget for the Quantification tab."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from qtpy.QtCore import QObject, QThread, Qt, QTimer, Signal
|
|
5
|
+
from qtpy.QtGui import QGuiApplication
|
|
6
|
+
from qtpy.QtWidgets import (
|
|
7
|
+
QComboBox,
|
|
8
|
+
QFileDialog,
|
|
9
|
+
QFormLayout,
|
|
10
|
+
QGroupBox,
|
|
11
|
+
QFrame,
|
|
12
|
+
QHBoxLayout,
|
|
13
|
+
QLineEdit,
|
|
14
|
+
QPushButton,
|
|
15
|
+
QScrollArea,
|
|
16
|
+
QSizePolicy,
|
|
17
|
+
QVBoxLayout,
|
|
18
|
+
QWidget,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
from napari.utils.notifications import (
|
|
23
|
+
Notification,
|
|
24
|
+
NotificationSeverity,
|
|
25
|
+
show_console_notification,
|
|
26
|
+
)
|
|
27
|
+
except Exception: # pragma: no cover - optional import for runtime
|
|
28
|
+
show_console_notification = None
|
|
29
|
+
Notification = None
|
|
30
|
+
NotificationSeverity = None
|
|
31
|
+
|
|
32
|
+
from .backend import QuantificationBackend
|
|
33
|
+
from .features import FeatureConfig, build_feature_data, get_feature_registry
|
|
34
|
+
from .features.base import RefreshingComboBox
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class FeatureUIContext:
|
|
39
|
+
"""UI context for a single feature row."""
|
|
40
|
+
|
|
41
|
+
state: FeatureConfig
|
|
42
|
+
section: QGroupBox
|
|
43
|
+
name_input: QLineEdit
|
|
44
|
+
type_combo: QComboBox
|
|
45
|
+
left_dynamic_layout: QVBoxLayout
|
|
46
|
+
left_layout: QVBoxLayout
|
|
47
|
+
right_layout: QVBoxLayout
|
|
48
|
+
feature_handler: object | None = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class QuantificationTab(QWidget):
|
|
52
|
+
"""Quantification tab UI for configuring feature extraction.
|
|
53
|
+
|
|
54
|
+
Parameters
|
|
55
|
+
----------
|
|
56
|
+
backend : QuantificationBackend or None
|
|
57
|
+
Backend instance for quantification workflows.
|
|
58
|
+
napari_viewer : object or None
|
|
59
|
+
Napari viewer used to populate layer dropdowns.
|
|
60
|
+
"""
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
backend: QuantificationBackend | None = None,
|
|
64
|
+
napari_viewer=None,
|
|
65
|
+
*,
|
|
66
|
+
show_output_section: bool = True,
|
|
67
|
+
show_process_button: bool = True,
|
|
68
|
+
enable_rois: bool = True,
|
|
69
|
+
show_right_column: bool = True,
|
|
70
|
+
enable_thresholds: bool = True,
|
|
71
|
+
) -> None:
|
|
72
|
+
"""Initialize the quantification tab UI.
|
|
73
|
+
|
|
74
|
+
Parameters
|
|
75
|
+
----------
|
|
76
|
+
backend : QuantificationBackend or None
|
|
77
|
+
Backend instance for quantification workflows.
|
|
78
|
+
napari_viewer : object or None
|
|
79
|
+
Napari viewer used to populate layer dropdowns.
|
|
80
|
+
show_output_section : bool, optional
|
|
81
|
+
Whether to show the output configuration controls.
|
|
82
|
+
show_process_button : bool, optional
|
|
83
|
+
Whether to show the process button.
|
|
84
|
+
enable_rois : bool, optional
|
|
85
|
+
Whether to show ROI configuration controls within features.
|
|
86
|
+
show_right_column : bool, optional
|
|
87
|
+
Whether to show the right-hand feature column.
|
|
88
|
+
enable_thresholds : bool, optional
|
|
89
|
+
Whether to show threshold controls within features.
|
|
90
|
+
"""
|
|
91
|
+
super().__init__()
|
|
92
|
+
self._backend = backend or QuantificationBackend()
|
|
93
|
+
self._viewer = napari_viewer
|
|
94
|
+
self._enable_rois = enable_rois
|
|
95
|
+
self._show_right_column = show_right_column
|
|
96
|
+
self._enable_thresholds = enable_thresholds
|
|
97
|
+
self._feature_configs: list[FeatureUIContext] = []
|
|
98
|
+
self._feature_registry = get_feature_registry()
|
|
99
|
+
self._features_watch_timer: QTimer | None = None
|
|
100
|
+
self._features_last_size: tuple[int, int] | None = None
|
|
101
|
+
self._active_workers: list[tuple[QThread, QObject]] = []
|
|
102
|
+
|
|
103
|
+
layout = QVBoxLayout()
|
|
104
|
+
layout.addWidget(self._make_features_section())
|
|
105
|
+
if show_output_section:
|
|
106
|
+
layout.addWidget(self._make_output_section())
|
|
107
|
+
if show_process_button:
|
|
108
|
+
process_button = QPushButton("Process")
|
|
109
|
+
process_button.clicked.connect(self._process_features)
|
|
110
|
+
layout.addWidget(process_button)
|
|
111
|
+
self._process_button = process_button
|
|
112
|
+
layout.addStretch(1)
|
|
113
|
+
self.setLayout(layout)
|
|
114
|
+
|
|
115
|
+
def _make_output_section(self) -> QGroupBox:
|
|
116
|
+
"""Build the output configuration section.
|
|
117
|
+
|
|
118
|
+
Returns
|
|
119
|
+
-------
|
|
120
|
+
QGroupBox
|
|
121
|
+
Group box containing output settings.
|
|
122
|
+
"""
|
|
123
|
+
section = QGroupBox("Output")
|
|
124
|
+
section_layout = QVBoxLayout()
|
|
125
|
+
|
|
126
|
+
form_layout = QFormLayout()
|
|
127
|
+
form_layout.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
|
|
128
|
+
|
|
129
|
+
self._output_path_input = QLineEdit()
|
|
130
|
+
self._output_path_input.setPlaceholderText("Output folder")
|
|
131
|
+
browse_button = QPushButton("Browse")
|
|
132
|
+
browse_button.clicked.connect(self._select_output_path)
|
|
133
|
+
output_path_row = QHBoxLayout()
|
|
134
|
+
output_path_row.setContentsMargins(0, 0, 0, 0)
|
|
135
|
+
output_path_row.addWidget(self._output_path_input)
|
|
136
|
+
output_path_row.addWidget(browse_button)
|
|
137
|
+
output_path_widget = QWidget()
|
|
138
|
+
output_path_widget.setLayout(output_path_row)
|
|
139
|
+
|
|
140
|
+
self._save_name_input = QLineEdit()
|
|
141
|
+
self._save_name_input.setPlaceholderText("Output name")
|
|
142
|
+
self._save_name_input.setMinimumWidth(180)
|
|
143
|
+
self._save_name_input.setSizePolicy(
|
|
144
|
+
QSizePolicy.Expanding, QSizePolicy.Fixed
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
self._format_combo = QComboBox()
|
|
148
|
+
self._format_combo.addItems(["xlsx", "csv"])
|
|
149
|
+
self._configure_combo(self._format_combo)
|
|
150
|
+
|
|
151
|
+
form_layout.addRow("Output folder", output_path_widget)
|
|
152
|
+
form_layout.addRow("Save name", self._save_name_input)
|
|
153
|
+
form_layout.addRow("Format", self._format_combo)
|
|
154
|
+
|
|
155
|
+
section_layout.addLayout(form_layout)
|
|
156
|
+
section.setLayout(section_layout)
|
|
157
|
+
return section
|
|
158
|
+
|
|
159
|
+
def _make_features_section(self) -> QGroupBox:
|
|
160
|
+
"""Build the features configuration section.
|
|
161
|
+
|
|
162
|
+
Returns
|
|
163
|
+
-------
|
|
164
|
+
QGroupBox
|
|
165
|
+
Group box containing feature inputs.
|
|
166
|
+
"""
|
|
167
|
+
section = QGroupBox("Features")
|
|
168
|
+
section.setFlat(True)
|
|
169
|
+
section.setStyleSheet(
|
|
170
|
+
"QGroupBox {"
|
|
171
|
+
" margin-top: 8px;"
|
|
172
|
+
"}"
|
|
173
|
+
"QGroupBox::title {"
|
|
174
|
+
" subcontrol-origin: margin;"
|
|
175
|
+
" subcontrol-position: top left;"
|
|
176
|
+
" padding: 0 6px;"
|
|
177
|
+
"}"
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
frame = QFrame()
|
|
181
|
+
frame.setFrameShape(QFrame.StyledPanel)
|
|
182
|
+
frame.setFrameShadow(QFrame.Plain)
|
|
183
|
+
frame.setObjectName("features-section-frame")
|
|
184
|
+
frame.setStyleSheet(
|
|
185
|
+
"QFrame#features-section-frame {"
|
|
186
|
+
" border: 1px solid palette(mid);"
|
|
187
|
+
" border-radius: 4px;"
|
|
188
|
+
"}"
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
scroll_area = QScrollArea()
|
|
192
|
+
scroll_area.setWidgetResizable(True)
|
|
193
|
+
scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
|
|
194
|
+
scroll_area.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
|
195
|
+
self._features_scroll_area = scroll_area
|
|
196
|
+
|
|
197
|
+
features_container = QWidget()
|
|
198
|
+
self._features_container = features_container
|
|
199
|
+
features_container.setSizePolicy(
|
|
200
|
+
QSizePolicy.Expanding, QSizePolicy.Minimum
|
|
201
|
+
)
|
|
202
|
+
features_container.setMinimumWidth(200)
|
|
203
|
+
self._features_min_width = 200
|
|
204
|
+
self._features_layout = QVBoxLayout()
|
|
205
|
+
self._features_layout.setContentsMargins(0, 0, 0, 0)
|
|
206
|
+
self._features_layout.setSpacing(8)
|
|
207
|
+
self._features_layout.setSizeConstraint(QVBoxLayout.SetMinAndMaxSize)
|
|
208
|
+
features_container.setLayout(self._features_layout)
|
|
209
|
+
scroll_area.setWidget(features_container)
|
|
210
|
+
|
|
211
|
+
frame_layout = QVBoxLayout()
|
|
212
|
+
frame_layout.setContentsMargins(10, 12, 10, 10)
|
|
213
|
+
frame_layout.addWidget(scroll_area)
|
|
214
|
+
frame.setLayout(frame_layout)
|
|
215
|
+
|
|
216
|
+
section_layout = QVBoxLayout()
|
|
217
|
+
section_layout.setContentsMargins(8, 12, 8, 4)
|
|
218
|
+
section_layout.addWidget(frame)
|
|
219
|
+
|
|
220
|
+
self._add_feature_button = QPushButton("Add feature")
|
|
221
|
+
self._add_feature_button.clicked.connect(self._add_feature_row)
|
|
222
|
+
section_layout.addWidget(self._add_feature_button)
|
|
223
|
+
section.setLayout(section_layout)
|
|
224
|
+
|
|
225
|
+
self._add_feature_row()
|
|
226
|
+
self._apply_features_layout()
|
|
227
|
+
self._start_features_watch()
|
|
228
|
+
return section
|
|
229
|
+
|
|
230
|
+
def showEvent(self, event) -> None:
|
|
231
|
+
"""Ensure layout sizing is applied on initial show.
|
|
232
|
+
|
|
233
|
+
Parameters
|
|
234
|
+
----------
|
|
235
|
+
event : QShowEvent
|
|
236
|
+
Qt show event passed by the widget.
|
|
237
|
+
"""
|
|
238
|
+
super().showEvent(event)
|
|
239
|
+
self._apply_features_layout()
|
|
240
|
+
|
|
241
|
+
def resizeEvent(self, event) -> None:
|
|
242
|
+
"""Resize handler to keep the features list at a capped height.
|
|
243
|
+
|
|
244
|
+
Parameters
|
|
245
|
+
----------
|
|
246
|
+
event : QResizeEvent
|
|
247
|
+
Qt resize event passed by the widget.
|
|
248
|
+
"""
|
|
249
|
+
super().resizeEvent(event)
|
|
250
|
+
self._apply_features_layout()
|
|
251
|
+
|
|
252
|
+
def _add_feature_row(self, state: FeatureConfig | None = None) -> None:
|
|
253
|
+
"""Add a new feature input row."""
|
|
254
|
+
if isinstance(state, bool):
|
|
255
|
+
state = None
|
|
256
|
+
index = len(self._feature_configs)
|
|
257
|
+
feature_section = QGroupBox(f"Feature {index}")
|
|
258
|
+
feature_section.setFlat(True)
|
|
259
|
+
feature_section.setStyleSheet(
|
|
260
|
+
"QGroupBox {"
|
|
261
|
+
" margin-top: 6px;"
|
|
262
|
+
"}"
|
|
263
|
+
"QGroupBox::title {"
|
|
264
|
+
" subcontrol-origin: margin;"
|
|
265
|
+
" subcontrol-position: top left;"
|
|
266
|
+
" padding: 0 6px;"
|
|
267
|
+
"}"
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
section_layout = QVBoxLayout()
|
|
271
|
+
|
|
272
|
+
content_layout = QHBoxLayout()
|
|
273
|
+
content_layout.setContentsMargins(0, 0, 0, 0)
|
|
274
|
+
content_layout.setSpacing(12)
|
|
275
|
+
content_layout.setAlignment(Qt.AlignTop)
|
|
276
|
+
left_layout = QVBoxLayout()
|
|
277
|
+
left_layout.setContentsMargins(0, 0, 0, 0)
|
|
278
|
+
left_layout.setSpacing(6)
|
|
279
|
+
right_layout = QVBoxLayout()
|
|
280
|
+
right_layout.setContentsMargins(0, 0, 0, 0)
|
|
281
|
+
right_layout.setSpacing(6)
|
|
282
|
+
|
|
283
|
+
form_layout = QFormLayout()
|
|
284
|
+
form_layout.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
|
|
285
|
+
|
|
286
|
+
name_input = QLineEdit()
|
|
287
|
+
name_input.setPlaceholderText("Feature name")
|
|
288
|
+
name_input.setMinimumWidth(180)
|
|
289
|
+
name_input.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
|
290
|
+
|
|
291
|
+
type_combo = RefreshingComboBox(
|
|
292
|
+
refresh_callback=self._notify_features_changed
|
|
293
|
+
)
|
|
294
|
+
feature_types = self._feature_types()
|
|
295
|
+
type_combo.addItems(feature_types)
|
|
296
|
+
self._configure_combo(type_combo)
|
|
297
|
+
|
|
298
|
+
form_layout.addRow("Name", name_input)
|
|
299
|
+
form_layout.addRow("Type", type_combo)
|
|
300
|
+
left_layout.addLayout(form_layout)
|
|
301
|
+
|
|
302
|
+
delete_button = QPushButton("Delete")
|
|
303
|
+
delete_button.clicked.connect(
|
|
304
|
+
lambda _checked=False, section=feature_section: self._remove_feature(
|
|
305
|
+
section
|
|
306
|
+
)
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
left_dynamic_container = QWidget()
|
|
310
|
+
left_dynamic_container.setSizePolicy(
|
|
311
|
+
QSizePolicy.Expanding, QSizePolicy.Fixed
|
|
312
|
+
)
|
|
313
|
+
left_dynamic_layout = QVBoxLayout()
|
|
314
|
+
left_dynamic_layout.setContentsMargins(0, 0, 0, 0)
|
|
315
|
+
left_dynamic_layout.setSpacing(6)
|
|
316
|
+
left_dynamic_container.setLayout(left_dynamic_layout)
|
|
317
|
+
left_layout.addWidget(left_dynamic_container)
|
|
318
|
+
left_layout.addWidget(delete_button)
|
|
319
|
+
|
|
320
|
+
left_container = QWidget()
|
|
321
|
+
left_container.setLayout(left_layout)
|
|
322
|
+
left_container.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
|
323
|
+
|
|
324
|
+
right_container = QWidget()
|
|
325
|
+
right_container.setLayout(right_layout)
|
|
326
|
+
right_container.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
|
327
|
+
|
|
328
|
+
self._left_container = left_container
|
|
329
|
+
self._right_container = right_container
|
|
330
|
+
|
|
331
|
+
content_layout.addWidget(left_container, 3)
|
|
332
|
+
if self._show_right_column:
|
|
333
|
+
content_layout.addWidget(right_container, 2)
|
|
334
|
+
section_layout.addLayout(content_layout)
|
|
335
|
+
self._apply_features_layout()
|
|
336
|
+
feature_section.setLayout(section_layout)
|
|
337
|
+
feature_section.setSizePolicy(
|
|
338
|
+
QSizePolicy.Expanding, QSizePolicy.Fixed
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
self._features_layout.addWidget(feature_section)
|
|
342
|
+
feature_type = (
|
|
343
|
+
state.type_name
|
|
344
|
+
if state is not None and state.type_name
|
|
345
|
+
else type_combo.currentText()
|
|
346
|
+
)
|
|
347
|
+
if state is None:
|
|
348
|
+
state = FeatureConfig(
|
|
349
|
+
name="",
|
|
350
|
+
type_name=feature_type,
|
|
351
|
+
data=build_feature_data(feature_type),
|
|
352
|
+
)
|
|
353
|
+
if feature_type in feature_types:
|
|
354
|
+
type_combo.blockSignals(True)
|
|
355
|
+
type_combo.setCurrentText(feature_type)
|
|
356
|
+
type_combo.blockSignals(False)
|
|
357
|
+
context = FeatureUIContext(
|
|
358
|
+
state=state,
|
|
359
|
+
section=feature_section,
|
|
360
|
+
name_input=name_input,
|
|
361
|
+
type_combo=type_combo,
|
|
362
|
+
left_dynamic_layout=left_dynamic_layout,
|
|
363
|
+
left_layout=left_layout,
|
|
364
|
+
right_layout=right_layout,
|
|
365
|
+
)
|
|
366
|
+
self._feature_configs.append(context)
|
|
367
|
+
name_input.setText(state.name)
|
|
368
|
+
name_input.textChanged.connect(
|
|
369
|
+
lambda text, ctx=context: self._on_feature_name_changed(ctx, text)
|
|
370
|
+
)
|
|
371
|
+
type_combo.currentTextChanged.connect(
|
|
372
|
+
lambda _text, ctx=context: self._on_feature_type_changed(ctx)
|
|
373
|
+
)
|
|
374
|
+
self._build_feature_handler(context, preserve_data=True)
|
|
375
|
+
self._notify_features_changed()
|
|
376
|
+
self._features_layout.activate()
|
|
377
|
+
QTimer.singleShot(0, self._apply_features_layout)
|
|
378
|
+
|
|
379
|
+
def _on_feature_type_changed(self, context: FeatureUIContext) -> None:
|
|
380
|
+
"""Update a feature section when its type changes.
|
|
381
|
+
|
|
382
|
+
Parameters
|
|
383
|
+
----------
|
|
384
|
+
context : FeatureUIContext
|
|
385
|
+
Feature UI context and data.
|
|
386
|
+
"""
|
|
387
|
+
self._build_feature_handler(context, preserve_data=False)
|
|
388
|
+
|
|
389
|
+
def _build_feature_handler(
|
|
390
|
+
self,
|
|
391
|
+
context: FeatureUIContext,
|
|
392
|
+
*,
|
|
393
|
+
preserve_data: bool,
|
|
394
|
+
) -> None:
|
|
395
|
+
left_dynamic_layout = context.left_dynamic_layout
|
|
396
|
+
self._clear_layout(left_dynamic_layout)
|
|
397
|
+
self._clear_layout(context.right_layout)
|
|
398
|
+
feature_type = context.type_combo.currentText()
|
|
399
|
+
context.state.type_name = feature_type
|
|
400
|
+
if not preserve_data:
|
|
401
|
+
context.state.data = build_feature_data(feature_type)
|
|
402
|
+
|
|
403
|
+
feature_handler = self._feature_handler_for_type(feature_type, context)
|
|
404
|
+
context.feature_handler = feature_handler
|
|
405
|
+
if feature_handler is not None:
|
|
406
|
+
feature_handler.build()
|
|
407
|
+
self._notify_features_changed()
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _remove_feature(self, feature_section: QGroupBox) -> None:
|
|
411
|
+
"""Remove a feature section and renumber remaining entries.
|
|
412
|
+
|
|
413
|
+
Parameters
|
|
414
|
+
----------
|
|
415
|
+
feature_section : QGroupBox
|
|
416
|
+
Feature section widget to remove.
|
|
417
|
+
"""
|
|
418
|
+
context = next(
|
|
419
|
+
(cfg for cfg in self._feature_configs if cfg.section is feature_section),
|
|
420
|
+
None,
|
|
421
|
+
)
|
|
422
|
+
if context is None:
|
|
423
|
+
return
|
|
424
|
+
self._feature_configs.remove(context)
|
|
425
|
+
self._features_layout.removeWidget(feature_section)
|
|
426
|
+
feature_section.deleteLater()
|
|
427
|
+
self._renumber_features()
|
|
428
|
+
self._notify_features_changed()
|
|
429
|
+
self._features_layout.activate()
|
|
430
|
+
if hasattr(self, "_features_container"):
|
|
431
|
+
self._features_container.adjustSize()
|
|
432
|
+
QTimer.singleShot(0, self._apply_features_layout)
|
|
433
|
+
|
|
434
|
+
def _renumber_features(self) -> None:
|
|
435
|
+
"""Renumber feature sections after insertions/removals."""
|
|
436
|
+
for index, context in enumerate(self._feature_configs, start=0):
|
|
437
|
+
context.section.setTitle(f"Feature {index}")
|
|
438
|
+
|
|
439
|
+
def _notify_features_changed(self) -> None:
|
|
440
|
+
"""Notify feature handlers that the feature list has changed."""
|
|
441
|
+
for feature_cls in self._feature_registry.values():
|
|
442
|
+
feature_cls.update_type_options(self, self._feature_configs)
|
|
443
|
+
for context in self._feature_configs:
|
|
444
|
+
handler = context.feature_handler
|
|
445
|
+
if handler is not None:
|
|
446
|
+
handler.on_features_changed(self._feature_configs)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _feature_types(self) -> list[str]:
|
|
450
|
+
"""Return the available feature type names."""
|
|
451
|
+
return list(self._feature_registry.keys())
|
|
452
|
+
|
|
453
|
+
def load_feature_configs(self, configs: list[FeatureConfig]) -> None:
|
|
454
|
+
"""Replace the current feature list with provided configs."""
|
|
455
|
+
for context in list(self._feature_configs):
|
|
456
|
+
self._remove_feature(context.section)
|
|
457
|
+
if not configs:
|
|
458
|
+
self._add_feature_row()
|
|
459
|
+
return
|
|
460
|
+
for config in configs:
|
|
461
|
+
self._add_feature_row(config)
|
|
462
|
+
|
|
463
|
+
def _select_output_path(self) -> None:
|
|
464
|
+
"""Open a folder selection dialog for the output path."""
|
|
465
|
+
path = QFileDialog.getExistingDirectory(
|
|
466
|
+
self,
|
|
467
|
+
"Select output folder",
|
|
468
|
+
self._output_path_input.text(),
|
|
469
|
+
)
|
|
470
|
+
if path:
|
|
471
|
+
self._output_path_input.setText(path)
|
|
472
|
+
|
|
473
|
+
def _process_features(self) -> None:
|
|
474
|
+
"""Trigger quantification processing for configured features."""
|
|
475
|
+
process = getattr(self._backend, "process", None)
|
|
476
|
+
if not callable(process):
|
|
477
|
+
return
|
|
478
|
+
features = list(self._feature_configs)
|
|
479
|
+
output_path = self._output_path_input.text()
|
|
480
|
+
output_name = self._save_name_input.text()
|
|
481
|
+
export_format = self._format_combo.currentText()
|
|
482
|
+
if hasattr(self, "_process_button"):
|
|
483
|
+
self._start_background_run(
|
|
484
|
+
run_button=self._process_button,
|
|
485
|
+
run_text="Process",
|
|
486
|
+
run_callable=lambda: process(
|
|
487
|
+
features,
|
|
488
|
+
output_path,
|
|
489
|
+
output_name,
|
|
490
|
+
export_format,
|
|
491
|
+
),
|
|
492
|
+
on_success=self._handle_process_complete,
|
|
493
|
+
)
|
|
494
|
+
else:
|
|
495
|
+
process(features, output_path, output_name, export_format)
|
|
496
|
+
|
|
497
|
+
def _feature_handler_for_type(
|
|
498
|
+
self, feature_type: str, context: FeatureUIContext
|
|
499
|
+
):
|
|
500
|
+
"""Return the feature handler for a given feature type.
|
|
501
|
+
|
|
502
|
+
Parameters
|
|
503
|
+
----------
|
|
504
|
+
feature_type : str
|
|
505
|
+
Selected feature type.
|
|
506
|
+
config : dict
|
|
507
|
+
Feature configuration dictionary.
|
|
508
|
+
|
|
509
|
+
Returns
|
|
510
|
+
-------
|
|
511
|
+
SenoQuantFeature or None
|
|
512
|
+
Feature handler instance for the selected type.
|
|
513
|
+
"""
|
|
514
|
+
feature_cls = self._feature_registry.get(feature_type)
|
|
515
|
+
if feature_cls is None:
|
|
516
|
+
return None
|
|
517
|
+
return feature_cls(self, context)
|
|
518
|
+
|
|
519
|
+
def _configure_combo(self, combo: QComboBox) -> None:
|
|
520
|
+
"""Apply sizing defaults to combo boxes.
|
|
521
|
+
|
|
522
|
+
Parameters
|
|
523
|
+
----------
|
|
524
|
+
combo : QComboBox
|
|
525
|
+
Combo box to configure.
|
|
526
|
+
"""
|
|
527
|
+
combo.setSizeAdjustPolicy(
|
|
528
|
+
QComboBox.AdjustToMinimumContentsLengthWithIcon
|
|
529
|
+
)
|
|
530
|
+
combo.setMinimumContentsLength(8)
|
|
531
|
+
combo.setMinimumWidth(140)
|
|
532
|
+
combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
|
533
|
+
|
|
534
|
+
def _clear_layout(self, layout: QVBoxLayout) -> None:
|
|
535
|
+
"""Remove all widgets and layouts from a layout.
|
|
536
|
+
|
|
537
|
+
Parameters
|
|
538
|
+
----------
|
|
539
|
+
layout : QVBoxLayout
|
|
540
|
+
Layout to clear.
|
|
541
|
+
"""
|
|
542
|
+
while layout.count():
|
|
543
|
+
item = layout.takeAt(0)
|
|
544
|
+
widget = item.widget()
|
|
545
|
+
if widget is not None:
|
|
546
|
+
widget.deleteLater()
|
|
547
|
+
child_layout = item.layout()
|
|
548
|
+
if child_layout is not None:
|
|
549
|
+
self._clear_layout(child_layout)
|
|
550
|
+
|
|
551
|
+
def _start_background_run(
|
|
552
|
+
self,
|
|
553
|
+
*,
|
|
554
|
+
run_button: QPushButton,
|
|
555
|
+
run_text: str,
|
|
556
|
+
run_callable,
|
|
557
|
+
on_success,
|
|
558
|
+
) -> None:
|
|
559
|
+
"""Run quantification in a background thread and manage UI state."""
|
|
560
|
+
run_button.setEnabled(False)
|
|
561
|
+
run_button.setText("Running...")
|
|
562
|
+
|
|
563
|
+
thread = QThread(self)
|
|
564
|
+
worker = _RunWorker(run_callable)
|
|
565
|
+
worker.moveToThread(thread)
|
|
566
|
+
|
|
567
|
+
def handle_success(result) -> None:
|
|
568
|
+
on_success(result)
|
|
569
|
+
self._finish_background_run(run_button, run_text, thread, worker)
|
|
570
|
+
|
|
571
|
+
def handle_error(message: str) -> None:
|
|
572
|
+
self._notify(f"Quantification failed: {message}")
|
|
573
|
+
self._finish_background_run(run_button, run_text, thread, worker)
|
|
574
|
+
|
|
575
|
+
thread.started.connect(worker.run)
|
|
576
|
+
worker.finished.connect(handle_success)
|
|
577
|
+
worker.error.connect(handle_error)
|
|
578
|
+
worker.finished.connect(thread.quit)
|
|
579
|
+
worker.error.connect(thread.quit)
|
|
580
|
+
thread.finished.connect(thread.deleteLater)
|
|
581
|
+
thread.finished.connect(worker.deleteLater)
|
|
582
|
+
|
|
583
|
+
self._active_workers.append((thread, worker))
|
|
584
|
+
thread.start()
|
|
585
|
+
|
|
586
|
+
def _finish_background_run(
|
|
587
|
+
self,
|
|
588
|
+
run_button: QPushButton,
|
|
589
|
+
run_text: str,
|
|
590
|
+
thread: QThread,
|
|
591
|
+
worker: QObject,
|
|
592
|
+
) -> None:
|
|
593
|
+
"""Restore UI state after a background run completes."""
|
|
594
|
+
run_button.setEnabled(True)
|
|
595
|
+
run_button.setText(run_text)
|
|
596
|
+
try:
|
|
597
|
+
self._active_workers.remove((thread, worker))
|
|
598
|
+
except ValueError:
|
|
599
|
+
pass
|
|
600
|
+
|
|
601
|
+
def _handle_process_complete(self, result) -> None:
|
|
602
|
+
"""Notify the user when quantification completes."""
|
|
603
|
+
output_root = getattr(result, "output_root", None)
|
|
604
|
+
if output_root:
|
|
605
|
+
self._notify(f"Quantification complete: {output_root}")
|
|
606
|
+
else:
|
|
607
|
+
self._notify("Quantification complete.")
|
|
608
|
+
|
|
609
|
+
def _notify(self, message: str) -> None:
|
|
610
|
+
"""Send a user-visible notification."""
|
|
611
|
+
if (
|
|
612
|
+
show_console_notification is not None
|
|
613
|
+
and Notification is not None
|
|
614
|
+
and NotificationSeverity is not None
|
|
615
|
+
):
|
|
616
|
+
show_console_notification(
|
|
617
|
+
Notification(message, severity=NotificationSeverity.WARNING)
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
def _feature_index(self, context: FeatureUIContext) -> int:
|
|
621
|
+
"""Return the 0-based index for a feature config.
|
|
622
|
+
|
|
623
|
+
Parameters
|
|
624
|
+
----------
|
|
625
|
+
context : FeatureUIContext
|
|
626
|
+
Feature UI context.
|
|
627
|
+
|
|
628
|
+
Returns
|
|
629
|
+
-------
|
|
630
|
+
int
|
|
631
|
+
0-based index of the feature.
|
|
632
|
+
"""
|
|
633
|
+
return self._feature_configs.index(context)
|
|
634
|
+
|
|
635
|
+
def _on_feature_name_changed(
|
|
636
|
+
self, context: FeatureUIContext, text: str
|
|
637
|
+
) -> None:
|
|
638
|
+
"""Store feature name updates and refresh dependent combos.
|
|
639
|
+
|
|
640
|
+
Parameters
|
|
641
|
+
----------
|
|
642
|
+
context : FeatureUIContext
|
|
643
|
+
Feature UI context.
|
|
644
|
+
text : str
|
|
645
|
+
Updated name string.
|
|
646
|
+
"""
|
|
647
|
+
context.state.name = text
|
|
648
|
+
self._notify_features_changed()
|
|
649
|
+
|
|
650
|
+
def _start_features_watch(self) -> None:
|
|
651
|
+
"""Start a timer to monitor feature sizing changes.
|
|
652
|
+
|
|
653
|
+
The watcher polls for content size changes and reapplies layout
|
|
654
|
+
constraints without blocking the UI thread.
|
|
655
|
+
"""
|
|
656
|
+
if self._features_watch_timer is not None:
|
|
657
|
+
return
|
|
658
|
+
self._features_watch_timer = QTimer(self)
|
|
659
|
+
self._features_watch_timer.setInterval(150)
|
|
660
|
+
self._features_watch_timer.timeout.connect(self._poll_features_geometry)
|
|
661
|
+
self._features_watch_timer.start()
|
|
662
|
+
|
|
663
|
+
def _poll_features_geometry(self) -> None:
|
|
664
|
+
"""Recompute layout sizing when content size changes."""
|
|
665
|
+
if not hasattr(self, "_features_scroll_area"):
|
|
666
|
+
return
|
|
667
|
+
size = self._features_content_size()
|
|
668
|
+
if size == self._features_last_size:
|
|
669
|
+
return
|
|
670
|
+
self._features_last_size = size
|
|
671
|
+
self._apply_features_layout(size)
|
|
672
|
+
|
|
673
|
+
def _apply_features_layout(
|
|
674
|
+
self, content_size: tuple[int, int] | None = None
|
|
675
|
+
) -> None:
|
|
676
|
+
"""Apply sizing rules for the features container and scroll area.
|
|
677
|
+
|
|
678
|
+
Parameters
|
|
679
|
+
----------
|
|
680
|
+
content_size : tuple of int or None
|
|
681
|
+
Optional (width, height) of the features content. If None, the
|
|
682
|
+
size is computed from the current layout.
|
|
683
|
+
"""
|
|
684
|
+
if not hasattr(self, "_features_scroll_area"):
|
|
685
|
+
return
|
|
686
|
+
if content_size is None:
|
|
687
|
+
content_size = self._features_content_size()
|
|
688
|
+
content_width, content_height = content_size
|
|
689
|
+
|
|
690
|
+
total_min = getattr(self, "_features_min_width", 0)
|
|
691
|
+
if total_min <= 0 and hasattr(self, "_features_container"):
|
|
692
|
+
total_min = self._features_container.minimumWidth()
|
|
693
|
+
left_hint = 0
|
|
694
|
+
right_hint = 0
|
|
695
|
+
if hasattr(self, "_left_container") and self._left_container is not None:
|
|
696
|
+
try:
|
|
697
|
+
left_hint = self._left_container.sizeHint().width()
|
|
698
|
+
except RuntimeError:
|
|
699
|
+
self._left_container = None
|
|
700
|
+
if hasattr(self, "_right_container") and self._right_container is not None:
|
|
701
|
+
try:
|
|
702
|
+
right_hint = self._right_container.sizeHint().width()
|
|
703
|
+
except RuntimeError:
|
|
704
|
+
self._right_container = None
|
|
705
|
+
left_min = max(int(total_min * 0.6), left_hint)
|
|
706
|
+
right_min = max(int(total_min * 0.4), right_hint)
|
|
707
|
+
if self._left_container is not None:
|
|
708
|
+
try:
|
|
709
|
+
self._left_container.setMinimumWidth(left_min)
|
|
710
|
+
except RuntimeError:
|
|
711
|
+
self._left_container = None
|
|
712
|
+
if self._right_container is not None:
|
|
713
|
+
try:
|
|
714
|
+
self._right_container.setMinimumWidth(right_min)
|
|
715
|
+
except RuntimeError:
|
|
716
|
+
self._right_container = None
|
|
717
|
+
|
|
718
|
+
if hasattr(self, "_features_container"):
|
|
719
|
+
self._features_container.setMinimumHeight(0)
|
|
720
|
+
self._features_container.setMinimumWidth(
|
|
721
|
+
max(total_min, content_width)
|
|
722
|
+
)
|
|
723
|
+
self._features_container.updateGeometry()
|
|
724
|
+
|
|
725
|
+
screen = self.window().screen() if self.window() is not None else None
|
|
726
|
+
if screen is None:
|
|
727
|
+
screen = QGuiApplication.primaryScreen()
|
|
728
|
+
screen_height = screen.availableGeometry().height() if screen else 720
|
|
729
|
+
target_height = max(180, int(screen_height * 0.5))
|
|
730
|
+
frame = self._features_scroll_area.frameWidth() * 2
|
|
731
|
+
scroll_slack = 2
|
|
732
|
+
effective_height = content_height + scroll_slack
|
|
733
|
+
height = max(0, min(target_height, effective_height + frame))
|
|
734
|
+
self._features_scroll_area.setUpdatesEnabled(False)
|
|
735
|
+
self._features_scroll_area.setFixedHeight(height)
|
|
736
|
+
self._features_scroll_area.setUpdatesEnabled(True)
|
|
737
|
+
self._features_scroll_area.updateGeometry()
|
|
738
|
+
widget = self._features_scroll_area.widget()
|
|
739
|
+
if widget is not None:
|
|
740
|
+
widget.adjustSize()
|
|
741
|
+
widget.updateGeometry()
|
|
742
|
+
self._features_scroll_area.viewport().updateGeometry()
|
|
743
|
+
bar = self._features_scroll_area.verticalScrollBar()
|
|
744
|
+
if bar.maximum() > 0:
|
|
745
|
+
self._features_scroll_area.setVerticalScrollBarPolicy(
|
|
746
|
+
Qt.ScrollBarAsNeeded
|
|
747
|
+
)
|
|
748
|
+
else:
|
|
749
|
+
self._features_scroll_area.setVerticalScrollBarPolicy(
|
|
750
|
+
Qt.ScrollBarAlwaysOff
|
|
751
|
+
)
|
|
752
|
+
bar.setRange(0, 0)
|
|
753
|
+
bar.setValue(0)
|
|
754
|
+
|
|
755
|
+
def _features_content_size(self) -> tuple[int, int]:
|
|
756
|
+
"""Compute the content size for the features layout.
|
|
757
|
+
|
|
758
|
+
Returns
|
|
759
|
+
-------
|
|
760
|
+
tuple of int
|
|
761
|
+
(width, height) of the content.
|
|
762
|
+
"""
|
|
763
|
+
if not hasattr(self, "_features_layout"):
|
|
764
|
+
return (0, 0)
|
|
765
|
+
layout = self._features_layout
|
|
766
|
+
layout.activate()
|
|
767
|
+
margins = layout.contentsMargins()
|
|
768
|
+
spacing = layout.spacing()
|
|
769
|
+
count = layout.count()
|
|
770
|
+
total_height = margins.top() + margins.bottom()
|
|
771
|
+
max_width = 0
|
|
772
|
+
for index in range(count):
|
|
773
|
+
item = layout.itemAt(index)
|
|
774
|
+
widget = item.widget()
|
|
775
|
+
if widget is None:
|
|
776
|
+
item_size = item.sizeHint()
|
|
777
|
+
else:
|
|
778
|
+
widget.adjustSize()
|
|
779
|
+
item_size = widget.sizeHint().expandedTo(
|
|
780
|
+
widget.minimumSizeHint()
|
|
781
|
+
)
|
|
782
|
+
max_width = max(max_width, item_size.width())
|
|
783
|
+
total_height += item_size.height()
|
|
784
|
+
if count > 1:
|
|
785
|
+
total_height += spacing * (count - 1)
|
|
786
|
+
total_width = margins.left() + margins.right() + max_width
|
|
787
|
+
if hasattr(self, "_features_container"):
|
|
788
|
+
self._features_container.adjustSize()
|
|
789
|
+
container_size = self._features_container.sizeHint().expandedTo(
|
|
790
|
+
self._features_container.minimumSizeHint()
|
|
791
|
+
)
|
|
792
|
+
total_width = max(total_width, container_size.width())
|
|
793
|
+
total_height = max(total_height, container_size.height())
|
|
794
|
+
return (total_width, total_height)
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
class _RunWorker(QObject):
|
|
798
|
+
"""Worker that executes a callable in a background thread."""
|
|
799
|
+
|
|
800
|
+
finished = Signal(object)
|
|
801
|
+
error = Signal(str)
|
|
802
|
+
|
|
803
|
+
def __init__(self, run_callable) -> None:
|
|
804
|
+
"""Initialize the worker with a callable."""
|
|
805
|
+
super().__init__()
|
|
806
|
+
self._run_callable = run_callable
|
|
807
|
+
|
|
808
|
+
def run(self) -> None:
|
|
809
|
+
"""Execute the callable and emit results."""
|
|
810
|
+
try:
|
|
811
|
+
result = self._run_callable()
|
|
812
|
+
except Exception as exc: # pragma: no cover - runtime error path
|
|
813
|
+
self.error.emit(str(exc))
|
|
814
|
+
return
|
|
815
|
+
self.finished.emit(result)
|