senoquant 1.0.0b3__py3-none-any.whl → 1.0.0b4__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 +1 -1
- senoquant/_widget.py +9 -1
- senoquant/tabs/__init__.py +2 -0
- senoquant/tabs/batch/backend.py +58 -24
- senoquant/tabs/batch/frontend.py +119 -21
- senoquant/tabs/spots/frontend.py +54 -0
- senoquant/tabs/spots/models/rmp/details.json +55 -0
- senoquant/tabs/spots/models/rmp/model.py +574 -0
- senoquant/tabs/spots/models/ufish/details.json +16 -1
- senoquant/tabs/spots/models/ufish/model.py +211 -13
- senoquant/tabs/spots/ufish_utils/core.py +31 -1
- senoquant/tabs/visualization/__init__.py +1 -0
- senoquant/tabs/visualization/backend.py +306 -0
- senoquant/tabs/visualization/frontend.py +1113 -0
- senoquant/tabs/visualization/plots/__init__.py +80 -0
- senoquant/tabs/visualization/plots/base.py +152 -0
- senoquant/tabs/visualization/plots/double_expression.py +187 -0
- senoquant/tabs/visualization/plots/spatialplot.py +156 -0
- senoquant/tabs/visualization/plots/umap.py +140 -0
- {senoquant-1.0.0b3.dist-info → senoquant-1.0.0b4.dist-info}/METADATA +7 -6
- {senoquant-1.0.0b3.dist-info → senoquant-1.0.0b4.dist-info}/RECORD +25 -15
- {senoquant-1.0.0b3.dist-info → senoquant-1.0.0b4.dist-info}/WHEEL +0 -0
- {senoquant-1.0.0b3.dist-info → senoquant-1.0.0b4.dist-info}/entry_points.txt +0 -0
- {senoquant-1.0.0b3.dist-info → senoquant-1.0.0b4.dist-info}/licenses/LICENSE +0 -0
- {senoquant-1.0.0b3.dist-info → senoquant-1.0.0b4.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,1113 @@
|
|
|
1
|
+
"""Frontend widget for the Visualization tab."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import pandas as pd
|
|
7
|
+
import shutil
|
|
8
|
+
from qtpy.QtCore import Qt, QTimer
|
|
9
|
+
from qtpy.QtGui import QGuiApplication, QPixmap
|
|
10
|
+
from qtpy.QtWidgets import (
|
|
11
|
+
QComboBox,
|
|
12
|
+
QFileDialog,
|
|
13
|
+
QFormLayout,
|
|
14
|
+
QGroupBox,
|
|
15
|
+
QFrame,
|
|
16
|
+
QHeaderView,
|
|
17
|
+
QHBoxLayout,
|
|
18
|
+
QLabel,
|
|
19
|
+
QLineEdit,
|
|
20
|
+
QPushButton,
|
|
21
|
+
QScrollArea,
|
|
22
|
+
QSizePolicy,
|
|
23
|
+
QTableWidget,
|
|
24
|
+
QTableWidgetItem,
|
|
25
|
+
QVBoxLayout,
|
|
26
|
+
QWidget,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
from .backend import VisualizationBackend
|
|
30
|
+
from .plots import PlotConfig, build_feature_data, get_feature_registry
|
|
31
|
+
from .plots.base import RefreshingComboBox
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class PlotUIContext:
|
|
36
|
+
"""UI context for a single plot row."""
|
|
37
|
+
|
|
38
|
+
state: PlotConfig
|
|
39
|
+
section: QGroupBox
|
|
40
|
+
type_combo: QComboBox
|
|
41
|
+
left_dynamic_layout: QVBoxLayout
|
|
42
|
+
left_layout: QVBoxLayout
|
|
43
|
+
right_layout: QVBoxLayout
|
|
44
|
+
plot_handler: object | None = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class VisualizationTab(QWidget):
|
|
48
|
+
"""Visualization tab UI for configuring plot generation.
|
|
49
|
+
|
|
50
|
+
Parameters
|
|
51
|
+
----------
|
|
52
|
+
backend : VisualizationBackend or None
|
|
53
|
+
Backend instance for visualization workflows.
|
|
54
|
+
napari_viewer : object or None
|
|
55
|
+
Napari viewer used to populate layer dropdowns.
|
|
56
|
+
"""
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
backend: VisualizationBackend | None = None,
|
|
60
|
+
napari_viewer=None,
|
|
61
|
+
*,
|
|
62
|
+
show_output_section: bool = True,
|
|
63
|
+
show_process_button: bool = True,
|
|
64
|
+
# enable_rois: bool = True,
|
|
65
|
+
show_right_column: bool = True,
|
|
66
|
+
enable_thresholds: bool = True,
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Initialize the visualization tab UI.
|
|
69
|
+
|
|
70
|
+
Parameters
|
|
71
|
+
----------
|
|
72
|
+
backend : VisualizationBackend or None
|
|
73
|
+
Backend instance for visualization workflows.
|
|
74
|
+
napari_viewer : object or None
|
|
75
|
+
Napari viewer used to populate layer dropdowns.
|
|
76
|
+
show_output_section : bool, optional
|
|
77
|
+
Whether to show the output configuration controls.
|
|
78
|
+
show_process_button : bool, optional
|
|
79
|
+
Whether to show the process button.
|
|
80
|
+
# enable_rois : bool, optional
|
|
81
|
+
# Whether to show ROI configuration controls within features.
|
|
82
|
+
show_right_column : bool, optional
|
|
83
|
+
Whether to show the right-hand feature column.
|
|
84
|
+
enable_thresholds : bool, optional
|
|
85
|
+
Whether to show threshold controls within features.
|
|
86
|
+
"""
|
|
87
|
+
super().__init__()
|
|
88
|
+
self._backend = backend or VisualizationBackend()
|
|
89
|
+
self._viewer = napari_viewer
|
|
90
|
+
# self._enable_rois = enable_rois
|
|
91
|
+
self._show_right_column = show_right_column
|
|
92
|
+
self._enable_thresholds = enable_thresholds
|
|
93
|
+
self._feature_configs: list[PlotUIContext] = []
|
|
94
|
+
self._feature_registry = get_feature_registry()
|
|
95
|
+
self._features_watch_timer: QTimer | None = None
|
|
96
|
+
self._features_last_size: tuple[int, int] | None = None
|
|
97
|
+
|
|
98
|
+
layout = QVBoxLayout()
|
|
99
|
+
|
|
100
|
+
layout.addWidget(self._make_input_section())
|
|
101
|
+
layout.addWidget(self._make_marker_section())
|
|
102
|
+
layout.addWidget(self._make_plots_section())
|
|
103
|
+
|
|
104
|
+
# Add plot display area
|
|
105
|
+
layout.addWidget(self._make_plot_display_section(show_process_button))
|
|
106
|
+
|
|
107
|
+
if show_output_section:
|
|
108
|
+
layout.addWidget(self._make_output_section())
|
|
109
|
+
|
|
110
|
+
layout.addStretch(1)
|
|
111
|
+
self.setLayout(layout)
|
|
112
|
+
|
|
113
|
+
def _make_input_section(self) -> QGroupBox:
|
|
114
|
+
"""Build the input configuration section."""
|
|
115
|
+
section = QGroupBox("Input")
|
|
116
|
+
section_layout = QVBoxLayout()
|
|
117
|
+
form_layout = QFormLayout()
|
|
118
|
+
form_layout.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
|
|
119
|
+
|
|
120
|
+
self._input_path = QLineEdit()
|
|
121
|
+
self._input_path.setPlaceholderText("Folder with quantification files")
|
|
122
|
+
browse_button = QPushButton("Browse")
|
|
123
|
+
browse_button.clicked.connect(self._select_input_path)
|
|
124
|
+
input_row = QHBoxLayout()
|
|
125
|
+
input_row.setContentsMargins(0, 0, 0, 0)
|
|
126
|
+
input_row.addWidget(self._input_path)
|
|
127
|
+
input_row.addWidget(browse_button)
|
|
128
|
+
input_widget = QWidget()
|
|
129
|
+
input_widget.setLayout(input_row)
|
|
130
|
+
self._input_path.textChanged.connect(self._on_input_path_changed)
|
|
131
|
+
|
|
132
|
+
self._extensions = QLineEdit()
|
|
133
|
+
self._extensions.setText(".csv, .xlsx, .xls")
|
|
134
|
+
|
|
135
|
+
form_layout.addRow("Input folder", input_widget)
|
|
136
|
+
form_layout.addRow("Extensions", self._extensions)
|
|
137
|
+
|
|
138
|
+
section_layout.addLayout(form_layout)
|
|
139
|
+
section.setLayout(section_layout)
|
|
140
|
+
return section
|
|
141
|
+
|
|
142
|
+
def _make_marker_section(self) -> QGroupBox:
|
|
143
|
+
"""Build the marker selection and thresholding section."""
|
|
144
|
+
section = QGroupBox("Marker selection && thresholding")
|
|
145
|
+
layout = QVBoxLayout()
|
|
146
|
+
|
|
147
|
+
# Add Select/Deselect buttons
|
|
148
|
+
btn_layout = QHBoxLayout()
|
|
149
|
+
btn_layout.setContentsMargins(0, 8, 0, 5)
|
|
150
|
+
sel_all = QPushButton("Select All")
|
|
151
|
+
sel_all.clicked.connect(self._select_all_markers)
|
|
152
|
+
desel_all = QPushButton("Deselect All")
|
|
153
|
+
desel_all.clicked.connect(self._deselect_all_markers)
|
|
154
|
+
btn_layout.addWidget(sel_all)
|
|
155
|
+
btn_layout.addWidget(desel_all)
|
|
156
|
+
btn_layout.addStretch()
|
|
157
|
+
layout.addLayout(btn_layout)
|
|
158
|
+
|
|
159
|
+
self._marker_table = QTableWidget()
|
|
160
|
+
self._marker_table.setColumnCount(3)
|
|
161
|
+
self._marker_table.setHorizontalHeaderLabels(["Include", "Marker", "Threshold"])
|
|
162
|
+
|
|
163
|
+
header = self._marker_table.horizontalHeader()
|
|
164
|
+
header.setSectionResizeMode(0, QHeaderView.ResizeToContents)
|
|
165
|
+
header.setSectionResizeMode(1, QHeaderView.Stretch)
|
|
166
|
+
header.setSectionResizeMode(2, QHeaderView.ResizeToContents)
|
|
167
|
+
|
|
168
|
+
# Hide vertical header
|
|
169
|
+
self._marker_table.verticalHeader().setVisible(False)
|
|
170
|
+
|
|
171
|
+
layout.addWidget(self._marker_table)
|
|
172
|
+
section.setLayout(layout)
|
|
173
|
+
return section
|
|
174
|
+
|
|
175
|
+
def _on_input_path_changed(self, path_text: str) -> None:
|
|
176
|
+
"""Handle input path changes to populate markers."""
|
|
177
|
+
path = Path(path_text)
|
|
178
|
+
if not path.exists() or not path.is_dir():
|
|
179
|
+
return
|
|
180
|
+
|
|
181
|
+
# Find first CSV or Excel file
|
|
182
|
+
data_file = None
|
|
183
|
+
for ext in [".csv", ".xlsx", ".xls"]:
|
|
184
|
+
found = list(path.glob(f"*{ext}"))
|
|
185
|
+
if found:
|
|
186
|
+
data_file = found[0]
|
|
187
|
+
break
|
|
188
|
+
|
|
189
|
+
if data_file:
|
|
190
|
+
self._populate_markers_from_file(data_file)
|
|
191
|
+
|
|
192
|
+
# Look for JSON thresholds
|
|
193
|
+
json_files = list(path.glob("*.json"))
|
|
194
|
+
target_json = None
|
|
195
|
+
if json_files:
|
|
196
|
+
# Prioritize files with 'threshold' in the name
|
|
197
|
+
for jf in json_files:
|
|
198
|
+
if "threshold" in jf.name.lower():
|
|
199
|
+
target_json = jf
|
|
200
|
+
break
|
|
201
|
+
if not target_json:
|
|
202
|
+
target_json = json_files[0]
|
|
203
|
+
self._load_thresholds_from_json(target_json)
|
|
204
|
+
|
|
205
|
+
def _populate_markers_from_file(self, file_path: Path) -> None:
|
|
206
|
+
"""Read header from file and populate marker table."""
|
|
207
|
+
try:
|
|
208
|
+
if file_path.suffix == ".csv":
|
|
209
|
+
df = pd.read_csv(file_path, nrows=0)
|
|
210
|
+
else:
|
|
211
|
+
df = pd.read_excel(file_path, nrows=0)
|
|
212
|
+
|
|
213
|
+
markers = set()
|
|
214
|
+
for col in df.columns:
|
|
215
|
+
if "_mean_intensity" in col:
|
|
216
|
+
# Extract marker name (first part)
|
|
217
|
+
marker = col.split("_mean_intensity")[0]
|
|
218
|
+
markers.add(marker)
|
|
219
|
+
|
|
220
|
+
self._marker_table.setRowCount(0)
|
|
221
|
+
for i, marker in enumerate(sorted(markers)):
|
|
222
|
+
self._marker_table.insertRow(i)
|
|
223
|
+
|
|
224
|
+
# Checkbox
|
|
225
|
+
chk_item = QTableWidgetItem()
|
|
226
|
+
chk_item.setCheckState(Qt.Checked)
|
|
227
|
+
chk_item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled)
|
|
228
|
+
self._marker_table.setItem(i, 0, chk_item)
|
|
229
|
+
|
|
230
|
+
# Marker Name
|
|
231
|
+
name_item = QTableWidgetItem(marker)
|
|
232
|
+
name_item.setFlags(Qt.ItemIsEnabled)
|
|
233
|
+
self._marker_table.setItem(i, 1, name_item)
|
|
234
|
+
|
|
235
|
+
# Threshold Input
|
|
236
|
+
thresh_input = QLineEdit()
|
|
237
|
+
thresh_input.setPlaceholderText("Auto")
|
|
238
|
+
self._marker_table.setCellWidget(i, 2, thresh_input)
|
|
239
|
+
|
|
240
|
+
except Exception as e:
|
|
241
|
+
print(f"Error populating markers: {e}")
|
|
242
|
+
|
|
243
|
+
def _load_thresholds_from_json(self, json_path: Path) -> None:
|
|
244
|
+
"""Load thresholds from a JSON file."""
|
|
245
|
+
try:
|
|
246
|
+
print(f"[Frontend] Loading thresholds from {json_path}")
|
|
247
|
+
with open(json_path, "r") as f:
|
|
248
|
+
data = json.load(f)
|
|
249
|
+
|
|
250
|
+
thresholds_map = {}
|
|
251
|
+
|
|
252
|
+
# Handle SenoQuant export format (dict with "channels" list)
|
|
253
|
+
if isinstance(data, dict) and "channels" in data and isinstance(data["channels"], list):
|
|
254
|
+
for ch in data["channels"]:
|
|
255
|
+
name = ch.get("name") or ch.get("channel")
|
|
256
|
+
if not name:
|
|
257
|
+
continue
|
|
258
|
+
|
|
259
|
+
# Replicate sanitization to match CSV headers
|
|
260
|
+
safe_name = "".join(
|
|
261
|
+
c if c.isalnum() or c in "-_ " else "_" for c in name
|
|
262
|
+
).strip().replace(" ", "_").lower()
|
|
263
|
+
|
|
264
|
+
# Prefer threshold_min
|
|
265
|
+
val = ch.get("threshold_min")
|
|
266
|
+
if val is None:
|
|
267
|
+
val = ch.get("threshold")
|
|
268
|
+
|
|
269
|
+
if val is not None:
|
|
270
|
+
thresholds_map[safe_name] = val
|
|
271
|
+
thresholds_map[name] = val
|
|
272
|
+
|
|
273
|
+
# Handle simple key-value format
|
|
274
|
+
elif isinstance(data, dict):
|
|
275
|
+
thresholds_map = data
|
|
276
|
+
|
|
277
|
+
# Iterate over table rows and set thresholds if found
|
|
278
|
+
for row in range(self._marker_table.rowCount()):
|
|
279
|
+
marker_item = self._marker_table.item(row, 1)
|
|
280
|
+
if not marker_item:
|
|
281
|
+
continue
|
|
282
|
+
marker = marker_item.text()
|
|
283
|
+
|
|
284
|
+
val = None
|
|
285
|
+
if marker in thresholds_map:
|
|
286
|
+
val = thresholds_map[marker]
|
|
287
|
+
elif f"{marker}_mean_intensity" in thresholds_map:
|
|
288
|
+
val = thresholds_map[f"{marker}_mean_intensity"]
|
|
289
|
+
|
|
290
|
+
if val is not None:
|
|
291
|
+
widget = self._marker_table.cellWidget(row, 2)
|
|
292
|
+
if isinstance(widget, QLineEdit):
|
|
293
|
+
widget.setText(str(val))
|
|
294
|
+
except Exception as e:
|
|
295
|
+
print(f"Error loading thresholds from JSON: {e}")
|
|
296
|
+
|
|
297
|
+
def _select_all_markers(self) -> None:
|
|
298
|
+
"""Select all markers in the table."""
|
|
299
|
+
for row in range(self._marker_table.rowCount()):
|
|
300
|
+
item = self._marker_table.item(row, 0)
|
|
301
|
+
if item:
|
|
302
|
+
item.setCheckState(Qt.Checked)
|
|
303
|
+
|
|
304
|
+
def _deselect_all_markers(self) -> None:
|
|
305
|
+
"""Deselect all markers in the table."""
|
|
306
|
+
for row in range(self._marker_table.rowCount()):
|
|
307
|
+
item = self._marker_table.item(row, 0)
|
|
308
|
+
if item:
|
|
309
|
+
item.setCheckState(Qt.Unchecked)
|
|
310
|
+
|
|
311
|
+
def _get_marker_settings(self) -> tuple[list[str], dict[str, float]]:
|
|
312
|
+
"""Retrieve selected markers and their thresholds from the table."""
|
|
313
|
+
selected_markers = []
|
|
314
|
+
thresholds = {}
|
|
315
|
+
|
|
316
|
+
for row in range(self._marker_table.rowCount()):
|
|
317
|
+
# Check if selected
|
|
318
|
+
chk_item = self._marker_table.item(row, 0)
|
|
319
|
+
if not chk_item or chk_item.checkState() != Qt.Checked:
|
|
320
|
+
continue
|
|
321
|
+
|
|
322
|
+
# Get marker name
|
|
323
|
+
name_item = self._marker_table.item(row, 1)
|
|
324
|
+
if not name_item:
|
|
325
|
+
continue
|
|
326
|
+
marker = name_item.text()
|
|
327
|
+
selected_markers.append(marker)
|
|
328
|
+
|
|
329
|
+
# Get threshold
|
|
330
|
+
thresh_widget = self._marker_table.cellWidget(row, 2)
|
|
331
|
+
if isinstance(thresh_widget, QLineEdit):
|
|
332
|
+
text = thresh_widget.text().strip()
|
|
333
|
+
if text:
|
|
334
|
+
try:
|
|
335
|
+
val = float(text)
|
|
336
|
+
thresholds[marker] = val
|
|
337
|
+
except ValueError:
|
|
338
|
+
pass # Ignore invalid numbers
|
|
339
|
+
|
|
340
|
+
return selected_markers, thresholds
|
|
341
|
+
|
|
342
|
+
def _make_output_section(self) -> QGroupBox:
|
|
343
|
+
"""Build the output configuration section.
|
|
344
|
+
|
|
345
|
+
Returns
|
|
346
|
+
-------
|
|
347
|
+
QGroupBox
|
|
348
|
+
Group box containing output settings.
|
|
349
|
+
"""
|
|
350
|
+
section = QGroupBox("Output")
|
|
351
|
+
section_layout = QVBoxLayout()
|
|
352
|
+
|
|
353
|
+
form_layout = QFormLayout()
|
|
354
|
+
form_layout.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
|
|
355
|
+
|
|
356
|
+
self._output_path_input = QLineEdit()
|
|
357
|
+
default_output = str(Path.cwd())
|
|
358
|
+
self._output_path_input.setText(default_output)
|
|
359
|
+
self._output_path_input.setPlaceholderText("Output folder path")
|
|
360
|
+
browse_button = QPushButton("Browse")
|
|
361
|
+
browse_button.clicked.connect(self._select_output_path)
|
|
362
|
+
output_path_row = QHBoxLayout()
|
|
363
|
+
output_path_row.setContentsMargins(0, 0, 0, 0)
|
|
364
|
+
output_path_row.addWidget(self._output_path_input)
|
|
365
|
+
output_path_row.addWidget(browse_button)
|
|
366
|
+
output_path_widget = QWidget()
|
|
367
|
+
output_path_widget.setLayout(output_path_row)
|
|
368
|
+
|
|
369
|
+
self._save_name_input = QLineEdit()
|
|
370
|
+
self._save_name_input.setPlaceholderText("Plot name")
|
|
371
|
+
self._save_name_input.setMinimumWidth(180)
|
|
372
|
+
self._save_name_input.setSizePolicy(
|
|
373
|
+
QSizePolicy.Expanding, QSizePolicy.Fixed
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
self._format_combo = QComboBox()
|
|
377
|
+
self._format_combo.addItems(["png", "svg", "pdf"])
|
|
378
|
+
self._configure_combo(self._format_combo)
|
|
379
|
+
|
|
380
|
+
form_layout.addRow("Output path", output_path_widget)
|
|
381
|
+
form_layout.addRow("Plot name", self._save_name_input)
|
|
382
|
+
form_layout.addRow("Format", self._format_combo)
|
|
383
|
+
|
|
384
|
+
section_layout.addLayout(form_layout)
|
|
385
|
+
|
|
386
|
+
# Add Save button
|
|
387
|
+
save_button = QPushButton("Save Plot")
|
|
388
|
+
save_button.clicked.connect(self._save_plots)
|
|
389
|
+
section_layout.addWidget(save_button)
|
|
390
|
+
self._save_button = save_button
|
|
391
|
+
|
|
392
|
+
section.setLayout(section_layout)
|
|
393
|
+
return section
|
|
394
|
+
|
|
395
|
+
def _make_plot_display_section(self, show_process_button: bool = True) -> QGroupBox:
|
|
396
|
+
"""Build the plot display section.
|
|
397
|
+
|
|
398
|
+
Parameters
|
|
399
|
+
----------
|
|
400
|
+
show_process_button : bool, optional
|
|
401
|
+
Whether to show the Process button.
|
|
402
|
+
|
|
403
|
+
Returns
|
|
404
|
+
-------
|
|
405
|
+
QGroupBox
|
|
406
|
+
Group box containing generated plot images and process button.
|
|
407
|
+
"""
|
|
408
|
+
section = QGroupBox("Plot Preview")
|
|
409
|
+
section_layout = QVBoxLayout()
|
|
410
|
+
|
|
411
|
+
# Create a resizable widget for displaying plots (no scrolling)
|
|
412
|
+
self._plot_display_widget = QWidget()
|
|
413
|
+
self._plot_display_widget.setMinimumHeight(300)
|
|
414
|
+
self._plot_display_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
|
415
|
+
self._plot_display_layout = QVBoxLayout()
|
|
416
|
+
self._plot_display_layout.setContentsMargins(0, 0, 0, 0)
|
|
417
|
+
self._plot_display_layout.setSpacing(6)
|
|
418
|
+
self._plot_display_widget.setLayout(self._plot_display_layout)
|
|
419
|
+
section_layout.addWidget(self._plot_display_widget)
|
|
420
|
+
|
|
421
|
+
# Add Process button
|
|
422
|
+
if show_process_button:
|
|
423
|
+
process_button = QPushButton("Process")
|
|
424
|
+
process_button.clicked.connect(self._process_features)
|
|
425
|
+
section_layout.addWidget(process_button)
|
|
426
|
+
self._process_button = process_button
|
|
427
|
+
|
|
428
|
+
section.setLayout(section_layout)
|
|
429
|
+
return section
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _make_plots_section(self) -> QGroupBox:
|
|
433
|
+
"""Build the plots configuration section.
|
|
434
|
+
|
|
435
|
+
Returns
|
|
436
|
+
-------
|
|
437
|
+
QGroupBox
|
|
438
|
+
Group box containing plot inputs.
|
|
439
|
+
"""
|
|
440
|
+
section = QGroupBox("Plots")
|
|
441
|
+
section.setFlat(True)
|
|
442
|
+
section.setStyleSheet(
|
|
443
|
+
"QGroupBox {"
|
|
444
|
+
" margin-top: 8px;"
|
|
445
|
+
"}"
|
|
446
|
+
"QGroupBox::title {"
|
|
447
|
+
" subcontrol-origin: margin;"
|
|
448
|
+
" subcontrol-position: top left;"
|
|
449
|
+
" padding: 0 6px;"
|
|
450
|
+
"}"
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
frame = QFrame()
|
|
454
|
+
frame.setFrameShape(QFrame.StyledPanel)
|
|
455
|
+
frame.setFrameShadow(QFrame.Plain)
|
|
456
|
+
frame.setObjectName("features-section-frame")
|
|
457
|
+
frame.setStyleSheet(
|
|
458
|
+
"QFrame#features-section-frame {"
|
|
459
|
+
" border: 1px solid palette(mid);"
|
|
460
|
+
" border-radius: 4px;"
|
|
461
|
+
"}"
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
scroll_area = QScrollArea()
|
|
465
|
+
scroll_area.setWidgetResizable(True)
|
|
466
|
+
scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
|
|
467
|
+
scroll_area.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
|
468
|
+
self._features_scroll_area = scroll_area
|
|
469
|
+
|
|
470
|
+
features_container = QWidget()
|
|
471
|
+
self._features_container = features_container
|
|
472
|
+
features_container.setSizePolicy(
|
|
473
|
+
QSizePolicy.Expanding, QSizePolicy.Minimum
|
|
474
|
+
)
|
|
475
|
+
features_container.setMinimumWidth(200)
|
|
476
|
+
self._features_min_width = 200
|
|
477
|
+
self._features_layout = QVBoxLayout()
|
|
478
|
+
self._features_layout.setContentsMargins(0, 0, 0, 0)
|
|
479
|
+
self._features_layout.setSpacing(8)
|
|
480
|
+
self._features_layout.setSizeConstraint(QVBoxLayout.SetMinAndMaxSize)
|
|
481
|
+
features_container.setLayout(self._features_layout)
|
|
482
|
+
scroll_area.setWidget(features_container)
|
|
483
|
+
|
|
484
|
+
frame_layout = QVBoxLayout()
|
|
485
|
+
frame_layout.setContentsMargins(10, 12, 10, 10)
|
|
486
|
+
frame_layout.addWidget(scroll_area)
|
|
487
|
+
frame.setLayout(frame_layout)
|
|
488
|
+
|
|
489
|
+
section_layout = QVBoxLayout()
|
|
490
|
+
section_layout.setContentsMargins(8, 12, 8, 4)
|
|
491
|
+
section_layout.addWidget(frame)
|
|
492
|
+
|
|
493
|
+
section.setLayout(section_layout)
|
|
494
|
+
|
|
495
|
+
self._add_feature_row()
|
|
496
|
+
self._apply_features_layout()
|
|
497
|
+
self._start_features_watch()
|
|
498
|
+
return section
|
|
499
|
+
|
|
500
|
+
def showEvent(self, event) -> None:
|
|
501
|
+
"""Ensure layout sizing is applied on initial show.
|
|
502
|
+
|
|
503
|
+
Parameters
|
|
504
|
+
----------
|
|
505
|
+
event : QShowEvent
|
|
506
|
+
Qt show event passed by the widget.
|
|
507
|
+
"""
|
|
508
|
+
super().showEvent(event)
|
|
509
|
+
self._apply_features_layout()
|
|
510
|
+
|
|
511
|
+
def resizeEvent(self, event) -> None:
|
|
512
|
+
"""Resize handler to keep the features list at a capped height.
|
|
513
|
+
|
|
514
|
+
Parameters
|
|
515
|
+
----------
|
|
516
|
+
event : QResizeEvent
|
|
517
|
+
Qt resize event passed by the widget.
|
|
518
|
+
"""
|
|
519
|
+
super().resizeEvent(event)
|
|
520
|
+
self._apply_features_layout()
|
|
521
|
+
# Rescale any preview images to fit the new size
|
|
522
|
+
try:
|
|
523
|
+
self._rescale_all_plot_labels()
|
|
524
|
+
except Exception:
|
|
525
|
+
pass
|
|
526
|
+
|
|
527
|
+
def _add_feature_row(self, state: PlotConfig | None = None) -> None:
|
|
528
|
+
"""Add a new feature input row."""
|
|
529
|
+
if isinstance(state, bool):
|
|
530
|
+
state = None
|
|
531
|
+
|
|
532
|
+
section_layout = QVBoxLayout()
|
|
533
|
+
|
|
534
|
+
content_layout = QHBoxLayout()
|
|
535
|
+
content_layout.setContentsMargins(0, 0, 0, 0)
|
|
536
|
+
content_layout.setSpacing(12)
|
|
537
|
+
content_layout.setAlignment(Qt.AlignTop)
|
|
538
|
+
left_layout = QVBoxLayout()
|
|
539
|
+
left_layout.setContentsMargins(0, 0, 0, 0)
|
|
540
|
+
left_layout.setSpacing(6)
|
|
541
|
+
right_layout = QVBoxLayout()
|
|
542
|
+
right_layout.setContentsMargins(0, 0, 0, 0)
|
|
543
|
+
right_layout.setSpacing(6)
|
|
544
|
+
|
|
545
|
+
form_layout = QFormLayout()
|
|
546
|
+
form_layout.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
|
|
547
|
+
|
|
548
|
+
type_combo = RefreshingComboBox(
|
|
549
|
+
refresh_callback=self._notify_features_changed
|
|
550
|
+
)
|
|
551
|
+
feature_types = self._feature_types()
|
|
552
|
+
type_combo.addItems(feature_types)
|
|
553
|
+
self._configure_combo(type_combo)
|
|
554
|
+
|
|
555
|
+
form_layout.addRow("Plot Type", type_combo)
|
|
556
|
+
left_layout.addLayout(form_layout)
|
|
557
|
+
|
|
558
|
+
left_dynamic_container = QWidget()
|
|
559
|
+
left_dynamic_container.setSizePolicy(
|
|
560
|
+
QSizePolicy.Expanding, QSizePolicy.Fixed
|
|
561
|
+
)
|
|
562
|
+
left_dynamic_layout = QVBoxLayout()
|
|
563
|
+
left_dynamic_layout.setContentsMargins(0, 0, 0, 0)
|
|
564
|
+
left_dynamic_layout.setSpacing(6)
|
|
565
|
+
left_dynamic_container.setLayout(left_dynamic_layout)
|
|
566
|
+
left_layout.addWidget(left_dynamic_container)
|
|
567
|
+
|
|
568
|
+
left_container = QWidget()
|
|
569
|
+
left_container.setLayout(left_layout)
|
|
570
|
+
left_container.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
|
571
|
+
|
|
572
|
+
right_container = QWidget()
|
|
573
|
+
right_container.setLayout(right_layout)
|
|
574
|
+
right_container.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
|
575
|
+
|
|
576
|
+
self._left_container = left_container
|
|
577
|
+
self._right_container = right_container
|
|
578
|
+
|
|
579
|
+
content_layout.addWidget(left_container, 3)
|
|
580
|
+
if self._show_right_column:
|
|
581
|
+
content_layout.addWidget(right_container, 2)
|
|
582
|
+
section_layout.addLayout(content_layout)
|
|
583
|
+
self._apply_features_layout()
|
|
584
|
+
|
|
585
|
+
# Determine feature type first
|
|
586
|
+
feature_type = (
|
|
587
|
+
state.type_name
|
|
588
|
+
if state is not None and state.type_name
|
|
589
|
+
else type_combo.currentText()
|
|
590
|
+
)
|
|
591
|
+
if state is None:
|
|
592
|
+
state = PlotConfig(
|
|
593
|
+
type_name=feature_type,
|
|
594
|
+
data=build_feature_data(feature_type),
|
|
595
|
+
)
|
|
596
|
+
if feature_type in feature_types:
|
|
597
|
+
type_combo.blockSignals(True)
|
|
598
|
+
type_combo.setCurrentText(feature_type)
|
|
599
|
+
type_combo.blockSignals(False)
|
|
600
|
+
|
|
601
|
+
# Create feature section with feature type as title
|
|
602
|
+
feature_section = QGroupBox()
|
|
603
|
+
feature_section.setFlat(True)
|
|
604
|
+
feature_section.setStyleSheet(
|
|
605
|
+
"QGroupBox {"
|
|
606
|
+
" margin-top: 6px;"
|
|
607
|
+
"}"
|
|
608
|
+
"QGroupBox::title {"
|
|
609
|
+
" subcontrol-origin: margin;"
|
|
610
|
+
" subcontrol-position: top left;"
|
|
611
|
+
" padding: 0 6px;"
|
|
612
|
+
"}"
|
|
613
|
+
)
|
|
614
|
+
feature_section.setLayout(section_layout)
|
|
615
|
+
feature_section.setSizePolicy(
|
|
616
|
+
QSizePolicy.Expanding, QSizePolicy.Fixed
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
self._features_layout.addWidget(feature_section)
|
|
620
|
+
context = PlotUIContext(
|
|
621
|
+
state=state,
|
|
622
|
+
section=feature_section,
|
|
623
|
+
type_combo=type_combo,
|
|
624
|
+
left_dynamic_layout=left_dynamic_layout,
|
|
625
|
+
left_layout=left_layout,
|
|
626
|
+
right_layout=right_layout,
|
|
627
|
+
)
|
|
628
|
+
self._feature_configs.append(context)
|
|
629
|
+
type_combo.currentTextChanged.connect(
|
|
630
|
+
lambda _text, ctx=context: self._on_feature_type_changed(ctx)
|
|
631
|
+
)
|
|
632
|
+
self._build_feature_handler(context, preserve_data=True)
|
|
633
|
+
self._notify_features_changed()
|
|
634
|
+
self._features_layout.activate()
|
|
635
|
+
QTimer.singleShot(0, self._apply_features_layout)
|
|
636
|
+
|
|
637
|
+
def _on_feature_type_changed(self, context: PlotUIContext) -> None:
|
|
638
|
+
"""Update a plot section when its type changes.
|
|
639
|
+
|
|
640
|
+
Parameters
|
|
641
|
+
----------
|
|
642
|
+
context : PlotUIContext
|
|
643
|
+
Plot UI context and data.
|
|
644
|
+
"""
|
|
645
|
+
self._build_feature_handler(context, preserve_data=False)
|
|
646
|
+
|
|
647
|
+
def _build_feature_handler(
|
|
648
|
+
self,
|
|
649
|
+
context: PlotUIContext,
|
|
650
|
+
*,
|
|
651
|
+
preserve_data: bool,
|
|
652
|
+
) -> None:
|
|
653
|
+
left_dynamic_layout = context.left_dynamic_layout
|
|
654
|
+
self._clear_layout(left_dynamic_layout)
|
|
655
|
+
self._clear_layout(context.right_layout)
|
|
656
|
+
feature_type = context.type_combo.currentText()
|
|
657
|
+
context.state.type_name = feature_type
|
|
658
|
+
if not preserve_data:
|
|
659
|
+
context.state.data = build_feature_data(feature_type)
|
|
660
|
+
|
|
661
|
+
feature_handler = self._feature_handler_for_type(feature_type, context)
|
|
662
|
+
print(f"[Frontend] Built handler for {feature_type}: {feature_handler}")
|
|
663
|
+
context.plot_handler = feature_handler
|
|
664
|
+
if feature_handler is not None:
|
|
665
|
+
feature_handler.build()
|
|
666
|
+
print(f"[Frontend] Handler build() called")
|
|
667
|
+
else:
|
|
668
|
+
print(f"[Frontend] Handler is None!")
|
|
669
|
+
self._notify_features_changed()
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def _notify_features_changed(self) -> None:
|
|
673
|
+
"""Notify plot handlers that the plot list has changed."""
|
|
674
|
+
for feature_cls in self._feature_registry.values():
|
|
675
|
+
feature_cls.update_type_options(self, self._feature_configs)
|
|
676
|
+
for context in self._feature_configs:
|
|
677
|
+
handler = context.plot_handler
|
|
678
|
+
if handler is not None:
|
|
679
|
+
handler.on_features_changed(self._feature_configs)
|
|
680
|
+
# Update default plot name shown in the output section
|
|
681
|
+
self._update_default_plot_name()
|
|
682
|
+
|
|
683
|
+
def _update_default_plot_name(self) -> None:
|
|
684
|
+
"""Compute and set a sensible default for the Plot name field.
|
|
685
|
+
|
|
686
|
+
Uses the joined feature type names separated by hyphens. Only sets
|
|
687
|
+
the field when the user has not provided a custom name (empty) or
|
|
688
|
+
when the current value matches the previous auto-generated value.
|
|
689
|
+
"""
|
|
690
|
+
try:
|
|
691
|
+
names = [ctx.state.type_name for ctx in self._feature_configs if getattr(ctx, 'state', None)]
|
|
692
|
+
if not names:
|
|
693
|
+
auto = "visualization"
|
|
694
|
+
else:
|
|
695
|
+
auto = "-".join(names)
|
|
696
|
+
current = self._save_name_input.text().strip() if hasattr(self, '_save_name_input') else ''
|
|
697
|
+
prev_auto = getattr(self, '_plot_name_auto', '')
|
|
698
|
+
if not current or current == prev_auto:
|
|
699
|
+
self._save_name_input.setText(auto)
|
|
700
|
+
self._plot_name_auto = auto
|
|
701
|
+
except Exception:
|
|
702
|
+
# Fail silently; this is only a nicety
|
|
703
|
+
pass
|
|
704
|
+
|
|
705
|
+
def _feature_types(self) -> list[str]:
|
|
706
|
+
"""Return the available feature type names."""
|
|
707
|
+
return list(self._feature_registry.keys())
|
|
708
|
+
|
|
709
|
+
def load_feature_configs(self, configs: list[PlotConfig]) -> None:
|
|
710
|
+
"""Replace the current plot list with provided configs."""
|
|
711
|
+
for context in list(self._feature_configs):
|
|
712
|
+
self._remove_feature(context.section)
|
|
713
|
+
if not configs:
|
|
714
|
+
self._add_feature_row()
|
|
715
|
+
return
|
|
716
|
+
for config in configs:
|
|
717
|
+
self._add_feature_row(config)
|
|
718
|
+
|
|
719
|
+
def _select_input_path(self) -> None:
|
|
720
|
+
"""Open a folder picker for the input path."""
|
|
721
|
+
path = QFileDialog.getExistingDirectory(self, "Select input folder")
|
|
722
|
+
if path:
|
|
723
|
+
self._input_path.setText(path)
|
|
724
|
+
|
|
725
|
+
def _select_output_path(self) -> None:
|
|
726
|
+
"""Open a folder selection dialog for the output path."""
|
|
727
|
+
path = QFileDialog.getExistingDirectory(
|
|
728
|
+
self,
|
|
729
|
+
"Select output folder",
|
|
730
|
+
self._output_path_input.text(),
|
|
731
|
+
)
|
|
732
|
+
if path:
|
|
733
|
+
self._output_path_input.setText(path)
|
|
734
|
+
|
|
735
|
+
def _process_features(self) -> None:
|
|
736
|
+
"""Trigger visualization processing for configured plots."""
|
|
737
|
+
# Clear previous plots
|
|
738
|
+
while self._plot_display_layout.count():
|
|
739
|
+
child = self._plot_display_layout.takeAt(0)
|
|
740
|
+
if child.widget():
|
|
741
|
+
child.widget().deleteLater()
|
|
742
|
+
|
|
743
|
+
print(f"[Frontend] Processing {len(self._feature_configs)} plot configs")
|
|
744
|
+
for i, cfg in enumerate(self._feature_configs):
|
|
745
|
+
print(f"[Frontend] Config {i}: type={cfg.state.type_name}, handler={cfg.plot_handler}")
|
|
746
|
+
|
|
747
|
+
# Clean up previous result temp files if they exist
|
|
748
|
+
if hasattr(self, "_last_visualization_result") and self._last_visualization_result:
|
|
749
|
+
try:
|
|
750
|
+
shutil.rmtree(self._last_visualization_result.temp_root, ignore_errors=True)
|
|
751
|
+
except Exception as e:
|
|
752
|
+
print(f"[Frontend] Warning: Failed to cleanup previous temp dir: {e}")
|
|
753
|
+
|
|
754
|
+
markers, thresholds = self._get_marker_settings()
|
|
755
|
+
|
|
756
|
+
process = getattr(self._backend, "process", None)
|
|
757
|
+
if callable(process):
|
|
758
|
+
input_path = Path(self._input_path.text())
|
|
759
|
+
result = process(
|
|
760
|
+
self._feature_configs,
|
|
761
|
+
input_path,
|
|
762
|
+
self._output_path_input.text(),
|
|
763
|
+
self._save_name_input.text(),
|
|
764
|
+
self._format_combo.currentText(),
|
|
765
|
+
markers=markers,
|
|
766
|
+
thresholds=thresholds,
|
|
767
|
+
save=False,
|
|
768
|
+
cleanup=False,
|
|
769
|
+
)
|
|
770
|
+
|
|
771
|
+
# Store result for later saving
|
|
772
|
+
self._last_visualization_result = result
|
|
773
|
+
|
|
774
|
+
print(f"[Frontend] Process returned result: {result}")
|
|
775
|
+
print(f"[Frontend] Output root: {result.output_root if result else 'None'}")
|
|
776
|
+
|
|
777
|
+
# Display generated plots using the backend-returned final paths
|
|
778
|
+
if result and hasattr(result, "plot_outputs"):
|
|
779
|
+
print(f"[Frontend] Found {len(result.plot_outputs)} plot outputs")
|
|
780
|
+
for plot_output in result.plot_outputs:
|
|
781
|
+
for output_file in getattr(plot_output, "outputs", []):
|
|
782
|
+
try:
|
|
783
|
+
output_file = Path(output_file)
|
|
784
|
+
except Exception:
|
|
785
|
+
output_file = None
|
|
786
|
+
if output_file and output_file.exists() and output_file.suffix.lower() in [".png", ".svg", ".pdf"]:
|
|
787
|
+
print(f"[Frontend] Displaying: {output_file}")
|
|
788
|
+
self._display_plot_file(output_file)
|
|
789
|
+
else:
|
|
790
|
+
print(f"[Frontend] Skipping non-existent or unsupported file: {output_file}")
|
|
791
|
+
|
|
792
|
+
def _plot_dir_name(self, plot_output: object) -> str:
|
|
793
|
+
"""Build filesystem-friendly folder name for a plot (matches backend)."""
|
|
794
|
+
plot_type = getattr(plot_output, "plot_type", "unknown")
|
|
795
|
+
name = plot_type.strip()
|
|
796
|
+
safe = "".join(
|
|
797
|
+
c if c.isalnum() or c in " -_" else "_" for c in name
|
|
798
|
+
)
|
|
799
|
+
return safe
|
|
800
|
+
|
|
801
|
+
def _save_plots(self) -> None:
|
|
802
|
+
"""Save the current plot results to the output directory."""
|
|
803
|
+
if not hasattr(self, "_last_visualization_result") or self._last_visualization_result is None:
|
|
804
|
+
print("No plots to save. Run Process first.")
|
|
805
|
+
return
|
|
806
|
+
|
|
807
|
+
result = self._last_visualization_result
|
|
808
|
+
output_root = result.output_root
|
|
809
|
+
|
|
810
|
+
# Perform the save using the backend
|
|
811
|
+
if hasattr(self._backend, "save_result"):
|
|
812
|
+
self._backend.save_result(
|
|
813
|
+
result,
|
|
814
|
+
self._output_path_input.text(),
|
|
815
|
+
self._save_name_input.text()
|
|
816
|
+
)
|
|
817
|
+
|
|
818
|
+
saved_files: list[str] = []
|
|
819
|
+
for plot_output in getattr(result, "plot_outputs", []):
|
|
820
|
+
for p in getattr(plot_output, "outputs", []):
|
|
821
|
+
try:
|
|
822
|
+
path = Path(p)
|
|
823
|
+
except Exception:
|
|
824
|
+
continue
|
|
825
|
+
if path.exists():
|
|
826
|
+
saved_files.append(str(path))
|
|
827
|
+
|
|
828
|
+
if saved_files:
|
|
829
|
+
print(f"Plots saved to: {output_root}")
|
|
830
|
+
for f in saved_files:
|
|
831
|
+
print(f" - {f}")
|
|
832
|
+
else:
|
|
833
|
+
# No files present: re-run process to force saving
|
|
834
|
+
result = process(
|
|
835
|
+
self._feature._input_path.text(),
|
|
836
|
+
self._output_path_input.text(),
|
|
837
|
+
self._save_na._format_combo.currentText(),
|
|
838
|
+
markers=markers,
|
|
839
|
+
thresholds=thresholds,
|
|
840
|
+
save=True,
|
|
841
|
+
cleanup=True,
|
|
842
|
+
)
|
|
843
|
+
self._last_visualization_result = result
|
|
844
|
+
print(f"Re-run complete. Check folder: {self._output_path_input.text() or Path.cwd()}")
|
|
845
|
+
|
|
846
|
+
def _feature_handler_for_type(
|
|
847
|
+
self, feature_type: str, context: PlotUIContext
|
|
848
|
+
):
|
|
849
|
+
"""Return the feature handler for a given feature type.
|
|
850
|
+
|
|
851
|
+
Parameters
|
|
852
|
+
----------
|
|
853
|
+
feature_type : str
|
|
854
|
+
Selected feature type.
|
|
855
|
+
config : dict
|
|
856
|
+
Feature configuration dictionary.
|
|
857
|
+
|
|
858
|
+
Returns
|
|
859
|
+
-------
|
|
860
|
+
SenoQuantFeature or None
|
|
861
|
+
Feature handler instance for the selected type.
|
|
862
|
+
"""
|
|
863
|
+
feature_cls = self._feature_registry.get(feature_type)
|
|
864
|
+
if feature_cls is None:
|
|
865
|
+
return None
|
|
866
|
+
return feature_cls(self, context)
|
|
867
|
+
|
|
868
|
+
def _display_plot_file(self, file_path) -> None:
|
|
869
|
+
"""Display a plot image file in the preview area.
|
|
870
|
+
|
|
871
|
+
Parameters
|
|
872
|
+
----------
|
|
873
|
+
file_path : Path or str
|
|
874
|
+
Path to the plot image file (PNG or SVG).
|
|
875
|
+
"""
|
|
876
|
+
from pathlib import Path
|
|
877
|
+
file_path = Path(file_path)
|
|
878
|
+
|
|
879
|
+
if file_path.suffix.lower() == ".png":
|
|
880
|
+
# Display PNG directly and scale to fit preview widget
|
|
881
|
+
pixmap = QPixmap(str(file_path))
|
|
882
|
+
if not pixmap.isNull():
|
|
883
|
+
label = QLabel()
|
|
884
|
+
label.setAlignment(Qt.AlignCenter)
|
|
885
|
+
label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
|
886
|
+
# store original pixmap for later rescaling
|
|
887
|
+
label._orig_pixmap = pixmap
|
|
888
|
+
# scale now to current widget size
|
|
889
|
+
self._rescale_plot_label(label)
|
|
890
|
+
self._plot_display_layout.addWidget(label)
|
|
891
|
+
elif file_path.suffix.lower() == ".svg":
|
|
892
|
+
# For SVG, display filename with link
|
|
893
|
+
link_label = QLabel(f'<a href="file:///{file_path}">View {file_path.name}</a>')
|
|
894
|
+
link_label.setOpenExternalLinks(True)
|
|
895
|
+
self._plot_display_layout.addWidget(link_label)
|
|
896
|
+
elif file_path.suffix.lower() == ".pdf":
|
|
897
|
+
# For PDF, display filename with link
|
|
898
|
+
link_label = QLabel(f'<a href="file:///{file_path}">View {file_path.name}</a>')
|
|
899
|
+
link_label.setOpenExternalLinks(True)
|
|
900
|
+
self._plot_display_layout.addWidget(link_label)
|
|
901
|
+
|
|
902
|
+
def _rescale_plot_label(self, label: QLabel) -> None:
|
|
903
|
+
"""Rescale a QLabel containing an original QPixmap to fit preview area."""
|
|
904
|
+
try:
|
|
905
|
+
orig = getattr(label, "_orig_pixmap", None)
|
|
906
|
+
if orig is None:
|
|
907
|
+
return
|
|
908
|
+
max_w = max(10, self._plot_display_widget.width() - 20)
|
|
909
|
+
max_h = max(10, self._plot_display_widget.height() - 20)
|
|
910
|
+
scaled = orig.scaled(max_w, max_h, Qt.KeepAspectRatio, Qt.SmoothTransformation)
|
|
911
|
+
label.setPixmap(scaled)
|
|
912
|
+
except Exception:
|
|
913
|
+
pass
|
|
914
|
+
|
|
915
|
+
def _rescale_all_plot_labels(self) -> None:
|
|
916
|
+
"""Rescale all displayed plot labels to fit the preview area."""
|
|
917
|
+
for i in range(self._plot_display_layout.count()):
|
|
918
|
+
item = self._plot_display_layout.itemAt(i)
|
|
919
|
+
widget = item.widget() if item is not None else None
|
|
920
|
+
if isinstance(widget, QLabel) and hasattr(widget, "_orig_pixmap"):
|
|
921
|
+
self._rescale_plot_label(widget)
|
|
922
|
+
|
|
923
|
+
def _configure_combo(self, combo: QComboBox) -> None:
|
|
924
|
+
"""Apply sizing defaults to combo boxes.
|
|
925
|
+
|
|
926
|
+
Parameters
|
|
927
|
+
----------
|
|
928
|
+
combo : QComboBox
|
|
929
|
+
Combo box to configure.
|
|
930
|
+
"""
|
|
931
|
+
combo.setSizeAdjustPolicy(
|
|
932
|
+
QComboBox.AdjustToMinimumContentsLengthWithIcon
|
|
933
|
+
)
|
|
934
|
+
combo.setMinimumContentsLength(8)
|
|
935
|
+
combo.setMinimumWidth(140)
|
|
936
|
+
combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
|
937
|
+
|
|
938
|
+
def _clear_layout(self, layout: QVBoxLayout) -> None:
|
|
939
|
+
"""Remove all widgets and layouts from a layout.
|
|
940
|
+
|
|
941
|
+
Parameters
|
|
942
|
+
----------
|
|
943
|
+
layout : QVBoxLayout
|
|
944
|
+
Layout to clear.
|
|
945
|
+
"""
|
|
946
|
+
while layout.count():
|
|
947
|
+
item = layout.takeAt(0)
|
|
948
|
+
widget = item.widget()
|
|
949
|
+
if widget is not None:
|
|
950
|
+
widget.deleteLater()
|
|
951
|
+
child_layout = item.layout()
|
|
952
|
+
if child_layout is not None:
|
|
953
|
+
self._clear_layout(child_layout)
|
|
954
|
+
|
|
955
|
+
def _feature_index(self, context: PlotUIContext) -> int:
|
|
956
|
+
"""Return the 0-based index for a plot config.
|
|
957
|
+
|
|
958
|
+
Parameters
|
|
959
|
+
----------
|
|
960
|
+
context : PlotUIContext
|
|
961
|
+
Plot UI context.
|
|
962
|
+
|
|
963
|
+
Returns
|
|
964
|
+
-------
|
|
965
|
+
int
|
|
966
|
+
0-based index of the plot.
|
|
967
|
+
"""
|
|
968
|
+
return self._feature_configs.index(context)
|
|
969
|
+
def _start_features_watch(self) -> None:
|
|
970
|
+
"""Start a timer to monitor feature sizing changes.
|
|
971
|
+
|
|
972
|
+
The watcher polls for content size changes and reapplies layout
|
|
973
|
+
constraints without blocking the UI thread.
|
|
974
|
+
"""
|
|
975
|
+
if self._features_watch_timer is not None:
|
|
976
|
+
return
|
|
977
|
+
self._features_watch_timer = QTimer(self)
|
|
978
|
+
self._features_watch_timer.setInterval(150)
|
|
979
|
+
self._features_watch_timer.timeout.connect(self._poll_features_geometry)
|
|
980
|
+
self._features_watch_timer.start()
|
|
981
|
+
|
|
982
|
+
def _poll_features_geometry(self) -> None:
|
|
983
|
+
"""Recompute layout sizing when content size changes."""
|
|
984
|
+
if not hasattr(self, "_features_scroll_area"):
|
|
985
|
+
return
|
|
986
|
+
size = self._features_content_size()
|
|
987
|
+
if size == self._features_last_size:
|
|
988
|
+
return
|
|
989
|
+
self._features_last_size = size
|
|
990
|
+
self._apply_features_layout(size)
|
|
991
|
+
|
|
992
|
+
def _apply_features_layout(
|
|
993
|
+
self, content_size: tuple[int, int] | None = None
|
|
994
|
+
) -> None:
|
|
995
|
+
"""Apply sizing rules for the features container and scroll area.
|
|
996
|
+
|
|
997
|
+
Parameters
|
|
998
|
+
----------
|
|
999
|
+
content_size : tuple of int or None
|
|
1000
|
+
Optional (width, height) of the features content. If None, the
|
|
1001
|
+
size is computed from the current layout.
|
|
1002
|
+
"""
|
|
1003
|
+
if not hasattr(self, "_features_scroll_area"):
|
|
1004
|
+
return
|
|
1005
|
+
if content_size is None:
|
|
1006
|
+
content_size = self._features_content_size()
|
|
1007
|
+
content_width, content_height = content_size
|
|
1008
|
+
|
|
1009
|
+
total_min = getattr(self, "_features_min_width", 0)
|
|
1010
|
+
if total_min <= 0 and hasattr(self, "_features_container"):
|
|
1011
|
+
total_min = self._features_container.minimumWidth()
|
|
1012
|
+
left_hint = 0
|
|
1013
|
+
right_hint = 0
|
|
1014
|
+
if hasattr(self, "_left_container") and self._left_container is not None:
|
|
1015
|
+
try:
|
|
1016
|
+
left_hint = self._left_container.sizeHint().width()
|
|
1017
|
+
except RuntimeError:
|
|
1018
|
+
self._left_container = None
|
|
1019
|
+
if hasattr(self, "_right_container") and self._right_container is not None:
|
|
1020
|
+
try:
|
|
1021
|
+
right_hint = self._right_container.sizeHint().width()
|
|
1022
|
+
except RuntimeError:
|
|
1023
|
+
self._right_container = None
|
|
1024
|
+
left_min = max(int(total_min * 0.6), left_hint)
|
|
1025
|
+
right_min = max(int(total_min * 0.4), right_hint)
|
|
1026
|
+
if self._left_container is not None:
|
|
1027
|
+
try:
|
|
1028
|
+
self._left_container.setMinimumWidth(left_min)
|
|
1029
|
+
except RuntimeError:
|
|
1030
|
+
self._left_container = None
|
|
1031
|
+
if self._right_container is not None:
|
|
1032
|
+
try:
|
|
1033
|
+
self._right_container.setMinimumWidth(right_min)
|
|
1034
|
+
except RuntimeError:
|
|
1035
|
+
self._right_container = None
|
|
1036
|
+
|
|
1037
|
+
if hasattr(self, "_features_container"):
|
|
1038
|
+
self._features_container.setMinimumHeight(0)
|
|
1039
|
+
self._features_container.setMinimumWidth(
|
|
1040
|
+
max(total_min, content_width)
|
|
1041
|
+
)
|
|
1042
|
+
self._features_container.updateGeometry()
|
|
1043
|
+
|
|
1044
|
+
screen = self.window().screen() if self.window() is not None else None
|
|
1045
|
+
if screen is None:
|
|
1046
|
+
screen = QGuiApplication.primaryScreen()
|
|
1047
|
+
screen_height = screen.availableGeometry().height() if screen else 720
|
|
1048
|
+
target_height = max(180, int(screen_height * 0.5))
|
|
1049
|
+
frame = self._features_scroll_area.frameWidth() * 2
|
|
1050
|
+
scroll_slack = 2
|
|
1051
|
+
effective_height = content_height + scroll_slack
|
|
1052
|
+
height = max(0, min(target_height, effective_height + frame))
|
|
1053
|
+
self._features_scroll_area.setUpdatesEnabled(False)
|
|
1054
|
+
self._features_scroll_area.setFixedHeight(height)
|
|
1055
|
+
self._features_scroll_area.setUpdatesEnabled(True)
|
|
1056
|
+
self._features_scroll_area.updateGeometry()
|
|
1057
|
+
widget = self._features_scroll_area.widget()
|
|
1058
|
+
if widget is not None:
|
|
1059
|
+
widget.adjustSize()
|
|
1060
|
+
widget.updateGeometry()
|
|
1061
|
+
self._features_scroll_area.viewport().updateGeometry()
|
|
1062
|
+
bar = self._features_scroll_area.verticalScrollBar()
|
|
1063
|
+
if bar.maximum() > 0:
|
|
1064
|
+
self._features_scroll_area.setVerticalScrollBarPolicy(
|
|
1065
|
+
Qt.ScrollBarAsNeeded
|
|
1066
|
+
)
|
|
1067
|
+
else:
|
|
1068
|
+
self._features_scroll_area.setVerticalScrollBarPolicy(
|
|
1069
|
+
Qt.ScrollBarAlwaysOff
|
|
1070
|
+
)
|
|
1071
|
+
bar.setRange(0, 0)
|
|
1072
|
+
bar.setValue(0)
|
|
1073
|
+
|
|
1074
|
+
def _features_content_size(self) -> tuple[int, int]:
|
|
1075
|
+
"""Compute the content size for the features layout.
|
|
1076
|
+
|
|
1077
|
+
Returns
|
|
1078
|
+
-------
|
|
1079
|
+
tuple of int
|
|
1080
|
+
(width, height) of the content.
|
|
1081
|
+
"""
|
|
1082
|
+
if not hasattr(self, "_features_layout"):
|
|
1083
|
+
return (0, 0)
|
|
1084
|
+
layout = self._features_layout
|
|
1085
|
+
layout.activate()
|
|
1086
|
+
margins = layout.contentsMargins()
|
|
1087
|
+
spacing = layout.spacing()
|
|
1088
|
+
count = layout.count()
|
|
1089
|
+
total_height = margins.top() + margins.bottom()
|
|
1090
|
+
max_width = 0
|
|
1091
|
+
for index in range(count):
|
|
1092
|
+
item = layout.itemAt(index)
|
|
1093
|
+
widget = item.widget()
|
|
1094
|
+
if widget is None:
|
|
1095
|
+
item_size = item.sizeHint()
|
|
1096
|
+
else:
|
|
1097
|
+
widget.adjustSize()
|
|
1098
|
+
item_size = widget.sizeHint().expandedTo(
|
|
1099
|
+
widget.minimumSizeHint()
|
|
1100
|
+
)
|
|
1101
|
+
max_width = max(max_width, item_size.width())
|
|
1102
|
+
total_height += item_size.height()
|
|
1103
|
+
if count > 1:
|
|
1104
|
+
total_height += spacing * (count - 1)
|
|
1105
|
+
total_width = margins.left() + margins.right() + max_width
|
|
1106
|
+
if hasattr(self, "_features_container"):
|
|
1107
|
+
self._features_container.adjustSize()
|
|
1108
|
+
container_size = self._features_container.sizeHint().expandedTo(
|
|
1109
|
+
self._features_container.minimumSizeHint()
|
|
1110
|
+
)
|
|
1111
|
+
total_width = max(total_width, container_size.width())
|
|
1112
|
+
total_height = max(total_height, container_size.height())
|
|
1113
|
+
return (total_width, total_height)
|