1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-01-01 03:21:19 +01:00

feat(main_app): main app with interactive app switcher

This commit is contained in:
2025-09-04 16:30:59 +02:00
parent ba113aaaa2
commit cfaaef95fd
8 changed files with 1278 additions and 0 deletions

View File

@@ -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())

View 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
]

View File

@@ -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)

View File

@@ -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()

View File

@@ -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)

View 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

View 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