From cfaaef95fd1dbf94ced410ff85a13c2e067e1138 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Thu, 4 Sep 2025 16:30:59 +0200 Subject: [PATCH] feat(main_app): main app with interactive app switcher --- bec_widgets/applications/main_app.py | 118 ++++++ .../navigation_centre/__init__.py | 0 .../navigation_centre/reveal_animator.py | 114 ++++++ .../navigation_centre/side_bar.py | 355 +++++++++++++++++ .../navigation_centre/side_bar_components.py | 372 ++++++++++++++++++ .../advanced_dock_area/advanced_dock_area.py | 2 + tests/unit_tests/test_app_side_bar.py | 189 +++++++++ tests/unit_tests/test_reveal_animator.py | 128 ++++++ 8 files changed, 1278 insertions(+) create mode 100644 bec_widgets/applications/main_app.py create mode 100644 bec_widgets/applications/navigation_centre/__init__.py create mode 100644 bec_widgets/applications/navigation_centre/reveal_animator.py create mode 100644 bec_widgets/applications/navigation_centre/side_bar.py create mode 100644 bec_widgets/applications/navigation_centre/side_bar_components.py create mode 100644 tests/unit_tests/test_app_side_bar.py create mode 100644 tests/unit_tests/test_reveal_animator.py diff --git a/bec_widgets/applications/main_app.py b/bec_widgets/applications/main_app.py new file mode 100644 index 00000000..2a6ebd2d --- /dev/null +++ b/bec_widgets/applications/main_app.py @@ -0,0 +1,118 @@ +from qtpy.QtWidgets import QApplication, QHBoxLayout, QStackedWidget, QWidget + +from bec_widgets.applications.navigation_centre.side_bar import SideBar +from bec_widgets.applications.navigation_centre.side_bar_components import NavigationItem +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, **kwargs): + super().__init__(parent=parent, *args, **kwargs) + + # --- Compose central UI (sidebar + stack) + self.sidebar = SideBar(parent=self) + 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.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" + ) + 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, + ) + idx = self.stack.addWidget(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) + self._on_view_selected(id) + + # Internal: route sidebar selection to the stack + def _on_view_selected(self, vid: str) -> None: + idx = self._view_index.get(vid) + if idx is not None and 0 <= idx < self.stack.count(): + self.stack.setCurrentIndex(idx) + + +if __name__ == "__main__": # pragma: no cover + + import sys + + app = QApplication(sys.argv) + apply_theme("dark") + w = BECMainApp() + w.show() + + sys.exit(app.exec()) diff --git a/bec_widgets/applications/navigation_centre/__init__.py b/bec_widgets/applications/navigation_centre/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bec_widgets/applications/navigation_centre/reveal_animator.py b/bec_widgets/applications/navigation_centre/reveal_animator.py new file mode 100644 index 00000000..714f69da --- /dev/null +++ b/bec_widgets/applications/navigation_centre/reveal_animator.py @@ -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 + ] diff --git a/bec_widgets/applications/navigation_centre/side_bar.py b/bec_widgets/applications/navigation_centre/side_bar.py new file mode 100644 index 00000000..9bfff79f --- /dev/null +++ b/bec_widgets/applications/navigation_centre/side_bar.py @@ -0,0 +1,355 @@ +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 = 200, + 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(2) + 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): + 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 + 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 + 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) diff --git a/bec_widgets/applications/navigation_centre/side_bar_components.py b/bec_widgets/applications/navigation_centre/side_bar_components.py new file mode 100644 index 00000000..67bb7666 --- /dev/null +++ b/bec_widgets/applications/navigation_centre/side_bar_components.py @@ -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() diff --git a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py index 5b3f5a93..4b0e11f9 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py @@ -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, @@ -901,6 +902,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) diff --git a/tests/unit_tests/test_app_side_bar.py b/tests/unit_tests/test_app_side_bar.py new file mode 100644 index 00000000..830844ac --- /dev/null +++ b/tests/unit_tests/test_app_side_bar.py @@ -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 diff --git a/tests/unit_tests/test_reveal_animator.py b/tests/unit_tests/test_reveal_animator.py new file mode 100644 index 00000000..5704eb6e --- /dev/null +++ b/tests/unit_tests/test_reveal_animator.py @@ -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