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,367 @@
1
+ """Pure Qt sub-widget builders for the nucleus correction widget.
2
+
3
+ These are the self-contained construction helpers the correction widget used to
4
+ carry inline: each takes the widgets/data it needs and returns a ready ``QWidget``
5
+ (or mutates a passed-in section), with no back-reference to the host widget. The
6
+ host stays responsible for owning the resulting widgets and wiring their signals.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from qtpy.QtCore import Qt, Signal
11
+ from qtpy.QtWidgets import (
12
+ QFrame,
13
+ QGridLayout,
14
+ QGroupBox,
15
+ QHBoxLayout,
16
+ QLabel,
17
+ QMessageBox,
18
+ QSizePolicy,
19
+ QStackedWidget,
20
+ QToolButton,
21
+ QVBoxLayout,
22
+ QWidget,
23
+ )
24
+
25
+ from itasc.napari.correction.correction_widget import CorrectionWidget
26
+ from itasc.napari.ui_style import (
27
+ stage_header_action_button,
28
+ stage_header_label,
29
+ )
30
+ from itasc.napari.widgets import CollapsibleSection
31
+
32
+
33
+ def set_checked_without_signal(button: QWidget, checked: bool) -> None:
34
+ """Toggle a checkable button's state without emitting its ``toggled`` signal."""
35
+ old = button.blockSignals(True)
36
+ try:
37
+ button.setChecked(checked)
38
+ finally:
39
+ button.blockSignals(old)
40
+
41
+
42
+ def confirm_unsaved_before_deactivate(parent: QWidget, *, save_noun: str) -> str:
43
+ """Prompt before leaving correction mode with unsaved changes.
44
+
45
+ ``save_noun`` names what would be saved (e.g. ``"tracked labels"``). Returns
46
+ ``"save"``, ``"discard"``, or ``"cancel"``; the caller performs the actual
47
+ save and clears its dirty flag.
48
+ """
49
+ choice = QMessageBox.question(
50
+ parent,
51
+ "Save correction changes?",
52
+ (
53
+ "Correction mode has unsaved changes. "
54
+ f"Save {save_noun} before turning correction mode off?"
55
+ ),
56
+ QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel,
57
+ QMessageBox.Save,
58
+ )
59
+ if choice == QMessageBox.Cancel:
60
+ return "cancel"
61
+ if choice == QMessageBox.Save:
62
+ return "save"
63
+ return "discard"
64
+
65
+
66
+ # Width of the slim show-tab a collapsed pane shrinks to.
67
+ _PANE_STRIP_W = 24
68
+
69
+
70
+ class CollapsiblePane(QWidget):
71
+ """Wrap a content panel with a titled header whose ✕ collapses it to a tab.
72
+
73
+ Expanded, the pane shows a header row (title + a ✕ hide button) over the
74
+ content. Hiding swaps in a slim full-height ``▸`` show-tab and pins the pane
75
+ narrow; clicking the tab expands it again. ``collapsed_changed`` lets the host
76
+ redistribute the surrounding splitter when the state flips.
77
+ """
78
+
79
+ collapsed_changed = Signal(bool)
80
+
81
+ def __init__(self, content: QWidget, *, title: str, parent: QWidget | None = None) -> None:
82
+ super().__init__(parent)
83
+ self._collapsed = False
84
+
85
+ # Claim the full dock height: without an explicit Expanding vertical
86
+ # policy the pane (and its stack) fall back to their content's size hint,
87
+ # which on some platforms / packaged builds leaves the workspace panels
88
+ # bunched at the top instead of filling the dock.
89
+ self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Expanding)
90
+
91
+ outer = QVBoxLayout(self)
92
+ outer.setContentsMargins(0, 0, 0, 0)
93
+ outer.setSpacing(0)
94
+ self._stack = QStackedWidget(self)
95
+ self._stack.setSizePolicy(
96
+ QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Expanding
97
+ )
98
+ outer.addWidget(self._stack)
99
+
100
+ page = QWidget()
101
+ page_lay = QVBoxLayout(page)
102
+ page_lay.setContentsMargins(0, 0, 0, 0)
103
+ page_lay.setSpacing(2)
104
+
105
+ header = QWidget()
106
+ header_lay = QHBoxLayout(header)
107
+ header_lay.setContentsMargins(6, 2, 2, 2)
108
+ header_lay.setSpacing(4)
109
+ title_lbl = QLabel(title)
110
+ title_lbl.setStyleSheet("font-weight: bold;")
111
+ hide_btn = QToolButton()
112
+ hide_btn.setText("✕")
113
+ hide_btn.setToolTip(f"Hide {title}")
114
+ hide_btn.setAutoRaise(True)
115
+ hide_btn.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
116
+ hide_btn.clicked.connect(lambda: self.set_collapsed(True))
117
+ header_lay.addWidget(title_lbl)
118
+ header_lay.addStretch(1)
119
+ header_lay.addWidget(hide_btn)
120
+
121
+ page_lay.addWidget(header)
122
+ page_lay.addWidget(content, stretch=1)
123
+ self._stack.addWidget(page)
124
+
125
+ # The show-tab pins the ✕/▸ buttons to the same top edge: a thin column
126
+ # with the ▸ button at the top and a stretch beneath, rather than a
127
+ # full-height button whose glyph floats to the vertical centre.
128
+ strip = QWidget()
129
+ strip_lay = QVBoxLayout(strip)
130
+ strip_lay.setContentsMargins(2, 2, 2, 2)
131
+ strip_lay.setSpacing(0)
132
+ strip_btn = QToolButton()
133
+ strip_btn.setText("▸")
134
+ strip_btn.setToolTip(f"Show {title}")
135
+ strip_btn.setAutoRaise(True)
136
+ strip_btn.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
137
+ strip_btn.clicked.connect(lambda: self.set_collapsed(False))
138
+ strip_lay.addWidget(strip_btn, alignment=Qt.AlignTop)
139
+ strip_lay.addStretch(1)
140
+ self._strip = strip
141
+ self._stack.addWidget(strip)
142
+
143
+ def is_collapsed(self) -> bool:
144
+ return self._collapsed
145
+
146
+ def set_collapsed(self, collapsed: bool) -> None:
147
+ collapsed = bool(collapsed)
148
+ if collapsed == self._collapsed:
149
+ return
150
+ self._collapsed = collapsed
151
+ self._stack.setCurrentIndex(1 if collapsed else 0)
152
+ if collapsed:
153
+ self.setMinimumWidth(_PANE_STRIP_W)
154
+ self.setMaximumWidth(_PANE_STRIP_W)
155
+ else:
156
+ self.setMinimumWidth(0)
157
+ self.setMaximumWidth(16777215)
158
+ self.collapsed_changed.emit(collapsed)
159
+
160
+
161
+ def flatten_embedded_section(section: CollapsibleSection) -> None:
162
+ """Strip a section's header/margins so it nests flush inside another panel."""
163
+ section.set_header_visible(False)
164
+ section.layout().setContentsMargins(0, 0, 0, 0)
165
+ section._content_frame.layout().setContentsMargins(0, 0, 0, 0)
166
+ section._content_frame.setStyleSheet(
167
+ "QFrame#collapsible_content { border: none; margin: 0px; }"
168
+ )
169
+
170
+
171
+ def build_correction_header(
172
+ parent: QWidget,
173
+ *,
174
+ shortcuts_btn: QWidget,
175
+ params_btn: QWidget,
176
+ active_btn: QWidget,
177
+ finalize_btn: QWidget | None = None,
178
+ view_toggle_btns: tuple[QWidget, ...] = (),
179
+ status_lbl: QWidget | None = None,
180
+ ) -> tuple[QWidget, QLabel]:
181
+ """Build the full-width correction top bar; returns ``(header, title_label)``.
182
+
183
+ Left→right: the stage title, the activate / shortcuts / params toggles, the
184
+ checkable view-toggle tool-buttons, a stretch, then a single one-line status
185
+ label right-aligned at the end of the bar (the save/action status only — the
186
+ track / validated summary lives in the tracking-overview panel). The bar
187
+ spans the whole workspace dock — over the toolbar, gallery, and accordion.
188
+ """
189
+ header = QWidget(parent)
190
+ row = QHBoxLayout(header)
191
+ row.setContentsMargins(0, 0, 0, 0)
192
+ row.setSpacing(4)
193
+
194
+ # The title doubles as the inactive plugin-dock entry (a stage "pill" next
195
+ # to the on/off button) and, once correction is active, a proper workspace
196
+ # title. The host toggles between the two looks; it starts as the pill.
197
+ header_lbl = QLabel("Tracking Correction")
198
+ stage_header_label(header_lbl, "nucleus")
199
+ finalize_btns = (finalize_btn,) if finalize_btn is not None else ()
200
+ for button in (
201
+ active_btn, *finalize_btns, shortcuts_btn, params_btn, *view_toggle_btns
202
+ ):
203
+ stage_header_action_button(button, "nucleus")
204
+ row.addWidget(header_lbl)
205
+ # Activate first, then Finalize right beside it (both stay visible even in
206
+ # the inactive plugin dock), then the reveal toggles — so correction mode
207
+ # can be entered/exited and the labels finalized even while the dock is hidden.
208
+ row.addWidget(active_btn)
209
+ for button in finalize_btns:
210
+ row.addWidget(button)
211
+ row.addWidget(shortcuts_btn)
212
+ row.addWidget(params_btn)
213
+ if view_toggle_btns:
214
+ row.addSpacing(8)
215
+ for button in view_toggle_btns:
216
+ row.addWidget(button)
217
+ row.addStretch(1)
218
+ if status_lbl is not None:
219
+ row.addWidget(status_lbl)
220
+ return header, header_lbl
221
+
222
+
223
+ # Glyphs are scaled up by this factor (relative to each button's default font)
224
+ # so the thin vertical action toolbar reads clearly at its narrow width.
225
+ _TOOLBAR_ICON_SCALE = 1.6
226
+
227
+
228
+ def _enlarge_glyph(button: QWidget) -> None:
229
+ """Bump a tool button's glyph font, leaving any stylesheet untouched."""
230
+ font = button.font()
231
+ if font.pointSizeF() > 0:
232
+ font.setPointSizeF(font.pointSizeF() * _TOOLBAR_ICON_SCALE)
233
+ else:
234
+ font.setPixelSize(max(1, round(font.pixelSize() * _TOOLBAR_ICON_SCALE)))
235
+ button.setFont(font)
236
+
237
+
238
+ def build_correction_toolbar(
239
+ parent: QWidget, button_groups: list[tuple[QWidget, ...]]
240
+ ) -> QWidget:
241
+ """Stack groups of action buttons vertically in a thin column, ruled between.
242
+
243
+ The toolbar is the narrow leftmost panel of the workspace body splitter, so
244
+ its buttons run top-to-bottom in a single column with bigger glyphs; group
245
+ separators become horizontal rules. The column hugs its buttons' width so it
246
+ stays thin while the accordion to its right absorbs the spare width.
247
+ """
248
+ toolbar = QWidget(parent)
249
+ col = QVBoxLayout(toolbar)
250
+ col.setContentsMargins(2, 2, 2, 2)
251
+ col.setSpacing(4)
252
+
253
+ def _sep() -> QFrame:
254
+ line = QFrame()
255
+ line.setFrameShape(QFrame.HLine)
256
+ line.setFrameShadow(QFrame.Sunken)
257
+ return line
258
+
259
+ for i, group in enumerate(button_groups):
260
+ if i > 0:
261
+ col.addWidget(_sep())
262
+ for b in group:
263
+ _enlarge_glyph(b)
264
+ col.addWidget(b)
265
+ col.addStretch(1)
266
+ return toolbar
267
+
268
+
269
+ # The shortcut groups, split into side-by-side columns so the reveal area below
270
+ # the top bar reads wide-and-short instead of as one tall stack.
271
+ _SHORTCUT_COLUMNS = (
272
+ (
273
+ (
274
+ "Track Workflow",
275
+ (
276
+ ("V", "Validate selected track"),
277
+ ("B", "Anchor selected cell at current frame"),
278
+ ("A / D", "Extend selected track backward / forward"),
279
+ ("Q / E", "Retrack backward / forward"),
280
+ ("Z / C", "Swap with smaller / larger candidate fragment"),
281
+ ("S", "Save tracked labels"),
282
+ ("Space", "Play / stop the movie"),
283
+ ),
284
+ ),
285
+ ),
286
+ (
287
+ (
288
+ "Manual Labels",
289
+ (
290
+ ("Middle-click empty space", "Spawn new cell"),
291
+ ("Middle-click on cell or Delete", "Erase cell"),
292
+ ("Ctrl+Left-click", "Merge with the clicked cell (same frame)"),
293
+ ("Ctrl+Middle-click", "Grow / link selected track here"),
294
+ ("Ctrl+Right-click", "Swap with the clicked cell, or attach it to the selected track (other frame)"),
295
+ ("Shift+Left-drag", "Draw / extend cell path"),
296
+ ("Shift+Right-drag", "Split by drawn line"),
297
+ ),
298
+ ),
299
+ ("History", (("Ctrl+Z", "Undo"),)),
300
+ ),
301
+ (
302
+ (
303
+ "Selection",
304
+ (
305
+ ("Left-click", "Select / highlight cell"),
306
+ ("← / →", "Previous / next thumbnail"),
307
+ ("↑ / ↓", "Thumbnail row up / down"),
308
+ ("Shift+↑ / ↓", "Previous / next track"),
309
+ ),
310
+ ),
311
+ ),
312
+ )
313
+
314
+
315
+ def _shortcut_column(groups, footer: QWidget | None = None) -> QWidget:
316
+ """One vertical column of shortcut groups, with an optional footer below."""
317
+ column = QWidget()
318
+ col_lay = QVBoxLayout(column)
319
+ col_lay.setContentsMargins(0, 0, 0, 0)
320
+ col_lay.setSpacing(4)
321
+
322
+ grid_host = QWidget()
323
+ grid = QGridLayout(grid_host)
324
+ grid.setContentsMargins(8, 6, 8, 6)
325
+ grid.setHorizontalSpacing(10)
326
+ grid.setVerticalSpacing(2)
327
+ row = 0
328
+ for i, (title, items) in enumerate(groups):
329
+ row = CorrectionWidget._add_shortcut_group(
330
+ grid, title, list(items), start_row=row, is_first=(i == 0)
331
+ )
332
+ grid.setColumnStretch(1, 1)
333
+ col_lay.addWidget(grid_host)
334
+ if footer is not None:
335
+ col_lay.addWidget(footer)
336
+ col_lay.addStretch(1)
337
+ return column
338
+
339
+
340
+ def build_shortcuts_widget(attrib_lbl: QWidget | None = None) -> QWidget:
341
+ """Build the wide multi-column correction-shortcuts reference panel.
342
+
343
+ The shortcut groups are arranged in side-by-side columns (so the panel is
344
+ wide and short under the top bar). The disclaimer / attribution label, when
345
+ supplied, rides at the bottom of the last column (alongside Selection).
346
+ """
347
+ group = QGroupBox("Correction shortcuts")
348
+ outer = QVBoxLayout(group)
349
+ # A titled group box draws its title inside the top inset, so the top margin
350
+ # must leave room for it — zeroing all four (as the other panels do) clipped
351
+ # the title against the first shortcut row. Reserve a title's height up top.
352
+ title_h = group.fontMetrics().height() + 6
353
+ outer.setContentsMargins(0, title_h, 0, 0)
354
+ outer.setSpacing(2)
355
+
356
+ columns = QHBoxLayout()
357
+ columns.setContentsMargins(0, 0, 0, 0)
358
+ columns.setSpacing(12)
359
+ last = len(_SHORTCUT_COLUMNS) - 1
360
+ for i, col_groups in enumerate(_SHORTCUT_COLUMNS):
361
+ footer = attrib_lbl if i == last else None
362
+ columns.addWidget(
363
+ _shortcut_column(col_groups, footer), alignment=Qt.AlignTop
364
+ )
365
+ columns.addStretch(1)
366
+ outer.addLayout(columns)
367
+ return group