diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index 1baf7b61..8d523cf8 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -359,7 +359,6 @@ class MainWindow(QMainWindow): self.compact_automation_panel.show_full_view_requested.connect(self._return_from_compact_automation_view) self.compact_automation_panel.annotation_selected.connect(self._handle_compact_annotation) - self.tell_samples_dock = QDockWidget("Sample List", self) self.tell_samples_dock.setObjectName("tell_samples_dock") self.tell_samples_dock.setWidget(self.tell_samples) @@ -374,7 +373,6 @@ class MainWindow(QMainWindow): self.tabifyDockWidget(self.ref_tools_dock, self.tell_samples_dock) if self.__decoded_token.staff: self.ref_tools_dock.show() - self.ref_tools_dock.raise_() else: self.ref_tools_dock.hide() @@ -419,7 +417,6 @@ class MainWindow(QMainWindow): self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, self.fluor_panel_dock) self.fluor_panel_dock.hide() - # Create and add the dock to your main window self.log_dock = LogDock("Console Log", self) self.log_dock.setObjectName("log_dock") self.addDockWidget(Qt.BottomDockWidgetArea, self.log_dock) @@ -427,6 +424,8 @@ class MainWindow(QMainWindow): self.log_dock.attach_logger("aareGUI") self.log_dock.hide() + self.tabifyDockWidget(self.automation_progress_dock, self.log_dock) + self.tabifyDockWidget(self.manual_sample_dock, self.automation_progress_dock) self.tabifyDockWidget(self.automation_progress_dock, self.log_dock) @@ -933,13 +932,18 @@ class MainWindow(QMainWindow): def _show_samcam_feed_banner(self, message: str) -> None: self.__samcam_feed_banner_message = message - self.alert_banner_secondary.show_message(message, True) + self._show_runtime_notification( + title="Sample camera", + message=message, + level="warning", + sticky=True, + ) self.__samcam_feed_banner_active = True def _clear_samcam_feed_banner(self) -> None: if not self.__samcam_feed_banner_active: return - self.alert_banner_secondary.clear_message() + self._clear_runtime_notification() self.__samcam_feed_banner_active = False def _stop_axis_camera_threads(self) -> None: @@ -1183,19 +1187,31 @@ class MainWindow(QMainWindow): @Slot(str, bool) def _portrait_alert_primary(self, msg: str, is_error: bool) -> None: - """Route primary alert banner — use in-panel toast in portrait mode.""" + """Route primary alerts into the runtime dock.""" if self.content_stack.currentWidget() is self.portrait_mode_page: self.portrait_mode_panel.show_portrait_alert(msg, is_error) else: - self.alert_banner.show_message(msg, is_error) + self._show_runtime_notification( + title="Runtime alert" if is_error else "Runtime update", + message=msg, + level="error" if is_error else "success", + sticky=is_error, + auto_clear_ms=None if is_error else 4000, + ) @Slot(str, bool) def _portrait_alert_secondary(self, msg: str, is_error: bool) -> None: - """Route secondary alert banner — use in-panel toast in portrait mode.""" + """Route secondary alerts into the runtime dock.""" if self.content_stack.currentWidget() is self.portrait_mode_page: self.portrait_mode_panel.show_portrait_alert(msg, is_error) else: - self.alert_banner_secondary.show_message(msg, is_error) + self._show_runtime_notification( + title="Device alert" if is_error else "Device update", + message=msg, + level="warning" if is_error else "info", + sticky=is_error, + auto_clear_ms=None if is_error else 4000, + ) @Slot() def _refresh_portrait_queue_preview(self) -> None: @@ -1546,16 +1562,46 @@ class MainWindow(QMainWindow): self._dev_help_dialog.raise_() self._dev_help_dialog.activateWindow() + def _show_runtime_notification( + self, + *, + title: str, + message: str, + level: str = "error", + sticky: bool = True, + auto_clear_ms: int | None = None, + ) -> None: + self.log_dock.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) + + def _clear_runtime_notification(self) -> None: + self.log_dock.clear_notification() + def _clear_automation_critical_banner(self) -> None: if not self._automation_critical_banner_active: return - self.alert_banner.clear_message() + self._clear_runtime_notification() self._automation_critical_banner_active = False @Slot(bool) def _on_automation_running_changed(self, running: bool) -> None: if running: self._clear_automation_critical_banner() + self._show_runtime_notification( + title="Automation running", + message="Automation resumed.", + level="success", + sticky=False, + auto_clear_ms=3500, + ) def _is_detector_critical_failure(self, message: str) -> bool: text = (message or "").lower() @@ -1589,14 +1635,12 @@ class MainWindow(QMainWindow): def _on_manual_collection_critical_failure(self, message: str) -> None: logger.critical(f"Manual collection critical detector failure: {message}") - try: - self.alert_banner_secondary.show_message( - self._detector_error_banner_text(automation=False, message=message), - True, - auto_clear_ms=0, - ) - except Exception: - pass + self._show_runtime_notification( + title="Collection paused", + message=message, + level="error", + sticky=True, + ) try: QMessageBox.critical( @@ -1674,14 +1718,12 @@ class MainWindow(QMainWindow): except Exception as e: logger.error(f"Failed to update automation progress after critical failure: {e}") - # 3. Banner so the operator sees it immediately - try: - self.alert_banner.show_message( - banner_message, True, auto_clear_ms=0 - ) - self._automation_critical_banner_active = True - except Exception: - pass + self._show_runtime_notification( + title="Automation paused", + message=message, + level="error", + sticky=True, + ) # 4. Surface recovery UI try: @@ -1692,13 +1734,19 @@ class MainWindow(QMainWindow): self._detector_error_dialog_title(message), ( "Automation has been stopped because there is an error with the detector.\n\n" - "Please call your local contact.\n\n" - f"Details:\n{message}" + f"Details:\n{message}" ), ) - self.show_local_contact("Detector") else: - self.show_local_contact("Recovery") + QMessageBox.critical( + self, + "Automation halted", + ( + "A critical error occurred during automation and the " + "beamline could not recover automatically:\n\n" + f"{message}\n\n" + ), + ) else: if is_detector_failure: QMessageBox.critical( diff --git a/src/aare/gui/panels/LogPanel.py b/src/aare/gui/panels/LogPanel.py index 9af0e920..8af2f928 100644 --- a/src/aare/gui/panels/LogPanel.py +++ b/src/aare/gui/panels/LogPanel.py @@ -1,32 +1,239 @@ -# Python - -from PySide6.QtCore import Qt -from PySide6.QtWidgets import QDockWidget, QPlainTextEdit +from PySide6.QtCore import Qt, QTimer, Signal, Slot +from PySide6.QtWidgets import ( + QDockWidget, + QPlainTextEdit, + QWidget, + QVBoxLayout, + QHBoxLayout, + QLabel, + QPushButton, + QToolButton, + QFrame, +) from aare.common.logger_config import QtLogEmitter, QtLogHandler, find_existing_formatter, attach_to_logger +class RuntimeNotificationWidget(QFrame): + cleared = Signal() + show_log_requested = Signal() + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("runtimeNotification") + self.setProperty("noticeLevel", "error") + self.setFrameShape(QFrame.Shape.StyledPanel) + self.setVisible(False) + + self._auto_clear_timer = QTimer(self) + self._auto_clear_timer.setSingleShot(True) + self._auto_clear_timer.timeout.connect(self.clear_notification) + + self._title = QLabel("", self) + self._title.setObjectName("runtimeNotificationTitle") + + self._message = QLabel("", self) + self._message.setObjectName("runtimeNotificationMessage") + self._message.setWordWrap(True) + + self._minimise_button = QToolButton(self) + self._minimise_button.setText("—") + self._minimise_button.setToolTip("Minimise notification") + self._minimise_button.clicked.connect(self._toggle_minimised) + + 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.clicked.connect(self.show_log_requested.emit) + + header_layout = QHBoxLayout() + header_layout.setContentsMargins(0, 0, 0, 0) + header_layout.addWidget(self._title, 1) + header_layout.addWidget(self._minimise_button) + + button_layout = QHBoxLayout() + button_layout.setContentsMargins(0, 0, 0, 0) + button_layout.addStretch(1) + button_layout.addWidget(self._show_log_button) + button_layout.addWidget(self._clear_button) + + self._body = QWidget(self) + body_layout = QVBoxLayout(self._body) + body_layout.setContentsMargins(0, 0, 0, 0) + body_layout.setSpacing(8) + body_layout.addWidget(self._message) + body_layout.addLayout(button_layout) + + root_layout = QVBoxLayout(self) + root_layout.setContentsMargins(12, 10, 12, 10) + root_layout.setSpacing(8) + root_layout.addLayout(header_layout) + root_layout.addWidget(self._body) + + self._full_title = "" + self._full_message = "" + self._minimised = False + self._sticky = True + + self.setStyleSheet( + """ + QFrame#runtimeNotification { + border: 1px solid #8a8a8a; + border-radius: 8px; + background-color: #fff4f4; + } + QFrame#runtimeNotification[noticeLevel="error"] { + background-color: #fff1f1; + border: 1px solid #d66; + } + QFrame#runtimeNotification[noticeLevel="warning"] { + background-color: #fff8e8; + border: 1px solid #d7aa42; + } + QFrame#runtimeNotification[noticeLevel="success"] { + background-color: #eefaf0; + border: 1px solid #6cb37a; + } + QFrame#runtimeNotification[noticeLevel="info"] { + background-color: #eef5ff; + border: 1px solid #6b9bd6; + } + QLabel#runtimeNotificationTitle { + font-weight: bold; + } + """ + ) + + def _set_level(self, level: str) -> None: + self.setProperty("noticeLevel", level) + self.style().unpolish(self) + self.style().polish(self) + self.update() + + @Slot() + def _toggle_minimised(self) -> None: + self._minimised = not self._minimised + self._body.setVisible(not self._minimised) + self._minimise_button.setText("+" if self._minimised else "—") + if self._minimised: + self._title.setText(self._full_title or "Notification") + else: + self._title.setText(self._full_title) + + def show_notification( + self, + *, + title: str, + message: str, + level: str = "error", + sticky: bool = True, + auto_clear_ms: int | None = None, + ) -> None: + self._auto_clear_timer.stop() + self._sticky = sticky + self._full_title = title.strip() or "Notification" + self._full_message = message.strip() + + self._set_level(level) + self._title.setText(self._full_title) + self._message.setText(self._full_message) + self._clear_button.setVisible(not sticky) + self._body.setVisible(True) + self._minimised = False + self._minimise_button.setText("—") + self.setVisible(True) + + if not sticky: + timeout = 5000 if auto_clear_ms is None else int(auto_clear_ms) + self._auto_clear_timer.start(timeout) + + def show_waiting(self, *, title: str, message: str, auto_clear_ms: int | None = None) -> None: + self.show_notification( + title=title, + message=message, + level="warning", + sticky=True, + auto_clear_ms=auto_clear_ms, + ) + + @Slot() + def clear_notification(self) -> None: + self._auto_clear_timer.stop() + self._full_title = "" + self._full_message = "" + self._title.clear() + self._message.clear() + self.setVisible(False) + self.cleared.emit() + + class LogDock(QDockWidget): def __init__(self, title="Log", parent=None): super().__init__(title, parent) - self.setAllowedAreas(Qt.BottomDockWidgetArea | Qt.TopDockWidgetArea) - self.view = QPlainTextEdit(self) + self.setAllowedAreas(Qt.BottomDockWidgetArea | Qt.RightDockWidgetArea | Qt.LeftDockWidgetArea) + + self.container = QWidget(self) + + self.notification = RuntimeNotificationWidget(self.container) + self.notification.show_log_requested.connect(self._raise_and_focus_log) + + self.view = QPlainTextEdit(self.container) self.view.setReadOnly(True) - self.setWidget(self.view) + + layout = QVBoxLayout(self.container) + layout.setContentsMargins(6, 6, 6, 6) + layout.setSpacing(6) + layout.addWidget(self.notification) + layout.addWidget(self.view, 1) + + self.setWidget(self.container) - # connect emitter to append text safely from any thread self.emitter = QtLogEmitter() self.emitter.message.connect(self._append_line) - # logging handler self.handler = QtLogHandler(self.emitter) self.handler.setFormatter(find_existing_formatter()) def attach_logger(self, logger_name: str = "aareGUI"): attach_to_logger(logger_name, self.handler) + @Slot(str) def _append_line(self, text: str): self.view.appendPlainText(text) + @Slot() + def _raise_and_focus_log(self) -> None: + self.setVisible(True) + self.raise_() + self.view.setFocus() + + def show_notification( + self, + *, + title: str, + message: str, + level: str = "error", + sticky: bool = True, + auto_clear_ms: int | None = None, + ) -> None: + self.setVisible(True) + self.raise_() + 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.notification.show_waiting(title=title, message=message) + + def clear_notification(self) -> None: + self.notification.clear_notification() + def clear(self): self.view.clear() \ No newline at end of file diff --git a/src/aare/gui/panels/portrait_mode.py b/src/aare/gui/panels/portrait_mode.py index 26d7d4fd..d5ae693a 100644 --- a/src/aare/gui/panels/portrait_mode.py +++ b/src/aare/gui/panels/portrait_mode.py @@ -9,9 +9,13 @@ from PySide6.QtGui import ( from PySide6.QtWidgets import ( QFrame, QHBoxLayout, QLabel, QPushButton, QScrollArea, QSizePolicy, QStackedWidget, QVBoxLayout, QWidget, + QSpinBox, ) from aare.common.automation_models import AutomationProgress, StepStatus, WorkflowStateKind +from aare.common.logger_config import setup_logger + +logger = setup_logger('aareGUI') # --------------------------------------------------------------------------- # Colour palette (kept identical to gui_designer.py) @@ -249,6 +253,15 @@ class PortraitModePanel(QWidget): self._job_list_panel = None self._tell_samples = None self._is_running = False + self._loop_enabled = False + self._loop_remaining = 0 + self._loop_samples = [] + + self._loop_restart_timer = QTimer(self) + self._loop_restart_timer.setInterval(1000) + self._loop_restart_timer.timeout.connect(self._try_loop_restart) + + self._loop_restart_deadline = None # Pending alert messages received while in portrait mode # Each entry: (msg, is_error) @@ -349,13 +362,48 @@ class PortraitModePanel(QWidget): ctrl_frame.setStyleSheet( f"QFrame {{ background: {CARD_BG}; border-radius: 18px; }}" ) + ctrl_layout = QHBoxLayout(ctrl_frame) - ctrl_layout.setContentsMargins(16, 12, 16, 12) - ctrl_layout.addStretch(1) + ctrl_layout.setContentsMargins(12, 12, 12, 12) + ctrl_layout.setSpacing(12) + + ctrl_layout.addStretch() + self._play_pause_btn = PlayPauseButton() self._play_pause_btn.clicked.connect(self._on_play_pause_clicked) - ctrl_layout.addWidget(self._play_pause_btn, alignment=Qt.AlignCenter) - ctrl_layout.addStretch(1) + ctrl_layout.addWidget(self._play_pause_btn) + + # Loop arrow button + self._loop_btn = QPushButton("↻") + self._loop_btn.setFixedSize(52, 52) + self._loop_btn.setCheckable(True) + self._loop_btn.setToolTip("Loop queue") + self._loop_btn.setStyleSheet(f""" + QPushButton {{ + border-radius: 26px; + background: {BUTTON_BG}; + color: {ACCENT}; + font-size: 28px; + font-weight: bold; + }} + QPushButton:checked {{ + background: {ACCENT}; + color: {BG}; + }} + """) + self._loop_btn.toggled.connect(self._toggle_loop) + ctrl_layout.addWidget(self._loop_btn) + + self._loop_count = QSpinBox() + self._loop_count.setRange(1, 999) + self._loop_count.setValue(2) + self._loop_count.setPrefix("× ") + self._loop_count.setFixedHeight(40) + self._loop_count.setToolTip("Number of queue repeats") + ctrl_layout.addWidget(self._loop_count) + + ctrl_layout.addStretch() + layout.addWidget(ctrl_frame) # "UP NEXT" header @@ -444,6 +492,13 @@ class PortraitModePanel(QWidget): self._job_list_panel = job_list_panel self._tell_samples = tell_samples + self._job_list_panel.loop_restart_requested = ( + self._restart_loop_if_needed + ) + + self._populate_queue_from_tell_samples_if_empty() + self.refresh_queue_preview() + # ------------------------------------------------------------------ # Public update slots # ------------------------------------------------------------------ @@ -665,4 +720,126 @@ class PortraitModePanel(QWidget): last_msg, last_is_error = errors[-1] primary_banner.show_message(last_msg, last_is_error) self._pending_alerts.clear() - self._dismiss_portrait_alert() \ No newline at end of file + self._dismiss_portrait_alert() + + @Slot(bool) + def _toggle_loop(self, enabled: bool) -> None: + self._loop_enabled = enabled + + if enabled and self._job_list_panel: + self._loop_samples = list( + self._job_list_panel.table_model.samples + ) + self._loop_remaining = self._loop_count.value() + else: + self._loop_remaining = 0 + + def _try_loop_restart(self): + """ + Polls beamline state until safe to restart. + """ + + if self._job_list_panel is None: + self._loop_restart_timer.stop() + return + + # Timeout protection + if ( + self._loop_restart_deadline is not None + and self._loop_restart_deadline.hasExpired() + ): + self._loop_restart_timer.stop() + + self._loop_enabled = False + self._loop_btn.setChecked(False) + + self.show_portrait_alert( + "Loop stopped: beamline remained busy too long", + True, + ) + return + + # Still busy, wait + if getattr( + self._job_list_panel, + "_SampleQueuePanel__busy", + False, + ): + return + + # Safe to restart + self._loop_restart_timer.stop() + + self._job_list_panel.run() + + def _restart_loop_if_needed(self) -> bool: + """ + Restore queue and wait for beamline idle before restarting. + Has timeout protection. + """ + + if not self._loop_enabled: + return False + + if not self._loop_samples: + return False + + if self._loop_remaining <= 0: + return False + + self._job_list_panel.queue_samples( + list(self._loop_samples), + replace=True, + ) + + self._loop_remaining -= 1 + + # Start waiting for idle + self._loop_restart_deadline = ( + QTimer().remainingTime() + ) + + # 30 second safety timeout + from PySide6.QtCore import QDeadlineTimer + self._loop_restart_deadline = QDeadlineTimer(30000) + + self._loop_restart_timer.start() + + return True + + def _populate_queue_from_tell_samples_if_empty(self) -> None: + """ + Fill the automation queue from TELL samples if no queue exists. + Samples are ordered by physical sample position. + """ + + if self._job_list_panel is None or self._tell_samples is None: + logger.info("Job list panel or tell_samples not ready") + return + + # Do not overwrite an existing queue + if self._job_list_panel.table_model.samples: + logger.info("Queue already exists, not populating from tell_samples") + return + + samples = list( + getattr(self._tell_samples.table_model, "samples", []) + ) + + ordered = sorted( + [ + s for s in samples + if getattr(s, "location", None) is not None + ], + key=lambda s: ( + s.loc_str_sort() + if hasattr(s, "loc_str_sort") + else "" + ), + ) + + if ordered: + self._job_list_panel.queue_samples( + ordered, + replace=True, + ) \ No newline at end of file diff --git a/src/aare/gui/panels/sample_queue_panel.py b/src/aare/gui/panels/sample_queue_panel.py index 8692be3f..2a8ee99e 100644 --- a/src/aare/gui/panels/sample_queue_panel.py +++ b/src/aare/gui/panels/sample_queue_panel.py @@ -37,6 +37,7 @@ class SampleQueuePanel(QFrame): self._step_through = False self.ring_current = None self._experiment_shutter_state = None + self.loop_restart_requested = None self.__recovery_timer = QTimer(self) self.__recovery_timer.setSingleShot(True) @@ -231,6 +232,15 @@ class SampleQueuePanel(QFrame): def _finish_empty_queue(self): self.pause_automation() + + if hasattr(self, "loop_restart_requested"): + logger.info("Loop restart requested") + try: + if self.loop_restart_requested(): + return + except Exception as e: + logger.exception(e) + if self.park_and_dry_when_cleared.isChecked(): logger.info("Automation queue empty; parking and drying TELL.") self.park_and_dry.emit() @@ -255,6 +265,7 @@ class SampleQueuePanel(QFrame): if self.__pause: if self.__busy: + logger.error(f"Cannot run automation while beamline is busy. Busy flag = {self.__busy}") self.show_error_dialog( title="Beamline is busy", msg="Cannot run automation while beamline is busy",