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 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 17:09:18 +02:00
co-authored by Claude Fable 5
parent 669ba90317
commit e572a439b5
3 changed files with 147 additions and 138 deletions
+87 -55
View File
@@ -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,
)
+35 -58
View File
@@ -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()
+25 -25
View File
@@ -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"