mirror of
https://github.com/bec-project/bec_widgets.git
synced 2025-07-13 19:21: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 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)
|
||||
|
@ -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."
|
||||
|
Reference in New Issue
Block a user