diff --git a/bec_widgets/qt_utils/toolbar.py b/bec_widgets/qt_utils/toolbar.py index 59e64fa9..ca85f6c0 100644 --- a/bec_widgets/qt_utils/toolbar.py +++ b/bec_widgets/qt_utils/toolbar.py @@ -8,7 +8,7 @@ from collections import defaultdict from typing import Dict, List, Literal, Tuple from bec_qthemes._icon.material_icons import material_icon -from qtpy.QtCore import QSize, Qt +from qtpy.QtCore import QSize, Qt, QTimer from qtpy.QtGui import QAction, QColor, QIcon from qtpy.QtWidgets import ( QApplication, @@ -21,6 +21,7 @@ from qtpy.QtWidgets import ( QStyle, QToolBar, QToolButton, + QVBoxLayout, QWidget, ) @@ -32,6 +33,38 @@ MODULE_PATH = os.path.dirname(bec_widgets.__file__) QApplication.setAttribute(Qt.AA_DontShowIconsInMenus, False) +class LongPressToolButton(QToolButton): + def __init__(self, *args, long_press_threshold=500, **kwargs): + super().__init__(*args, **kwargs) + self.long_press_threshold = long_press_threshold + self._long_press_timer = QTimer(self) + self._long_press_timer.setSingleShot(True) + self._long_press_timer.timeout.connect(self.handleLongPress) + self._pressed = False + self._longPressed = False + + def mousePressEvent(self, event): + self._pressed = True + self._longPressed = False + self._long_press_timer.start(self.long_press_threshold) + super().mousePressEvent(event) + + def mouseReleaseEvent(self, event): + self._pressed = False + if self._longPressed: + self._longPressed = False + self._long_press_timer.stop() + event.accept() # Prevent normal click action after a long press + return + self._long_press_timer.stop() + super().mouseReleaseEvent(event) + + def handleLongPress(self): + if self._pressed: + self._longPressed = True + self.showMenu() + + class ToolBarAction(ABC): """ Abstract base class for toolbar actions. @@ -192,6 +225,86 @@ class DeviceSelectionAction(ToolBarAction): self.device_combobox.setStyleSheet(f"QComboBox {{ background-color: {color}; }}") +class SwitchableToolBarAction(ToolBarAction): + """ + A split toolbar action that combines a main action and a drop-down menu for additional actions. + + The main button displays the currently selected action's icon and tooltip. Clicking on the main button + triggers that action. Clicking on the drop-down arrow displays a menu with alternative actions. When an + alternative action is selected, it becomes the new default and its callback is immediately executed. + + This design mimics the behavior seen in Adobe Photoshop or Affinity Designer toolbars. + + Args: + actions (dict): A dictionary mapping a unique key to a ToolBarAction instance. + initial_action (str, optional): The key of the initial default action. If not provided, the first action is used. + tooltip (str, optional): An optional tooltip for the split action; if provided, it overrides the default action's tooltip. + checkable (bool, optional): Whether the action is checkable. Defaults to True. + parent (QWidget, optional): Parent widget for the underlying QAction. + """ + + def __init__( + self, + actions: Dict[str, ToolBarAction], + initial_action: str = None, + tooltip: str = None, + checkable: bool = True, + parent=None, + ): + super().__init__(icon_path=None, tooltip=tooltip, checkable=checkable) + self.actions = actions + self.current_key = initial_action if initial_action is not None else next(iter(actions)) + self.parent = parent + self.checkable = checkable + self.main_button = None + self.menu_actions: Dict[str, QAction] = {} + + def add_to_toolbar(self, toolbar: QToolBar, target: QWidget): + """ + Adds the split action to the toolbar. + + Args: + toolbar (QToolBar): The toolbar to add the action to. + target (QWidget): The target widget for the action. + """ + self.main_button = LongPressToolButton(toolbar) + self.main_button.setPopupMode(QToolButton.MenuButtonPopup) + self.main_button.setCheckable(self.checkable) + default_action = self.actions[self.current_key] + self.main_button.setIcon(default_action.get_icon()) + self.main_button.setToolTip(default_action.tooltip) + self.main_button.clicked.connect(self._trigger_current_action) + menu = QMenu(self.main_button) + self.menu_actions = {} + for key, action_obj in self.actions.items(): + menu_action = QAction(action_obj.get_icon(), action_obj.tooltip, self.main_button) + menu_action.setIconVisibleInMenu(True) + menu_action.setCheckable(self.checkable) + menu_action.setChecked(key == self.current_key) + menu_action.triggered.connect(lambda checked, k=key: self._set_default_action(k)) + menu.addAction(menu_action) + self.menu_actions[key] = menu_action + self.main_button.setMenu(menu) + toolbar.addWidget(self.main_button) + + def _trigger_current_action(self): + action_obj = self.actions[self.current_key] + action_obj.action.trigger() + + def _set_default_action(self, key: str): + self.current_key = key + new_action = self.actions[self.current_key] + self.main_button.setIcon(new_action.get_icon()) + self.main_button.setToolTip(new_action.tooltip) + # Update check state of menu items + for k, menu_act in self.menu_actions.items(): + menu_act.setChecked(k == key) + new_action.action.trigger() + + def get_icon(self) -> QIcon: + return self.actions[self.current_key].get_icon() + + class WidgetAction(ToolBarAction): """ Action for adding any widget to the toolbar. @@ -677,10 +790,20 @@ class MainWindow(QMainWindow): # pragma: no cover self.setWindowTitle("Toolbar / ToolbarBundle Demo") self.central_widget = QWidget() self.setCentralWidget(self.central_widget) + self.test_label = QLabel(text="This is a test label.") + self.central_widget.layout = QVBoxLayout(self.central_widget) + self.central_widget.layout.addWidget(self.test_label) self.toolbar = ModularToolBar(parent=self, target_widget=self) self.addToolBar(self.toolbar) + self.add_switchable_button_checkable() + self.add_switchable_button_non_checkable() + self.add_widget_actions() + self.add_bundles() + self.add_menus() + + def add_bundles(self): home_action = MaterialIconAction( icon_name="home", tooltip="Home", checkable=True, parent=self ) @@ -701,10 +824,10 @@ class MainWindow(QMainWindow): # pragma: no cover self.toolbar.add_bundle(main_actions_bundle, target_widget=self) search_action = MaterialIconAction( - icon_name="search", tooltip="Search", checkable=True, parent=self + icon_name="search", tooltip="Search", checkable=False, parent=self ) help_action = MaterialIconAction( - icon_name="help", tooltip="Help", checkable=True, parent=self + icon_name="help", tooltip="Help", checkable=False, parent=self ) second_bundle = ToolbarBundle( bundle_id="secondary_actions", @@ -718,6 +841,8 @@ class MainWindow(QMainWindow): # pragma: no cover self.toolbar.add_action_to_bundle( "main_actions", "new_action", new_action, target_widget=self ) + + def add_menus(self): menu_material_actions = { "mat1": MaterialIconAction( icon_name="home", tooltip="Material Home", checkable=True, parent=self @@ -748,6 +873,55 @@ class MainWindow(QMainWindow): # pragma: no cover self.toolbar.add_action("material_menu", expandable_menu_material, self) self.toolbar.add_action("qt_menu", expandable_menu_qt, self) + def add_switchable_button_checkable(self): + action1 = MaterialIconAction( + icon_name="counter_1", tooltip="Action 1", checkable=True, parent=self + ) + action2 = MaterialIconAction( + icon_name="counter_2", tooltip="Action 2", checkable=True, parent=self + ) + + switchable_action = SwitchableToolBarAction( + actions={"action1": action1, "action2": action2}, + initial_action="action1", + tooltip="Switchable Action", + checkable=True, + parent=self, + ) + self.toolbar.add_action("switchable_action", switchable_action, self) + + action1.action.toggled.connect( + lambda checked: self.test_label.setText(f"Action 1 triggered, checked = {checked}") + ) + action2.action.toggled.connect( + lambda checked: self.test_label.setText(f"Action 2 triggered, checked = {checked}") + ) + + def add_switchable_button_non_checkable(self): + action1 = MaterialIconAction( + icon_name="counter_1", tooltip="Action 1", checkable=False, parent=self + ) + action2 = MaterialIconAction( + icon_name="counter_2", tooltip="Action 2", checkable=False, parent=self + ) + + switchable_action = SwitchableToolBarAction( + actions={"action1": action1, "action2": action2}, + initial_action="action1", + tooltip="Switchable Action", + checkable=False, + parent=self, + ) + self.toolbar.add_action("switchable_action_no_toggle", switchable_action, self) + + action1.action.triggered.connect( + lambda checked: self.test_label.setText(f"Action 1 triggered, checked = {checked}") + ) + action2.action.triggered.connect( + lambda checked: self.test_label.setText(f"Action 2 triggered, checked = {checked}") + ) + + def add_widget_actions(self): combo = QComboBox() combo.addItems(["Option 1", "Option 2", "Option 3"]) self.toolbar.add_action("device_combo", WidgetAction(label="Device:", widget=combo), self) diff --git a/tests/unit_tests/test_modular_toolbar.py b/tests/unit_tests/test_modular_toolbar.py index 17440232..f9f2951e 100644 --- a/tests/unit_tests/test_modular_toolbar.py +++ b/tests/unit_tests/test_modular_toolbar.py @@ -1,21 +1,21 @@ from typing import Literal import pytest -from qtpy.QtCore import QPoint, Qt -from qtpy.QtGui import QContextMenuEvent -from qtpy.QtWidgets import QComboBox, QLabel, QMenu, QToolButton, QWidget +from qtpy.QtCore import Qt +from qtpy.QtWidgets import QComboBox, QLabel, QToolButton, QWidget from bec_widgets.qt_utils.toolbar import ( DeviceSelectionAction, ExpandableMenuAction, IconAction, + LongPressToolButton, MaterialIconAction, ModularToolBar, SeparatorAction, + SwitchableToolBarAction, ToolbarBundle, WidgetAction, ) -from tests.unit_tests.conftest import create_widget @pytest.fixture @@ -89,6 +89,20 @@ def expandable_menu_action(): ) +@pytest.fixture +def switchable_toolbar_action(): + """Fixture to create a switchable toolbar action with two MaterialIconActions.""" + action1 = MaterialIconAction(icon_name="counter_1", tooltip="Action 1", checkable=True) + action2 = MaterialIconAction(icon_name="counter_2", tooltip="Action 2", checkable=True) + switchable = SwitchableToolBarAction( + actions={"action1": action1, "action2": action2}, + initial_action="action1", + tooltip="Switchable Action", + checkable=True, + ) + return switchable + + def test_initialization(toolbar_fixture): """Test that ModularToolBar initializes correctly with different orientations.""" toolbar = toolbar_fixture @@ -426,3 +440,76 @@ def test_add_action_to_bundle(toolbar_fixture, dummy_widget, material_icon_actio # # menu.actions()[0].trigger() # Trigger the first action to close the menu # toolbar.close() + + +def test_switchable_toolbar_action_add(toolbar_fixture, dummy_widget, switchable_toolbar_action): + """Test that a switchable toolbar action can be added to the toolbar correctly.""" + toolbar = toolbar_fixture + toolbar.add_action("switch_action", switchable_toolbar_action, dummy_widget) + + # Verify the action was added correctly + assert "switch_action" in toolbar.widgets + assert toolbar.widgets["switch_action"] == switchable_toolbar_action + + # Verify the button is present and is the correct type + button = switchable_toolbar_action.main_button + assert isinstance(button, LongPressToolButton) + + # Verify initial state + assert switchable_toolbar_action.current_key == "action1" + assert button.toolTip() == "Action 1" + + +def test_switchable_toolbar_action_switching( + toolbar_fixture, dummy_widget, switchable_toolbar_action, qtbot +): + toolbar = toolbar_fixture + toolbar.add_action("switch_action", switchable_toolbar_action, dummy_widget) + # Verify initial state is set to action1 + assert switchable_toolbar_action.current_key == "action1" + assert switchable_toolbar_action.main_button.toolTip() == "Action 1" + # Access the dropdown menu from the main button + menu = switchable_toolbar_action.main_button.menu() + assert menu is not None + # Find the QAction corresponding to "Action 2" + action_for_2 = None + for act in menu.actions(): + if act.text() == "Action 2": + action_for_2 = act + break + assert action_for_2 is not None, "Menu action for 'Action 2' not found." + # Trigger the QAction to switch to action2 + action_for_2.trigger() + qtbot.wait(100) + # Verify that the switchable action has updated its state + assert switchable_toolbar_action.current_key == "action2" + assert switchable_toolbar_action.main_button.toolTip() == "Action 2" + + +# FIXME test is stucking CI, works locally +# def test_long_pressbutton(toolbar_fixture, dummy_widget, switchable_toolbar_action, qtbot): +# toolbar = toolbar_fixture +# toolbar.add_action("switch_action", switchable_toolbar_action, dummy_widget) +# +# # Verify the button is a LongPressToolButton +# button = switchable_toolbar_action.main_button +# assert isinstance(button, LongPressToolButton) +# +# # Override showMenu() to record when it is called. +# call_flag = [] +# original_showMenu = button.showMenu +# +# # had to put some fake menu, we cannot call .isVisible at CI +# def fake_showMenu(): +# call_flag.append(True) +# original_showMenu() +# +# button.showMenu = fake_showMenu +# +# # Simulate a long press (exceeding the threshold, default 500ms). +# qtbot.mousePress(button, Qt.LeftButton) +# qtbot.wait(600) # wait longer than long_press_threshold +# qtbot.mouseRelease(button, Qt.LeftButton) +# +# # Verify that fake_showMenu() was called. +# assert call_flag, "Long press did not trigger showMenu() as expected."