diff --git a/brainglobe_utils/qtpy/chest.py b/brainglobe_utils/qtpy/chest.py deleted file mode 100644 index 0de99cf..0000000 --- a/brainglobe_utils/qtpy/chest.py +++ /dev/null @@ -1,27 +0,0 @@ -from qtpy.QtCore import Qt -from qtpy.QtWidgets import QGroupBox, QVBoxLayout - - -class Chest(QGroupBox): - def __init__(self): - super().__init__() - self.setLayout(QVBoxLayout()) - - self.layout().setAlignment(Qt.AlignTop) - self.layout().setSpacing(0) - self.layout().setContentsMargins(0, 0, 0, 0) - self.drawers = [] - - def add_drawer(self, drawer): - self.drawers.append(drawer) - self.layout().addWidget(drawer, 0, Qt.AlignTop) - drawer.toggled_signal.connect(self._update_drawers) - - def _update_drawers(self, signalling_drawer, state): - if state: - for drawer in self.drawers: - if ( - drawer is not signalling_drawer - and drawer.currently_expanded - ): - drawer.toggle_expansion() diff --git a/brainglobe_utils/qtpy/collapsible_widget.py b/brainglobe_utils/qtpy/collapsible_widget.py new file mode 100644 index 0000000..0dcac7c --- /dev/null +++ b/brainglobe_utils/qtpy/collapsible_widget.py @@ -0,0 +1,162 @@ +from typing import List, overload + +from qtpy.QtCore import Qt, Signal +from qtpy.QtWidgets import QGroupBox, QVBoxLayout, QWidget +from superqt.collapsible import QCollapsible + + +class CollapsibleWidget(QCollapsible): + """ + Custom collapsible widget. + + Attributes + ---------- + toggled_signal_with_self : Signal + Emitted when the CollapsibleWidget is toggled. + Provides the CollapsibleWidget instance and the new state. + + Parameters + ---------- + title : str, optional + The title of the CollapsibleWidget. + parent : QWidget or None, optional + The parent widget. + expanded_icon : str or None, optional + The ASCII symbol for the expanded state. + collapsed_icon : str or None, optional + The ASCII symbol for the collapsed state. + """ + + toggled_signal_with_self = Signal(QCollapsible, bool) + + def __init__( + self, + title: str = "", + parent: QWidget | None = None, + expanded_icon: str | None = "▲", + collapsed_icon: str | None = "▼", + ): + """ + Initializes a new CollapsibleWidget instance. + + Parameters + ---------- + title : str, optional + The title of the CollapsibleWidget. + parent : QWidget or None, optional + The parent widget. + expanded_icon : str or None, optional + The icon for the expanded state. + collapsed_icon : str or None, optional + The icon for the collapsed state. + """ + super().__init__(title, parent, expanded_icon, collapsed_icon) + self.currently_expanded = False + + self.toggled.connect(self._on_toggle) + + def _on_toggle(self, state): + """ + Handles the toggled signal by emitting the custom signal + with the CollapsibleWidget instance and the new state. + + Parameters + ---------- + state : bool + The new state of the CollapsibleWidget + (True if expanded, False if collapsed). + """ + self.toggled_signal_with_self.emit(self, state) + + +class CollapsibleWidgetContainer(QGroupBox): + """ + Container for multiple CollapsibleWidgets with the ability to add, + remove, and synchronize their states. + + Methods + ------- + add_widget(QWidget or CollapsibleWidget) + Adds a widget to the CollapsibleWidgetContainer. + remove_drawer(QWidget or CollapsibleWidget) + Removes a widget from the CollapsibleWidgetContainer. + _update_drawers(signalling_drawer, state) + Private method to synchronize drawer states. + """ + + def __init__(self): + """ + Initializes a new CollapsibleWidgetContainer instance. + """ + super().__init__() + self.setLayout(QVBoxLayout()) + + self.layout().setAlignment(Qt.AlignTop) + self.layout().setSpacing(0) + self.layout().setContentsMargins(0, 0, 0, 0) + self.collapsible_widgets: List[CollapsibleWidget] = [] + + @overload + def add_widget(self, widget: QWidget): + ... + + @overload + def add_widget(self, widget: CollapsibleWidget): + ... + + def add_widget(self, widget: QWidget | CollapsibleWidget): + """ + Adds a QWidget or a CollapsibleWidget to the chest. + + Parameters + ---------- + widget : QWidget or CollapsibleWidget + The widget instance to be added. + """ + if isinstance(widget, CollapsibleWidget): + self.collapsible_widgets.append(widget) + widget.toggled_signal_with_self.connect(self._update_drawers) + + self.layout().addWidget(widget, 0, Qt.AlignTop) + + @overload + def remove_widget(self, widget: QWidget): + ... + + @overload + def remove_widget(self, widget: CollapsibleWidget): + ... + + def remove_widget(self, widget: QWidget | CollapsibleWidget): + """ + Removes a widget from the chest. + + Parameters + ---------- + widget : QWidget or CollapsibleWidget + The widget instance to be removed. + """ + if isinstance(widget, CollapsibleWidget): + self.collapsible_widgets.remove(widget) + widget.toggled_signal_with_self.disconnect(self._update_drawers) + + self.layout().removeWidget(widget) + + def _update_drawers( + self, signalling_widget: CollapsibleWidget, state: bool + ): + """ + Synchronizes CollapsibleWidget states to ensure only one + CollapsibleWidget is expanded at a time. + + Parameters + ---------- + signalling_widget : CollapsibleWidget + The CollapsibleWidget emitting the signal. + state : bool + The new state of the signalling_widget. + """ + if state: + for collapsible_widget in self.collapsible_widgets: + if collapsible_widget is not signalling_widget: + collapsible_widget.collapse(False) diff --git a/brainglobe_utils/qtpy/drawer.py b/brainglobe_utils/qtpy/drawer.py deleted file mode 100644 index 34af89d..0000000 --- a/brainglobe_utils/qtpy/drawer.py +++ /dev/null @@ -1,41 +0,0 @@ -from qtpy.QtCore import Qt, Signal -from qtpy.QtWidgets import QPushButton, QVBoxLayout, QWidget - - -class Drawer(QWidget): - """ - LayerList class which acts as collapsable list. - """ - - toggled_signal = Signal(QWidget, bool) - - def __init__(self, name, expand=False): - super().__init__() - self.currently_expanded = expand - self.main_layout = QVBoxLayout() - - self.expand_button = QPushButton(name) - self.expand_button.setToolTip(f"{name}") - - # self.expand_button.setIcon \ - # (QIcon(os.path.join(PATH, 'LayersList_Up.png'))) - - self.main_layout.addWidget(self.expand_button, 0, Qt.AlignTop) - - self.expand_button.clicked.connect(self._on_expand_toggle) - self.setLayout(self.main_layout) - - def _on_expand_toggle(self): - self.toggle_expansion() - self.toggled_signal.emit(self, self.currently_expanded) - - def toggle_expansion(self): - self.currently_expanded = not self.currently_expanded - for i in range(1, self.layout().count()): - self.layout().itemAt(i).widget().setVisible( - self.currently_expanded - ) - - def add(self, widget: QWidget): - widget.setVisible(False) - self.layout().addWidget(widget) diff --git a/pyproject.toml b/pyproject.toml index f096267..931043e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ "PyYAML", "scipy", "scikit-image", + "superqt", ] license = {text = "MIT"} diff --git a/tests/tests/test_unit/test_qtpy/test_drawer.py b/tests/tests/test_unit/test_qtpy/test_drawer.py index ee0bb0b..23e42f2 100644 --- a/tests/tests/test_unit/test_qtpy/test_drawer.py +++ b/tests/tests/test_unit/test_qtpy/test_drawer.py @@ -1,22 +1,179 @@ -from qtpy.QtWidgets import QLabel +import pytest +from qtpy.QtWidgets import QLabel, QPushButton -from brainglobe_utils.qtpy.chest import Chest -from brainglobe_utils.qtpy.drawer import Drawer +from brainglobe_utils.qtpy.collapsible_widget import ( + CollapsibleWidget, + CollapsibleWidgetContainer, +) +widget_title = "Title" -def test_layers_list(qtbot): - chest = Chest() - drawer = Drawer("test") - drawer_2 = Drawer("test2") - drawer.add(QLabel("test")) - drawer.add(QLabel("test2")) - drawer_2.add(QLabel("test")) - drawer_2.add(QLabel("test2")) - chest.add_drawer(drawer) - chest.add_drawer(drawer_2) - qtbot.addWidget(chest) +@pytest.fixture(scope="class") +def collapsible_widget() -> CollapsibleWidget: + collapsible_widget = CollapsibleWidget( + widget_title, expanded_icon="▲", collapsed_icon="▼" + ) + return collapsible_widget - chest.window().show() - qtbot.wait(100000) +@pytest.fixture(scope="class") +def collapsible_widget_container() -> CollapsibleWidgetContainer: + collapsible_widget_container = CollapsibleWidgetContainer() + return collapsible_widget_container + + +def test_collapsible_widget_empty(qtbot, collapsible_widget): + qtbot.addWidget(collapsible_widget) + + assert collapsible_widget.text() == widget_title + assert not collapsible_widget.isExpanded() + assert collapsible_widget.content().layout().count() == 0 + + +def test_collapsible_widget_filled(qtbot, collapsible_widget): + label_str = "test" + collapsible_widget.addWidget(QPushButton(label_str)) + + qtbot.addWidget(collapsible_widget) + + assert collapsible_widget.content().layout().count() == 1 + + +def test_collapsible_widget_click_once(qtbot, collapsible_widget): + qtbot.addWidget(collapsible_widget) + + with qtbot.waitSignal( + collapsible_widget.toggled_signal_with_self, timeout=1000 + ) as blocker: + collapsible_widget._toggle_btn.click() + + assert blocker.args == [collapsible_widget, True] + + +@pytest.mark.parametrize("num_clicks", [2, 5, 100]) +def test_collapsible_widget_click_multiple( + qtbot, collapsible_widget, num_clicks +): + qtbot.addWidget(collapsible_widget) + + current_state = collapsible_widget.isExpanded() + + def check_signal_valid(signaller, state): + return signaller == collapsible_widget and state == current_state + + with qtbot.waitSignals( + [collapsible_widget.toggled_signal_with_self] * num_clicks, + check_params_cbs=[check_signal_valid] * num_clicks, + timeout=1000, + ): + for _ in range(num_clicks + 1): + current_state = not current_state + collapsible_widget._toggle_btn.click() + + +def test_collapsible_widget_container(qtbot, collapsible_widget_container): + qtbot.addWidget(collapsible_widget_container) + + assert collapsible_widget_container.layout().count() == 0 + assert len(collapsible_widget_container.collapsible_widgets) == 0 + + +def test_collapsible_widget_container_add_collapsible_widget( + qtbot, collapsible_widget_container, collapsible_widget +): + qtbot.addWidget(collapsible_widget_container) + + collapsible_widget_container.add_widget(collapsible_widget) + + assert collapsible_widget_container.layout().count() == 1 + assert ( + collapsible_widget_container.collapsible_widgets[0] + == collapsible_widget + ) + assert len(collapsible_widget_container.collapsible_widgets) == 1 + + +def test_collapsible_widget_container_add_other_widget( + qtbot, collapsible_widget_container +): + qtbot.addWidget(collapsible_widget_container) + + collapsible_widget_container.add_widget(QLabel("test")) + + assert collapsible_widget_container.layout().count() == 1 + assert len(collapsible_widget_container.collapsible_widgets) == 0 + + +def test_collapsible_widget_container_add_remove_widgets( + qtbot, collapsible_widget, collapsible_widget_container +): + qtbot.addWidget(collapsible_widget_container) + + collapsible_widget_container.add_widget(collapsible_widget) + + assert collapsible_widget_container.layout().count() == 1 + assert len(collapsible_widget_container.collapsible_widgets) == 1 + + collapsible_widget_container.remove_widget(collapsible_widget) + + assert collapsible_widget_container.layout().count() == 0 + assert len(collapsible_widget_container.collapsible_widgets) == 0 + + +def test_collapsible_widget_container_add_diff_widgets( + qtbot, collapsible_widget, collapsible_widget_container +): + qtbot.addWidget(collapsible_widget_container) + other_widget = QLabel("test") + + collapsible_widget_container.add_widget(collapsible_widget) + collapsible_widget_container.add_widget(other_widget) + + assert collapsible_widget_container.layout().count() == 2 + assert len(collapsible_widget_container.collapsible_widgets) == 1 + + collapsible_widget_container.remove_widget(collapsible_widget) + + assert collapsible_widget_container.layout().count() == 1 + assert len(collapsible_widget_container.collapsible_widgets) == 0 + + collapsible_widget_container.remove_widget(other_widget) + + assert collapsible_widget_container.layout().count() == 0 + assert len(collapsible_widget_container.collapsible_widgets) == 0 + + +@pytest.mark.parametrize( + "num_collapsible_widgets, num_other_widgets, index_expanded", + [(2, 4, 1), (5, 1, 3), (10, 0, 9)], +) +def test_collapsible_widget_container_update_drawers( + qtbot, + collapsible_widget_container, + num_collapsible_widgets, + num_other_widgets, + index_expanded, +): + qtbot.addWidget(collapsible_widget_container) + collapsible_widgets = [] + non_collapsible_widgets = [] + + for _ in range(num_collapsible_widgets): + collapsible_widgets.append(CollapsibleWidget(widget_title)) + collapsible_widget_container.add_widget(collapsible_widgets[-1]) + + for _ in range(num_other_widgets): + non_collapsible_widgets.append(QLabel("test")) + collapsible_widget_container.add_widget(non_collapsible_widgets[-1]) + + for _ in range(num_collapsible_widgets): + collapsible_widgets[index_expanded]._toggle_btn.click() + + for i in range(num_collapsible_widgets): + assert collapsible_widgets[i].isExpanded() == (i == index_expanded) + + for widget in non_collapsible_widgets: + assert not widget.isHidden() + + index_expanded = (index_expanded + 1) % num_collapsible_widgets diff --git a/tests/tests/test_unit/test_qtpy/test_logo.py b/tests/tests/test_unit/test_qtpy/test_logo.py index 5033419..e1134be 100644 --- a/tests/tests/test_unit/test_qtpy/test_logo.py +++ b/tests/tests/test_unit/test_qtpy/test_logo.py @@ -10,8 +10,6 @@ def test_logo(qtbot): qtbot.addWidget(header) - # header.window().show() - expected_strings_logo = [package_name, "brainglobe.png"] expected_strings_docs = [ package_tagline,