From e572a439b5c7e2ad2d9de0aca684c71d7bbc420d Mon Sep 17 00:00:00 2001 From: Dawn Date: Mon, 10 Aug 2026 17:09:18 +0200 Subject: [PATCH] feat: merge Automation progress and Console log into one Information dock The two tabified bottom docks left their switcher tabs stranded at the window bottom; one Information dock with proper tabs mirrors the Sample List dock's layout. LogDock becomes the LogPanel widget with mirror views for the pop-out and a reveal_requested signal so the owner controls visibility (Ctrl+Shift+L, notifications). Co-Authored-By: Claude Fable 5 --- src/aare/gui/main_window.py | 142 +++++++++++++++++++------------ src/aare/gui/panels/log_panel.py | 93 ++++++++------------ tests/unit/gui/test_log_panel.py | 50 +++++------ 3 files changed, 147 insertions(+), 138 deletions(-) diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index faead5d3..7cf2f43a 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -78,7 +78,7 @@ from aare.gui.panels.fluorescence_panel import FluorescencePanel from aare.gui.panels.local_contact_panel import LocalContactDialog # panels -from aare.gui.panels.log_panel import LogDock +from aare.gui.panels.log_panel import LogPanel from aare.gui.panels.monochromator_panel import MonochromatorPanel from aare.gui.panels.portrait_mode import PortraitModePanel from aare.gui.panels.prediction_metrics_panel import PredictionMetricsPanel @@ -611,21 +611,46 @@ class MainWindow(QMainWindow): self.manual_sample_panel = self.data_collection.manual_sample_panel self.automation_progress_panel = AutomationProgressWidget() - self.automation_progress_dock = QDockWidget("Automation progress", self) - self.automation_progress_dock.setObjectName("automation_progress_dock") # Scroll host: the panel's ~420px minimum otherwise dictates the whole # bottom row's height and squeezes the Beamline column into a scrollbar. - automation_scroll = NoWheelScrollArea(self.automation_progress_dock) + automation_scroll = NoWheelScrollArea() automation_scroll.setWidget(self.automation_progress_panel) automation_scroll.setWidgetResizable(True) automation_scroll.setFrameShape(QFrame.Shape.NoFrame) - self.automation_progress_dock.setWidget(automation_scroll) - self.automation_progress_dock.setAllowedAreas(Qt.DockWidgetArea.BottomDockWidgetArea) - self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, self.automation_progress_dock) - # Same title-bar icons (⤢ pop-out + ✕) as Sample List / Console Log. - self._automation_popout: PopoutWindow | None = None - self.automation_progress_dock.setTitleBarWidget( - DockTitleBar(self.automation_progress_dock, self._open_automation_popout) + + # One "Information" dock with Automation progress + Console log tabs, + # mirroring the Sample List dock's Dewar/Auxiliary layout — replaces + # the two tabified docks whose switcher tabs sat at the window bottom. + self.log_panel = LogPanel() + self.log_panel.attach_logger("") + self.log_panel.attach_logger("aareGUI") + self.log_panel.reveal_requested.connect(self._reveal_console_log) + + self.information_tabs = QTabWidget() + self.information_tabs.addTab(automation_scroll, "Automation progress") + self.information_tabs.addTab(self.log_panel, "Console log") + + # Same wrapper trick as the Sample List dock: QTabWidget ignores its + # own contents margins for the tab bar, so the inset lives one level up. + information_wrap = QWidget() + information_wrap_layout = QVBoxLayout(information_wrap) + information_wrap_layout.setContentsMargins(DOCK_CONTENT_LEFT_PAD, 0, 0, 0) + information_wrap_layout.setSpacing(0) + information_wrap_layout.addWidget(self.information_tabs) + + self.information_dock = QDockWidget("Information", self) + self.information_dock.setObjectName("information_dock") + self.information_dock.setWidget(information_wrap) + self.information_dock.setAllowedAreas(Qt.DockWidgetArea.BottomDockWidgetArea) + self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, self.information_dock) + self.information_dock.setFeatures( + QDockWidget.DockWidgetFeature.DockWidgetMovable + | QDockWidget.DockWidgetFeature.DockWidgetClosable + ) + # Same title-bar icons (⤢ pop-out + ✕) as Sample List. + self._information_popout: PopoutWindow | None = None + self.information_dock.setTitleBarWidget( + DockTitleBar(self.information_dock, self._open_information_popout) ) self.face_panel = FaceDetectionPanel() @@ -650,15 +675,6 @@ class MainWindow(QMainWindow): self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, self.fluor_panel_dock) self.fluor_panel_dock.hide() - self.log_dock = LogDock("Console Log", self) - self.log_dock.setObjectName("log_dock") - self.addDockWidget(Qt.BottomDockWidgetArea, self.log_dock) - self.log_dock.attach_logger("") - self.log_dock.attach_logger("aareGUI") - self.log_dock.hide() - - self.tabifyDockWidget(self.automation_progress_dock, self.log_dock) - self.job_list_panel.samples_in_queue_changed.connect( self.automation_progress_panel.set_samples_in_queue ) @@ -788,11 +804,11 @@ class MainWindow(QMainWindow): # the default-state capture so "reset layout" gets it too; a saved # user layout (restored below) still wins. self.resizeDocks( - [self.tell_samples_dock, self.log_dock], [240, 240], Qt.Orientation.Vertical + [self.tell_samples_dock, self.information_dock], [240, 240], Qt.Orientation.Vertical ) # Equal oversized requests -> Qt distributes proportionally = 50/50. self.resizeDocks( - [self.tell_samples_dock, self.automation_progress_dock], + [self.tell_samples_dock, self.information_dock], [10000, 10000], Qt.Orientation.Horizontal, ) @@ -1200,11 +1216,9 @@ class MainWindow(QMainWindow): ) self.addAction(self._shortcut_toggle_smargon_trace) - self._shortcut_console_log = QAction("Toggle Console Log", self) + self._shortcut_console_log = QAction("Show Console log", self) self._shortcut_console_log.setShortcut(QKeySequence("Ctrl+Shift+L")) - self._shortcut_console_log.triggered.connect( - lambda: (self.log_dock.setVisible(True), self.log_dock.raise_()) - ) + self._shortcut_console_log.triggered.connect(self._reveal_console_log) self.addAction(self._shortcut_console_log) @Slot() @@ -1278,20 +1292,33 @@ class MainWindow(QMainWindow): self._sample_popout.raise_() self._sample_popout.activateWindow() - def _open_automation_popout(self) -> None: - if self._automation_popout is None: - # Mirror wired to the same feeds as the docked panel. + def _open_information_popout(self) -> None: + if self._information_popout is None: + # Automation mirror wired to the same feeds as the docked panel. panel = AutomationProgressWidget() self.job_list_panel.samples_in_queue_changed.connect(panel.set_samples_in_queue) self.job_list_panel.automation_running_changed.connect(panel.set_running) self.daq.automation_progress.connect(panel.set_progress) panel.set_samples_in_queue(len(self.job_list_panel.table_model.samples)) panel.set_running(self.job_list_panel.is_running()) - self._automation_popout = PopoutWindow("Automation progress", panel, parent=self) - self._automation_popout.resize(420, 520) - self._automation_popout.show() - self._automation_popout.raise_() - self._automation_popout.activateWindow() + + tabs = QTabWidget() + tabs.addTab(panel, "Automation progress") + tabs.addTab(self.log_panel.make_mirror_view(), "Console log") + self._information_popout = PopoutWindow("Information", tabs, parent=self) + self._information_popout.resize(1000, 520) + self._information_popout.show() + self._information_popout.raise_() + self._information_popout.activateWindow() + + @Slot() + def _reveal_console_log(self) -> None: + """Show the Information dock with the Console log tab on top — the + one entry point for 'the user must see the log now' (notifications, + Ctrl+Shift+L).""" + self.information_dock.setVisible(True) + self.information_dock.raise_() + self.information_tabs.setCurrentWidget(self.log_panel) def _clone_automation_row(self, dewar_panel: TellSamplePanel) -> QHBoxLayout: """Pop-out copy of the automation controls, driving the same queue @@ -1520,13 +1547,12 @@ class MainWindow(QMainWindow): self._pre_automation_right_column_visible = self.beamline_controls_scroll.isVisible() self.tell_samples_dock.setVisible(False) - self.automation_progress_dock.setVisible(False) + self.information_dock.setVisible(False) self.face_panel_dock.setVisible(False) self.fluor_panel_dock.setVisible(False) self.smargon_trace_dock.setVisible(False) self.target_stability_dock.setVisible(False) self.prediction_metrics_dock.setVisible(False) - self.log_dock.setVisible(False) self.collection_controls_scroll.setVisible(False) self.beamline_controls_scroll.setVisible(False) @@ -1589,13 +1615,12 @@ class MainWindow(QMainWindow): # Hide all dock widgets for dock_attr in ( "tell_samples_dock", - "automation_progress_dock", + "information_dock", "face_panel_dock", "fluor_panel_dock", "smargon_trace_dock", "target_stability_dock", "prediction_metrics_dock", - "log_dock", ): dock = getattr(self, dock_attr, None) if dock is not None: @@ -1654,13 +1679,12 @@ class MainWindow(QMainWindow): self._pre_portrait_geometry = None self.tell_samples_dock.setVisible(True) - self.automation_progress_dock.setVisible(False) + self.information_dock.setVisible(False) self.face_panel_dock.setVisible(False) self.fluor_panel_dock.setVisible(False) self.smargon_trace_dock.setVisible(False) self.target_stability_dock.setVisible(False) self.prediction_metrics_dock.setVisible(False) - self.log_dock.setVisible(False) @Slot(str, bool) def _portrait_alert_primary(self, msg: str, is_error: bool) -> None: @@ -1943,12 +1967,14 @@ class MainWindow(QMainWindow): ) view_menu.addAction(show_prediction_metrics_action) - show_log_action = QAction("Show Log", self) - show_log_action.setCheckable(True) - show_log_action.setChecked(False) - show_log_action.triggered.connect(lambda checked: self.log_dock.setVisible(checked)) - self.log_dock.visibilityChanged.connect(show_log_action.setChecked) - view_menu.addAction(show_log_action) + show_information_action = QAction("Show Information", self) + show_information_action.setCheckable(True) + show_information_action.setChecked(False) + show_information_action.triggered.connect( + lambda checked: self.information_dock.setVisible(checked) + ) + self.information_dock.visibilityChanged.connect(show_information_action.setChecked) + view_menu.addAction(show_information_action) view_menu.addSeparator() @@ -2036,7 +2062,10 @@ class MainWindow(QMainWindow): self.smargon_trace_dock.setVisible(False) self.target_stability_dock.setVisible(False) self.prediction_metrics_dock.setVisible(False) - self.log_dock.setVisible(False) + # Default look: Information dock open on the Automation progress tab + # (the old automation dock was visible by default, the log hidden). + self.information_dock.setVisible(True) + self.information_tabs.setCurrentIndex(0) self.tell_samples_dock.raise_() @@ -2073,15 +2102,15 @@ class MainWindow(QMainWindow): sticky: bool = True, auto_clear_ms: int | None = None, ) -> None: - self.log_dock.show_notification( + self.log_panel.show_notification( title=title, message=message, level=level, sticky=sticky, auto_clear_ms=auto_clear_ms ) def _show_runtime_waiting_notification(self, *, title: str, message: str) -> None: - self.log_dock.show_waiting_notification(title=title, message=message) + self.log_panel.show_waiting_notification(title=title, message=message) def _clear_runtime_notification(self) -> None: - self.log_dock.clear_notification() + self.log_panel.clear_notification() def _clear_automation_critical_banner(self) -> None: if not self._automation_critical_banner_active: @@ -2765,7 +2794,7 @@ class MainWindow(QMainWindow): settings.setValue("prediction_metrics", self.prediction_metrics_dock.isVisible()) settings.setValue("face_detection", self.face_panel_dock.isVisible()) settings.setValue("fluorescence", self.fluor_panel_dock.isVisible()) - settings.setValue("log", self.log_dock.isVisible()) + settings.setValue("information", self.information_dock.isVisible()) settings.endGroup() def _restore_panel_visibility_settings(self) -> None: @@ -2787,8 +2816,11 @@ class MainWindow(QMainWindow): self.face_panel_dock.setVisible(settings.value("face_detection", False, type=bool)) if settings.contains("fluorescence"): self.fluor_panel_dock.setVisible(settings.value("fluorescence", False, type=bool)) - if settings.contains("log"): - self.log_dock.setVisible(settings.value("log", False, type=bool)) + # New key: the old "log" flag described a dock that defaulted hidden; + # the merged Information dock defaults visible, so old values would + # wrongly hide it. + if settings.contains("information"): + self.information_dock.setVisible(settings.value("information", True, type=bool)) settings.endGroup() @@ -2818,10 +2850,10 @@ class MainWindow(QMainWindow): def _apply_default_dock_split(self) -> None: self.resizeDocks( - [self.tell_samples_dock, self.log_dock], [240, 240], Qt.Orientation.Vertical + [self.tell_samples_dock, self.information_dock], [240, 240], Qt.Orientation.Vertical ) self.resizeDocks( - [self.tell_samples_dock, self.automation_progress_dock], + [self.tell_samples_dock, self.information_dock], [10000, 10000], Qt.Orientation.Horizontal, ) diff --git a/src/aare/gui/panels/log_panel.py b/src/aare/gui/panels/log_panel.py index 12289ea4..2477ab55 100644 --- a/src/aare/gui/panels/log_panel.py +++ b/src/aare/gui/panels/log_panel.py @@ -1,7 +1,6 @@ from aarecommon.config.logger import attach_to_logger, find_existing_formatter -from PySide6.QtCore import Qt, QTimer, Signal, Slot +from PySide6.QtCore import QTimer, Signal, Slot from PySide6.QtWidgets import ( - QDockWidget, QFrame, QHBoxLayout, QLabel, @@ -28,7 +27,6 @@ from aare.gui.styles import ( TEXT, card_style, ) -from aare.gui.widgets.popout_window import DockTitleBar, PopoutWindow class RuntimeNotificationWidget(QFrame): @@ -61,7 +59,7 @@ class RuntimeNotificationWidget(QFrame): self._clear_button = QPushButton("Clear", self) self._clear_button.clicked.connect(self.clear_notification) - self._show_log_button = QPushButton("Show Log", self) + self._show_log_button = QPushButton("Show log", self) self._show_log_button.clicked.connect(self.show_log_requested.emit) header_layout = QHBoxLayout() @@ -194,43 +192,30 @@ class RuntimeNotificationWidget(QFrame): self.cleared.emit() -class LogDock(QDockWidget): - def __init__(self, title="Log", parent=None): - super().__init__(title, parent) - # saveState/restoreState (watch-mode hide/restore) skips unnamed docks. - self.setObjectName("log_dock") - self.setAllowedAreas( - Qt.DockWidgetArea.BottomDockWidgetArea - | Qt.DockWidgetArea.RightDockWidgetArea - | Qt.DockWidgetArea.LeftDockWidgetArea - ) +class LogPanel(QWidget): + """Console-log card: notification banner + log view. A tab inside the + Information dock (was its own LogDock QDockWidget until the Automation + progress / Console log docks merged). Revealing the dock/tab is the + owner's job — this panel only signals when it needs to be seen.""" - # No floating: popping a dock out rips it from the row and reshuffles - # the rest. The ⤢ in the title bar (next to ✕) opens an ADDITIONAL - # window on the same log instead. - self.setFeatures( - QDockWidget.DockWidgetFeature.DockWidgetMovable - | QDockWidget.DockWidgetFeature.DockWidgetClosable - ) - self._popout: PopoutWindow | None = None - self._popout_view: QPlainTextEdit | None = None - self.setTitleBarWidget(DockTitleBar(self, self._open_popout)) + reveal_requested = Signal() - self.container = QWidget(self) + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("logPanel") + self.notification = RuntimeNotificationWidget(self) + self.notification.show_log_requested.connect(self._focus_log) - self.notification = RuntimeNotificationWidget(self.container) - self.notification.show_log_requested.connect(self._raise_and_focus_log) - - self.view = QPlainTextEdit(self.container) + self.view = QPlainTextEdit(self) self.view.setReadOnly(True) - layout = QVBoxLayout(self.container) + layout = QVBoxLayout(self) layout.setContentsMargins(6, 6, 6, 6) layout.setSpacing(6) layout.addWidget(self.notification) layout.addWidget(self.view, 1) - self.setWidget(self.container) + self._mirror_views: list[QPlainTextEdit] = [] self.emitter = QtLogEmitter() self.emitter.message.connect(self._append_line) @@ -245,29 +230,23 @@ class LogDock(QDockWidget): def _append_line(self, text: str): self.view.appendPlainText(text) - @Slot() - def _open_popout(self) -> None: - if self._popout is None: - # Mirror view fed by the same emitter; history is copied once at - # creation. (One QTextDocument shared by two QPlainTextEdits would - # make their layouts fight, hence the second document.) - view = QPlainTextEdit() - view.setReadOnly(True) - # Frameless inside the pop-out — no nested boxes in this window. - view.setStyleSheet("QPlainTextEdit { border: none; }") - view.setPlainText(self.view.toPlainText()) - self.emitter.message.connect(view.appendPlainText) - self._popout_view = view - self._popout = PopoutWindow("Console Log", view, parent=self.window()) - self._popout.resize(1000, 450) - self._popout.show() - self._popout.raise_() - self._popout.activateWindow() + def make_mirror_view(self) -> QPlainTextEdit: + """Second view on the same emitter, for pop-out windows: history is + copied once at creation, live lines reach every mirror, clear() + empties them all. (One QTextDocument shared by two QPlainTextEdits + would make their layouts fight, hence the separate documents.)""" + view = QPlainTextEdit() + view.setReadOnly(True) + # Frameless inside the pop-out — no nested boxes in this window. + view.setStyleSheet("QPlainTextEdit { border: none; }") + view.setPlainText(self.view.toPlainText()) + self.emitter.message.connect(view.appendPlainText) + self._mirror_views.append(view) + return view @Slot() - def _raise_and_focus_log(self) -> None: - self.setVisible(True) - self.raise_() + def _focus_log(self) -> None: + self.reveal_requested.emit() self.view.setFocus() def show_notification( @@ -279,15 +258,13 @@ class LogDock(QDockWidget): sticky: bool = True, auto_clear_ms: int | None = None, ) -> None: - self.setVisible(True) - self.raise_() + self.reveal_requested.emit() self.notification.show_notification( title=title, message=message, level=level, sticky=sticky, auto_clear_ms=auto_clear_ms ) def show_waiting_notification(self, *, title: str, message: str) -> None: - self.setVisible(True) - self.raise_() + self.reveal_requested.emit() self.notification.show_waiting(title=title, message=message) def clear_notification(self) -> None: @@ -295,5 +272,5 @@ class LogDock(QDockWidget): def clear(self): self.view.clear() - if self._popout_view is not None: - self._popout_view.clear() + for mirror in self._mirror_views: + mirror.clear() diff --git a/tests/unit/gui/test_log_panel.py b/tests/unit/gui/test_log_panel.py index 6e69bae9..962ad6df 100644 --- a/tests/unit/gui/test_log_panel.py +++ b/tests/unit/gui/test_log_panel.py @@ -1,31 +1,31 @@ -"""The console-log pop-out is a second view on the same emitter: history is -copied on open, live lines reach both views, and clear() empties both.""" +"""A log mirror view is a second view on the same emitter: history is copied +on creation, live lines reach both views, and clear() empties them all.""" -from aare.gui.panels.log_panel import LogDock +from aare.gui.panels.log_panel import LogPanel -def test_log_popout_mirrors_and_clears(qtbot): - dock = LogDock() - qtbot.addWidget(dock) - dock.emitter.message.emit("first line") - assert "first line" in dock.view.toPlainText() +def test_log_mirror_view_and_clear(qtbot): + panel = LogPanel() + qtbot.addWidget(panel) + panel.emitter.message.emit("first line") + assert "first line" in panel.view.toPlainText() - dock._open_popout() - assert dock._popout is not None - assert dock._popout.isVisible() - popout_view = dock._popout_view - assert popout_view is not None - # History copied on open, live lines reach both views. - assert "first line" in popout_view.toPlainText() - dock.emitter.message.emit("second line") - assert "second line" in dock.view.toPlainText() - assert "second line" in popout_view.toPlainText() + mirror = panel.make_mirror_view() + qtbot.addWidget(mirror) + # History copied on creation, live lines reach both views. + assert "first line" in mirror.toPlainText() + panel.emitter.message.emit("second line") + assert "second line" in panel.view.toPlainText() + assert "second line" in mirror.toPlainText() - # Reopening reuses the window instead of stacking mirrors. - popout = dock._popout - dock._open_popout() - assert dock._popout is popout + panel.clear() + assert panel.view.toPlainText() == "" + assert mirror.toPlainText() == "" - dock.clear() - assert dock.view.toPlainText() == "" - assert popout_view.toPlainText() == "" + +def test_notification_requests_reveal(qtbot): + panel = LogPanel() + qtbot.addWidget(panel) + with qtbot.waitSignal(panel.reveal_requested, timeout=1000): + panel.show_notification(title="Boom", message="it broke") + assert panel.notification._title.text() == "Boom"