Skip to content

Commit f63f37b

Browse files
authored
feat: Use QScrollArea for components selection (#1328)
## Summary by Sourcery Make component selection scrollable and update ROI handling hooks to keep chosen components in sync with ROI changes. New Features: - Allow scrolling through the list of selectable components using a scrollable container. Enhancements: - Ensure selected components are automatically brought into view when toggled or changed. - Separate initialization of component checkboxes from updating their checked state via a dedicated setter. - Adjust the main ROI mask layout to use a splitter between algorithm selection and component selection panels. - Introduce a post-ROI-set hook for subclasses to perform additional updates after ROI changes. - Tighten type annotations for napari layer addition utility. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Component list now scrolls vertically and auto-scrolls toggled items into view * Component and algorithm selectors placed in a resizable split layout * Settings run a post-ROI sync so component choices stay in sync after ROI changes * **Refactor** * Component-selection API reorganized to support typed calls and a lighter update method that updates checks without full rebuild * **Bug Fixes** * Batch select/unselect updates checkboxes while emitting a single consolidated change signal <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 23de3ff commit f63f37b

4 files changed

Lines changed: 87 additions & 48 deletions

File tree

package/PartSeg/_roi_mask/main_window.py

Lines changed: 61 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import os
2+
from collections.abc import Sequence
23
from contextlib import suppress
34
from functools import partial
5+
from typing import Union
46

57
import numpy as np
68
from qtpy.QtCore import QByteArray, Qt, Signal, Slot
@@ -16,8 +18,10 @@
1618
QMessageBox,
1719
QProgressBar,
1820
QPushButton,
21+
QScrollArea,
1922
QSizePolicy,
2023
QSpinBox,
24+
QSplitter,
2125
QTabWidget,
2226
QTextEdit,
2327
QVBoxLayout,
@@ -393,7 +397,7 @@ def leaveEvent(self, _event):
393397
self.mouse_leave.emit(self.number)
394398

395399

396-
class ChosenComponents(QWidget):
400+
class ChosenComponents(QScrollArea):
397401
"""
398402
:type check_box: dict[int, ComponentCheckBox]
399403
"""
@@ -404,6 +408,7 @@ class ChosenComponents(QWidget):
404408

405409
def __init__(self):
406410
super().__init__()
411+
self.setWidget(QWidget(self))
407412
self.check_box = {}
408413
self.check_all_btn = QPushButton("Select all")
409414
self.check_all_btn.clicked.connect(self.check_all)
@@ -416,19 +421,33 @@ def __init__(self):
416421
self.check_layout = FlowLayout()
417422
main_layout.addLayout(btn_layout)
418423
main_layout.addLayout(self.check_layout)
419-
self.setLayout(main_layout)
424+
self.widget().setLayout(main_layout)
425+
self.setWidgetResizable(True)
426+
self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
427+
self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
420428

421429
def other_component_choose(self, num):
422430
check = self.check_box[num]
423431
check.setChecked(not check.isChecked())
432+
self.ensureWidgetVisible(check)
424433

425434
def check_all(self):
426-
for el in self.check_box.values():
427-
el.setChecked(True)
435+
prev = self.blockSignals(True)
436+
try:
437+
for el in self.check_box.values():
438+
el.setChecked(True)
439+
finally:
440+
self.blockSignals(prev)
441+
self.check_change_signal.emit()
428442

429443
def un_check_all(self):
430-
for el in self.check_box.values():
431-
el.setChecked(False)
444+
prev = self.blockSignals(True)
445+
try:
446+
for el in self.check_box.values():
447+
el.setChecked(False)
448+
finally:
449+
self.blockSignals(prev)
450+
self.check_change_signal.emit()
432451

433452
def remove_components(self):
434453
self.check_layout.clear()
@@ -439,31 +458,46 @@ def remove_components(self):
439458
el.mouse_enter.disconnect()
440459
self.check_box.clear()
441460

442-
def new_choose(self, num, chosen_components):
443-
self.set_chose(range(1, num + 1), chosen_components)
461+
def new_choose(self, num: int, chosen_components: Sequence[int]) -> None:
462+
self.set_components(range(1, num + 1), chosen_components)
444463

445-
def set_chose(self, components_index, chosen_components):
464+
def set_components(self, components_index, chosen_components: Union[Sequence[int], None] = None):
465+
if chosen_components is None:
466+
chosen_components = []
446467
chosen_components = set(chosen_components)
447-
self.blockSignals(True)
448-
self.remove_components()
449-
for el in components_index:
450-
check = ComponentCheckBox(el)
451-
if el in chosen_components:
452-
check.setChecked(True)
453-
check.stateChanged.connect(self.check_change)
454-
check.mouse_enter.connect(self.mouse_enter.emit)
455-
check.mouse_leave.connect(self.mouse_leave.emit)
456-
self.check_box[el] = check
457-
self.check_layout.addWidget(check)
458-
self.blockSignals(False)
468+
prev = self.blockSignals(True)
469+
try:
470+
self.remove_components()
471+
for el in components_index:
472+
check = ComponentCheckBox(el)
473+
if el in chosen_components:
474+
check.setChecked(True)
475+
check.stateChanged.connect(self.check_change)
476+
check.mouse_enter.connect(self.mouse_enter.emit)
477+
check.mouse_leave.connect(self.mouse_leave.emit)
478+
self.check_box[el] = check
479+
self.check_layout.addWidget(check)
480+
finally:
481+
self.blockSignals(prev)
459482
self.update()
460483
self.check_change_signal.emit()
461484

485+
def set_chosen(self, chosen_components: Sequence[int]):
486+
prev = self.blockSignals(True)
487+
chosen_components = set(chosen_components)
488+
try:
489+
for num, check in self.check_box.items():
490+
check.setChecked(num in chosen_components)
491+
finally:
492+
self.blockSignals(prev)
493+
self.check_change_signal.emit()
494+
462495
def check_change(self):
463496
self.check_change_signal.emit()
464497

465498
def change_state(self, num, val):
466499
self.check_box[num].setChecked(val)
500+
self.ensureWidgetVisible(self.check_box[num])
467501

468502
def get_state(self, num: int) -> bool:
469503
# TODO Check what situation create report of id ID: af9b57f074264169b4353aa1e61d8bc2
@@ -527,6 +561,7 @@ def __init__(self, settings: StackSettings, image_view: StackImageView): # noqa
527561
self.choose_components.check_change_signal.connect(image_view.refresh_selected)
528562
self.choose_components.mouse_leave.connect(image_view.component_unmark)
529563
self.choose_components.mouse_enter.connect(image_view.component_mark)
564+
530565
self.chosen_list = []
531566
self.progress_bar2 = QProgressBar()
532567
self.progress_bar2.setHidden(True)
@@ -566,8 +601,10 @@ def __init__(self, settings: StackSettings, image_view: StackImageView): # noqa
566601
main_layout.addWidget(self.progress_bar2)
567602
main_layout.addWidget(self.progress_bar)
568603
main_layout.addWidget(self.progress_info_lab)
569-
main_layout.addWidget(self.algorithm_choose_widget, 1)
570-
main_layout.addWidget(self.choose_components)
604+
split = QSplitter(Qt.Orientation.Vertical)
605+
split.addWidget(self.algorithm_choose_widget)
606+
split.addWidget(self.choose_components)
607+
main_layout.addWidget(split, 1)
571608
down_layout = QHBoxLayout()
572609
down_layout.addWidget(self.keep_chosen_components_chk)
573610
down_layout.addWidget(self.show_parameters)
@@ -659,7 +696,7 @@ def segmentation(self, val):
659696

660697
def _image_changed(self):
661698
self.settings.roi = None
662-
self.choose_components.set_chose([], [])
699+
self.choose_components.set_components([], [])
663700

664701
def _execute_in_background_init(self):
665702
if self.batch_process.isRunning():

package/PartSeg/_roi_mask/stack_settings.py

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -170,17 +170,15 @@ def set_project_info(self, data: typing.Union[MaskProjectTuple, PointsInfo]):
170170
data.selected_components,
171171
self.keep_chosen_components,
172172
)
173-
self.chosen_components_widget.set_chose(
174-
sorted(state2.roi_extraction_parameters.keys()), state2.selected_components
175-
)
176173
self.roi = state2.roi_info
174+
self.chosen_components_widget.set_chosen(state2.selected_components)
175+
177176
self.components_parameters_dict = state2.roi_extraction_parameters
178177
else:
179178
self.set_history(data.history)
180-
self.chosen_components_widget.set_chose(
181-
sorted(data.roi_extraction_parameters.keys()), data.selected_components
182-
)
183179
self.roi = data.roi_info
180+
self.chosen_components_widget.set_chosen(data.selected_components)
181+
184182
self.components_parameters_dict = data.roi_extraction_parameters
185183

186184
@staticmethod
@@ -304,18 +302,22 @@ def _set_roi_info(
304302
raise ValueError("ROI do not fit to image") from e
305303
if save_chosen:
306304
state2 = self.transform_state(state, new_roi_info, segmentation_parameters, list_of_components, save_chosen)
307-
self.chosen_components_widget.set_chose(
308-
sorted(state2.roi_extraction_parameters.keys()), state2.selected_components
309-
)
310305
self.roi = state2.roi_info
306+
self.chosen_components_widget.set_chosen(state2.selected_components)
311307
self.components_parameters_dict = state2.roi_extraction_parameters
312308
else:
313-
selected_parameters = {i: segmentation_parameters[i] for i in new_roi_info.bound_info}
314-
315-
self.chosen_components_widget.set_chose(sorted(selected_parameters.keys()), list_of_components)
316309
self.roi = new_roi_info
310+
self.chosen_components_widget.set_chosen(list_of_components)
317311
self.components_parameters_dict = segmentation_parameters
318312

313+
def post_roi_set(self):
314+
if self.chosen_components_widget is not None:
315+
prev = self.chosen_components_widget.blockSignals(True)
316+
try:
317+
self.chosen_components_widget.set_components(self.roi_info.bound_info.keys(), [])
318+
finally:
319+
self.chosen_components_widget.blockSignals(prev)
320+
319321

320322
def get_mask(
321323
segmentation: typing.Optional[np.ndarray], mask: typing.Optional[np.ndarray], selected: list[int]

package/PartSeg/common_backend/base_settings.py

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -126,11 +126,6 @@ def segmentation(self) -> np.ndarray: # pragma: no cover
126126
warnings.warn("segmentation parameter is renamed to roi", DeprecationWarning, stacklevel=2)
127127
return self.roi
128128

129-
@property
130-
def roi(self) -> np.ndarray:
131-
"""current roi"""
132-
return self._roi_info.roi
133-
134129
@property
135130
def segmentation_info(self) -> ROIInfo: # pragma: no cover
136131
warnings.warn("segmentation info parameter is renamed to roi", DeprecationWarning, stacklevel=2)
@@ -140,11 +135,17 @@ def segmentation_info(self) -> ROIInfo: # pragma: no cover
140135
def roi_info(self) -> ROIInfo:
141136
return self._roi_info
142137

138+
@property
139+
def roi(self) -> np.ndarray:
140+
"""current roi"""
141+
return self._roi_info.roi
142+
143143
@roi.setter
144144
def roi(self, val: Union[np.ndarray, ROIInfo]):
145145
if val is None:
146146
self._roi_info = ROIInfo(val)
147147
self._additional_layers = {}
148+
self.post_roi_set()
148149
self.roi_clean.emit()
149150
return
150151
try:
@@ -155,8 +156,12 @@ def roi(self, val: Union[np.ndarray, ROIInfo]):
155156
except ValueError as e:
156157
raise ValueError(ROI_NOT_FIT) from e
157158
self._additional_layers = {}
159+
self.post_roi_set()
158160
self.roi_changed.emit(self._roi_info)
159161

162+
def post_roi_set(self) -> None:
163+
"""called after roi is set, for subclasses to override"""
164+
160165
@property
161166
def sizes(self):
162167
return self._roi_info.sizes
@@ -524,14 +529,9 @@ def set_segmentation_result(self, result: ROIExtractionResult):
524529
self.last_executed_algorithm = result.parameters.algorithm
525530
self.set_algorithm(f"algorithms.{result.parameters.algorithm}", result.parameters.values)
526531
# Fixme not use EventedDict here
527-
try:
528-
roi_info = result.roi_info.fit_to_image(self.image)
529-
except ValueError as e: # pragma: no cover
530-
raise ValueError(ROI_NOT_FIT) from e
532+
self.roi = result.roi_info
531533
if result.points is not None:
532534
self.points = result.points
533-
self._roi_info = roi_info
534-
self.roi_changed.emit(self._roi_info)
535535

536536
def _load_files_call(self, files_list: list[str]):
537537
self.request_load_files.emit(files_list)

package/PartSeg/common_gui/napari_image_view.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -584,7 +584,7 @@ def _remove_worker(self, sender=None):
584584
else:
585585
logging.debug("[_remove_worker] %s", sender)
586586

587-
def _add_layer_util(self, index, layer, filters):
587+
def _add_layer_util(self, index: int, layer: _NapariImage, filters: list[tuple[NoiseFilterType, float]]) -> None:
588588
if layer not in self.viewer.layers:
589589
self.viewer.add_layer(layer)
590590

0 commit comments

Comments
 (0)