Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
b092d2a
build: PySide6-QtAds dependency added
wyzula-jan Aug 7, 2025
df9b5b7
fix(bec_connector): dedicated remove signal added for listeners
wyzula-jan Aug 5, 2025
c20a1ac
fix(bec_connector): added name established signal for listeners
wyzula-jan Aug 6, 2025
bfc72e8
refactor(bec_connector): signals renamed
wyzula-jan Aug 19, 2025
bc8a328
feat(widget_io): widget hierarchy can grap all bec connectors from th…
wyzula-jan Aug 6, 2025
02887d2
feat(widget_io): widget hierarchy find_ancestor added
wyzula-jan Aug 7, 2025
d7a946e
refactor(widget_io): ancestor hierarchy methods consolidated
wyzula-jan Aug 19, 2025
0a4d3b5
fix(widget_state_manager): state manager can save all properties recu…
wyzula-jan Aug 6, 2025
2eb04e0
fix(widget_state_manager): state manager can save to already existing…
wyzula-jan Aug 6, 2025
4495cc7
feat(bec_widget): attach/detach method for all widgets + client regen…
wyzula-jan Aug 7, 2025
25f9b09
refactor(bec_main_window): main app theme renamed to View
wyzula-jan Aug 13, 2025
e7f9919
feat(advanced_dock_area): added ads based dock area with profiles
wyzula-jan Aug 5, 2025
7884aec
fix(bec_widgets): by default the linux display manager is switched to…
wyzula-jan Aug 14, 2025
166b56b
refactor(advanced_dock_area): ads changed to separate widget
wyzula-jan Aug 15, 2025
0756ebb
feat(advanced_dock_area): ads has default direction
wyzula-jan Aug 18, 2025
062042c
fix(advanced_dock_area): dock manager global flags initialised in BW …
wyzula-jan Aug 18, 2025
022b10f
refactor(advanced_dock_area): profile tools moved to separate module
wyzula-jan Aug 19, 2025
ab5a78e
build(bec_qthemes): version 1.0 dependency
wyzula-jan Aug 21, 2025
6932a5e
fix(bec_widgets): adapt to bec_qthemes 1.0
wyzula-jan Aug 19, 2025
cea2e68
fix:queue abort button fixed
wyzula-jan Aug 25, 2025
19e8e5a
fix(toolbar): toolbar menu button fixed
wyzula-jan Aug 25, 2025
09d00c4
fix: device combobox change paint event to stylesheet change
wyzula-jan Aug 25, 2025
eb5d56a
fix: tree items due to pushbutton margins
wyzula-jan Aug 25, 2025
e015188
fix: remove pyqtgraph styling logic
wyzula-jan Aug 25, 2025
9bd1efa
fix: compact popup layout spacing
wyzula-jan Aug 25, 2025
391e2f7
chore: fix formatter
wakonig Aug 25, 2025
652ec81
fix(compact_popup): import from qtpy instead of pyside6
wakonig Aug 25, 2025
4889f01
build: add missing darkdetect dependency
wakonig Aug 25, 2025
38a4f3a
test: fixes after theme changes
wakonig Aug 26, 2025
1d5d83c
ci: add artifact upload
wakonig Aug 26, 2025
7421166
fix(serializer): remove deprecated serializer
wakonig Aug 26, 2025
0dce0a0
test: fix tests for qtheme v1
wakonig Aug 26, 2025
a84459a
fix(BECWidget): ensure that theme changes are only triggered from ali…
wakonig Aug 26, 2025
9c40e31
fix(themes): move apply theme from BECWidget class to server init
wakonig Aug 28, 2025
ab787fc
refactor(spinner): improve enum access
wakonig Aug 28, 2025
353c82c
test: apply theme on qapp creation
wakonig Aug 28, 2025
d69220c
refactor: move to qthemes 1.1.2
wakonig Aug 28, 2025
a3dc509
fix: process all deletion events before applying a new theme.
wakonig Aug 30, 2025
6e4b669
feat: add SafeConnect
wakonig Aug 29, 2025
4e172a8
test: remove outdated tests
wakonig Sep 1, 2025
0e356e0
feat(main_app): main app with interactive app switcher
wyzula-jan Sep 4, 2025
eaf1634
feat(main_app):views with examples for enter and exit hook
wyzula-jan Sep 10, 2025
2a4a11a
test(main_app): test extended
wyzula-jan Sep 10, 2025
4fe3018
fix(colors): accent colors fetching if theme not provided
wyzula-jan Sep 11, 2025
9371d4a
build: allow pyside 6.9.2
wakonig Sep 23, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/pytest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,14 @@ jobs:
id: coverage
run: pytest --random-order --cov=bec_widgets --cov-config=pyproject.toml --cov-branch --cov-report=xml --no-cov-on-fail tests/unit_tests/

- name: Upload test artifacts
uses: actions/upload-artifact@v4
if: failure()
with:
name: image-references
path: bec_widgets/tests/reference_failures/
if-no-files-found: ignore

- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
with:
Expand Down
16 changes: 16 additions & 0 deletions bec_widgets/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,20 @@
import os
import sys

import PySide6QtAds as QtAds

from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot

if sys.platform.startswith("linux"):
qt_platform = os.environ.get("QT_QPA_PLATFORM", "")
if qt_platform != "offscreen":
os.environ["QT_QPA_PLATFORM"] = "xcb"

# Default QtAds configuration
QtAds.CDockManager.setConfigFlag(QtAds.CDockManager.eConfigFlag.FocusHighlighting, True)
QtAds.CDockManager.setConfigFlag(
QtAds.CDockManager.eConfigFlag.RetainTabSizeWhenCloseButtonHidden, True
)

__all__ = ["BECWidget", "SafeSlot", "SafeProperty"]
189 changes: 189 additions & 0 deletions bec_widgets/applications/main_app.py
Original file line number Diff line number Diff line change
@@ -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())
Empty file.
114 changes: 114 additions & 0 deletions bec_widgets/applications/navigation_centre/reveal_animator.py
Original file line number Diff line number Diff line change
@@ -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
]
Loading
Loading