diff --git a/bec_widgets/applications/main_app.py b/bec_widgets/applications/main_app.py index 2a6ebd2d..791f0751 100644 --- a/bec_widgets/applications/main_app.py +++ b/bec_widgets/applications/main_app.py @@ -1,18 +1,29 @@ 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, **kwargs): + + 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) + self.sidebar = SideBar(parent=self, anim_duration=anim_duration) self.stack = QStackedWidget(self) container = QWidget(self) @@ -25,6 +36,7 @@ class BECMainApp(BECMainWindow): # 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() @@ -32,9 +44,35 @@ class BECMainApp(BECMainWindow): 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() @@ -90,29 +128,62 @@ class BECMainApp(BECMainWindow): toggleable=toggleable, exclusive=exclusive, ) - idx = self.stack.addWidget(widget) + # 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) - self._on_view_selected(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 not None and 0 <= idx < self.stack.count(): - self.stack.setCurrentIndex(idx) + 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 - app = QApplication(sys.argv) + 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() + w = BECMainApp(show_examples=args.examples) w.show() sys.exit(app.exec()) diff --git a/bec_widgets/applications/navigation_centre/side_bar.py b/bec_widgets/applications/navigation_centre/side_bar.py index 9bfff79f..6354cafe 100644 --- a/bec_widgets/applications/navigation_centre/side_bar.py +++ b/bec_widgets/applications/navigation_centre/side_bar.py @@ -32,7 +32,7 @@ class SideBar(QScrollArea): parent=None, title: str = "Control Panel", collapsed_width: int = 56, - expanded_width: int = 200, + expanded_width: int = 250, anim_duration: int = ANIMATION_DURATION, ): super().__init__(parent=parent) @@ -59,7 +59,7 @@ class SideBar(QScrollArea): self.content = QWidget(self) self.content_layout = QVBoxLayout(self.content) self.content_layout.setContentsMargins(0, 0, 0, 0) - self.content_layout.setSpacing(2) + self.content_layout.setSpacing(4) self.setWidget(self.content) # Track active navigation item @@ -291,14 +291,15 @@ class SideBar(QScrollArea): item.activated.connect(lambda id=id: self.activate_item(id)) return item - def activate_item(self, target_id: str): + def activate_item(self, target_id: str, *, emit_signal: bool = True): target = self.components.get(target_id) if target is None: return # Non-toggleable acts like an action: do not change any toggled states if hasattr(target, "toggleable") and not target.toggleable: self._active_id = target_id - self.view_selected.emit(target_id) + if emit_signal: + self.view_selected.emit(target_id) return is_exclusive = getattr(target, "exclusive", True) @@ -319,7 +320,8 @@ class SideBar(QScrollArea): target.set_active(not target.is_active()) self._active_id = target_id - self.view_selected.emit(target_id) + if emit_signal: + self.view_selected.emit(target_id) def add_dark_mode_item( self, id: str = "dark_mode", position: int | None = None diff --git a/bec_widgets/applications/views/__init__.py b/bec_widgets/applications/views/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bec_widgets/applications/views/view.py b/bec_widgets/applications/views/view.py new file mode 100644 index 00000000..3b98f756 --- /dev/null +++ b/bec_widgets/applications/views/view.py @@ -0,0 +1,262 @@ +from __future__ import annotations + +from qtpy.QtCore import QEventLoop +from qtpy.QtWidgets import ( + QDialog, + QDialogButtonBox, + QFormLayout, + QHBoxLayout, + QLabel, + QMessageBox, + QPushButton, + QStackedLayout, + QVBoxLayout, + QWidget, +) + +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox +from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import SignalComboBox +from bec_widgets.widgets.plots.waveform.waveform import Waveform + + +class ViewBase(QWidget): + """Wrapper for a content widget used inside the main app's stacked view. + + Subclasses can implement `on_enter` and `on_exit` to run custom logic when the view becomes visible or is about to be hidden. + + Args: + content (QWidget): The actual view widget to display. + parent (QWidget | None): Parent widget. + id (str | None): Optional view id, useful for debugging or introspection. + title (str | None): Optional human-readable title. + """ + + def __init__( + self, + parent: QWidget | None = None, + content: QWidget | None = None, + *, + id: str | None = None, + title: str | None = None, + ): + super().__init__(parent=parent) + self.content: QWidget | None = None + self.view_id = id + self.view_title = title + + lay = QVBoxLayout(self) + lay.setContentsMargins(0, 0, 0, 0) + lay.setSpacing(0) + + if content is not None: + self.set_content(content) + + def set_content(self, content: QWidget) -> None: + """Replace the current content widget with a new one.""" + if self.content is not None: + self.content.setParent(None) + self.content = content + self.layout().addWidget(content) + + @SafeSlot() + def on_enter(self) -> None: + """Called after the view becomes current/visible. + + Default implementation does nothing. Override in subclasses. + """ + pass + + @SafeSlot() + def on_exit(self) -> bool: + """Called before the view is switched away/hidden. + + Return True to allow switching, or False to veto. + Default implementation allows switching. + """ + return True + + +#################################################################################################### +# Example views for demonstration/testing purposes +#################################################################################################### + + +# --- Popup UI version --- +class WaveformViewPopup(ViewBase): # pragma: no cover + def __init__(self, parent=None, *args, **kwargs): + super().__init__(parent=parent, *args, **kwargs) + + self.waveform = Waveform(parent=self) + self.set_content(self.waveform) + + @SafeSlot() + def on_enter(self) -> None: + dialog = QDialog(self) + dialog.setWindowTitle("Configure Waveform View") + + label = QLabel("Select device and signal for the waveform plot:", parent=dialog) + + # same as in the CurveRow used in waveform + self.device_edit = DeviceComboBox(parent=self) + self.device_edit.insertItem(0, "") + self.device_edit.setEditable(True) + self.device_edit.setCurrentIndex(0) + self.entry_edit = SignalComboBox(parent=self) + self.entry_edit.include_config_signals = False + self.entry_edit.insertItem(0, "") + self.entry_edit.setEditable(True) + self.device_edit.currentTextChanged.connect(self.entry_edit.set_device) + self.device_edit.device_reset.connect(self.entry_edit.reset_selection) + + form = QFormLayout() + form.addRow(label) + form.addRow("Device", self.device_edit) + form.addRow("Signal", self.entry_edit) + + buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel, parent=dialog) + buttons.accepted.connect(dialog.accept) + buttons.rejected.connect(dialog.reject) + + v = QVBoxLayout(dialog) + v.addLayout(form) + v.addWidget(buttons) + + if dialog.exec_() == QDialog.Accepted: + self.waveform.plot( + y_name=self.device_edit.currentText(), y_entry=self.entry_edit.currentText() + ) + + @SafeSlot() + def on_exit(self) -> bool: + ans = QMessageBox.question( + self, + "Switch and clear?", + "Do you want to switch views and clear the plot?", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No, + ) + if ans == QMessageBox.Yes: + self.waveform.clear_all() + return True + return False + + +# --- Inline stacked UI version --- +class WaveformViewInline(ViewBase): # pragma: no cover + def __init__(self, parent=None, *args, **kwargs): + super().__init__(parent=parent, *args, **kwargs) + + # Root layout for this view uses a stacked layout + self.stack = QStackedLayout() + container = QWidget(self) + container.setLayout(self.stack) + self.set_content(container) + + # --- Page 0: Settings page (inline form) + self.settings_page = QWidget() + sp_layout = QVBoxLayout(self.settings_page) + sp_layout.setContentsMargins(16, 16, 16, 16) + sp_layout.setSpacing(12) + + title = QLabel("Select device and signal for the waveform plot:", parent=self.settings_page) + self.device_edit = DeviceComboBox(parent=self.settings_page) + self.device_edit.insertItem(0, "") + self.device_edit.setEditable(True) + self.device_edit.setCurrentIndex(0) + + self.entry_edit = SignalComboBox(parent=self.settings_page) + self.entry_edit.include_config_signals = False + self.entry_edit.insertItem(0, "") + self.entry_edit.setEditable(True) + self.device_edit.currentTextChanged.connect(self.entry_edit.set_device) + self.device_edit.device_reset.connect(self.entry_edit.reset_selection) + + form = QFormLayout() + form.addRow(title) + form.addRow("Device", self.device_edit) + form.addRow("Signal", self.entry_edit) + + btn_row = QHBoxLayout() + ok_btn = QPushButton("OK", parent=self.settings_page) + cancel_btn = QPushButton("Cancel", parent=self.settings_page) + btn_row.addStretch(1) + btn_row.addWidget(cancel_btn) + btn_row.addWidget(ok_btn) + + sp_layout.addLayout(form) + sp_layout.addLayout(btn_row) + + # --- Page 1: Waveform page + self.waveform_page = QWidget() + wf_layout = QVBoxLayout(self.waveform_page) + wf_layout.setContentsMargins(0, 0, 0, 0) + self.waveform = Waveform(parent=self.waveform_page) + wf_layout.addWidget(self.waveform) + + # --- Page 2: Exit confirmation page (inline) + self.confirm_page = QWidget() + cp_layout = QVBoxLayout(self.confirm_page) + cp_layout.setContentsMargins(16, 16, 16, 16) + cp_layout.setSpacing(12) + qlabel = QLabel("Do you want to switch views and clear the plot?", parent=self.confirm_page) + cp_buttons = QHBoxLayout() + no_btn = QPushButton("No", parent=self.confirm_page) + yes_btn = QPushButton("Yes", parent=self.confirm_page) + cp_buttons.addStretch(1) + cp_buttons.addWidget(no_btn) + cp_buttons.addWidget(yes_btn) + cp_layout.addWidget(qlabel) + cp_layout.addLayout(cp_buttons) + + # Add pages to the stack + self.stack.addWidget(self.settings_page) # index 0 + self.stack.addWidget(self.waveform_page) # index 1 + self.stack.addWidget(self.confirm_page) # index 2 + + # Wire settings buttons + ok_btn.clicked.connect(self._apply_settings_and_show_waveform) + cancel_btn.clicked.connect(self._show_waveform_without_changes) + + # Prepare result holder for the inline confirmation + self._exit_choice_yes = None + yes_btn.clicked.connect(lambda: self._exit_reply(True)) + no_btn.clicked.connect(lambda: self._exit_reply(False)) + + @SafeSlot() + def on_enter(self) -> None: + # Always start on the settings page when entering + self.stack.setCurrentIndex(0) + + @SafeSlot() + def on_exit(self) -> bool: + # Show inline confirmation page and synchronously wait for a choice + # -> trick to make the choice blocking, however popup would be cleaner solution + self._exit_choice_yes = None + self.stack.setCurrentIndex(2) + loop = QEventLoop() + self._exit_loop = loop + loop.exec_() + + if self._exit_choice_yes: + self.waveform.clear_all() + return True + # Revert to waveform view if user cancelled switching + self.stack.setCurrentIndex(1) + return False + + def _apply_settings_and_show_waveform(self): + dev = self.device_edit.currentText() + sig = self.entry_edit.currentText() + if dev and sig: + self.waveform.plot(y_name=dev, y_entry=sig) + self.stack.setCurrentIndex(1) + + def _show_waveform_without_changes(self): + # Just show waveform page without plotting + self.stack.setCurrentIndex(1) + + def _exit_reply(self, yes: bool): + self._exit_choice_yes = bool(yes) + if hasattr(self, "_exit_loop") and self._exit_loop.isRunning(): + self._exit_loop.quit()