mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-03-05 00:12:49 +01:00
feat(main_app): main app with interactive app switcher
This commit is contained in:
189
bec_widgets/applications/main_app.py
Normal file
189
bec_widgets/applications/main_app.py
Normal file
@@ -0,0 +1,189 @@
|
||||
from qtpy.QtWidgets import QApplication, QHBoxLayout, QStackedWidget, QWidget
|
||||
|
||||
from bec_widgets.applications.navigation_centre.reveal_animator import ANIMATION_DURATION
|
||||
from bec_widgets.applications.navigation_centre.side_bar import SideBar
|
||||
from bec_widgets.applications.navigation_centre.side_bar_components import NavigationItem
|
||||
from bec_widgets.applications.views.view import ViewBase, WaveformViewInline, WaveformViewPopup
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea
|
||||
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
|
||||
|
||||
|
||||
class BECMainApp(BECMainWindow):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
*args,
|
||||
anim_duration: int = ANIMATION_DURATION,
|
||||
show_examples: bool = False,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(parent=parent, *args, **kwargs)
|
||||
self._show_examples = bool(show_examples)
|
||||
|
||||
# --- Compose central UI (sidebar + stack)
|
||||
self.sidebar = SideBar(parent=self, anim_duration=anim_duration)
|
||||
self.stack = QStackedWidget(self)
|
||||
|
||||
container = QWidget(self)
|
||||
layout = QHBoxLayout(container)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
layout.addWidget(self.sidebar, 0)
|
||||
layout.addWidget(self.stack, 1)
|
||||
self.setCentralWidget(container)
|
||||
|
||||
# Mapping for view switching
|
||||
self._view_index: dict[str, int] = {}
|
||||
self._current_view_id: str | None = None
|
||||
self.sidebar.view_selected.connect(self._on_view_selected)
|
||||
|
||||
self._add_views()
|
||||
|
||||
def _add_views(self):
|
||||
self.add_section("BEC Applications", "bec_apps")
|
||||
self.ads = AdvancedDockArea(self)
|
||||
|
||||
self.add_view(
|
||||
icon="widgets", title="Dock Area", id="dock_area", widget=self.ads, mini_text="Docks"
|
||||
)
|
||||
|
||||
if self._show_examples:
|
||||
self.add_section("Examples", "examples")
|
||||
waveform_view_popup = WaveformViewPopup(
|
||||
parent=self, id="waveform_view_popup", title="Waveform Plot"
|
||||
)
|
||||
waveform_view_stack = WaveformViewInline(
|
||||
parent=self, id="waveform_view_stack", title="Waveform Plot"
|
||||
)
|
||||
|
||||
self.add_view(
|
||||
icon="show_chart",
|
||||
title="Waveform With Popup",
|
||||
id="waveform_popup",
|
||||
widget=waveform_view_popup,
|
||||
mini_text="Popup",
|
||||
)
|
||||
self.add_view(
|
||||
icon="show_chart",
|
||||
title="Waveform InLine Stack",
|
||||
id="waveform_stack",
|
||||
widget=waveform_view_stack,
|
||||
mini_text="Stack",
|
||||
)
|
||||
|
||||
self.set_current("dock_area")
|
||||
self.sidebar.add_dark_mode_item()
|
||||
|
||||
# --- Public API ------------------------------------------------------
|
||||
def add_section(self, title: str, id: str, position: int | None = None):
|
||||
return self.sidebar.add_section(title, id, position)
|
||||
|
||||
def add_separator(self):
|
||||
return self.sidebar.add_separator()
|
||||
|
||||
def add_dark_mode_item(self, id: str = "dark_mode", position: int | None = None):
|
||||
return self.sidebar.add_dark_mode_item(id=id, position=position)
|
||||
|
||||
def add_view(
|
||||
self,
|
||||
*,
|
||||
icon: str,
|
||||
title: str,
|
||||
id: str,
|
||||
widget: QWidget,
|
||||
mini_text: str | None = None,
|
||||
position: int | None = None,
|
||||
from_top: bool = True,
|
||||
toggleable: bool = True,
|
||||
exclusive: bool = True,
|
||||
) -> NavigationItem:
|
||||
"""
|
||||
Register a view in the stack and create a matching nav item in the sidebar.
|
||||
|
||||
Args:
|
||||
icon(str): Icon name for the nav item.
|
||||
title(str): Title for the nav item.
|
||||
id(str): Unique ID for the view/item.
|
||||
widget(QWidget): The widget to add to the stack.
|
||||
mini_text(str, optional): Short text for the nav item when sidebar is collapsed.
|
||||
position(int, optional): Position to insert the nav item.
|
||||
from_top(bool, optional): Whether to count position from the top or bottom.
|
||||
toggleable(bool, optional): Whether the nav item is toggleable.
|
||||
exclusive(bool, optional): Whether the nav item is exclusive.
|
||||
|
||||
Returns:
|
||||
NavigationItem: The created navigation item.
|
||||
|
||||
|
||||
"""
|
||||
item = self.sidebar.add_item(
|
||||
icon=icon,
|
||||
title=title,
|
||||
id=id,
|
||||
mini_text=mini_text,
|
||||
position=position,
|
||||
from_top=from_top,
|
||||
toggleable=toggleable,
|
||||
exclusive=exclusive,
|
||||
)
|
||||
# Wrap plain widgets into a ViewBase so enter/exit hooks are available
|
||||
if isinstance(widget, ViewBase):
|
||||
view_widget = widget
|
||||
else:
|
||||
view_widget = ViewBase(content=widget, parent=self, id=id, title=title)
|
||||
|
||||
idx = self.stack.addWidget(view_widget)
|
||||
self._view_index[id] = idx
|
||||
return item
|
||||
|
||||
def set_current(self, id: str) -> None:
|
||||
if id in self._view_index:
|
||||
self.sidebar.activate_item(id)
|
||||
|
||||
# Internal: route sidebar selection to the stack
|
||||
def _on_view_selected(self, vid: str) -> None:
|
||||
# Determine current view
|
||||
current_index = self.stack.currentIndex()
|
||||
current_view = (
|
||||
self.stack.widget(current_index) if 0 <= current_index < self.stack.count() else None
|
||||
)
|
||||
|
||||
# Ask current view whether we may leave
|
||||
if current_view is not None and hasattr(current_view, "on_exit"):
|
||||
may_leave = current_view.on_exit()
|
||||
if may_leave is False:
|
||||
# Veto: restore previous highlight without re-emitting selection
|
||||
if self._current_view_id is not None:
|
||||
self.sidebar.activate_item(self._current_view_id, emit_signal=False)
|
||||
return
|
||||
|
||||
# Proceed with switch
|
||||
idx = self._view_index.get(vid)
|
||||
if idx is None or not (0 <= idx < self.stack.count()):
|
||||
return
|
||||
self.stack.setCurrentIndex(idx)
|
||||
new_view = self.stack.widget(idx)
|
||||
self._current_view_id = vid
|
||||
if hasattr(new_view, "on_enter"):
|
||||
new_view.on_enter()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
parser = argparse.ArgumentParser(description="BEC Main Application")
|
||||
parser.add_argument(
|
||||
"--examples", action="store_true", help="Show the Examples section with waveform demo views"
|
||||
)
|
||||
# Let Qt consume the remaining args
|
||||
args, qt_args = parser.parse_known_args(sys.argv[1:])
|
||||
|
||||
app = QApplication([sys.argv[0], *qt_args])
|
||||
apply_theme("dark")
|
||||
w = BECMainApp(show_examples=args.examples)
|
||||
w.show()
|
||||
|
||||
sys.exit(app.exec())
|
||||
114
bec_widgets/applications/navigation_centre/reveal_animator.py
Normal file
114
bec_widgets/applications/navigation_centre/reveal_animator.py
Normal file
@@ -0,0 +1,114 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from qtpy.QtCore import QEasingCurve, QParallelAnimationGroup, QPropertyAnimation
|
||||
from qtpy.QtWidgets import QGraphicsOpacityEffect, QWidget
|
||||
|
||||
ANIMATION_DURATION = 500 # ms
|
||||
|
||||
|
||||
class RevealAnimator:
|
||||
"""Animate reveal/hide for a single widget using opacity + max W/H.
|
||||
|
||||
This keeps the widget always visible to avoid jitter from setVisible().
|
||||
Collapsed state: opacity=0, maxW=0, maxH=0.
|
||||
Expanded state: opacity=1, maxW=sizeHint.width(), maxH=sizeHint.height().
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
widget: QWidget,
|
||||
duration: int = ANIMATION_DURATION,
|
||||
easing: QEasingCurve.Type = QEasingCurve.InOutCubic,
|
||||
initially_revealed: bool = False,
|
||||
*,
|
||||
animate_opacity: bool = True,
|
||||
animate_width: bool = True,
|
||||
animate_height: bool = True,
|
||||
):
|
||||
self.widget = widget
|
||||
self.animate_opacity = animate_opacity
|
||||
self.animate_width = animate_width
|
||||
self.animate_height = animate_height
|
||||
# Opacity effect
|
||||
self.fx = QGraphicsOpacityEffect(widget)
|
||||
widget.setGraphicsEffect(self.fx)
|
||||
# Animations
|
||||
self.opacity_anim = (
|
||||
QPropertyAnimation(self.fx, b"opacity") if self.animate_opacity else None
|
||||
)
|
||||
self.width_anim = (
|
||||
QPropertyAnimation(widget, b"maximumWidth") if self.animate_width else None
|
||||
)
|
||||
self.height_anim = (
|
||||
QPropertyAnimation(widget, b"maximumHeight") if self.animate_height else None
|
||||
)
|
||||
for anim in (self.opacity_anim, self.width_anim, self.height_anim):
|
||||
if anim is not None:
|
||||
anim.setDuration(duration)
|
||||
anim.setEasingCurve(easing)
|
||||
# Initialize to requested state
|
||||
self.set_immediate(initially_revealed)
|
||||
|
||||
def _natural_sizes(self) -> tuple[int, int]:
|
||||
sh = self.widget.sizeHint()
|
||||
w = max(sh.width(), 1)
|
||||
h = max(sh.height(), 1)
|
||||
return w, h
|
||||
|
||||
def set_immediate(self, revealed: bool):
|
||||
"""
|
||||
Immediately set the widget to the target revealed/collapsed state.
|
||||
|
||||
Args:
|
||||
revealed(bool): True to reveal, False to collapse.
|
||||
"""
|
||||
w, h = self._natural_sizes()
|
||||
if self.animate_opacity:
|
||||
self.fx.setOpacity(1.0 if revealed else 0.0)
|
||||
if self.animate_width:
|
||||
self.widget.setMaximumWidth(w if revealed else 0)
|
||||
if self.animate_height:
|
||||
self.widget.setMaximumHeight(h if revealed else 0)
|
||||
|
||||
def setup(self, reveal: bool):
|
||||
"""
|
||||
Prepare animations to transition to the target revealed/collapsed state.
|
||||
|
||||
Args:
|
||||
reveal(bool): True to reveal, False to collapse.
|
||||
"""
|
||||
# Prepare animations from current state to target
|
||||
target_w, target_h = self._natural_sizes()
|
||||
if self.opacity_anim is not None:
|
||||
self.opacity_anim.setStartValue(self.fx.opacity())
|
||||
self.opacity_anim.setEndValue(1.0 if reveal else 0.0)
|
||||
if self.width_anim is not None:
|
||||
self.width_anim.setStartValue(self.widget.maximumWidth())
|
||||
self.width_anim.setEndValue(target_w if reveal else 0)
|
||||
if self.height_anim is not None:
|
||||
self.height_anim.setStartValue(self.widget.maximumHeight())
|
||||
self.height_anim.setEndValue(target_h if reveal else 0)
|
||||
|
||||
def add_to_group(self, group: QParallelAnimationGroup):
|
||||
"""
|
||||
Add the prepared animations to the given animation group.
|
||||
|
||||
Args:
|
||||
group(QParallelAnimationGroup): The animation group to add to.
|
||||
"""
|
||||
if self.opacity_anim is not None:
|
||||
group.addAnimation(self.opacity_anim)
|
||||
if self.width_anim is not None:
|
||||
group.addAnimation(self.width_anim)
|
||||
if self.height_anim is not None:
|
||||
group.addAnimation(self.height_anim)
|
||||
|
||||
def animations(self):
|
||||
"""
|
||||
Get a list of all animations (non-None) for adding to a group.
|
||||
"""
|
||||
return [
|
||||
anim
|
||||
for anim in (self.opacity_anim, self.height_anim, self.width_anim)
|
||||
if anim is not None
|
||||
]
|
||||
357
bec_widgets/applications/navigation_centre/side_bar.py
Normal file
357
bec_widgets/applications/navigation_centre/side_bar.py
Normal file
@@ -0,0 +1,357 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy import QtWidgets
|
||||
from qtpy.QtCore import QEasingCurve, QParallelAnimationGroup, QPropertyAnimation, Qt, Signal
|
||||
from qtpy.QtWidgets import (
|
||||
QGraphicsOpacityEffect,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QScrollArea,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets import SafeProperty, SafeSlot
|
||||
from bec_widgets.applications.navigation_centre.reveal_animator import ANIMATION_DURATION
|
||||
from bec_widgets.applications.navigation_centre.side_bar_components import (
|
||||
DarkModeNavItem,
|
||||
NavigationItem,
|
||||
SectionHeader,
|
||||
SideBarSeparator,
|
||||
)
|
||||
|
||||
|
||||
class SideBar(QScrollArea):
|
||||
view_selected = Signal(str)
|
||||
toggled = Signal(bool)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
title: str = "Control Panel",
|
||||
collapsed_width: int = 56,
|
||||
expanded_width: int = 250,
|
||||
anim_duration: int = ANIMATION_DURATION,
|
||||
):
|
||||
super().__init__(parent=parent)
|
||||
self.setObjectName("SideBar")
|
||||
|
||||
# private attributes
|
||||
self._is_expanded = False
|
||||
self._collapsed_width = collapsed_width
|
||||
self._expanded_width = expanded_width
|
||||
self._anim_duration = anim_duration
|
||||
|
||||
# containers
|
||||
self.components = {}
|
||||
self._item_opts: dict[str, dict] = {}
|
||||
|
||||
# Scroll area properties
|
||||
self.setWidgetResizable(True)
|
||||
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
self.setFrameShape(QtWidgets.QFrame.NoFrame)
|
||||
self.setFixedWidth(self._collapsed_width)
|
||||
|
||||
# Content widget holding buttons for switching views
|
||||
self.content = QWidget(self)
|
||||
self.content_layout = QVBoxLayout(self.content)
|
||||
self.content_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.content_layout.setSpacing(4)
|
||||
self.setWidget(self.content)
|
||||
|
||||
# Track active navigation item
|
||||
self._active_id = None
|
||||
|
||||
# Top row with title and toggle button
|
||||
self.toggle_row = QWidget(self)
|
||||
self.toggle_row_layout = QHBoxLayout(self.toggle_row)
|
||||
|
||||
self.title_label = QLabel(title, self)
|
||||
self.title_label.setObjectName("TopTitle")
|
||||
self.title_label.setStyleSheet("font-weight: 600;")
|
||||
self.title_fx = QGraphicsOpacityEffect(self.title_label)
|
||||
self.title_label.setGraphicsEffect(self.title_fx)
|
||||
self.title_fx.setOpacity(0.0)
|
||||
self.title_label.setVisible(False) # TODO dirty trick to avoid layout shift
|
||||
|
||||
self.toggle = QToolButton(self)
|
||||
self.toggle.setCheckable(False)
|
||||
self.toggle.setIcon(material_icon("keyboard_arrow_right", convert_to_pixmap=False))
|
||||
self.toggle.clicked.connect(self.on_expand)
|
||||
|
||||
self.toggle_row_layout.addWidget(self.title_label, 1, Qt.AlignLeft | Qt.AlignVCenter)
|
||||
self.toggle_row_layout.addWidget(self.toggle, 1, Qt.AlignHCenter | Qt.AlignVCenter)
|
||||
|
||||
# To push the content up always
|
||||
self._bottom_spacer = QtWidgets.QSpacerItem(
|
||||
0, 0, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding
|
||||
)
|
||||
|
||||
# Add core widgets to layout
|
||||
self.content_layout.addWidget(self.toggle_row)
|
||||
self.content_layout.addItem(self._bottom_spacer)
|
||||
|
||||
# Animations
|
||||
self.width_anim = QPropertyAnimation(self, b"bar_width")
|
||||
self.width_anim.setDuration(self._anim_duration)
|
||||
self.width_anim.setEasingCurve(QEasingCurve.InOutCubic)
|
||||
|
||||
self.title_anim = QPropertyAnimation(self.title_fx, b"opacity")
|
||||
self.title_anim.setDuration(self._anim_duration)
|
||||
self.title_anim.setEasingCurve(QEasingCurve.InOutCubic)
|
||||
|
||||
self.group = QParallelAnimationGroup(self)
|
||||
self.group.addAnimation(self.width_anim)
|
||||
self.group.addAnimation(self.title_anim)
|
||||
self.group.finished.connect(self._on_anim_finished)
|
||||
|
||||
app = QtWidgets.QApplication.instance()
|
||||
if app is not None and hasattr(app, "theme") and hasattr(app.theme, "theme_changed"):
|
||||
app.theme.theme_changed.connect(self._on_theme_changed)
|
||||
|
||||
@SafeProperty(int)
|
||||
def bar_width(self) -> int:
|
||||
"""
|
||||
Get the current width of the side bar.
|
||||
|
||||
Returns:
|
||||
int: The current width of the side bar.
|
||||
"""
|
||||
return self.width()
|
||||
|
||||
@bar_width.setter
|
||||
def bar_width(self, width: int):
|
||||
"""
|
||||
Set the width of the side bar.
|
||||
|
||||
Args:
|
||||
width(int): The new width of the side bar.
|
||||
"""
|
||||
self.setFixedWidth(width)
|
||||
|
||||
@SafeProperty(bool)
|
||||
def is_expanded(self) -> bool:
|
||||
"""
|
||||
Check if the side bar is expanded.
|
||||
|
||||
Returns:
|
||||
bool: True if the side bar is expanded, False otherwise.
|
||||
"""
|
||||
return self._is_expanded
|
||||
|
||||
@SafeSlot()
|
||||
@SafeSlot(bool)
|
||||
def on_expand(self):
|
||||
"""
|
||||
Toggle the expansion state of the side bar.
|
||||
"""
|
||||
self._is_expanded = not self._is_expanded
|
||||
self.toggle.setIcon(
|
||||
material_icon(
|
||||
"keyboard_arrow_left" if self._is_expanded else "keyboard_arrow_right",
|
||||
convert_to_pixmap=False,
|
||||
)
|
||||
)
|
||||
|
||||
if self._is_expanded:
|
||||
self.toggle_row_layout.setAlignment(self.toggle, Qt.AlignRight | Qt.AlignVCenter)
|
||||
|
||||
self.group.stop()
|
||||
# Setting limits for animations of the side bar
|
||||
self.width_anim.setStartValue(self.width())
|
||||
self.width_anim.setEndValue(
|
||||
self._expanded_width if self._is_expanded else self._collapsed_width
|
||||
)
|
||||
self.title_anim.setStartValue(self.title_fx.opacity())
|
||||
self.title_anim.setEndValue(1.0 if self._is_expanded else 0.0)
|
||||
|
||||
# Setting limits for animations of the components
|
||||
for comp in self.components.values():
|
||||
if hasattr(comp, "setup_animations"):
|
||||
comp.setup_animations(self._is_expanded)
|
||||
|
||||
self.group.start()
|
||||
if self._is_expanded:
|
||||
# TODO do not like this trick, but it is what it is for now
|
||||
self.title_label.setVisible(self._is_expanded)
|
||||
for comp in self.components.values():
|
||||
if hasattr(comp, "set_visible"):
|
||||
comp.set_visible(self._is_expanded)
|
||||
self.toggled.emit(self._is_expanded)
|
||||
|
||||
@SafeSlot()
|
||||
def _on_anim_finished(self):
|
||||
if not self._is_expanded:
|
||||
self.toggle_row_layout.setAlignment(self.toggle, Qt.AlignHCenter | Qt.AlignVCenter)
|
||||
# TODO do not like this trick, but it is what it is for now
|
||||
self.title_label.setVisible(self._is_expanded)
|
||||
for comp in self.components.values():
|
||||
if hasattr(comp, "set_visible"):
|
||||
comp.set_visible(self._is_expanded)
|
||||
|
||||
@SafeSlot(str)
|
||||
def _on_theme_changed(self, theme_name: str):
|
||||
# Refresh toggle arrow icon so it picks up the new theme
|
||||
self.toggle.setIcon(
|
||||
material_icon(
|
||||
"keyboard_arrow_left" if self._is_expanded else "keyboard_arrow_right",
|
||||
convert_to_pixmap=False,
|
||||
)
|
||||
)
|
||||
# Refresh each component that supports it
|
||||
for comp in self.components.values():
|
||||
if hasattr(comp, "refresh_theme"):
|
||||
comp.refresh_theme()
|
||||
else:
|
||||
comp.style().unpolish(comp)
|
||||
comp.style().polish(comp)
|
||||
comp.update()
|
||||
self.style().unpolish(self)
|
||||
self.style().polish(self)
|
||||
self.update()
|
||||
|
||||
def add_section(self, title: str, id: str, position: int | None = None) -> SectionHeader:
|
||||
"""
|
||||
Add a section header to the side bar.
|
||||
|
||||
Args:
|
||||
title(str): The title of the section.
|
||||
id(str): Unique ID for the section.
|
||||
position(int, optional): Position to insert the section header.
|
||||
|
||||
Returns:
|
||||
SectionHeader: The created section header.
|
||||
|
||||
"""
|
||||
header = SectionHeader(self, title, anim_duration=self._anim_duration)
|
||||
position = position if position is not None else self.content_layout.count() - 1
|
||||
self.content_layout.insertWidget(position, header)
|
||||
for anim in header.animations:
|
||||
self.group.addAnimation(anim)
|
||||
self.components[id] = header
|
||||
return header
|
||||
|
||||
def add_separator(
|
||||
self, *, from_top: bool = True, position: int | None = None
|
||||
) -> SideBarSeparator:
|
||||
"""
|
||||
Add a separator line to the side bar. Separators are treated like regular
|
||||
items; you can place multiple separators anywhere using `from_top` and `position`.
|
||||
"""
|
||||
line = SideBarSeparator(self)
|
||||
line.setStyleSheet("margin:12px;")
|
||||
self._insert_nav_item(line, from_top=from_top, position=position)
|
||||
return line
|
||||
|
||||
def add_item(
|
||||
self,
|
||||
icon: str,
|
||||
title: str,
|
||||
id: str,
|
||||
mini_text: str | None = None,
|
||||
position: int | None = None,
|
||||
*,
|
||||
from_top: bool = True,
|
||||
toggleable: bool = True,
|
||||
exclusive: bool = True,
|
||||
) -> NavigationItem:
|
||||
"""
|
||||
Add a navigation item to the side bar.
|
||||
|
||||
Args:
|
||||
icon(str): Icon name for the nav item.
|
||||
title(str): Title for the nav item.
|
||||
id(str): Unique ID for the nav item.
|
||||
mini_text(str, optional): Short text for the nav item when sidebar is collapsed.
|
||||
position(int, optional): Position to insert the nav item.
|
||||
from_top(bool, optional): Whether to count position from the top or bottom.
|
||||
toggleable(bool, optional): Whether the nav item is toggleable.
|
||||
exclusive(bool, optional): Whether the nav item is exclusive.
|
||||
|
||||
Returns:
|
||||
NavigationItem: The created navigation item.
|
||||
"""
|
||||
item = NavigationItem(
|
||||
parent=self,
|
||||
title=title,
|
||||
icon_name=icon,
|
||||
mini_text=mini_text,
|
||||
toggleable=toggleable,
|
||||
exclusive=exclusive,
|
||||
anim_duration=self._anim_duration,
|
||||
)
|
||||
self._insert_nav_item(item, from_top=from_top, position=position)
|
||||
for anim in item.build_animations():
|
||||
self.group.addAnimation(anim)
|
||||
self.components[id] = item
|
||||
# Connect activation to activation logic, passing id unchanged
|
||||
item.activated.connect(lambda id=id: self.activate_item(id))
|
||||
return item
|
||||
|
||||
def activate_item(self, target_id: str, *, emit_signal: bool = True):
|
||||
target = self.components.get(target_id)
|
||||
if target is None:
|
||||
return
|
||||
# Non-toggleable acts like an action: do not change any toggled states
|
||||
if hasattr(target, "toggleable") and not target.toggleable:
|
||||
self._active_id = target_id
|
||||
if emit_signal:
|
||||
self.view_selected.emit(target_id)
|
||||
return
|
||||
|
||||
is_exclusive = getattr(target, "exclusive", True)
|
||||
if is_exclusive:
|
||||
# Radio-like behavior among exclusive items only
|
||||
for comp_id, comp in self.components.items():
|
||||
if not isinstance(comp, NavigationItem):
|
||||
continue
|
||||
if comp is target:
|
||||
comp.set_active(True)
|
||||
else:
|
||||
# Only untoggle other items that are also exclusive
|
||||
if getattr(comp, "exclusive", True):
|
||||
comp.set_active(False)
|
||||
# Leave non-exclusive items as they are
|
||||
else:
|
||||
# Non-exclusive toggles independently
|
||||
target.set_active(not target.is_active())
|
||||
|
||||
self._active_id = target_id
|
||||
if emit_signal:
|
||||
self.view_selected.emit(target_id)
|
||||
|
||||
def add_dark_mode_item(
|
||||
self, id: str = "dark_mode", position: int | None = None
|
||||
) -> DarkModeNavItem:
|
||||
"""
|
||||
Add a dark mode toggle item to the side bar.
|
||||
|
||||
Args:
|
||||
id(str): Unique ID for the dark mode item.
|
||||
position(int, optional): Position to insert the dark mode item.
|
||||
|
||||
Returns:
|
||||
DarkModeNavItem: The created dark mode navigation item.
|
||||
"""
|
||||
item = DarkModeNavItem(parent=self, id=id, anim_duration=self._anim_duration)
|
||||
# compute bottom insertion point (same semantics as from_top=False)
|
||||
self._insert_nav_item(item, from_top=False, position=position)
|
||||
for anim in item.build_animations():
|
||||
self.group.addAnimation(anim)
|
||||
self.components[id] = item
|
||||
item.activated.connect(lambda id=id: self.activate_item(id))
|
||||
return item
|
||||
|
||||
def _insert_nav_item(
|
||||
self, item: QWidget, *, from_top: bool = True, position: int | None = None
|
||||
):
|
||||
if from_top:
|
||||
base_index = self.content_layout.indexOf(self._bottom_spacer)
|
||||
pos = base_index if position is None else min(base_index, position)
|
||||
else:
|
||||
base = self.content_layout.indexOf(self._bottom_spacer) + 1
|
||||
pos = base if position is None else base + max(0, position)
|
||||
self.content_layout.insertWidget(pos, item)
|
||||
@@ -0,0 +1,372 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy import QtCore
|
||||
from qtpy.QtCore import QEasingCurve, QPropertyAnimation, Qt
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QSizePolicy,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets import SafeProperty
|
||||
from bec_widgets.applications.navigation_centre.reveal_animator import (
|
||||
ANIMATION_DURATION,
|
||||
RevealAnimator,
|
||||
)
|
||||
|
||||
|
||||
def get_on_primary():
|
||||
app = QApplication.instance()
|
||||
if app is not None and hasattr(app, "theme"):
|
||||
return app.theme.color("ON_PRIMARY")
|
||||
return "#FFFFFF"
|
||||
|
||||
|
||||
def get_fg():
|
||||
app = QApplication.instance()
|
||||
if app is not None and hasattr(app, "theme"):
|
||||
return app.theme.color("FG")
|
||||
return "#FFFFFF"
|
||||
|
||||
|
||||
class SideBarSeparator(QFrame):
|
||||
"""A horizontal line separator for use in SideBar."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setObjectName("SideBarSeparator")
|
||||
self.setFrameShape(QFrame.NoFrame)
|
||||
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
self.setFixedHeight(2)
|
||||
self.setProperty("variant", "separator")
|
||||
|
||||
|
||||
class SectionHeader(QWidget):
|
||||
"""A section header with a label and a horizontal line below."""
|
||||
|
||||
def __init__(self, parent=None, text: str = None, anim_duration: int = ANIMATION_DURATION):
|
||||
super().__init__(parent)
|
||||
self.setObjectName("SectionHeader")
|
||||
|
||||
self.lbl = QLabel(text, self)
|
||||
self.lbl.setObjectName("SectionHeaderLabel")
|
||||
self.lbl.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
|
||||
self._reveal = RevealAnimator(self.lbl, duration=anim_duration, initially_revealed=False)
|
||||
|
||||
self.line = SideBarSeparator(self)
|
||||
|
||||
lay = QVBoxLayout(self)
|
||||
# keep your margins/spacing preferences here if needed
|
||||
lay.setContentsMargins(12, 0, 12, 0)
|
||||
lay.setSpacing(6)
|
||||
lay.addWidget(self.lbl)
|
||||
lay.addWidget(self.line)
|
||||
|
||||
self.animations = self.build_animations()
|
||||
|
||||
def build_animations(self) -> list[QPropertyAnimation]:
|
||||
"""
|
||||
Build and return animations for expanding/collapsing the sidebar.
|
||||
|
||||
Returns:
|
||||
list[QPropertyAnimation]: List of animations.
|
||||
"""
|
||||
return self._reveal.animations()
|
||||
|
||||
def setup_animations(self, expanded: bool):
|
||||
"""
|
||||
Setup animations for expanding/collapsing the sidebar.
|
||||
|
||||
Args:
|
||||
expanded(bool): True if the sidebar is expanded, False if collapsed.
|
||||
"""
|
||||
self._reveal.setup(expanded)
|
||||
|
||||
|
||||
class NavigationItem(QWidget):
|
||||
"""A nav tile with an icon + labels and an optional expandable body.
|
||||
Provides animations for collapsed/expanded sidebar states via
|
||||
build_animations()/setup_animations(), similar to SectionHeader.
|
||||
"""
|
||||
|
||||
activated = QtCore.Signal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
*,
|
||||
title: str,
|
||||
icon_name: str,
|
||||
mini_text: str | None = None,
|
||||
toggleable: bool = True,
|
||||
exclusive: bool = True,
|
||||
anim_duration: int = ANIMATION_DURATION,
|
||||
):
|
||||
super().__init__(parent=parent)
|
||||
self.setObjectName("NavigationItem")
|
||||
|
||||
# Private attributes
|
||||
self._title = title
|
||||
self._icon_name = icon_name
|
||||
self._mini_text = mini_text or title
|
||||
self._toggleable = toggleable
|
||||
self._toggled = False
|
||||
self._exclusive = exclusive
|
||||
|
||||
# Main Icon
|
||||
self.icon_btn = QToolButton(self)
|
||||
self.icon_btn.setIcon(material_icon(self._icon_name, filled=False, convert_to_pixmap=False))
|
||||
self.icon_btn.setAutoRaise(True)
|
||||
self._icon_size_collapsed = QtCore.QSize(20, 20)
|
||||
self._icon_size_expanded = QtCore.QSize(26, 26)
|
||||
self.icon_btn.setIconSize(self._icon_size_collapsed)
|
||||
# Remove QToolButton hover/pressed background/outline
|
||||
self.icon_btn.setStyleSheet(
|
||||
"""
|
||||
QToolButton:hover { background: transparent; border: none; }
|
||||
QToolButton:pressed { background: transparent; border: none; }
|
||||
"""
|
||||
)
|
||||
|
||||
# Mini label below icon
|
||||
self.mini_lbl = QLabel(self._mini_text, self)
|
||||
self.mini_lbl.setObjectName("NavMiniLabel")
|
||||
self.mini_lbl.setAlignment(Qt.AlignCenter)
|
||||
self.mini_lbl.setStyleSheet("font-size: 10px;")
|
||||
self.reveal_mini_lbl = RevealAnimator(
|
||||
widget=self.mini_lbl,
|
||||
initially_revealed=True,
|
||||
animate_width=False,
|
||||
duration=anim_duration,
|
||||
)
|
||||
|
||||
# Container for icon + mini label
|
||||
self.mini_icon = QWidget(self)
|
||||
mini_lay = QVBoxLayout(self.mini_icon)
|
||||
mini_lay.setContentsMargins(0, 2, 0, 2)
|
||||
mini_lay.setSpacing(2)
|
||||
mini_lay.addWidget(self.icon_btn, 0, Qt.AlignCenter)
|
||||
mini_lay.addWidget(self.mini_lbl, 0, Qt.AlignCenter)
|
||||
|
||||
# Title label
|
||||
self.title_lbl = QLabel(self._title, self)
|
||||
self.title_lbl.setObjectName("NavTitleLabel")
|
||||
self.title_lbl.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
|
||||
self.title_lbl.setStyleSheet("font-size: 13px;")
|
||||
self.reveal_title_lbl = RevealAnimator(
|
||||
widget=self.title_lbl,
|
||||
initially_revealed=False,
|
||||
animate_height=False,
|
||||
duration=anim_duration,
|
||||
)
|
||||
self.title_lbl.setVisible(False) # TODO dirty trick to avoid layout shift
|
||||
|
||||
lay = QHBoxLayout(self)
|
||||
lay.setContentsMargins(12, 2, 12, 2)
|
||||
lay.setSpacing(6)
|
||||
lay.addWidget(self.mini_icon, 0, Qt.AlignHCenter | Qt.AlignTop)
|
||||
lay.addWidget(self.title_lbl, 1, Qt.AlignLeft | Qt.AlignVCenter)
|
||||
|
||||
self.icon_size_anim = QPropertyAnimation(self.icon_btn, b"iconSize")
|
||||
self.icon_size_anim.setDuration(anim_duration)
|
||||
self.icon_size_anim.setEasingCurve(QEasingCurve.InOutCubic)
|
||||
|
||||
# Connect icon button to emit activation
|
||||
self.icon_btn.clicked.connect(self._emit_activated)
|
||||
self.setMouseTracking(True)
|
||||
self.setAttribute(Qt.WA_StyledBackground, True)
|
||||
|
||||
def is_active(self) -> bool:
|
||||
"""Return whether the item is currently active/selected."""
|
||||
return self.property("toggled") is True
|
||||
|
||||
def build_animations(self) -> list[QPropertyAnimation]:
|
||||
"""
|
||||
Build and return animations for expanding/collapsing the sidebar.
|
||||
|
||||
Returns:
|
||||
list[QPropertyAnimation]: List of animations.
|
||||
"""
|
||||
return (
|
||||
self.reveal_title_lbl.animations()
|
||||
+ self.reveal_mini_lbl.animations()
|
||||
+ [self.icon_size_anim]
|
||||
)
|
||||
|
||||
def setup_animations(self, expanded: bool):
|
||||
"""
|
||||
Setup animations for expanding/collapsing the sidebar.
|
||||
|
||||
Args:
|
||||
expanded(bool): True if the sidebar is expanded, False if collapsed.
|
||||
"""
|
||||
self.reveal_mini_lbl.setup(not expanded)
|
||||
self.reveal_title_lbl.setup(expanded)
|
||||
self.icon_size_anim.setStartValue(self.icon_btn.iconSize())
|
||||
self.icon_size_anim.setEndValue(
|
||||
self._icon_size_expanded if expanded else self._icon_size_collapsed
|
||||
)
|
||||
|
||||
def set_visible(self, visible: bool):
|
||||
"""Set visibility of the title label."""
|
||||
self.title_lbl.setVisible(visible)
|
||||
|
||||
def _emit_activated(self):
|
||||
self.activated.emit()
|
||||
|
||||
def set_active(self, active: bool):
|
||||
"""
|
||||
Set the active/selected state of the item.
|
||||
|
||||
Args:
|
||||
active(bool): True to set active, False to deactivate.
|
||||
"""
|
||||
self.setProperty("toggled", active)
|
||||
self.toggled = active
|
||||
# ensure style refresh
|
||||
self.style().unpolish(self)
|
||||
self.style().polish(self)
|
||||
self.update()
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
self.activated.emit()
|
||||
super().mousePressEvent(event)
|
||||
|
||||
@SafeProperty(bool)
|
||||
def toggleable(self) -> bool:
|
||||
"""
|
||||
Whether the item is toggleable (like a button) or not (like an action).
|
||||
|
||||
Returns:
|
||||
bool: True if toggleable, False otherwise.
|
||||
"""
|
||||
return self._toggleable
|
||||
|
||||
@toggleable.setter
|
||||
def toggleable(self, value: bool):
|
||||
"""
|
||||
Set whether the item is toggleable (like a button) or not (like an action).
|
||||
Args:
|
||||
value(bool): True to make toggleable, False otherwise.
|
||||
"""
|
||||
self._toggleable = bool(value)
|
||||
|
||||
@SafeProperty(bool)
|
||||
def toggled(self) -> bool:
|
||||
"""
|
||||
Whether the item is currently toggled/selected.
|
||||
|
||||
Returns:
|
||||
bool: True if toggled, False otherwise.
|
||||
"""
|
||||
return self._toggled
|
||||
|
||||
@toggled.setter
|
||||
def toggled(self, value: bool):
|
||||
"""
|
||||
Set whether the item is currently toggled/selected.
|
||||
|
||||
Args:
|
||||
value(bool): True to set toggled, False to untoggle.
|
||||
"""
|
||||
self._toggled = value
|
||||
if value:
|
||||
new_icon = material_icon(
|
||||
self._icon_name, filled=True, color=get_on_primary(), convert_to_pixmap=False
|
||||
)
|
||||
else:
|
||||
new_icon = material_icon(
|
||||
self._icon_name, filled=False, color=get_fg(), convert_to_pixmap=False
|
||||
)
|
||||
self.icon_btn.setIcon(new_icon)
|
||||
# Re-polish so QSS applies correct colors to icon/labels
|
||||
for w in (self, self.icon_btn, self.title_lbl, self.mini_lbl):
|
||||
w.style().unpolish(w)
|
||||
w.style().polish(w)
|
||||
w.update()
|
||||
|
||||
@SafeProperty(bool)
|
||||
def exclusive(self) -> bool:
|
||||
"""
|
||||
Whether the item is exclusive in its toggle group.
|
||||
|
||||
Returns:
|
||||
bool: True if exclusive, False otherwise.
|
||||
"""
|
||||
return self._exclusive
|
||||
|
||||
@exclusive.setter
|
||||
def exclusive(self, value: bool):
|
||||
"""
|
||||
Set whether the item is exclusive in its toggle group.
|
||||
|
||||
Args:
|
||||
value(bool): True to make exclusive, False otherwise.
|
||||
"""
|
||||
self._exclusive = bool(value)
|
||||
|
||||
def refresh_theme(self):
|
||||
# Recompute icon/label colors according to current theme and state
|
||||
# Trigger the toggled setter to rebuild the icon with the correct color
|
||||
self.toggled = self._toggled
|
||||
# Ensure QSS-driven text/icon colors refresh
|
||||
for w in (self, self.icon_btn, self.title_lbl, self.mini_lbl):
|
||||
w.style().unpolish(w)
|
||||
w.style().polish(w)
|
||||
w.update()
|
||||
|
||||
|
||||
class DarkModeNavItem(NavigationItem):
|
||||
"""Bottom action item that toggles app theme and updates its icon/text."""
|
||||
|
||||
def __init__(
|
||||
self, parent=None, *, id: str = "dark_mode", anim_duration: int = ANIMATION_DURATION
|
||||
):
|
||||
super().__init__(
|
||||
parent=parent,
|
||||
title="Dark mode",
|
||||
icon_name="dark_mode",
|
||||
mini_text="Dark",
|
||||
toggleable=False, # action-like, no selection highlight changes
|
||||
exclusive=False,
|
||||
anim_duration=anim_duration,
|
||||
)
|
||||
self._id = id
|
||||
self._sync_from_qapp_theme()
|
||||
self.activated.connect(self.toggle_theme)
|
||||
|
||||
def _qapp_dark_enabled(self) -> bool:
|
||||
qapp = QApplication.instance()
|
||||
return bool(getattr(getattr(qapp, "theme", None), "theme", None) == "dark")
|
||||
|
||||
def _sync_from_qapp_theme(self):
|
||||
is_dark = self._qapp_dark_enabled()
|
||||
# Update labels
|
||||
self.title_lbl.setText("Light mode" if is_dark else "Dark mode")
|
||||
self.mini_lbl.setText("Light" if is_dark else "Dark")
|
||||
# Update icon
|
||||
self.icon_btn.setIcon(
|
||||
material_icon("light_mode" if is_dark else "dark_mode", convert_to_pixmap=False)
|
||||
)
|
||||
|
||||
def refresh_theme(self):
|
||||
self._sync_from_qapp_theme()
|
||||
for w in (self, self.icon_btn, self.title_lbl, self.mini_lbl):
|
||||
w.style().unpolish(w)
|
||||
w.style().polish(w)
|
||||
w.update()
|
||||
|
||||
def toggle_theme(self):
|
||||
"""Toggle application theme and update icon/text."""
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
|
||||
is_dark = self._qapp_dark_enabled()
|
||||
|
||||
apply_theme("light" if is_dark else "dark")
|
||||
self._sync_from_qapp_theme()
|
||||
0
bec_widgets/applications/views/__init__.py
Normal file
0
bec_widgets/applications/views/__init__.py
Normal file
262
bec_widgets/applications/views/view.py
Normal file
262
bec_widgets/applications/views/view.py
Normal file
@@ -0,0 +1,262 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from qtpy.QtCore import QEventLoop
|
||||
from qtpy.QtWidgets import (
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QFormLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QStackedLayout,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
|
||||
from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import SignalComboBox
|
||||
from bec_widgets.widgets.plots.waveform.waveform import Waveform
|
||||
|
||||
|
||||
class ViewBase(QWidget):
|
||||
"""Wrapper for a content widget used inside the main app's stacked view.
|
||||
|
||||
Subclasses can implement `on_enter` and `on_exit` to run custom logic when the view becomes visible or is about to be hidden.
|
||||
|
||||
Args:
|
||||
content (QWidget): The actual view widget to display.
|
||||
parent (QWidget | None): Parent widget.
|
||||
id (str | None): Optional view id, useful for debugging or introspection.
|
||||
title (str | None): Optional human-readable title.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QWidget | None = None,
|
||||
content: QWidget | None = None,
|
||||
*,
|
||||
id: str | None = None,
|
||||
title: str | None = None,
|
||||
):
|
||||
super().__init__(parent=parent)
|
||||
self.content: QWidget | None = None
|
||||
self.view_id = id
|
||||
self.view_title = title
|
||||
|
||||
lay = QVBoxLayout(self)
|
||||
lay.setContentsMargins(0, 0, 0, 0)
|
||||
lay.setSpacing(0)
|
||||
|
||||
if content is not None:
|
||||
self.set_content(content)
|
||||
|
||||
def set_content(self, content: QWidget) -> None:
|
||||
"""Replace the current content widget with a new one."""
|
||||
if self.content is not None:
|
||||
self.content.setParent(None)
|
||||
self.content = content
|
||||
self.layout().addWidget(content)
|
||||
|
||||
@SafeSlot()
|
||||
def on_enter(self) -> None:
|
||||
"""Called after the view becomes current/visible.
|
||||
|
||||
Default implementation does nothing. Override in subclasses.
|
||||
"""
|
||||
pass
|
||||
|
||||
@SafeSlot()
|
||||
def on_exit(self) -> bool:
|
||||
"""Called before the view is switched away/hidden.
|
||||
|
||||
Return True to allow switching, or False to veto.
|
||||
Default implementation allows switching.
|
||||
"""
|
||||
return True
|
||||
|
||||
|
||||
####################################################################################################
|
||||
# Example views for demonstration/testing purposes
|
||||
####################################################################################################
|
||||
|
||||
|
||||
# --- Popup UI version ---
|
||||
class WaveformViewPopup(ViewBase): # pragma: no cover
|
||||
def __init__(self, parent=None, *args, **kwargs):
|
||||
super().__init__(parent=parent, *args, **kwargs)
|
||||
|
||||
self.waveform = Waveform(parent=self)
|
||||
self.set_content(self.waveform)
|
||||
|
||||
@SafeSlot()
|
||||
def on_enter(self) -> None:
|
||||
dialog = QDialog(self)
|
||||
dialog.setWindowTitle("Configure Waveform View")
|
||||
|
||||
label = QLabel("Select device and signal for the waveform plot:", parent=dialog)
|
||||
|
||||
# same as in the CurveRow used in waveform
|
||||
self.device_edit = DeviceComboBox(parent=self)
|
||||
self.device_edit.insertItem(0, "")
|
||||
self.device_edit.setEditable(True)
|
||||
self.device_edit.setCurrentIndex(0)
|
||||
self.entry_edit = SignalComboBox(parent=self)
|
||||
self.entry_edit.include_config_signals = False
|
||||
self.entry_edit.insertItem(0, "")
|
||||
self.entry_edit.setEditable(True)
|
||||
self.device_edit.currentTextChanged.connect(self.entry_edit.set_device)
|
||||
self.device_edit.device_reset.connect(self.entry_edit.reset_selection)
|
||||
|
||||
form = QFormLayout()
|
||||
form.addRow(label)
|
||||
form.addRow("Device", self.device_edit)
|
||||
form.addRow("Signal", self.entry_edit)
|
||||
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel, parent=dialog)
|
||||
buttons.accepted.connect(dialog.accept)
|
||||
buttons.rejected.connect(dialog.reject)
|
||||
|
||||
v = QVBoxLayout(dialog)
|
||||
v.addLayout(form)
|
||||
v.addWidget(buttons)
|
||||
|
||||
if dialog.exec_() == QDialog.Accepted:
|
||||
self.waveform.plot(
|
||||
y_name=self.device_edit.currentText(), y_entry=self.entry_edit.currentText()
|
||||
)
|
||||
|
||||
@SafeSlot()
|
||||
def on_exit(self) -> bool:
|
||||
ans = QMessageBox.question(
|
||||
self,
|
||||
"Switch and clear?",
|
||||
"Do you want to switch views and clear the plot?",
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
QMessageBox.No,
|
||||
)
|
||||
if ans == QMessageBox.Yes:
|
||||
self.waveform.clear_all()
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# --- Inline stacked UI version ---
|
||||
class WaveformViewInline(ViewBase): # pragma: no cover
|
||||
def __init__(self, parent=None, *args, **kwargs):
|
||||
super().__init__(parent=parent, *args, **kwargs)
|
||||
|
||||
# Root layout for this view uses a stacked layout
|
||||
self.stack = QStackedLayout()
|
||||
container = QWidget(self)
|
||||
container.setLayout(self.stack)
|
||||
self.set_content(container)
|
||||
|
||||
# --- Page 0: Settings page (inline form)
|
||||
self.settings_page = QWidget()
|
||||
sp_layout = QVBoxLayout(self.settings_page)
|
||||
sp_layout.setContentsMargins(16, 16, 16, 16)
|
||||
sp_layout.setSpacing(12)
|
||||
|
||||
title = QLabel("Select device and signal for the waveform plot:", parent=self.settings_page)
|
||||
self.device_edit = DeviceComboBox(parent=self.settings_page)
|
||||
self.device_edit.insertItem(0, "")
|
||||
self.device_edit.setEditable(True)
|
||||
self.device_edit.setCurrentIndex(0)
|
||||
|
||||
self.entry_edit = SignalComboBox(parent=self.settings_page)
|
||||
self.entry_edit.include_config_signals = False
|
||||
self.entry_edit.insertItem(0, "")
|
||||
self.entry_edit.setEditable(True)
|
||||
self.device_edit.currentTextChanged.connect(self.entry_edit.set_device)
|
||||
self.device_edit.device_reset.connect(self.entry_edit.reset_selection)
|
||||
|
||||
form = QFormLayout()
|
||||
form.addRow(title)
|
||||
form.addRow("Device", self.device_edit)
|
||||
form.addRow("Signal", self.entry_edit)
|
||||
|
||||
btn_row = QHBoxLayout()
|
||||
ok_btn = QPushButton("OK", parent=self.settings_page)
|
||||
cancel_btn = QPushButton("Cancel", parent=self.settings_page)
|
||||
btn_row.addStretch(1)
|
||||
btn_row.addWidget(cancel_btn)
|
||||
btn_row.addWidget(ok_btn)
|
||||
|
||||
sp_layout.addLayout(form)
|
||||
sp_layout.addLayout(btn_row)
|
||||
|
||||
# --- Page 1: Waveform page
|
||||
self.waveform_page = QWidget()
|
||||
wf_layout = QVBoxLayout(self.waveform_page)
|
||||
wf_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.waveform = Waveform(parent=self.waveform_page)
|
||||
wf_layout.addWidget(self.waveform)
|
||||
|
||||
# --- Page 2: Exit confirmation page (inline)
|
||||
self.confirm_page = QWidget()
|
||||
cp_layout = QVBoxLayout(self.confirm_page)
|
||||
cp_layout.setContentsMargins(16, 16, 16, 16)
|
||||
cp_layout.setSpacing(12)
|
||||
qlabel = QLabel("Do you want to switch views and clear the plot?", parent=self.confirm_page)
|
||||
cp_buttons = QHBoxLayout()
|
||||
no_btn = QPushButton("No", parent=self.confirm_page)
|
||||
yes_btn = QPushButton("Yes", parent=self.confirm_page)
|
||||
cp_buttons.addStretch(1)
|
||||
cp_buttons.addWidget(no_btn)
|
||||
cp_buttons.addWidget(yes_btn)
|
||||
cp_layout.addWidget(qlabel)
|
||||
cp_layout.addLayout(cp_buttons)
|
||||
|
||||
# Add pages to the stack
|
||||
self.stack.addWidget(self.settings_page) # index 0
|
||||
self.stack.addWidget(self.waveform_page) # index 1
|
||||
self.stack.addWidget(self.confirm_page) # index 2
|
||||
|
||||
# Wire settings buttons
|
||||
ok_btn.clicked.connect(self._apply_settings_and_show_waveform)
|
||||
cancel_btn.clicked.connect(self._show_waveform_without_changes)
|
||||
|
||||
# Prepare result holder for the inline confirmation
|
||||
self._exit_choice_yes = None
|
||||
yes_btn.clicked.connect(lambda: self._exit_reply(True))
|
||||
no_btn.clicked.connect(lambda: self._exit_reply(False))
|
||||
|
||||
@SafeSlot()
|
||||
def on_enter(self) -> None:
|
||||
# Always start on the settings page when entering
|
||||
self.stack.setCurrentIndex(0)
|
||||
|
||||
@SafeSlot()
|
||||
def on_exit(self) -> bool:
|
||||
# Show inline confirmation page and synchronously wait for a choice
|
||||
# -> trick to make the choice blocking, however popup would be cleaner solution
|
||||
self._exit_choice_yes = None
|
||||
self.stack.setCurrentIndex(2)
|
||||
loop = QEventLoop()
|
||||
self._exit_loop = loop
|
||||
loop.exec_()
|
||||
|
||||
if self._exit_choice_yes:
|
||||
self.waveform.clear_all()
|
||||
return True
|
||||
# Revert to waveform view if user cancelled switching
|
||||
self.stack.setCurrentIndex(1)
|
||||
return False
|
||||
|
||||
def _apply_settings_and_show_waveform(self):
|
||||
dev = self.device_edit.currentText()
|
||||
sig = self.entry_edit.currentText()
|
||||
if dev and sig:
|
||||
self.waveform.plot(y_name=dev, y_entry=sig)
|
||||
self.stack.setCurrentIndex(1)
|
||||
|
||||
def _show_waveform_without_changes(self):
|
||||
# Just show waveform page without plotting
|
||||
self.stack.setCurrentIndex(1)
|
||||
|
||||
def _exit_reply(self, yes: bool):
|
||||
self._exit_choice_yes = bool(yes)
|
||||
if hasattr(self, "_exit_loop") and self._exit_loop.isRunning():
|
||||
self._exit_loop.quit()
|
||||
@@ -25,6 +25,7 @@ from shiboken6 import isValid
|
||||
from bec_widgets import BECWidget, SafeProperty, SafeSlot
|
||||
from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler
|
||||
from bec_widgets.utils import BECDispatcher
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.utils.property_editor import PropertyEditor
|
||||
from bec_widgets.utils.toolbars.actions import (
|
||||
ExpandableMenuAction,
|
||||
@@ -923,6 +924,7 @@ if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
apply_theme("dark")
|
||||
dispatcher = BECDispatcher(gui_id="ads")
|
||||
window = BECMainWindowNoRPC()
|
||||
ads = AdvancedDockArea(mode="developer", root_widget=True)
|
||||
|
||||
189
tests/unit_tests/test_app_side_bar.py
Normal file
189
tests/unit_tests/test_app_side_bar.py
Normal file
@@ -0,0 +1,189 @@
|
||||
import pytest
|
||||
from qtpy.QtCore import QParallelAnimationGroup, QSize
|
||||
|
||||
from bec_widgets.applications.navigation_centre.side_bar import SideBar
|
||||
from bec_widgets.applications.navigation_centre.side_bar_components import (
|
||||
NavigationItem,
|
||||
SectionHeader,
|
||||
)
|
||||
|
||||
ANIM_TEST_DURATION = 60 # ms
|
||||
|
||||
|
||||
def _run(group: QParallelAnimationGroup, qtbot, duration=ANIM_TEST_DURATION):
|
||||
group.start()
|
||||
qtbot.wait(duration + 100)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def header(qtbot):
|
||||
w = SectionHeader(text="Group", anim_duration=ANIM_TEST_DURATION)
|
||||
qtbot.addWidget(w)
|
||||
qtbot.waitExposed(w)
|
||||
return w
|
||||
|
||||
|
||||
def test_section_header_initial_state_collapsed(header):
|
||||
# RevealAnimator is initially collapsed for the label
|
||||
assert header.lbl.maximumWidth() == 0
|
||||
assert header.lbl.maximumHeight() == 0
|
||||
|
||||
|
||||
def test_section_header_animates_reveal_and_hide(header, qtbot):
|
||||
group = QParallelAnimationGroup()
|
||||
for anim in header.build_animations():
|
||||
group.addAnimation(anim)
|
||||
|
||||
# Expand
|
||||
header.setup_animations(True)
|
||||
_run(group, qtbot)
|
||||
sh = header.lbl.sizeHint()
|
||||
assert header.lbl.maximumWidth() >= sh.width()
|
||||
assert header.lbl.maximumHeight() >= sh.height()
|
||||
|
||||
# Collapse
|
||||
header.setup_animations(False)
|
||||
_run(group, qtbot)
|
||||
assert header.lbl.maximumWidth() == 0
|
||||
assert header.lbl.maximumHeight() == 0
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def nav(qtbot):
|
||||
w = NavigationItem(
|
||||
title="Counter", icon_name="widgets", mini_text="cnt", anim_duration=ANIM_TEST_DURATION
|
||||
)
|
||||
qtbot.addWidget(w)
|
||||
qtbot.waitExposed(w)
|
||||
return w
|
||||
|
||||
|
||||
def test_build_animations_contains(nav):
|
||||
lst = nav.build_animations()
|
||||
assert len(lst) == 5
|
||||
|
||||
|
||||
def test_setup_animations_changes_targets(nav, qtbot):
|
||||
group = QParallelAnimationGroup()
|
||||
for a in nav.build_animations():
|
||||
group.addAnimation(a)
|
||||
|
||||
# collapsed -> expanded
|
||||
nav.setup_animations(True)
|
||||
_run(group, qtbot)
|
||||
|
||||
sh_title = nav.title_lbl.sizeHint()
|
||||
assert nav.title_lbl.maximumWidth() >= sh_title.width()
|
||||
assert nav.mini_lbl.maximumHeight() == 0
|
||||
assert nav.icon_btn.iconSize() == QSize(26, 26)
|
||||
|
||||
# expanded -> collapsed
|
||||
nav.setup_animations(False)
|
||||
_run(group, qtbot)
|
||||
assert nav.title_lbl.maximumWidth() == 0
|
||||
sh_mini = nav.mini_lbl.sizeHint()
|
||||
assert nav.mini_lbl.maximumHeight() >= sh_mini.height()
|
||||
assert nav.icon_btn.iconSize() == QSize(20, 20)
|
||||
|
||||
|
||||
def test_activation_signal_emits(nav, qtbot):
|
||||
with qtbot.waitSignal(nav.activated, timeout=1000):
|
||||
nav.icon_btn.click()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sidebar(qtbot):
|
||||
sb = SideBar(title="Controls", anim_duration=ANIM_TEST_DURATION)
|
||||
qtbot.addWidget(sb)
|
||||
qtbot.waitExposed(sb)
|
||||
return sb
|
||||
|
||||
|
||||
def test_add_section_and_separator(sidebar):
|
||||
sec = sidebar.add_section("Group A", id="group_a")
|
||||
assert sec is not None
|
||||
sep = sidebar.add_separator()
|
||||
assert sep is not None
|
||||
assert sidebar.content_layout.indexOf(sep) != -1
|
||||
|
||||
|
||||
def test_add_item_top_and_bottom_positions(sidebar):
|
||||
top_item = sidebar.add_item(icon="widgets", title="Top", id="top")
|
||||
bottom_item = sidebar.add_item(icon="widgets", title="Bottom", id="bottom", from_top=False)
|
||||
|
||||
i_spacer = sidebar.content_layout.indexOf(sidebar._bottom_spacer)
|
||||
i_top = sidebar.content_layout.indexOf(top_item)
|
||||
i_bottom = sidebar.content_layout.indexOf(bottom_item)
|
||||
|
||||
assert i_top != -1 and i_bottom != -1
|
||||
assert i_bottom > i_spacer # bottom items go after the spacer
|
||||
|
||||
|
||||
def test_selection_exclusive_and_nonexclusive(sidebar, qtbot):
|
||||
a = sidebar.add_item(icon="widgets", title="A", id="a", exclusive=True)
|
||||
b = sidebar.add_item(icon="widgets", title="B", id="b", exclusive=True)
|
||||
c = sidebar.add_item(icon="widgets", title="C", id="c", exclusive=False)
|
||||
|
||||
c._emit_activated()
|
||||
qtbot.wait(10)
|
||||
assert c.is_active() is True
|
||||
|
||||
a._emit_activated()
|
||||
qtbot.wait(10)
|
||||
assert a.is_active() is True
|
||||
assert b.is_active() is False
|
||||
assert c.is_active() is True
|
||||
|
||||
b._emit_activated()
|
||||
qtbot.wait(200)
|
||||
assert a.is_active() is False
|
||||
assert b.is_active() is True
|
||||
assert c.is_active() is True
|
||||
|
||||
|
||||
def test_on_expand_configures_targets_and_shows_title(sidebar, qtbot):
|
||||
# Start collapsed
|
||||
assert sidebar._is_expanded is False
|
||||
start_w = sidebar.width()
|
||||
|
||||
sidebar.on_expand()
|
||||
|
||||
assert sidebar.width_anim.startValue() == start_w
|
||||
assert sidebar.width_anim.endValue() == sidebar._expanded_width
|
||||
assert sidebar.title_anim.endValue() == 1.0
|
||||
|
||||
|
||||
def test__on_anim_finished_hides_on_collapse_and_resets_alignment(sidebar, qtbot):
|
||||
# Add one item so set_visible is called on components too
|
||||
item = sidebar.add_item(icon="widgets", title="Item", id="item")
|
||||
|
||||
# Expand first
|
||||
sidebar.on_expand()
|
||||
qtbot.wait(ANIM_TEST_DURATION + 150)
|
||||
assert sidebar._is_expanded is True
|
||||
|
||||
# Now collapse
|
||||
sidebar.on_expand()
|
||||
# Wait for animation group to finish and _on_anim_finished to run
|
||||
with qtbot.waitSignal(sidebar.group.finished, timeout=2000):
|
||||
pass
|
||||
|
||||
# Collapsed state
|
||||
assert sidebar._is_expanded is False
|
||||
|
||||
|
||||
def test_dark_mode_item_is_action(sidebar, qtbot, monkeypatch):
|
||||
dm = sidebar.add_dark_mode_item()
|
||||
|
||||
called = {"toggled": False}
|
||||
|
||||
def fake_apply(theme):
|
||||
called["toggled"] = True
|
||||
|
||||
monkeypatch.setattr("bec_widgets.utils.colors.apply_theme", fake_apply, raising=False)
|
||||
|
||||
before = dm.is_active()
|
||||
dm._emit_activated()
|
||||
qtbot.wait(200)
|
||||
assert called["toggled"] is True
|
||||
assert dm.is_active() == before
|
||||
111
tests/unit_tests/test_main_app.py
Normal file
111
tests/unit_tests/test_main_app.py
Normal file
@@ -0,0 +1,111 @@
|
||||
import pytest
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.applications.main_app import BECMainApp
|
||||
from bec_widgets.applications.views.view import ViewBase
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
|
||||
ANIM_TEST_DURATION = 60 # ms
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def viewbase(qtbot):
|
||||
v = ViewBase(content=QWidget())
|
||||
qtbot.addWidget(v)
|
||||
qtbot.waitExposed(v)
|
||||
yield v
|
||||
|
||||
|
||||
# Spy views for testing enter/exit hooks and veto logic
|
||||
class SpyView(ViewBase):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.enter_calls = 0
|
||||
self.exit_calls = 0
|
||||
|
||||
def on_enter(self) -> None:
|
||||
self.enter_calls += 1
|
||||
|
||||
def on_exit(self) -> bool:
|
||||
self.exit_calls += 1
|
||||
return True
|
||||
|
||||
|
||||
class SpyVetoView(SpyView):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.allow_exit = False
|
||||
|
||||
def on_exit(self) -> bool:
|
||||
self.exit_calls += 1
|
||||
return bool(self.allow_exit)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app_with_spies(qtbot, mocked_client):
|
||||
app = BECMainApp(client=mocked_client, anim_duration=ANIM_TEST_DURATION, show_examples=False)
|
||||
qtbot.addWidget(app)
|
||||
qtbot.waitExposed(app)
|
||||
|
||||
app.add_section("Tests", id="tests")
|
||||
|
||||
v1 = SpyView(id="v1", title="V1")
|
||||
v2 = SpyView(id="v2", title="V2")
|
||||
vv = SpyVetoView(id="vv", title="VV")
|
||||
|
||||
app.add_view(icon="widgets", title="View 1", id="v1", widget=v1, mini_text="v1")
|
||||
app.add_view(icon="widgets", title="View 2", id="v2", widget=v2, mini_text="v2")
|
||||
app.add_view(icon="widgets", title="Veto View", id="vv", widget=vv, mini_text="vv")
|
||||
|
||||
# Start from dock_area (default) to avoid extra enter/exit counts on spies
|
||||
assert app.stack.currentIndex() == app._view_index["dock_area"]
|
||||
return app, v1, v2, vv
|
||||
|
||||
|
||||
def test_viewbase_initializes(viewbase):
|
||||
assert viewbase.on_enter() is None
|
||||
assert viewbase.on_exit() is True
|
||||
|
||||
|
||||
def test_on_enter_and_on_exit_are_called_on_switch(app_with_spies, qtbot):
|
||||
app, v1, v2, _ = app_with_spies
|
||||
|
||||
app.set_current("v1")
|
||||
qtbot.wait(10)
|
||||
assert v1.enter_calls == 1
|
||||
|
||||
app.set_current("v2")
|
||||
qtbot.wait(10)
|
||||
assert v1.exit_calls == 1
|
||||
assert v2.enter_calls == 1
|
||||
|
||||
app.set_current("v1")
|
||||
qtbot.wait(10)
|
||||
assert v2.exit_calls == 1
|
||||
assert v1.enter_calls == 2
|
||||
|
||||
|
||||
def test_on_exit_veto_prevents_switch_until_allowed(app_with_spies, qtbot):
|
||||
app, v1, v2, vv = app_with_spies
|
||||
|
||||
# Move to veto view first
|
||||
app.set_current("vv")
|
||||
qtbot.wait(10)
|
||||
assert vv.enter_calls == 1
|
||||
|
||||
# Attempt to leave veto view -> should veto
|
||||
app.set_current("v1")
|
||||
qtbot.wait(10)
|
||||
assert vv.exit_calls == 1
|
||||
# Still on veto view because veto returned False
|
||||
assert app.stack.currentIndex() == app._view_index["vv"]
|
||||
|
||||
# Allow exit and try again
|
||||
vv.allow_exit = True
|
||||
app.set_current("v1")
|
||||
qtbot.wait(10)
|
||||
|
||||
# Now the switch should have happened, and v1 received on_enter
|
||||
assert app.stack.currentIndex() == app._view_index["v1"]
|
||||
assert v1.enter_calls >= 1
|
||||
128
tests/unit_tests/test_reveal_animator.py
Normal file
128
tests/unit_tests/test_reveal_animator.py
Normal file
@@ -0,0 +1,128 @@
|
||||
import pytest
|
||||
from qtpy.QtCore import QParallelAnimationGroup
|
||||
from qtpy.QtWidgets import QLabel
|
||||
|
||||
from bec_widgets.applications.navigation_centre.reveal_animator import RevealAnimator
|
||||
|
||||
ANIM_TEST_DURATION = 50 # ms
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def label(qtbot):
|
||||
w = QLabel("Reveal Label")
|
||||
qtbot.addWidget(w)
|
||||
qtbot.waitExposed(w)
|
||||
return w
|
||||
|
||||
|
||||
def _run_group(group: QParallelAnimationGroup, qtbot, duration_ms: int):
|
||||
group.start()
|
||||
qtbot.wait(duration_ms + 100)
|
||||
|
||||
|
||||
def test_immediate_collapsed_then_revealed(label):
|
||||
anim = RevealAnimator(label, duration=ANIM_TEST_DURATION, initially_revealed=False)
|
||||
|
||||
# Initially collapsed
|
||||
assert anim.fx.opacity() == pytest.approx(0.0)
|
||||
assert label.maximumWidth() == 0
|
||||
assert label.maximumHeight() == 0
|
||||
|
||||
# Snap to revealed
|
||||
anim.set_immediate(True)
|
||||
sh = label.sizeHint()
|
||||
assert anim.fx.opacity() == pytest.approx(1.0)
|
||||
assert label.maximumWidth() == max(sh.width(), 1)
|
||||
assert label.maximumHeight() == max(sh.height(), 1)
|
||||
|
||||
|
||||
def test_reveal_then_collapse_with_animation(label, qtbot):
|
||||
anim = RevealAnimator(label, duration=ANIM_TEST_DURATION, initially_revealed=False)
|
||||
|
||||
group = QParallelAnimationGroup()
|
||||
anim.setup(True)
|
||||
anim.add_to_group(group)
|
||||
_run_group(group, qtbot, ANIM_TEST_DURATION)
|
||||
|
||||
sh = label.sizeHint()
|
||||
assert anim.fx.opacity() == pytest.approx(1.0)
|
||||
assert label.maximumWidth() == max(sh.width(), 1)
|
||||
assert label.maximumHeight() == max(sh.height(), 1)
|
||||
|
||||
# Collapse using the SAME group; do not re-add animations to avoid deletion
|
||||
anim.setup(False)
|
||||
_run_group(group, qtbot, ANIM_TEST_DURATION)
|
||||
|
||||
assert anim.fx.opacity() == pytest.approx(0.0)
|
||||
assert label.maximumWidth() == 0
|
||||
assert label.maximumHeight() == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"flags",
|
||||
[
|
||||
dict(animate_opacity=False, animate_width=True, animate_height=True),
|
||||
dict(animate_opacity=True, animate_width=False, animate_height=True),
|
||||
dict(animate_opacity=True, animate_width=True, animate_height=False),
|
||||
],
|
||||
)
|
||||
def test_partial_flags_respectively_disable_properties(label, qtbot, flags):
|
||||
# Establish initial state
|
||||
label.setMaximumWidth(123)
|
||||
label.setMaximumHeight(456)
|
||||
|
||||
anim = RevealAnimator(label, duration=10, initially_revealed=False, **flags)
|
||||
|
||||
# Record baseline values for disabled properties
|
||||
baseline_opacity = anim.fx.opacity()
|
||||
baseline_w = label.maximumWidth()
|
||||
baseline_h = label.maximumHeight()
|
||||
|
||||
group = QParallelAnimationGroup()
|
||||
anim.setup(True)
|
||||
anim.add_to_group(group)
|
||||
_run_group(group, qtbot, ANIM_TEST_DURATION)
|
||||
|
||||
sh = label.sizeHint()
|
||||
|
||||
if flags.get("animate_opacity", True):
|
||||
assert anim.fx.opacity() == pytest.approx(1.0)
|
||||
else:
|
||||
# Opacity should remain unchanged
|
||||
assert anim.fx.opacity() == pytest.approx(baseline_opacity)
|
||||
|
||||
if flags.get("animate_width", True):
|
||||
assert label.maximumWidth() == max(sh.width(), 1)
|
||||
else:
|
||||
assert label.maximumWidth() == baseline_w
|
||||
|
||||
if flags.get("animate_height", True):
|
||||
assert label.maximumHeight() == max(sh.height(), 1)
|
||||
else:
|
||||
assert label.maximumHeight() == baseline_h
|
||||
|
||||
|
||||
def test_animations_list_and_order(label):
|
||||
anim = RevealAnimator(label, duration=ANIM_TEST_DURATION)
|
||||
lst = anim.animations()
|
||||
# All should be present and in defined order: opacity, height, width
|
||||
names = [a.propertyName() for a in lst]
|
||||
assert names == [b"opacity", b"maximumHeight", b"maximumWidth"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"flags,expected",
|
||||
[
|
||||
(dict(animate_opacity=False), [b"maximumHeight", b"maximumWidth"]),
|
||||
(dict(animate_width=False), [b"opacity", b"maximumHeight"]),
|
||||
(dict(animate_height=False), [b"opacity", b"maximumWidth"]),
|
||||
(dict(animate_opacity=False, animate_width=False, animate_height=True), [b"maximumHeight"]),
|
||||
(dict(animate_opacity=False, animate_width=True, animate_height=False), [b"maximumWidth"]),
|
||||
(dict(animate_opacity=True, animate_width=False, animate_height=False), [b"opacity"]),
|
||||
(dict(animate_opacity=False, animate_width=False, animate_height=False), []),
|
||||
],
|
||||
)
|
||||
def test_animations_respects_flags(label, flags, expected):
|
||||
anim = RevealAnimator(label, duration=ANIM_TEST_DURATION, **flags)
|
||||
names = [a.propertyName() for a in anim.animations()]
|
||||
assert names == expected
|
||||
Reference in New Issue
Block a user