mirror of
https://github.com/bec-project/bec_widgets.git
synced 2025-07-14 03:31:50 +02:00
feat(toolbar): SwitchableToolBarButton
This commit is contained in:
@ -8,7 +8,7 @@ from collections import defaultdict
|
|||||||
from typing import Dict, List, Literal, Tuple
|
from typing import Dict, List, Literal, Tuple
|
||||||
|
|
||||||
from bec_qthemes._icon.material_icons import material_icon
|
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.QtGui import QAction, QColor, QIcon
|
||||||
from qtpy.QtWidgets import (
|
from qtpy.QtWidgets import (
|
||||||
QApplication,
|
QApplication,
|
||||||
@ -21,6 +21,7 @@ from qtpy.QtWidgets import (
|
|||||||
QStyle,
|
QStyle,
|
||||||
QToolBar,
|
QToolBar,
|
||||||
QToolButton,
|
QToolButton,
|
||||||
|
QVBoxLayout,
|
||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -32,6 +33,38 @@ MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
|||||||
QApplication.setAttribute(Qt.AA_DontShowIconsInMenus, False)
|
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):
|
class ToolBarAction(ABC):
|
||||||
"""
|
"""
|
||||||
Abstract base class for toolbar actions.
|
Abstract base class for toolbar actions.
|
||||||
@ -192,6 +225,86 @@ class DeviceSelectionAction(ToolBarAction):
|
|||||||
self.device_combobox.setStyleSheet(f"QComboBox {{ background-color: {color}; }}")
|
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):
|
class WidgetAction(ToolBarAction):
|
||||||
"""
|
"""
|
||||||
Action for adding any widget to the toolbar.
|
Action for adding any widget to the toolbar.
|
||||||
@ -677,10 +790,20 @@ class MainWindow(QMainWindow): # pragma: no cover
|
|||||||
self.setWindowTitle("Toolbar / ToolbarBundle Demo")
|
self.setWindowTitle("Toolbar / ToolbarBundle Demo")
|
||||||
self.central_widget = QWidget()
|
self.central_widget = QWidget()
|
||||||
self.setCentralWidget(self.central_widget)
|
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.toolbar = ModularToolBar(parent=self, target_widget=self)
|
||||||
self.addToolBar(self.toolbar)
|
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(
|
home_action = MaterialIconAction(
|
||||||
icon_name="home", tooltip="Home", checkable=True, parent=self
|
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)
|
self.toolbar.add_bundle(main_actions_bundle, target_widget=self)
|
||||||
|
|
||||||
search_action = MaterialIconAction(
|
search_action = MaterialIconAction(
|
||||||
icon_name="search", tooltip="Search", checkable=True, parent=self
|
icon_name="search", tooltip="Search", checkable=False, parent=self
|
||||||
)
|
)
|
||||||
help_action = MaterialIconAction(
|
help_action = MaterialIconAction(
|
||||||
icon_name="help", tooltip="Help", checkable=True, parent=self
|
icon_name="help", tooltip="Help", checkable=False, parent=self
|
||||||
)
|
)
|
||||||
second_bundle = ToolbarBundle(
|
second_bundle = ToolbarBundle(
|
||||||
bundle_id="secondary_actions",
|
bundle_id="secondary_actions",
|
||||||
@ -718,6 +841,8 @@ class MainWindow(QMainWindow): # pragma: no cover
|
|||||||
self.toolbar.add_action_to_bundle(
|
self.toolbar.add_action_to_bundle(
|
||||||
"main_actions", "new_action", new_action, target_widget=self
|
"main_actions", "new_action", new_action, target_widget=self
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def add_menus(self):
|
||||||
menu_material_actions = {
|
menu_material_actions = {
|
||||||
"mat1": MaterialIconAction(
|
"mat1": MaterialIconAction(
|
||||||
icon_name="home", tooltip="Material Home", checkable=True, parent=self
|
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("material_menu", expandable_menu_material, self)
|
||||||
self.toolbar.add_action("qt_menu", expandable_menu_qt, 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 = QComboBox()
|
||||||
combo.addItems(["Option 1", "Option 2", "Option 3"])
|
combo.addItems(["Option 1", "Option 2", "Option 3"])
|
||||||
self.toolbar.add_action("device_combo", WidgetAction(label="Device:", widget=combo), self)
|
self.toolbar.add_action("device_combo", WidgetAction(label="Device:", widget=combo), self)
|
||||||
|
@ -1,21 +1,21 @@
|
|||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from qtpy.QtCore import QPoint, Qt
|
from qtpy.QtCore import Qt
|
||||||
from qtpy.QtGui import QContextMenuEvent
|
from qtpy.QtWidgets import QComboBox, QLabel, QToolButton, QWidget
|
||||||
from qtpy.QtWidgets import QComboBox, QLabel, QMenu, QToolButton, QWidget
|
|
||||||
|
|
||||||
from bec_widgets.qt_utils.toolbar import (
|
from bec_widgets.qt_utils.toolbar import (
|
||||||
DeviceSelectionAction,
|
DeviceSelectionAction,
|
||||||
ExpandableMenuAction,
|
ExpandableMenuAction,
|
||||||
IconAction,
|
IconAction,
|
||||||
|
LongPressToolButton,
|
||||||
MaterialIconAction,
|
MaterialIconAction,
|
||||||
ModularToolBar,
|
ModularToolBar,
|
||||||
SeparatorAction,
|
SeparatorAction,
|
||||||
|
SwitchableToolBarAction,
|
||||||
ToolbarBundle,
|
ToolbarBundle,
|
||||||
WidgetAction,
|
WidgetAction,
|
||||||
)
|
)
|
||||||
from tests.unit_tests.conftest import create_widget
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@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):
|
def test_initialization(toolbar_fixture):
|
||||||
"""Test that ModularToolBar initializes correctly with different orientations."""
|
"""Test that ModularToolBar initializes correctly with different orientations."""
|
||||||
toolbar = toolbar_fixture
|
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
|
# menu.actions()[0].trigger() # Trigger the first action to close the menu
|
||||||
# toolbar.close()
|
# 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."
|
||||||
|
Reference in New Issue
Block a user