itasc 0.2.0.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. itasc/__init__.py +16 -0
  2. itasc/cellpose/__init__.py +31 -0
  3. itasc/cellpose/cellpose_runner.py +334 -0
  4. itasc/cellpose/divergence_maps.py +380 -0
  5. itasc/cellpose/flow_following.py +283 -0
  6. itasc/cellpose/joint.py +98 -0
  7. itasc/cellpose/napari.yaml +10 -0
  8. itasc/cellpose/native_masks.py +313 -0
  9. itasc/cellpose/retrack.py +232 -0
  10. itasc/cellpose/shape.py +51 -0
  11. itasc/cellpose/track_laptrack.py +279 -0
  12. itasc/contact_analysis/__init__.py +61 -0
  13. itasc/contact_analysis/_provenance.py +29 -0
  14. itasc/contact_analysis/catalog.py +393 -0
  15. itasc/contact_analysis/config.py +155 -0
  16. itasc/contact_analysis/contacts/__init__.py +24 -0
  17. itasc/contact_analysis/contacts/batch.py +158 -0
  18. itasc/contact_analysis/contacts/build.py +837 -0
  19. itasc/contact_analysis/contacts/contact_labels.py +136 -0
  20. itasc/contact_analysis/contacts/neighborhood.py +146 -0
  21. itasc/contact_analysis/contacts/reader.py +58 -0
  22. itasc/contact_analysis/contacts/signed_contact_length.py +143 -0
  23. itasc/contact_analysis/curation.py +185 -0
  24. itasc/contact_analysis/dynamics/__init__.py +43 -0
  25. itasc/contact_analysis/dynamics/collective.py +206 -0
  26. itasc/contact_analysis/dynamics/kinematics.py +278 -0
  27. itasc/contact_analysis/dynamics/msd.py +165 -0
  28. itasc/contact_analysis/dynamics/store.py +251 -0
  29. itasc/contact_analysis/dynamics/trajectories.py +100 -0
  30. itasc/contact_analysis/frame_interval.py +85 -0
  31. itasc/contact_analysis/napari.yaml +10 -0
  32. itasc/contact_analysis/pipeline.py +313 -0
  33. itasc/contact_analysis/pixel_size.py +166 -0
  34. itasc/contact_analysis/quantifier.py +204 -0
  35. itasc/contact_analysis/quantifiers/__init__.py +7 -0
  36. itasc/contact_analysis/quantifiers/_contacts_derived.py +42 -0
  37. itasc/contact_analysis/quantifiers/cell_density.py +65 -0
  38. itasc/contact_analysis/quantifiers/cell_dynamics.py +81 -0
  39. itasc/contact_analysis/quantifiers/cell_shape.py +32 -0
  40. itasc/contact_analysis/quantifiers/contacts.py +72 -0
  41. itasc/contact_analysis/quantifiers/neighbor_count.py +26 -0
  42. itasc/contact_analysis/quantifiers/nucleus_dynamics.py +77 -0
  43. itasc/contact_analysis/quantifiers/nucleus_shape.py +35 -0
  44. itasc/contact_analysis/quantifiers/shape_relational.py +34 -0
  45. itasc/contact_analysis/quantifiers/signed_contact_length.py +44 -0
  46. itasc/contact_analysis/records.py +129 -0
  47. itasc/contact_analysis/shape/__init__.py +19 -0
  48. itasc/contact_analysis/shape/core.py +141 -0
  49. itasc/contact_analysis/shape/relational.py +134 -0
  50. itasc/contact_analysis/shape_tables.py +387 -0
  51. itasc/core/__init__.py +0 -0
  52. itasc/core/cancellation.py +15 -0
  53. itasc/core/commit.py +61 -0
  54. itasc/core/imageops.py +34 -0
  55. itasc/core/label_store.py +53 -0
  56. itasc/core/lineage.py +113 -0
  57. itasc/core/logging.py +51 -0
  58. itasc/core/paths.py +34 -0
  59. itasc/core/tiff.py +13 -0
  60. itasc/correction/__init__.py +25 -0
  61. itasc/correction/labels.py +983 -0
  62. itasc/napari/__init__.py +7 -0
  63. itasc/napari/_experiments_panel.py +1091 -0
  64. itasc/napari/_flow_layout.py +98 -0
  65. itasc/napari/_icons.py +126 -0
  66. itasc/napari/_napari_compat.py +118 -0
  67. itasc/napari/_paths.py +231 -0
  68. itasc/napari/_preview_cache.py +60 -0
  69. itasc/napari/_spotlight.py +46 -0
  70. itasc/napari/_stage_loader.py +146 -0
  71. itasc/napari/_stage_status.py +110 -0
  72. itasc/napari/_standalone_paths.py +89 -0
  73. itasc/napari/_state.py +146 -0
  74. itasc/napari/_status_rail.py +128 -0
  75. itasc/napari/_track_render.py +114 -0
  76. itasc/napari/_widget_helpers.py +236 -0
  77. itasc/napari/aggregate_widget.py +339 -0
  78. itasc/napari/candidate_gallery_controller.py +285 -0
  79. itasc/napari/cell_workflow_widget.py +1405 -0
  80. itasc/napari/cellpose_segment_track_widget.py +1253 -0
  81. itasc/napari/cellpose_widget.py +1034 -0
  82. itasc/napari/contact_analysis/__init__.py +6 -0
  83. itasc/napari/contact_analysis/curation_controller.py +94 -0
  84. itasc/napari/contact_analysis/plugins/__init__.py +102 -0
  85. itasc/napari/contact_analysis/plugins/catalog_summary.py +56 -0
  86. itasc/napari/contact_analysis/plugins/curation.py +247 -0
  87. itasc/napari/contact_analysis/plugins/visualize_contacts.py +64 -0
  88. itasc/napari/contact_analysis_widget.py +951 -0
  89. itasc/napari/contact_visualization.py +895 -0
  90. itasc/napari/correction/__init__.py +8 -0
  91. itasc/napari/correction/_correction_anchor.py +54 -0
  92. itasc/napari/correction/_correction_candidate_panel.py +259 -0
  93. itasc/napari/correction/_correction_candidates.py +169 -0
  94. itasc/napari/correction/_correction_centroids.py +132 -0
  95. itasc/napari/correction/_correction_commit.py +71 -0
  96. itasc/napari/correction/_correction_events.py +39 -0
  97. itasc/napari/correction/_correction_keymap.py +80 -0
  98. itasc/napari/correction/_correction_layer_lifecycle.py +137 -0
  99. itasc/napari/correction/_correction_layer_loader.py +86 -0
  100. itasc/napari/correction/_correction_navigation.py +97 -0
  101. itasc/napari/correction/_correction_paint.py +46 -0
  102. itasc/napari/correction/_correction_playback.py +68 -0
  103. itasc/napari/correction/_correction_protection.py +44 -0
  104. itasc/napari/correction/_correction_takeover.py +64 -0
  105. itasc/napari/correction/_correction_track_accordion.py +566 -0
  106. itasc/napari/correction/_correction_track_path.py +388 -0
  107. itasc/napari/correction/_correction_ui.py +367 -0
  108. itasc/napari/correction/_correction_ui_nucleus.py +393 -0
  109. itasc/napari/correction/_correction_utils.py +204 -0
  110. itasc/napari/correction/_correction_validation.py +83 -0
  111. itasc/napari/correction/cell_correction_widget.py +1190 -0
  112. itasc/napari/correction/correction_widget.py +1363 -0
  113. itasc/napari/correction/nucleus_correction_widget.py +2185 -0
  114. itasc/napari/divergence_maps_widget.py +795 -0
  115. itasc/napari/lineage_canvas_controller.py +351 -0
  116. itasc/napari/main_widget.py +1184 -0
  117. itasc/napari/nucleus_atom_extraction_widget.py +648 -0
  118. itasc/napari/nucleus_db_browser_widget.py +1442 -0
  119. itasc/napari/nucleus_pipeline_widget.py +579 -0
  120. itasc/napari/nucleus_segmentation_inputs_widget.py +21 -0
  121. itasc/napari/nucleus_tracking_inputs_widget.py +248 -0
  122. itasc/napari/nucleus_workflow_widget.py +963 -0
  123. itasc/napari/track_path_controller.py +387 -0
  124. itasc/napari/ui_gate.py +266 -0
  125. itasc/napari/ui_style.py +503 -0
  126. itasc/napari/validated_overlay_controller.py +254 -0
  127. itasc/napari/widgets.py +616 -0
  128. itasc/napari.yaml +45 -0
  129. itasc/segmentation/__init__.py +60 -0
  130. itasc/segmentation/cell_divergence_segmentation.py +433 -0
  131. itasc/segmentation/cell_label_icm.py +637 -0
  132. itasc/segmentation/contour_filtering.py +128 -0
  133. itasc/segmentation/lineage.py +18 -0
  134. itasc/segmentation/nucleus_segmentation.py +35 -0
  135. itasc/tracking_ultrack/__init__.py +12 -0
  136. itasc/tracking_ultrack/_node_geometry.py +191 -0
  137. itasc/tracking_ultrack/atoms.py +470 -0
  138. itasc/tracking_ultrack/config.py +76 -0
  139. itasc/tracking_ultrack/corrections.py +1213 -0
  140. itasc/tracking_ultrack/db_build.py +492 -0
  141. itasc/tracking_ultrack/db_query.py +952 -0
  142. itasc/tracking_ultrack/export.py +133 -0
  143. itasc/tracking_ultrack/extend.py +243 -0
  144. itasc/tracking_ultrack/ingest.py +83 -0
  145. itasc/tracking_ultrack/linking.py +286 -0
  146. itasc/tracking_ultrack/multi_threshold.py +46 -0
  147. itasc/tracking_ultrack/napari.yaml +10 -0
  148. itasc/tracking_ultrack/reseed.py +456 -0
  149. itasc/tracking_ultrack/retracker.py +153 -0
  150. itasc/tracking_ultrack/scoring.py +74 -0
  151. itasc/tracking_ultrack/seed_prior.py +175 -0
  152. itasc/tracking_ultrack/solve.py +52 -0
  153. itasc/tracking_ultrack/swap_candidate.py +171 -0
  154. itasc/tracking_ultrack/track_quality.py +142 -0
  155. itasc/tracking_ultrack/validation_nodes.py +315 -0
  156. itasc/tracking_ultrack/validation_state.py +430 -0
  157. itasc-0.2.0.dev0.data/data/itasc/napari.yaml +45 -0
  158. itasc-0.2.0.dev0.dist-info/METADATA +186 -0
  159. itasc-0.2.0.dev0.dist-info/RECORD +162 -0
  160. itasc-0.2.0.dev0.dist-info/WHEEL +4 -0
  161. itasc-0.2.0.dev0.dist-info/entry_points.txt +2 -0
  162. itasc-0.2.0.dev0.dist-info/licenses/LICENSE +661 -0
@@ -0,0 +1,1091 @@
1
+ """The shared experiments/positions panel (napariTFM ExperimentsList parity).
2
+
3
+ One panel, two homes: the main ITASC app and the standalone Contact Analysis
4
+ distro both mount this. Descended from napariTFM's ExperimentsList, but reshaped
5
+ around a filesystem-centric flow —
6
+
7
+ Setup (calibration + input-file names + optional output dir)
8
+ → Find data folders → one additive scan of a parent root; every matching
9
+ data folder is added straight to the list (deduped),
10
+ each a committed row with an editable folder-nesting
11
+ header, an accent select-bar, a per-position status
12
+ rail, and an overall-status chip
13
+ → tag selected rows → set a condition column on the current selection, the
14
+ grouping columns of the aggregate tidy table
15
+ → Run selected / Workers, and a running count
16
+
17
+ — and speaks ITASC: the rail is :class:`~itasc.napari._status_rail.StatusRail`
18
+ (the five-state commit-contract vocabulary), the styling is ITASC's own designed
19
+ surface tokens, and *what* a scan looks for / *how* a row maps to a host record is
20
+ injected by the host (``discover_fn`` / ``status_fn``).
21
+
22
+ The panel owns the displayed catalog (the ordered rows + their editable columns);
23
+ the host reads :meth:`payloads` for scope / run / save and reacts to the signals.
24
+ Each row carries an opaque ``payload`` (the host's record dict) threaded through
25
+ untouched, plus a ``columns`` dict keyed by the shared, editable column names.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import os
30
+ from collections.abc import Callable, Iterable, Sequence
31
+ from pathlib import Path
32
+
33
+ from qtpy.QtCore import Qt, Signal
34
+ from qtpy.QtGui import QColor, QDoubleValidator, QPainter
35
+ from qtpy.QtWidgets import (
36
+ QDialog,
37
+ QDialogButtonBox,
38
+ QFormLayout,
39
+ QFrame,
40
+ QHBoxLayout,
41
+ QLabel,
42
+ QLineEdit,
43
+ QScrollArea,
44
+ QSizePolicy,
45
+ QSpinBox,
46
+ QTextBrowser,
47
+ QToolButton,
48
+ QVBoxLayout,
49
+ QWidget,
50
+ )
51
+
52
+ from itasc.napari._stage_status import (
53
+ DONE,
54
+ STAGES,
55
+ STALE,
56
+ UNKNOWN,
57
+ WORKING,
58
+ )
59
+ from itasc.napari._status_rail import StatusRail
60
+ from itasc.napari.ui_style import (
61
+ COMPACT_SPACING,
62
+ TEXT_DIM,
63
+ TEXT_MID,
64
+ action_button_style,
65
+ experiment_name_color,
66
+ experiment_row_style,
67
+ experiment_status_color,
68
+ mono_input_style,
69
+ stage_accent,
70
+ )
71
+ from itasc.napari.widgets import CollapsibleSection
72
+
73
+ # Free-text calibration fields (soft validator, not spinbox stepping).
74
+ _CALIBRATION_SPECS = (
75
+ ("pixel_size_um", "Pixel Size (µm)", 0.0001, 1000.0),
76
+ # Entered in minutes (long timelapses read naturally that way); the host
77
+ # converts to the backend's seconds-based ``time_interval_s`` at the boundary.
78
+ ("time_interval_min", "Frame Length (min)", 0.0, 1e9),
79
+ )
80
+ _INPUT_DECIMALS = 6
81
+
82
+ # Fixed widths of the non-column cells, shared by the header and every data row so
83
+ # the editable column headers line up over their value cells.
84
+ _SELBAR_W = 3
85
+ _RAIL_W = 60
86
+ _CHIP_W = 56
87
+
88
+ #: The rows-list resize grip: its thickness, and the floor the list can't drag below.
89
+ _GRIP_H = 11
90
+ _MIN_ROWS_HEIGHT = 80
91
+
92
+ #: Overall-status word → chip text (kept short so the chip column stays narrow).
93
+ _CHIP_TEXT = {"run": "run", "done": "done", "queued": "queued"}
94
+
95
+ #: The `?` quickstart, distilled from docs/manual/full-app.md. Self-contained
96
+ #: (no browser / hosted-docs dependency: the standalone distros mount this too).
97
+ _QUICKSTART_HTML = """
98
+ <h3>One folder per movie</h3>
99
+ <p>Each movie or field of view lives in its own <i>data folder</i> holding the raw
100
+ nucleus and cell images, and every stage writes its results back into that same
101
+ folder: the folder on disk is the source of truth for results. The list of data
102
+ folders and how you classify them (conditions, replicates) is your <i>project</i>,
103
+ which you save to and reload from a <b>project catalog</b> (a CSV). That catalog is
104
+ also what drives aggregate quantification across the whole set.</p>
105
+
106
+ <h3>A worked example</h3>
107
+ <p>Say your images sit like this, two conditions and three fields of view:</p>
108
+ <pre>experiment/
109
+ WT/
110
+ pos01/ nucleus.tif cell.tif
111
+ pos02/ nucleus.tif cell.tif
112
+ KO/
113
+ pos01/ nucleus.tif cell.tif</pre>
114
+ <p>Name the two files in <b>Setup</b>, then point <b>Find data folders</b> at
115
+ <code>experiment/</code>. ITASC adds the three folders that hold both images and
116
+ reads the <code>WT</code> / <code>KO</code> / <code>pos..</code> nesting into
117
+ columns. Select the two <code>WT</code> rows and set <code>condition&nbsp;=&nbsp;WT</code>;
118
+ select the <code>KO</code> row and set <code>condition&nbsp;=&nbsp;KO</code>. Those
119
+ columns become the grouping columns of the aggregate table.</p>
120
+
121
+ <h3>Where results go</h3>
122
+ <p><b>Run</b> processes the selected folders and writes each stage back inside the
123
+ data folder: one numbered working folder per stage, and the final outputs in the
124
+ folder root beside the images:</p>
125
+ <pre>pos01/
126
+ 0_input 1_cellpose 2_nucleus 3_cell
127
+ cell_labels.tif nucleus_labels.tif contact_analysis.h5</pre>
128
+ <p>The status rail on each row shows how far that folder got. Run <b>Find data
129
+ folders</b> again on another parent to add more: the list accumulates and skips any
130
+ folder already listed.</p>
131
+ """
132
+
133
+
134
+ def overall_status(status: dict[str, str]) -> str:
135
+ """Collapse a per-stage status map into a single chip word.
136
+
137
+ ``done`` when every *known* stage is committed/present; ``run`` when the
138
+ position has some real progress (a committed, working, or stale stage) but is
139
+ not finished; ``queued`` otherwise (nothing on disk, or an all-``unknown`` row
140
+ with no canonical root).
141
+ """
142
+ real = [v for v in status.values() if v != UNKNOWN]
143
+ if real and all(v == DONE for v in real):
144
+ return "done"
145
+ if any(v in (DONE, WORKING, STALE) for v in real):
146
+ return "run"
147
+ return "queued"
148
+
149
+
150
+ def _format_value(value) -> str:
151
+ """Compact text for a calibration value — no trailing-zero noise."""
152
+ try:
153
+ return f"{float(value):g}"
154
+ except (TypeError, ValueError):
155
+ return str(value or "")
156
+
157
+
158
+ def _count_above_root(entries: list[dict], root: str) -> int:
159
+ """How many found entries resolve to a folder *above* the scanned *root*.
160
+
161
+ A folder-path key is "above root" when root is neither it nor one of its
162
+ ancestors: the sign of a too-deep pick (the user chose a data folder's own
163
+ subfolder, so its position resolves to root's parent). Keys that aren't
164
+ filesystem paths are ignored.
165
+ """
166
+ try:
167
+ root_resolved = Path(root).resolve()
168
+ except (OSError, ValueError):
169
+ return 0
170
+ n = 0
171
+ for entry in entries:
172
+ try:
173
+ key = Path(str(entry["key"])).resolve()
174
+ except (OSError, ValueError):
175
+ continue
176
+ if key != root_resolved and root_resolved not in key.parents:
177
+ n += 1
178
+ return n
179
+
180
+
181
+ class ExperimentRow(QWidget):
182
+ """One data folder: accent select-bar, per-column value cells, rail, status chip.
183
+
184
+ Shows its rail + chip and selects/activates on click.
185
+ """
186
+
187
+ clicked = Signal(str, int) # key, modifier flag: 0 plain, 1 ctrl, 2 shift
188
+ dot_clicked = Signal(str, str) # key, stage — one rail dot's on-demand load
189
+
190
+ def __init__(
191
+ self,
192
+ key: str,
193
+ values: list[str] | None = None,
194
+ *,
195
+ stages: Sequence[str] = STAGES,
196
+ parent: QWidget | None = None,
197
+ ) -> None:
198
+ super().__init__(parent)
199
+ self._key = key
200
+ self._selected = False
201
+ # The row paints its own (styled) background — selected rows lift.
202
+ self.setObjectName("experiment_row")
203
+ self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
204
+
205
+ layout = QHBoxLayout(self)
206
+ layout.setContentsMargins(7, 5, 10, 5)
207
+ layout.setSpacing(COMPACT_SPACING + 4)
208
+
209
+ self._selbar = QFrame()
210
+ self._selbar.setFixedWidth(_SELBAR_W)
211
+ self._selbar.setStyleSheet("background: transparent;")
212
+ layout.addWidget(self._selbar)
213
+
214
+ cells = list(values) if values else [key]
215
+ self._value_labels: list[QLabel] = []
216
+ for text in cells:
217
+ label = QLabel(str(text))
218
+ layout.addWidget(label, 1)
219
+ self._value_labels.append(label)
220
+
221
+ self.rail = StatusRail(stages)
222
+ self.rail.setFixedWidth(_RAIL_W)
223
+ self.rail.dotClicked.connect(
224
+ lambda stage: self.dot_clicked.emit(self._key, stage)
225
+ )
226
+ layout.addWidget(self.rail)
227
+
228
+ self._chip = QLabel("queued")
229
+ self._chip.setFixedWidth(_CHIP_W)
230
+ self._chip.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
231
+ self._chip.setStyleSheet(f"color: {experiment_status_color('queued')};")
232
+ layout.addWidget(self._chip)
233
+
234
+ self.set_selected(False)
235
+
236
+ @property
237
+ def key(self) -> str:
238
+ return self._key
239
+
240
+ def is_selected(self) -> bool:
241
+ return self._selected
242
+
243
+ def set_selected(self, on: bool) -> None:
244
+ self._selected = on
245
+ accent = stage_accent("nucleus")
246
+ self._selbar.setStyleSheet(
247
+ f"background: {accent};" if on else "background: transparent;"
248
+ )
249
+ self.setStyleSheet(experiment_row_style(on, accent))
250
+ color = experiment_name_color(on)
251
+ for label in self._value_labels:
252
+ label.setStyleSheet(f"color: {color};")
253
+
254
+ def set_status(self, status: dict[str, str]) -> None:
255
+ """Repaint the rail + derive the chip word from the full status map."""
256
+ self.rail.set_status(status)
257
+ word = overall_status(status)
258
+ self._chip.setText(_CHIP_TEXT[word])
259
+ self._chip.setStyleSheet(f"color: {experiment_status_color(word)};")
260
+
261
+ def mousePressEvent(self, event) -> None:
262
+ mods = event.modifiers()
263
+ if mods & Qt.ControlModifier:
264
+ flag = 1
265
+ elif mods & Qt.ShiftModifier:
266
+ flag = 2
267
+ else:
268
+ flag = 0
269
+ self.clicked.emit(self._key, flag)
270
+ super().mousePressEvent(event)
271
+
272
+
273
+ def _event_global_y(event) -> int:
274
+ """The event's global y, across the Qt5 (``globalPos``) / Qt6 split."""
275
+ if hasattr(event, "globalPosition"): # Qt6
276
+ return int(event.globalPosition().toPoint().y())
277
+ return int(event.globalPos().y()) # Qt5
278
+
279
+
280
+ class _RowsResizeGrip(QWidget):
281
+ """A thin handle under the rows list; drag it vertically to resize the list.
282
+
283
+ Emits ``pressed`` at grab (the panel snapshots the current height), then the
284
+ signed pixel delta since that press on every move — so the panel can size
285
+ the list live. A double-click emits ``reset`` to drop back to auto-fit.
286
+ Purely a controller: it owns no size state of its own.
287
+ """
288
+
289
+ pressed = Signal()
290
+ dragged = Signal(int) # pixels moved since press (down positive)
291
+ reset = Signal()
292
+
293
+ def __init__(self, parent: QWidget | None = None) -> None:
294
+ super().__init__(parent)
295
+ self.setObjectName("experiments_rows_grip")
296
+ self.setFixedHeight(_GRIP_H)
297
+ self.setCursor(Qt.SizeVerCursor)
298
+ self.setToolTip("Drag to resize the list — double-click to auto-fit")
299
+ self._press_y: int | None = None
300
+ self._hover = False
301
+
302
+ def enterEvent(self, event) -> None:
303
+ self._hover = True
304
+ self.update()
305
+ super().enterEvent(event)
306
+
307
+ def leaveEvent(self, event) -> None:
308
+ self._hover = False
309
+ self.update()
310
+ super().leaveEvent(event)
311
+
312
+ def paintEvent(self, event) -> None:
313
+ # A faint strip carrying a centred grab-bar — a recognisable drag handle
314
+ # (brighter on hover) rather than an invisible hit-zone.
315
+ painter = QPainter(self)
316
+ painter.setRenderHint(QPainter.Antialiasing, True)
317
+ painter.setPen(Qt.NoPen)
318
+ width, height = self.width(), self.height()
319
+ strip_a = 26 if self._hover else 16
320
+ painter.setBrush(QColor(128, 128, 128, strip_a))
321
+ painter.drawRect(0, 0, width, height)
322
+ bar_w, bar_h = 44, 4
323
+ bar_x = (width - bar_w) // 2
324
+ bar_y = (height - bar_h) // 2
325
+ bar_a = 200 if self._hover else 130
326
+ painter.setBrush(QColor(128, 128, 128, bar_a))
327
+ painter.drawRoundedRect(bar_x, bar_y, bar_w, bar_h, bar_h // 2, bar_h // 2)
328
+
329
+ def mousePressEvent(self, event) -> None:
330
+ self._press_y = _event_global_y(event)
331
+ self.pressed.emit()
332
+ event.accept()
333
+
334
+ def mouseMoveEvent(self, event) -> None:
335
+ if self._press_y is None:
336
+ return
337
+ self.dragged.emit(_event_global_y(event) - self._press_y)
338
+ event.accept()
339
+
340
+ def mouseReleaseEvent(self, event) -> None:
341
+ self._press_y = None
342
+ event.accept()
343
+
344
+ def mouseDoubleClickEvent(self, event) -> None:
345
+ self.reset.emit()
346
+ event.accept()
347
+
348
+
349
+ class ExperimentsPanel(QWidget):
350
+ """Setup + an additive Find + an editable-column list of data-folder rows.
351
+
352
+ Host contract:
353
+
354
+ * ``discover_fn(root, input_names) -> list[entry]`` — scan a root; each *entry*
355
+ is ``{"key": str, "columns": {name: value}, "payload": dict}`` (``key`` is the
356
+ row identity, typically the data-folder path).
357
+ * ``status_fn(payload) -> {stage: state}`` — the per-stage rail status.
358
+
359
+ Signals let the host stay the model of record for persistence/scope while the
360
+ panel owns the list UI, Setup, folder discovery, and selection.
361
+ """
362
+
363
+ active_changed = Signal(object) # payload | None — the single active row
364
+ selection_changed = Signal() # multi-selection changed (read selected_payloads)
365
+ records_changed = Signal() # committed row set changed
366
+ run_requested = Signal(list, int) # (selected payloads, num_workers)
367
+ stage_load_requested = Signal(object, str) # (payload, stage)
368
+ calibration_changed = Signal(str, str) # (name, text)
369
+ discover_requested = Signal() # Find-data-folders button clicked
370
+ output_dir_requested = Signal() # output-dir button clicked
371
+
372
+ def __init__(
373
+ self,
374
+ *,
375
+ title: str = "Data folders",
376
+ input_fields: Iterable[tuple[str, str, str]] = (),
377
+ discover_fn: Callable[[str, dict[str, str]], list[dict]] | None = None,
378
+ status_fn: Callable[[dict], dict[str, str]] | None = None,
379
+ stages: Sequence[str] = STAGES,
380
+ show_calibration: bool = True,
381
+ show_output_dir: bool = False,
382
+ show_run: bool = True,
383
+ max_rows_height: int = 480,
384
+ parent: QWidget | None = None,
385
+ ) -> None:
386
+ super().__init__(parent)
387
+ self.setFocusPolicy(Qt.StrongFocus)
388
+ self._input_fields = list(input_fields)
389
+ self._discover_fn = discover_fn
390
+ self._status_fn = status_fn
391
+ self._stages = tuple(stages)
392
+ self._show_calibration = show_calibration
393
+ self._show_output_dir = show_output_dir
394
+ self._show_run = show_run
395
+ self._max_rows_height = max_rows_height
396
+ # None → auto-fit (flush to rows, capped at max); an int → the user has
397
+ # dragged the grip to an explicit height. _grip_base is the drag anchor.
398
+ self._rows_height_override: int | None = None
399
+ self._grip_base: int | None = None
400
+ self._applied_rows_h: int = max_rows_height
401
+
402
+ # Committed rows: ordered keys + per-key {columns, payload}; the shared,
403
+ # editable column names line up over the value cells.
404
+ self._paths: list[str] = []
405
+ self._records: dict[str, dict] = {}
406
+ self._column_names: list[str] = []
407
+ self._rows: list[ExperimentRow] = []
408
+
409
+ # Committed-row selection: a single active row plus a multi-selection for
410
+ # delete/run; ``_anchor`` is the Shift-range pivot.
411
+ self._active: str | None = None
412
+ self._selected_paths: set[str] = set()
413
+ self._anchor: str | None = None
414
+
415
+ layout = QVBoxLayout(self)
416
+ layout.setContentsMargins(0, 0, 0, 0)
417
+ layout.setSpacing(COMPACT_SPACING)
418
+
419
+ layout.addLayout(self._build_heading(title))
420
+
421
+ self.setup_section = self._build_setup_section()
422
+ layout.addWidget(self.setup_section)
423
+
424
+ # One status line, double duty: the empty-state call to action when the
425
+ # list is empty, transient "Added N…" / dry-scan feedback after a Find.
426
+ self._hint = QLabel("")
427
+ self._hint.setObjectName("experiments_hint_label")
428
+ self._hint.setWordWrap(True)
429
+ self._hint.setStyleSheet(f"color: {TEXT_DIM};")
430
+ layout.addWidget(self._hint)
431
+
432
+ # Rows in a bounded scroll region: a long list scrolls internally instead
433
+ # of shoving the action bar off-screen. The editable column header is the
434
+ # first item inside, so it always aligns with the value cells below.
435
+ self._rows_box = QVBoxLayout()
436
+ self._rows_box.setContentsMargins(0, 0, 0, 0)
437
+ self._rows_box.setSpacing(2)
438
+ self._rows_box.setAlignment(Qt.AlignTop)
439
+ rows_container = QWidget()
440
+ rows_container.setLayout(self._rows_box)
441
+ self._rows_scroll = QScrollArea()
442
+ self._rows_scroll.setObjectName("experiments_rows_scroll")
443
+ self._rows_scroll.setWidgetResizable(True)
444
+ # Height tracks content (see _fit_rows_height): grows flush to the rows,
445
+ # clamped to max_rows_height, above which the list scrolls internally.
446
+ # Fixed vertical policy (not the QScrollArea default Expanding) so the
447
+ # pinned height is honoured verbatim — otherwise the panel reads as
448
+ # expandable and soaks up its host's spare space, ballooning the labels.
449
+ self._rows_container = rows_container
450
+ self._rows_scroll.setSizePolicy(
451
+ QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed
452
+ )
453
+ self._rows_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
454
+ self._rows_scroll.setWidget(rows_container)
455
+ layout.addWidget(self._rows_scroll)
456
+
457
+ # A drag handle flush under the list to resize it by hand; double-click
458
+ # to return to auto-fit. Hidden alongside the list when there are no rows.
459
+ self._rows_grip = _RowsResizeGrip()
460
+ self._rows_grip.pressed.connect(self._on_grip_pressed)
461
+ self._rows_grip.dragged.connect(self._on_grip_dragged)
462
+ self._rows_grip.reset.connect(self._on_grip_reset)
463
+ layout.addWidget(self._rows_grip)
464
+
465
+ layout.addLayout(self._build_list_actions())
466
+ if self._show_run:
467
+ layout.addLayout(self._build_run_actions())
468
+
469
+ self._meta = QLabel("")
470
+ self._meta.setStyleSheet(f"color: {TEXT_DIM};")
471
+ layout.addWidget(self._meta)
472
+
473
+ self._rebuild_table()
474
+ self._update_meta()
475
+
476
+ # -- heading + quickstart --------------------------------------------
477
+ def _build_heading(self, title: str) -> QHBoxLayout:
478
+ row = QHBoxLayout()
479
+ row.setContentsMargins(0, 0, 0, 0)
480
+ heading = QLabel(title)
481
+ heading.setObjectName("experiments_panel_label")
482
+ heading.setStyleSheet(f"color: {TEXT_MID}; font-weight: bold;")
483
+ row.addWidget(heading)
484
+ row.addStretch()
485
+ self.help_btn = QToolButton()
486
+ self.help_btn.setObjectName("experiments_help_button")
487
+ self.help_btn.setText("?")
488
+ self.help_btn.setToolTip("Quickstart: how ITASC manages your data")
489
+ self.help_btn.clicked.connect(self._show_quickstart)
490
+ row.addWidget(self.help_btn)
491
+ return row
492
+
493
+ def _show_quickstart(self) -> None:
494
+ dialog = QDialog(self)
495
+ dialog.setObjectName("experiments_quickstart_dialog")
496
+ dialog.setWindowTitle("ITASC quickstart")
497
+ dialog.setMinimumWidth(460)
498
+ box = QVBoxLayout(dialog)
499
+ body = QTextBrowser()
500
+ body.setOpenExternalLinks(False)
501
+ body.setHtml(_QUICKSTART_HTML)
502
+ box.addWidget(body)
503
+ buttons = QDialogButtonBox(QDialogButtonBox.Close)
504
+ buttons.rejected.connect(dialog.reject)
505
+ buttons.accepted.connect(dialog.accept)
506
+ box.addWidget(buttons)
507
+ dialog.exec_()
508
+
509
+ # -- setup: calibration + input names + optional output dir ----------
510
+ def _build_setup_section(self) -> CollapsibleSection:
511
+ inner = QWidget()
512
+ box = QVBoxLayout(inner)
513
+ box.setContentsMargins(0, 0, 0, 0)
514
+ box.setSpacing(COMPACT_SPACING)
515
+ if self._show_calibration:
516
+ box.addLayout(self._build_calibration_row())
517
+ box.addLayout(self._build_input_config())
518
+ if self._show_output_dir:
519
+ box.addLayout(self._build_output_dir_row())
520
+ return CollapsibleSection("Setup", inner, expanded=True, title_color=TEXT_MID)
521
+
522
+ def _build_calibration_row(self) -> QHBoxLayout:
523
+ self.calibration_controls: dict[str, QLineEdit] = {}
524
+ cal = QHBoxLayout()
525
+ cal.setContentsMargins(0, 0, 0, 0)
526
+ cal.setSpacing(COMPACT_SPACING + 4)
527
+ for name, label, min_val, max_val in _CALIBRATION_SPECS:
528
+ field = QLineEdit()
529
+ validator = QDoubleValidator(min_val, max_val, _INPUT_DECIMALS, field)
530
+ validator.setNotation(QDoubleValidator.StandardNotation)
531
+ field.setValidator(validator)
532
+ field.setObjectName(f"experiments_calibration_{name}")
533
+ field.setStyleSheet(mono_input_style())
534
+ field.editingFinished.connect(
535
+ lambda n=name, c=field: self.calibration_changed.emit(n, c.text())
536
+ )
537
+ self.calibration_controls[name] = field
538
+
539
+ caption = QLabel(label)
540
+ caption.setStyleSheet(f"color: {TEXT_MID};")
541
+ cell = QVBoxLayout()
542
+ cell.setContentsMargins(0, 0, 0, 0)
543
+ cell.setSpacing(1)
544
+ cell.addWidget(caption)
545
+ cell.addWidget(field)
546
+ cal.addLayout(cell, 1)
547
+ return cal
548
+
549
+ def calibration_values(self) -> dict[str, str]:
550
+ if not self._show_calibration:
551
+ return {}
552
+ return {n: c.text().strip() for n, c in self.calibration_controls.items()}
553
+
554
+ def set_calibration_values(self, values: dict) -> None:
555
+ if not self._show_calibration:
556
+ return
557
+ for name, control in self.calibration_controls.items():
558
+ if name in values:
559
+ control.blockSignals(True)
560
+ control.setText(_format_value(values[name]))
561
+ control.blockSignals(False)
562
+
563
+ def _build_input_config(self) -> QVBoxLayout:
564
+ """Input file names — the discovery requirements, copied to each scan."""
565
+ box = QVBoxLayout()
566
+ box.setContentsMargins(0, 0, 0, 0)
567
+ box.setSpacing(COMPACT_SPACING)
568
+ files = QFormLayout()
569
+ files.setContentsMargins(0, 0, 0, 0)
570
+ files.setSpacing(2)
571
+ self.input_name_fields: dict[str, QLineEdit] = {}
572
+ for key, label, default in self._input_fields:
573
+ field = QLineEdit(default)
574
+ field.setObjectName(f"experiments_input_{key}")
575
+ field.setStyleSheet(mono_input_style())
576
+ self.input_name_fields[key] = field
577
+ name = QLabel(label)
578
+ name.setStyleSheet(f"color: {TEXT_MID};")
579
+ files.addRow(name, field)
580
+ box.addLayout(files)
581
+ return box
582
+
583
+ def input_names(self) -> dict[str, str]:
584
+ """Current non-blank input file names, keyed by field key."""
585
+ return {
586
+ key: field.text().strip()
587
+ for key, field in self.input_name_fields.items()
588
+ if field.text().strip()
589
+ }
590
+
591
+ def _build_output_dir_row(self) -> QHBoxLayout:
592
+ out = QHBoxLayout()
593
+ out.setContentsMargins(0, 0, 0, 0)
594
+ self.output_dir_button = QToolButton()
595
+ self.output_dir_button.setObjectName("experiments_output_dir_button")
596
+ self.output_dir_button.setText("Add custom output directory")
597
+ self.output_dir_button.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
598
+ self.output_dir_button.clicked.connect(self.output_dir_requested)
599
+ self.output_dir_label = QLabel("")
600
+ self.output_dir_label.setObjectName("experiments_output_dir_label")
601
+ self.output_dir_label.setStyleSheet(f"color: {TEXT_DIM};")
602
+ self.output_dir_label.setVisible(False)
603
+ out.addWidget(self.output_dir_button)
604
+ out.addWidget(self.output_dir_label, 1)
605
+ return out
606
+
607
+ def set_output_dir_text(self, text: str) -> None:
608
+ if not self._show_output_dir:
609
+ return
610
+ self.output_dir_label.setText(text)
611
+ self.output_dir_label.setToolTip(text)
612
+ self.output_dir_label.setVisible(bool(text))
613
+ self.output_dir_button.setText(
614
+ "Change output directory" if text else "Add custom output directory"
615
+ )
616
+
617
+ # -- list + run actions ----------------------------------------------
618
+ def _build_list_actions(self) -> QHBoxLayout:
619
+ row = QHBoxLayout()
620
+ row.setContentsMargins(0, 0, 0, 0)
621
+ self.discover_btn = QToolButton()
622
+ self.discover_btn.setObjectName("experiments_discover_button")
623
+ self.discover_btn.setText("Find data folders…")
624
+ self.discover_btn.setToolTip(
625
+ "Pick a parent directory; every folder under it holding the Setup "
626
+ "image files is added to the list (run again to add more)."
627
+ )
628
+ self.discover_btn.setStyleSheet(action_button_style())
629
+ self.discover_btn.clicked.connect(self.discover_requested)
630
+ row.addWidget(self.discover_btn)
631
+
632
+ self.delete_btn = QToolButton()
633
+ self.delete_btn.setObjectName("experiments_delete_button")
634
+ self.delete_btn.setText("Delete selected")
635
+ self.delete_btn.setToolTip(
636
+ "Remove the selected rows (Ctrl/Shift-click to select several)"
637
+ )
638
+ self.delete_btn.setEnabled(False)
639
+ self.delete_btn.setStyleSheet(action_button_style())
640
+ self.delete_btn.clicked.connect(self.delete_selected)
641
+ row.addWidget(self.delete_btn)
642
+ row.addStretch()
643
+ return row
644
+
645
+ def _build_run_actions(self) -> QHBoxLayout:
646
+ row = QHBoxLayout()
647
+ row.setContentsMargins(0, 0, 0, 0)
648
+ self.run_btn = QToolButton()
649
+ self.run_btn.setObjectName("experiments_run_button")
650
+ self.run_btn.setText("Run selected")
651
+ self.run_btn.setToolTip(
652
+ "Run the selected rows (Ctrl/Shift-click for several, Ctrl+A for all)"
653
+ )
654
+ self.run_btn.setEnabled(False)
655
+ self.run_btn.setStyleSheet(action_button_style())
656
+ self.run_btn.clicked.connect(self._on_run_clicked)
657
+ row.addWidget(self.run_btn)
658
+ row.addStretch()
659
+
660
+ workers_label = QLabel("Workers:")
661
+ workers_label.setStyleSheet(f"color: {TEXT_DIM};")
662
+ row.addWidget(workers_label)
663
+ self.workers_spinbox = QSpinBox()
664
+ self.workers_spinbox.setObjectName("experiments_workers_spinbox")
665
+ self.workers_spinbox.setRange(1, os.cpu_count() or 1)
666
+ self.workers_spinbox.setValue(1)
667
+ self.workers_spinbox.setToolTip("How many data folders Run processes in parallel")
668
+ row.addWidget(self.workers_spinbox)
669
+ return row
670
+
671
+ def num_workers(self) -> int:
672
+ return self.workers_spinbox.value() if self._show_run else 1
673
+
674
+ def _on_run_clicked(self) -> None:
675
+ self.run_requested.emit(self.selected_payloads(), self.num_workers())
676
+
677
+ # -- discovery (one additive Find) -----------------------------------
678
+ def discover(self, root: str) -> list[dict]:
679
+ """Scan *root* via ``discover_fn`` and add every match straight to the list.
680
+
681
+ Additive and de-duped by key: re-running against another root accumulates
682
+ and never disturbs existing (possibly edited) rows. Returns the newly added
683
+ entries and leaves a transient hint naming what happened.
684
+ """
685
+ if self._discover_fn is None:
686
+ return []
687
+ found = list(self._discover_fn(root, self.input_names()))
688
+ added = self._add_entries(found)
689
+ if added:
690
+ message = (
691
+ f"Added {added} data folder{'s' if added != 1 else ''}."
692
+ + ("" if added == len(found) else f" ({len(found) - added} already listed.)")
693
+ )
694
+ above = _count_above_root(found, root)
695
+ if above:
696
+ message += (
697
+ f" {'It sits' if above == 1 else f'{above} sit'} above the folder "
698
+ "you picked: point Find at the parent directory to scan a whole batch."
699
+ )
700
+ self._refresh_hint(message)
701
+ else:
702
+ self._refresh_hint(
703
+ f"No new data folders found under {root} — "
704
+ "check the image filenames in Setup."
705
+ if not found
706
+ else f"All {len(found)} found data folders were already listed."
707
+ )
708
+ return found
709
+
710
+ # -- committed-row model ---------------------------------------------
711
+ def column_names(self) -> list[str]:
712
+ return list(self._column_names)
713
+
714
+ def rename_column(self, index: int, new_name: str) -> None:
715
+ """Rename column *index* table-wide, carrying each row's value across."""
716
+ if not 0 <= index < len(self._column_names):
717
+ return
718
+ old = self._column_names[index]
719
+ new = new_name.strip()
720
+ if not new or new == old:
721
+ return
722
+ self._column_names[index] = new
723
+ for key in self._paths:
724
+ cols = self._records[key]["columns"]
725
+ if old in cols:
726
+ cols[new] = cols.pop(old)
727
+ self._rebuild_table()
728
+ self.records_changed.emit()
729
+
730
+ def remove_column(self, index: int) -> None:
731
+ """Drop column *index* table-wide, discarding each row's value for it.
732
+
733
+ A folder-derived column re-appears on the next Discover; this only
734
+ removes it from the current committed list.
735
+ """
736
+ if not 0 <= index < len(self._column_names):
737
+ return
738
+ name = self._column_names.pop(index)
739
+ for key in self._paths:
740
+ self._records[key]["columns"].pop(name, None)
741
+ self._rebuild_table()
742
+ self.records_changed.emit()
743
+
744
+ def _ensure_columns(self, names: Iterable[str]) -> None:
745
+ for name in names:
746
+ if name and name not in self._column_names:
747
+ self._column_names.append(name)
748
+
749
+ def _add_entries(self, entries: list[dict]) -> int:
750
+ """Append entries as committed rows (de-duped by key), extending columns.
751
+
752
+ Returns the number of rows actually added (skips keys already listed).
753
+ """
754
+ was_empty = not self._paths
755
+ added = 0
756
+ for entry in entries:
757
+ key = str(entry["key"])
758
+ if key in self._records:
759
+ continue
760
+ cols = dict(entry.get("columns") or {})
761
+ self._ensure_columns(cols.keys())
762
+ self._records[key] = {"columns": cols, "payload": entry.get("payload")}
763
+ self._paths.append(key)
764
+ added += 1
765
+ if not added:
766
+ return 0
767
+ self._rebuild_table()
768
+ self._update_meta()
769
+ self.records_changed.emit()
770
+ if was_empty:
771
+ self.set_active(self._paths[0])
772
+ self.setup_section.collapse()
773
+ return added
774
+
775
+ def set_records(self, entries: list[dict]) -> None:
776
+ """Replace the whole committed list from host-owned records (CSV / load).
777
+
778
+ Each entry is ``{"key", "columns", "payload"}``. The shared column header is
779
+ rebuilt from the union of column names (first-seen order).
780
+ """
781
+ self._paths = []
782
+ self._records = {}
783
+ self._column_names = []
784
+ self._selected_paths = set()
785
+ for entry in entries:
786
+ key = str(entry["key"])
787
+ if key in self._records:
788
+ continue
789
+ cols = dict(entry.get("columns") or {})
790
+ self._ensure_columns(cols.keys())
791
+ self._records[key] = {"columns": cols, "payload": entry.get("payload")}
792
+ self._paths.append(key)
793
+ if self._active not in self._paths:
794
+ self._active = None
795
+ self._rebuild_table()
796
+ self._update_meta()
797
+ self.setup_section.expand() if not self._paths else self.setup_section.collapse()
798
+ self.records_changed.emit()
799
+
800
+ def payloads(self) -> list[object]:
801
+ """Every committed row's payload, in row order."""
802
+ return [self._records[key]["payload"] for key in self._paths]
803
+
804
+ def selected_payloads(self) -> list[object]:
805
+ return [
806
+ self._records[key]["payload"]
807
+ for key in self._paths
808
+ if key in self._selected_paths
809
+ ]
810
+
811
+ def active_payload(self) -> object:
812
+ return self._records[self._active]["payload"] if self._active else None
813
+
814
+ def _row_record(self, key: str) -> object:
815
+ """One row's payload with the *live* editable columns merged in.
816
+
817
+ When the payload is a dict (the catalog-record case), the row's current
818
+ column values — including in-header renames — override its ``columns`` bag,
819
+ so a host that reads :meth:`records` always sees what the header shows.
820
+ """
821
+ rec = self._records[key]
822
+ payload = rec["payload"]
823
+ if isinstance(payload, dict):
824
+ return {**payload, "columns": dict(rec["columns"])}
825
+ return payload
826
+
827
+ def records(self) -> list[dict]:
828
+ """Every committed row as ``payload + live columns`` (row order)."""
829
+ return [self._row_record(key) for key in self._paths]
830
+
831
+ def selected_records(self) -> list[dict]:
832
+ """In-scope rows as ``payload + live columns`` (empty if nothing selected)."""
833
+ return [
834
+ self._row_record(key) for key in self._paths if key in self._selected_paths
835
+ ]
836
+
837
+ def active_record(self) -> object:
838
+ return self._row_record(self._active) if self._active else None
839
+
840
+ def keys(self) -> list[str]:
841
+ return list(self._paths)
842
+
843
+ # -- selection --------------------------------------------------------
844
+ def set_active(self, key: str | None, *, selection=None) -> None:
845
+ if key is not None and key not in self._paths:
846
+ return
847
+ changed = key != self._active
848
+ self._active = key
849
+ if selection is None:
850
+ self._selected_paths = {key} if key else set()
851
+ else:
852
+ self._selected_paths = {k for k in selection if k in self._paths}
853
+ self._apply_selection_styles()
854
+ self._update_action_buttons()
855
+ if changed:
856
+ self.active_changed.emit(self.active_payload())
857
+ self.selection_changed.emit()
858
+
859
+ def select_all(self) -> None:
860
+ self._selected_paths = set(self._paths)
861
+ self._apply_selection_styles()
862
+ self._update_action_buttons()
863
+ self.selection_changed.emit()
864
+
865
+ def clear_selection(self) -> None:
866
+ self._active = None
867
+ self._selected_paths = set()
868
+ self._anchor = None
869
+ self._apply_selection_styles()
870
+ self._update_action_buttons()
871
+ self.selection_changed.emit()
872
+
873
+ def delete_selected(self) -> None:
874
+ """Remove the selected committed rows."""
875
+ if not self._selected_paths:
876
+ return
877
+ remaining = [k for k in self._paths if k not in self._selected_paths]
878
+ if self._active in self._selected_paths:
879
+ self._active = None
880
+ self._selected_paths = set()
881
+ self._paths = remaining
882
+ self._records = {k: self._records[k] for k in remaining}
883
+ self._rebuild_table()
884
+ self._update_meta()
885
+ self.records_changed.emit()
886
+ self.selection_changed.emit()
887
+
888
+ def _on_row_clicked(self, key: str, flag: int) -> None:
889
+ self.setFocus()
890
+ if flag == 1: # Ctrl: toggle
891
+ selection = set(self._selected_paths)
892
+ selection.discard(key) if key in selection else selection.add(key)
893
+ self._anchor = key
894
+ active = key if key in selection else next(iter(selection), None)
895
+ self.set_active(active, selection=selection)
896
+ elif flag == 2 and self._anchor in self._paths: # Shift: range
897
+ lo, hi = sorted((self._paths.index(self._anchor), self._paths.index(key)))
898
+ self.set_active(key, selection=set(self._paths[lo : hi + 1]))
899
+ else: # plain: single select + activate
900
+ self._anchor = key
901
+ self.set_active(key)
902
+
903
+ def _apply_selection_styles(self) -> None:
904
+ for row in self._rows:
905
+ row.set_selected(row.key in self._selected_paths)
906
+
907
+ def _update_action_buttons(self) -> None:
908
+ has_sel = bool(self._selected_paths)
909
+ self.delete_btn.setEnabled(has_sel)
910
+ if self._show_run:
911
+ self.run_btn.setEnabled(has_sel)
912
+
913
+ def keyPressEvent(self, event) -> None:
914
+ if event.key() in (Qt.Key_Delete, Qt.Key_Backspace) and self._selected_paths:
915
+ self.delete_selected()
916
+ event.accept()
917
+ return
918
+ if event.key() == Qt.Key_A and event.modifiers() & Qt.ControlModifier:
919
+ self.select_all()
920
+ event.accept()
921
+ return
922
+ super().keyPressEvent(event)
923
+
924
+ # -- status -----------------------------------------------------------
925
+ def refresh_statuses(self) -> None:
926
+ """Re-read every committed row's stage status and repaint rail + chip."""
927
+ if self._status_fn is None:
928
+ return
929
+ for row in self._rows:
930
+ payload = self._records[row.key]["payload"]
931
+ try:
932
+ status = self._status_fn(payload)
933
+ except Exception:
934
+ status = {stage: UNKNOWN for stage in self._stages}
935
+ row.set_status(status)
936
+
937
+ def _on_row_dot_clicked(self, key: str, stage: str) -> None:
938
+ payload = self._records.get(key, {}).get("payload")
939
+ self.stage_load_requested.emit(payload, stage)
940
+
941
+ # -- rendering --------------------------------------------------------
942
+ def _build_header_widget(self) -> QWidget:
943
+ widget = QWidget()
944
+ widget.setObjectName("experiments_table_header")
945
+ row = QHBoxLayout(widget)
946
+ row.setContentsMargins(7, 5, 10, 5)
947
+ row.setSpacing(COMPACT_SPACING + 4)
948
+
949
+ lead = QWidget()
950
+ lead.setFixedWidth(_SELBAR_W)
951
+ row.addWidget(lead)
952
+
953
+ self._header_fields: list[QLineEdit] = []
954
+ if self._column_names:
955
+ for index, name in enumerate(self._column_names):
956
+ cell = QWidget()
957
+ cell_row = QHBoxLayout(cell)
958
+ cell_row.setContentsMargins(0, 0, 0, 0)
959
+ cell_row.setSpacing(2)
960
+ field = QLineEdit(name)
961
+ field.setObjectName(f"experiments_column_header_{index}")
962
+ field.setStyleSheet(mono_input_style())
963
+ field.editingFinished.connect(
964
+ lambda i=index, f=field: self.rename_column(i, f.text())
965
+ )
966
+ cell_row.addWidget(field, 1)
967
+ remove = QToolButton()
968
+ remove.setObjectName(f"experiments_remove_column_{index}")
969
+ remove.setText("×")
970
+ remove.setToolTip(f"Remove the '{name}' column")
971
+ remove.setAutoRaise(True)
972
+ remove.setFixedWidth(16)
973
+ remove.setStyleSheet(f"color: {TEXT_DIM}; border: none;")
974
+ remove.clicked.connect(lambda _=False, i=index: self.remove_column(i))
975
+ cell_row.addWidget(remove)
976
+ row.addWidget(cell, 1)
977
+ self._header_fields.append(field)
978
+ else:
979
+ placeholder = QLineEdit("Folder")
980
+ placeholder.setEnabled(False)
981
+ placeholder.setStyleSheet(mono_input_style())
982
+ row.addWidget(placeholder, 1)
983
+
984
+ rail = QWidget()
985
+ rail.setFixedWidth(_RAIL_W)
986
+ row.addWidget(rail)
987
+ chip = QWidget()
988
+ chip.setFixedWidth(_CHIP_W)
989
+ row.addWidget(chip)
990
+ return widget
991
+
992
+ def _rebuild_table(self) -> None:
993
+ while self._rows_box.count():
994
+ item = self._rows_box.takeAt(0)
995
+ if item.widget() is not None:
996
+ item.widget().deleteLater()
997
+ self._rows = []
998
+
999
+ self._rows_box.addWidget(self._build_header_widget())
1000
+
1001
+ for key in self._paths:
1002
+ cols = self._records[key]["columns"]
1003
+ values = [cols.get(name, "") for name in self._column_names]
1004
+ row = ExperimentRow(key, values or None, stages=self._stages)
1005
+ row.clicked.connect(self._on_row_clicked)
1006
+ row.dot_clicked.connect(self._on_row_dot_clicked)
1007
+ row.set_selected(key in self._selected_paths)
1008
+ self._rows_box.addWidget(row)
1009
+ self._rows.append(row)
1010
+
1011
+ has_rows = bool(self._paths)
1012
+ self._rows_scroll.setVisible(has_rows)
1013
+ self._rows_grip.setVisible(has_rows)
1014
+ self._fit_rows_height()
1015
+ self.refresh_statuses()
1016
+
1017
+ def _rows_content_height(self) -> int:
1018
+ """The rows' natural stacked height, summed from the child hints.
1019
+
1020
+ Summing the children is deterministic right after a bulk rebuild, where
1021
+ the container's own aggregate size hint is momentarily stale (0) until
1022
+ the event loop lays the new rows out.
1023
+ """
1024
+ box = self._rows_box
1025
+ margins = box.contentsMargins()
1026
+ total = margins.top() + margins.bottom()
1027
+ count = box.count()
1028
+ for i in range(count):
1029
+ widget = box.itemAt(i).widget()
1030
+ if widget is not None:
1031
+ total += widget.sizeHint().height()
1032
+ if count > 1:
1033
+ total += box.spacing() * (count - 1)
1034
+ return total
1035
+
1036
+ def _fit_rows_height(self) -> None:
1037
+ """Size the scroll region, honouring a dragged override else auto-fitting.
1038
+
1039
+ Auto-fit grows the list flush to the number of rows instead of reserving
1040
+ a fixed slab, capped at ``max_rows_height``; past the cap it scrolls
1041
+ internally. A grip drag pins an explicit height instead, bounded to
1042
+ ``[_MIN_ROWS_HEIGHT, max(content, max_rows_height)]``.
1043
+ """
1044
+ # Add the frame both sides so the viewport fits content exactly — else a
1045
+ # 1-2px shortfall triggers a spurious scrollbar on a list that fits.
1046
+ natural = self._rows_content_height() + 2 * self._rows_scroll.frameWidth()
1047
+ if self._rows_height_override is None:
1048
+ height = min(natural, self._max_rows_height)
1049
+ else:
1050
+ upper = max(natural, self._max_rows_height)
1051
+ height = max(_MIN_ROWS_HEIGHT, min(self._rows_height_override, upper))
1052
+ self._rows_scroll.setFixedHeight(height)
1053
+ # Remember what we applied: the grip drags from this, not from live
1054
+ # geometry, which may not have settled between synchronous calls.
1055
+ self._applied_rows_h = height
1056
+
1057
+ # -- resize grip ------------------------------------------------------
1058
+ def _on_grip_pressed(self) -> None:
1059
+ """Anchor the drag to the list's current (last-applied) height."""
1060
+ self._grip_base = self._applied_rows_h
1061
+
1062
+ def _on_grip_dragged(self, delta: int) -> None:
1063
+ """Resize to the anchor height plus the drag delta (clamped in _fit)."""
1064
+ if self._grip_base is None:
1065
+ return
1066
+ self._rows_height_override = self._grip_base + delta
1067
+ self._fit_rows_height()
1068
+
1069
+ def _on_grip_reset(self) -> None:
1070
+ """Double-click: forget the manual size, return to auto-fit."""
1071
+ self._rows_height_override = None
1072
+ self._grip_base = None
1073
+ self._fit_rows_height()
1074
+
1075
+ def _refresh_hint(self, message: str | None = None) -> None:
1076
+ """The one status line: a transient *message*, else the empty-state CTA."""
1077
+ if message:
1078
+ self._hint.setText(message)
1079
+ elif not self._paths:
1080
+ self._hint.setText(
1081
+ "Add folders with cell and nucleus images to start. "
1082
+ "Click ? for how ITASC stores its work."
1083
+ )
1084
+ else:
1085
+ self._hint.setText("")
1086
+
1087
+ def _update_meta(self) -> None:
1088
+ n = len(self._paths)
1089
+ self._meta.setText(f"{n} data folder{'s' if n != 1 else ''}")
1090
+ self._refresh_hint()
1091
+ self._update_action_buttons()