diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index 260e9fec..ade38ac7 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -32,7 +32,6 @@ from PySide6.QtGui import ( QColor, QCursor, QFont, - QGuiApplication, QImage, QKeySequence, QPalette, @@ -56,7 +55,6 @@ from PySide6.QtWidgets import ( QSizePolicy, QSlider, QSplitter, - QStackedWidget, QTabWidget, QToolBar, QToolTip, @@ -75,7 +73,6 @@ from aare.gui.panels.axis_video_panel import AxisVideoPanel from aare.gui.panels.beamline_controls import BeamConfigPanel, BeamlineControls from aare.gui.panels.beamline_recovery_panel import BeamlineRecoveryDialog from aare.gui.panels.beamline_state_panel import BeamlineStatePanel -from aare.gui.panels.compact_automation_panel import CompactAutomationPanel from aare.gui.panels.data_collection_settings import DataCollectionSettings from aare.gui.panels.developer_help_dialog import DeveloperHelpDialog from aare.gui.panels.face_detection_panel import FaceDetectionPanel @@ -85,7 +82,6 @@ from aare.gui.panels.local_contact_panel import LocalContactDialog # panels 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 from aare.gui.panels.reference_tools_panel import ReferenceToolsPanel from aare.gui.panels.samcam_panel import SamcamPanel @@ -219,12 +215,6 @@ class MainWindow(QMainWindow): self._local_contact_dialog = None self._controls_help_dialog = None self._default_window_state = None - self._pre_automation_window_state = None - self._pre_automation_left_column_visible = True - self._pre_automation_right_column_visible = True - self._in_compact_automation_view = False - self._enter_automation_view_action = None - self._return_main_view_action = None self._beamline_cam_addr = beamline_cam_addr self._gonio_cam_addr = gonio_cam_addr @@ -313,9 +303,6 @@ class MainWindow(QMainWindow): self.alert_banner_secondary = AlertBanner(parent=root_widget) self.alert_banner_secondary.float_over(root_widget) - self.content_stack = QStackedWidget(parent=root_widget) - root_layout.addWidget(self.content_stack, 1) - top_widget = QWidget(parent=root_widget) top_widget.setObjectName("standardMainPage") top_widget_layout = QHBoxLayout(top_widget) @@ -542,40 +529,6 @@ class MainWindow(QMainWindow): # self.secondary_beamline_view_panel.refresh_requested.connect(self.refresh_axis_cameras) # self.video_tab.addTab(self.secondary_beamline_view_panel, "Secondary view") - self.compact_sample_camera = SampleCameraImageLabel( - geom=geom, raster=self.raster, parent=root_widget, default_image=default_image - ) - self.compact_automation_panel = CompactAutomationPanel( - self.compact_sample_camera, parent=root_widget - ) - - self.compact_automation_page = QWidget(parent=root_widget) - self.compact_automation_page.setObjectName("compactAutomationPage") - self.compact_automation_page_layout = QVBoxLayout(self.compact_automation_page) - self.compact_automation_page_layout.setContentsMargins(18, 18, 18, 18) - self.compact_automation_page_layout.setSpacing(0) - self.compact_automation_page_layout.addWidget(self.compact_automation_panel) - - # ── Portrait mode page ────────────────────────────────────────── - self.portrait_sample_camera = SampleCameraImageLabel( - geom=geom, raster=self.raster, parent=root_widget, default_image=default_image - ) - self.portrait_mode_panel = PortraitModePanel( - sample_camera_widget=self.portrait_sample_camera, parent=root_widget - ) - - self.portrait_mode_page = QWidget(parent=root_widget) - self.portrait_mode_page.setObjectName("portraitModePage") - portrait_page_layout = QHBoxLayout(self.portrait_mode_page) - portrait_page_layout.setContentsMargins(0, 0, 0, 0) - portrait_page_layout.setSpacing(0) - # No fixed page width: an explicit minimum beats the content_stack's - # Ignored-when-hidden policy and pinned the whole window >=444px in - # NORMAL mode. enter_portrait_mode pins the window width itself. - portrait_page_layout.addWidget( - self.portrait_mode_panel, alignment=Qt.AlignmentFlag.AlignHCenter - ) - self.center_splitter.addWidget(self.video_tab) self._start_axis_camera_threads() @@ -600,16 +553,6 @@ class MainWindow(QMainWindow): self.ref_tools_panel = ReferenceToolsPanel(samples=SampleShortInfoList(s=[])) self.job_list_panel = SampleQueuePanel(show_user=self._decoded_token.staff) - self.compact_automation_panel.play_pause_clicked.connect(self.job_list_panel.run) - self.compact_automation_panel.skip_clicked.connect(self.job_list_panel.skip_current_sample) - self.compact_automation_panel.step_through_toggled.connect( - self.job_list_panel.set_step_through - ) - 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) - # One dock for both lists: the old tabified Reference Tools dock was # staff-only and hid behind the Sample List tab, so it "sometimes" # showed. aaregui2 concept: one panel, Dewar + Auxiliary-puck tabs @@ -810,33 +753,11 @@ class MainWindow(QMainWindow): self.automation_progress_panel.set_running ) self.job_list_panel.automation_running_changed.connect(self._on_automation_running_changed) - self.job_list_panel.automation_running_changed.connect( - self.compact_automation_panel.set_running - ) - self.job_list_panel.step_through_changed.connect( - self.compact_automation_panel.set_step_through - ) - self.job_list_panel.samples_in_queue_changed.connect( - self.compact_automation_panel.set_samples_in_queue - ) - self.job_list_panel.samples_in_queue_changed.connect(self._refresh_compact_queue_preview) - # Portrait mode: queue size + running state + preview - self.job_list_panel.automation_running_changed.connect(self.portrait_mode_panel.set_running) - self.job_list_panel.samples_in_queue_changed.connect( - self.portrait_mode_panel.set_samples_in_queue - ) - self.job_list_panel.samples_in_queue_changed.connect(self._refresh_portrait_queue_preview) self.automation_progress_panel.set_samples_in_queue( len(self.job_list_panel.table_model.samples) ) self.automation_progress_panel.set_running(self.job_list_panel.is_running()) - self.compact_automation_panel.set_running(self.job_list_panel.is_running()) - self.compact_automation_panel.set_step_through(self.job_list_panel.is_step_through()) - self.compact_automation_panel.set_samples_in_queue( - len(self.job_list_panel.table_model.samples) - ) - self._refresh_compact_queue_preview() # smargon trace panel self.smargon_trace_panel = SmargonTracePanel() @@ -899,33 +820,20 @@ class MainWindow(QMainWindow): standard_page_scroll.setWidgetResizable(True) standard_page_scroll.setFrameShape(QFrame.Shape.NoFrame) standard_page_scroll.setWidget(top_widget) - - self.content_stack.addWidget(standard_page_scroll) - self.content_stack.addWidget(self.compact_automation_page) - self.content_stack.addWidget(self.portrait_mode_page) - self.content_stack.setCurrentWidget(standard_page_scroll) - self._standard_main_page = standard_page_scroll - - # QStackedWidget's minimum size is the max over ALL pages, so the hidden - # portrait/compact pages inflated the window minimum past small monitors - # (portrait alone demands 720px height). Only the visible page should - # count — the mode-switch code resizes the window explicitly anyway. - def _only_current_page_counts(index: int) -> None: - for i in range(self.content_stack.count()): - page = self.content_stack.widget(i) - policy = QSizePolicy.Policy.Preferred if i == index else QSizePolicy.Policy.Ignored - page.setSizePolicy(policy, policy) - - self.content_stack.currentChanged.connect(_only_current_page_counts) - _only_current_page_counts(self.content_stack.currentIndex()) + root_layout.addWidget(standard_page_scroll, 1) self.setCentralWidget(root_widget) + # Qt auto-pins a top-level window's minimum size to its layout + # minimum, which the bottom dock row pushed past small monitors. + # An explicit minimum disables that auto-pin: the window shrinks to + # this floor and the dock contents clip/scroll instead. + self.setMinimumSize(320, 240) + self.setWindowTitle("AareGUI") self._restore_theme_settings() self._apply_theme() self.create_menu_bar() - self._update_view_mode_actions() self._setup_global_shortcuts() # Default bottom-dock height: the sample list used to grab ~40% of the # window and squeeze the Beamline column behind a scrollbar. Before @@ -987,15 +895,8 @@ class MainWindow(QMainWindow): self._axis_camera_refresh_timer.timeout.connect(self.refresh_axis_cameras) self._axis_camera_refresh_timer.start() - self.portrait_mode_panel.wire_to_main_window( - job_list_panel=self.job_list_panel, tell_samples=self.tell_samples - ) - self.portrait_mode_panel._back_btn.clicked.connect(self._return_from_portrait_mode) - self.portrait_mode_panel.grab_session_requested.connect(self.status_bar.request_baton) - - # Route alert banner signals through portrait-aware interceptors - self.daq.polled_devices_status.connect(self._portrait_alert_primary) - self.daq.detector_error.connect(self._portrait_alert_secondary) + self.daq.polled_devices_status.connect(self._runtime_alert_primary) + self.daq.detector_error.connect(self._runtime_alert_secondary) if hasattr(self, "monochromator_panel"): self.daq.steer_beam_available.connect( @@ -1008,8 +909,6 @@ class MainWindow(QMainWindow): self.daq.baton_response_result.connect(self._on_baton_response_result) self.daq.baton_timeout_checked.connect(self._on_baton_timeout_checked) self.daq.automation_progress.connect(self.automation_progress_panel.set_progress) - self.daq.automation_progress.connect(self.compact_automation_panel.set_progress) - self.daq.automation_progress.connect(self.portrait_mode_panel.set_progress) self.status_bar.baton_request_received.connect(self._show_baton_request_dialog) self.status_bar.baton_request_accepted.connect(self._accept_baton_request) @@ -1089,18 +988,10 @@ class MainWindow(QMainWindow): self.prediction_thread = PredictionSubscriber(pred_zmq_url=sample_feed_addr, topic=b"") self.prediction_thread.image.connect(self._on_sample_camera_frame) self.prediction_thread.prediction.connect(self.sample_camera.update_detections) - self.prediction_thread.prediction.connect(self.compact_sample_camera.update_detections) - self.prediction_thread.prediction.connect(self.portrait_sample_camera.update_detections) self.prediction_thread.prediction.connect( self.prediction_metrics_panel.update_from_prediction ) self.prediction_thread.target_point.connect(self.sample_camera.update_target_point) - self.prediction_thread.target_point.connect( - self.compact_sample_camera.update_target_point - ) - self.prediction_thread.target_point.connect( - self.portrait_sample_camera.update_target_point - ) self.prediction_thread.target_point.connect( self.target_stability_panel.update_target_point ) @@ -1109,12 +1000,6 @@ class MainWindow(QMainWindow): self.prediction_thread.camera_availability_changed.connect( self.sample_camera.set_camera_available ) - self.prediction_thread.camera_availability_changed.connect( - self.compact_sample_camera.set_camera_available - ) - self.prediction_thread.camera_availability_changed.connect( - self.portrait_sample_camera.set_camera_available - ) self.prediction_thread.camera_availability_changed.connect( self._on_sample_camera_availability_changed ) @@ -1123,8 +1008,6 @@ class MainWindow(QMainWindow): else: self.prediction_thread = None self.sample_camera.set_camera_available(False) - self.compact_sample_camera.set_camera_available(False) - self.portrait_sample_camera.set_camera_available(False) self._show_samcam_feed_banner("Sample camera feed unavailable: no stream configured") # @@ -1236,10 +1119,7 @@ class MainWindow(QMainWindow): self.daq.update.connect(self.beamline.illumination_panel.update_daq_status) self.daq.update.connect(self.raster.update_daq_status) self.daq.update.connect(self.sample_camera.update_daq_status) - self.daq.update.connect(self.compact_sample_camera.update_daq_status) - self.daq.update.connect(self.portrait_sample_camera.update_daq_status) - for camera in (self.sample_camera, self.compact_sample_camera, self.portrait_sample_camera): - self.daq.auto_centering.connect(camera.set_auto_centering) + self.daq.auto_centering.connect(self.sample_camera.set_auto_centering) self.daq.update.connect(self.tell_samples.update_daq_status) self.daq.update.connect(self.ref_tools_panel.update_daq_status) if self.prediction_thread is not None: @@ -1578,23 +1458,6 @@ class MainWindow(QMainWindow): self.tell_samples_dock.raise_() self.sample_lists_tabs.setCurrentIndex(1) - def _return_to_main_view_for_shutdown(self) -> None: - try: - if getattr(self, "content_stack", None) is None: - return - - current_widget = self.content_stack.currentWidget() - - if hasattr(self, "portrait_mode_page") and current_widget is self.portrait_mode_page: - self._return_from_portrait_mode() - elif bool(getattr(self, "_in_compact_automation_view", False)): - self._return_from_compact_automation_view() - - if hasattr(self, "content_stack") and hasattr(self, "_standard_main_page"): - self.content_stack.setCurrentWidget(self._standard_main_page) - except Exception as e: - logger.warning(f"Failed to return to main view during shutdown: {e}", exc_info=True) - def _restore_samcam_overlay_settings(self) -> None: settings = QSettings("PSI", "AareGUI") # bool()/str() wraps: QSettings.value is typed "object" even with @@ -1659,13 +1522,8 @@ class MainWindow(QMainWindow): """ try: pixmap = QPixmap.fromImage(image) - for view in ( - self.sample_camera, - self.compact_sample_camera, - self.portrait_sample_camera, - ): - if view.isVisible(): - view.update_pixmap(pixmap) + if self.sample_camera.isVisible(): + self.sample_camera.update_pixmap(pixmap) finally: # The slot is only connected once the subscriber exists, but the # attribute is Optional so the guard is what the type checker needs. @@ -1685,13 +1543,7 @@ class MainWindow(QMainWindow): logger.warning(message) self._show_samcam_feed_banner(message or "Sample camera feed unavailable") # The camera overlays show the reason too, not just the boolean. - for cam in ( - self.sample_camera, - getattr(self, "compact_sample_camera", None), - getattr(self, "portrait_sample_camera", None), - ): - if cam is not None: - cam.set_camera_error_message(message or "Sample camera feed unavailable") + self.sample_camera.set_camera_error_message(message or "Sample camera feed unavailable") def _show_samcam_feed_banner(self, message: str) -> None: self._samcam_feed_banner_message = message @@ -1763,200 +1615,27 @@ class MainWindow(QMainWindow): key=lambda sample: sample.loc_str_sort(), ) - def _update_view_mode_actions(self) -> None: - in_automation_view = bool(self._in_compact_automation_view) - - if self._enter_automation_view_action is not None: - self._enter_automation_view_action.setVisible(not in_automation_view) - self._enter_automation_view_action.setEnabled(not in_automation_view) - - if self._return_main_view_action is not None: - self._return_main_view_action.setVisible(in_automation_view) - self._return_main_view_action.setEnabled(in_automation_view) - - @Slot() - def enter_compact_automation_view(self) -> None: - self.job_list_panel.ensure_default_queue_from_samples( - self._all_tell_samples_in_default_order() - ) - self._refresh_compact_queue_preview() - - if not self._in_compact_automation_view: - self._pre_automation_window_state = self.saveState() - self._pre_automation_left_column_visible = self.collection_controls_scroll.isVisible() - self._pre_automation_right_column_visible = self.beamline_controls_scroll.isVisible() - - self.tell_samples_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.collection_controls_scroll.setVisible(False) - self.beamline_controls_scroll.setVisible(False) - - self.content_stack.setCurrentWidget(self.compact_automation_page) - self._in_compact_automation_view = True - self._update_view_mode_actions() - - @Slot() - def _return_from_compact_automation_view(self) -> None: - self.content_stack.setCurrentWidget(self._standard_main_page) - - if self._pre_automation_window_state is not None: - self.restoreState(self._pre_automation_window_state) - - self.collection_controls_scroll.setVisible(self._pre_automation_left_column_visible) - self.beamline_controls_scroll.setVisible(self._pre_automation_right_column_visible) - - self._in_compact_automation_view = False - self._update_view_mode_actions() - - self.tell_samples_dock.setVisible(True) - self.tell_samples_dock.raise_() - - @Slot() - def _refresh_compact_queue_preview(self) -> None: - current_sample, next_sample, next_next_sample = self.job_list_panel.queue_preview() - self.compact_automation_panel.set_samples(current_sample, next_sample, next_next_sample) - - @Slot() - def enter_portrait_mode(self) -> None: - """Switch to the portrait / phone-screen view and resize the window.""" - self._pre_portrait_geometry = self.saveGeometry() - - self.portrait_mode_panel.set_running(self.job_list_panel.is_running()) - self.portrait_mode_panel.set_samples_in_queue(len(self.job_list_panel.table_model.samples)) - self._refresh_portrait_queue_preview() - self.content_stack.setCurrentWidget(self.portrait_mode_page) - - # ── Camera: scale-to-fit + hide legend ───────────────────────────── - self.portrait_sample_camera.set_show_overlay_legend(False) - try: - self.portrait_sample_camera._autoscale = True - self.portrait_sample_camera._scaling() - except Exception: - logger.debug("Could not autoscale the portrait-mode camera", exc_info=True) - - # ── Hide all chrome that contributes to window width ──────────────── - if self.status_bar is not None: - self.status_bar.setVisible(False) - self.menuBar().setVisible(False) - - # Alert banners take up horizontal space even when hidden via QFrame - # — force them to zero height so they cannot influence the minimum width. - self.alert_banner.setVisible(False) - self.alert_banner.setMaximumHeight(0) - self.alert_banner_secondary.setVisible(False) - self.alert_banner_secondary.setMaximumHeight(0) - - # Hide all dock widgets - for dock_attr in ( - "tell_samples_dock", - "information_dock", - "face_panel_dock", - "fluor_panel_dock", - "smargon_trace_dock", - "target_stability_dock", - "prediction_metrics_dock", - ): - dock = getattr(self, dock_attr, None) - if dock is not None: - dock.setVisible(False) - - # ── Resize to phone footprint ─────────────────────────────────────── - screen = QGuiApplication.screenAt(self.geometry().center()) - if screen is None: - screen = QGuiApplication.primaryScreen() - - available = screen.availableGeometry() - portrait_w = self.portrait_mode_panel.PORTRAIT_WIDTH + 24 - portrait_h = min(860, available.height() - 40) - - new_x = available.x() + (available.width() - portrait_w) // 2 - new_y = available.y() + (available.height() - portrait_h) // 2 - - self.setMinimumWidth(portrait_w) - self.setMaximumWidth(portrait_w) - self.resize(portrait_w, portrait_h) - self.move(new_x, new_y) - - @Slot() - def _return_from_portrait_mode(self) -> None: - """Restore the window to its pre-portrait geometry and switch page.""" - # ── Lift hard width cap before restoring geometry ─────────────────── - self.setMinimumWidth(0) - self.setMaximumWidth(16777215) # Qt's QWIDGETSIZE_MAX - - self.content_stack.setCurrentWidget(self._standard_main_page) - - # ── Restore chrome ────────────────────────────────────────────────── - if self.status_bar is not None: - self.status_bar.setVisible(True) - self.menuBar().setVisible(True) - - # Restore alert banners to normal operation - self.alert_banner.setMaximumHeight(16777215) - self.alert_banner_secondary.setMaximumHeight(16777215) - # Replay any pending messages that arrived during portrait mode - self.portrait_mode_panel._flush_portrait_alerts_to_banners( - self.alert_banner, self.alert_banner_secondary - ) - - # ── Restore camera legend ─────────────────────────────────────────── - try: - settings = self.portrait_sample_camera.target_overlay_settings() - self.portrait_sample_camera.set_show_overlay_legend( - settings.get("show_overlay_legend", False) - ) - except Exception: - logger.debug("Could not restore the camera overlay legend", exc_info=True) - - if hasattr(self, "_pre_portrait_geometry") and self._pre_portrait_geometry: - self.restoreGeometry(self._pre_portrait_geometry) - self._pre_portrait_geometry = None - - self.tell_samples_dock.setVisible(True) - 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) - @Slot(str, bool) - def _portrait_alert_primary(self, msg: str, is_error: bool) -> None: + def _runtime_alert_primary(self, msg: str, is_error: bool) -> None: """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._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, - ) + 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: + def _runtime_alert_secondary(self, msg: str, is_error: bool) -> None: """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._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: - self.portrait_mode_panel.refresh_queue_preview() + 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, + ) @staticmethod def _annotation_token(annotation: str) -> str: @@ -1983,26 +1662,6 @@ class MainWindow(QMainWindow): return f"{current} | {token}" - @Slot(str) - def _handle_compact_annotation(self, annotation: str) -> None: - current_sample, _, _ = self.job_list_panel.queue_preview() - if current_sample is None: - self.status_bar.show_connection_message("No sample selected for annotation.", True) - return - - updated_comment = self._append_annotation_to_comment( - getattr(current_sample, "comment", None), annotation - ) - - self.job_list_panel.annotate_sample_comment(current_sample.db_id, updated_comment) - self.tell_samples.annotate_sample_comment(current_sample.db_id, updated_comment) - self._refresh_compact_queue_preview() - - self.status_bar.show_connection_message( - f"Annotation added: {self._annotation_token(annotation)} — {current_sample.sample_name}", - False, - ) - @Slot() def refresh_axis_cameras(self) -> None: logger.info("Refreshing Axis camera threads") @@ -2143,24 +1802,6 @@ class MainWindow(QMainWindow): quit_action.triggered.connect(self.close) file_menu.addAction(quit_action) - # Prototype views live in the View menu (below the themes), not on the - # menubar; only their in-progress labels changed, the internal - # action/slot names stay. - self._enter_automation_view_action = QAction("Automation View (prototyping)", self) - self._enter_automation_view_action.setShortcut(QKeySequence("Ctrl+5")) - self._enter_automation_view_action.triggered.connect(self.enter_compact_automation_view) - - # Stays top-level: it only shows while INSIDE the automation view, - # where the way back must not hide in a menu. - self._return_main_view_action = QAction("Return to Main View", self) - self._return_main_view_action.setShortcut(QKeySequence("Ctrl+Shift+5")) - self._return_main_view_action.triggered.connect(self._return_from_compact_automation_view) - menu_bar.addAction(self._return_main_view_action) - - self._portrait_mode_action = QAction("Playlist Mode (work in progress)", self) - self._portrait_mode_action.setShortcut(QKeySequence("Ctrl+6")) - self._portrait_mode_action.triggered.connect(self.enter_portrait_mode) - view_menu = menu_bar.addMenu("View") self._theme_action_group = QActionGroup(self) @@ -2219,9 +1860,6 @@ class MainWindow(QMainWindow): self._show_right_panel_action.toggled.connect(self.beamline_controls_scroll.setVisible) view_menu.addAction(self._show_right_panel_action) view_menu.addSeparator() - view_menu.addAction(self._portrait_mode_action) - view_menu.addAction(self._enter_automation_view_action) - view_menu.addSeparator() show_samples_action = QAction("Show Sample List", self) show_samples_action.setCheckable(True) @@ -2359,10 +1997,6 @@ class MainWindow(QMainWindow): if self._default_window_state is not None: self.restoreState(self._default_window_state) - self.content_stack.setCurrentWidget(self._standard_main_page) - self._in_compact_automation_view = False - self._update_view_mode_actions() - self.collection_controls_scroll.setVisible(True) self.beamline_controls_scroll.setVisible(True) @@ -2477,7 +2111,7 @@ class MainWindow(QMainWindow): These come from polling, resync and other background requests; a modal pop-up would be too intrusive, so they go to the runtime dock/banner. """ - self._portrait_alert_primary(message, True) + self._runtime_alert_primary(message, True) @Slot(bool) def _on_pss_alarm_changed(self, active: bool) -> None: @@ -2940,8 +2574,6 @@ class MainWindow(QMainWindow): s.geom.beam_location_pxl.x, s.geom.beam_location_pxl.y ) - self._refresh_compact_queue_preview() - current_session = int(getattr(self._decoded_token, "session", -1)) for gui in getattr(s, "open_guis", []) or []: try: diff --git a/src/aare/gui/panels/compact_automation_panel.py b/src/aare/gui/panels/compact_automation_panel.py deleted file mode 100644 index cd48421b..00000000 --- a/src/aare/gui/panels/compact_automation_panel.py +++ /dev/null @@ -1,227 +0,0 @@ -from aarecommon.models.automation import AutomationProgress -from aarecommon.models.models import SampleShortInfo -from PySide6.QtCore import Qt, Signal, Slot -from PySide6.QtWidgets import ( - QFrame, - QHBoxLayout, - QLabel, - QMenu, - QPushButton, - QToolButton, - QVBoxLayout, - QWidget, -) - -from aare.gui.widgets.automation_progress import CompactAutomationProgressStrip - - -class CompactAutomationPanel(QFrame): - play_pause_clicked = Signal() - skip_clicked = Signal() - step_through_toggled = Signal(bool) - annotation_selected = Signal(str) - show_full_view_requested = Signal() - ensure_default_queue_requested = Signal() - - def __init__(self, camera_widget: QWidget, parent=None): - super().__init__(parent) - - self._running = False - self._step_through = False - self._current_sample: SampleShortInfo | None = None - self._next_sample: SampleShortInfo | None = None - self._next_next_sample: SampleShortInfo | None = None - - self.setFrameShape(QFrame.Shape.NoFrame) - self.setObjectName("compactAutomationPanel") - - main_layout = QVBoxLayout(self) - main_layout.setContentsMargins(18, 18, 18, 18) - main_layout.setSpacing(12) - - camera_card = QFrame(self) - camera_card.setObjectName("compactCameraCard") - camera_layout = QVBoxLayout(camera_card) - camera_layout.setContentsMargins(12, 12, 12, 12) - camera_layout.setSpacing(8) - - camera_title = QLabel("Camera", self) - camera_title.setObjectName("compactSectionTitle") - camera_layout.addWidget(camera_title) - camera_layout.addWidget(camera_widget, 1) - - main_layout.addWidget(camera_card, 1) - - controls_card = QFrame(self) - controls_card.setObjectName("compactControlsCard") - controls_layout = QVBoxLayout(controls_card) - controls_layout.setContentsMargins(12, 12, 12, 12) - controls_layout.setSpacing(10) - - controls_header = QHBoxLayout() - controls_header.setSpacing(10) - - controls_title_wrap = QVBoxLayout() - controls_title_wrap.setContentsMargins(0, 0, 0, 0) - controls_title_wrap.setSpacing(2) - - controls_title = QLabel("Controls", self) - controls_title.setObjectName("compactSectionTitle") - controls_hint = QLabel("Run and manage the automation queue.", self) - controls_hint.setObjectName("compactSectionHint") - - controls_title_wrap.addWidget(controls_title) - controls_title_wrap.addWidget(controls_hint) - - self.menu_button = QToolButton(self) - self.menu_button.setObjectName("compactMenuButton") - self.menu_button.setText("☰") - self.menu_button.setToolTip("Return to main view") - self.menu_button.clicked.connect(self.show_full_view_requested.emit) - - controls_header.addLayout(controls_title_wrap, 1) - controls_header.addWidget(self.menu_button, 0, Qt.AlignmentFlag.AlignTop) - - controls_layout.addLayout(controls_header) - - button_row = QHBoxLayout() - button_row.setSpacing(10) - - self.play_pause_button = QPushButton("▶ Play", self) - self.play_pause_button.setObjectName("compactPrimaryButton") - self.play_pause_button.clicked.connect(self.play_pause_clicked.emit) - - self.skip_button = QPushButton("⏭ Skip", self) - self.skip_button.setObjectName("compactSecondaryButton") - self.skip_button.clicked.connect(self.skip_clicked.emit) - - self.step_button = QPushButton("Step", self) - self.step_button.setObjectName("compactSecondaryButton") - self.step_button.setCheckable(True) - self.step_button.toggled.connect(self._on_step_toggled) - - self.annotation_button = QToolButton(self) - self.annotation_button.setObjectName("compactSecondaryButton") - self.annotation_button.setText("Annotate") - self.annotation_button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) - - annotation_menu = QMenu(self.annotation_button) - for label in ["Heart", "Thumbs Up", "Thumbs Down", "Eyes", "Scan Again"]: - action = annotation_menu.addAction(label) - action.triggered.connect( - lambda checked=False, value=label: self.annotation_selected.emit(value) - ) - self.annotation_button.setMenu(annotation_menu) - - button_row.addWidget(self.play_pause_button, 2) - button_row.addWidget(self.skip_button, 1) - button_row.addWidget(self.step_button, 1) - button_row.addWidget(self.annotation_button, 1) - - controls_layout.addLayout(button_row) - main_layout.addWidget(controls_card) - - progress_card = QFrame(self) - progress_card.setObjectName("compactProgressCard") - progress_layout = QVBoxLayout(progress_card) - progress_layout.setContentsMargins(12, 12, 12, 12) - progress_layout.setSpacing(8) - - progress_title = QLabel("Progress", self) - progress_title.setObjectName("compactSectionTitle") - progress_layout.addWidget(progress_title) - - self.progress_strip = CompactAutomationProgressStrip(self) - progress_layout.addWidget(self.progress_strip) - - main_layout.addWidget(progress_card) - - queue_card = QFrame(self) - queue_card.setObjectName("compactQueueCard") - queue_layout = QVBoxLayout(queue_card) - queue_layout.setContentsMargins(12, 12, 12, 12) - queue_layout.setSpacing(10) - - queue_title = QLabel("Queue Preview", self) - queue_title.setObjectName("compactSectionTitle") - queue_layout.addWidget(queue_title) - - queue_row = QHBoxLayout() - queue_row.setSpacing(10) - - self.current_card = self._build_queue_item("Now", "—") - self.next_card = self._build_queue_item("Next", "—") - self.next_next_card = self._build_queue_item("Later", "—") - - queue_row.addWidget(self.current_card["frame"], 1) - queue_row.addWidget(self.next_card["frame"], 1) - queue_row.addWidget(self.next_next_card["frame"], 1) - - queue_layout.addLayout(queue_row) - main_layout.addWidget(queue_card) - - def _build_queue_item(self, title: str, value: str) -> dict[str, QWidget | QLabel]: - frame = QFrame(self) - frame.setObjectName("compactQueueItem") - - layout = QVBoxLayout(frame) - layout.setContentsMargins(12, 10, 12, 10) - layout.setSpacing(4) - - title_label = QLabel(title, self) - title_label.setObjectName("compactQueueTitle") - - value_label = QLabel(value, self) - value_label.setObjectName("compactQueueValue") - value_label.setWordWrap(True) - - layout.addWidget(title_label) - layout.addWidget(value_label) - - return {"frame": frame, "title": title_label, "value": value_label} - - @Slot(bool) - def set_running(self, running: bool) -> None: - self._running = bool(running) - self.play_pause_button.setText("⏸ Pause" if self._running else "▶ Play") - self.progress_strip.set_running(running) - - @Slot(bool) - def set_step_through(self, enabled: bool) -> None: - self._step_through = bool(enabled) - self.step_button.blockSignals(True) - self.step_button.setChecked(self._step_through) - self.step_button.blockSignals(False) - - @Slot(int) - def set_samples_in_queue(self, count: int) -> None: - self.progress_strip.set_samples_in_queue(count) - - @Slot(object) - def set_progress(self, progress: AutomationProgress) -> None: - self.progress_strip.set_progress(progress) - - @Slot(object, object, object) - def set_samples( - self, - current_sample: SampleShortInfo | None, - next_sample: SampleShortInfo | None, - next_next_sample: SampleShortInfo | None, - ) -> None: - self._current_sample = current_sample - self._next_sample = next_sample - self._next_next_sample = next_next_sample - - self.current_card["value"].setText(self._format_sample(current_sample)) - self.next_card["value"].setText(self._format_sample(next_sample)) - self.next_next_card["value"].setText(self._format_sample(next_next_sample)) - - def _on_step_toggled(self, checked: bool) -> None: - self._step_through = checked - self.step_through_toggled.emit(checked) - - @staticmethod - def _format_sample(sample: SampleShortInfo | None) -> str: - if sample is None: - return "—" - return f"{sample.sample_name} ({sample.loc_str()})" diff --git a/src/aare/gui/panels/portrait_mode.py b/src/aare/gui/panels/portrait_mode.py deleted file mode 100644 index 50db0af5..00000000 --- a/src/aare/gui/panels/portrait_mode.py +++ /dev/null @@ -1,805 +0,0 @@ -from __future__ import annotations - -from typing import ClassVar - -from aarecommon.config.logger import setup_logger -from aarecommon.models.automation import AutomationProgress, StepStatus, WorkflowStateKind -from PySide6.QtCore import QPointF, QRectF, Qt, QTimer, Signal, Slot -from PySide6.QtGui import QColor, QFont, QFontMetrics, QPainter, QPen -from PySide6.QtWidgets import ( - QFrame, - QHBoxLayout, - QLabel, - QPushButton, - QScrollArea, - QSizePolicy, - QSpinBox, - QStackedWidget, - QVBoxLayout, - QWidget, -) - -from aare.gui.constants import LOGGER_NAME -from aare.gui.styles import DARK_ACCENT as ACCENT -from aare.gui.styles import DARK_BG as BG -from aare.gui.styles import DARK_BORDER as ACCENT_DIM -from aare.gui.styles import DARK_BORDER as LED_OFF -from aare.gui.styles import DARK_ELEVATED as BUTTON_BG -from aare.gui.styles import ( - DARK_ERROR_BG, - DARK_ERROR_BORDER, - DARK_ERROR_TEXT, - DARK_SUCCESS_BG, - DARK_SUCCESS_BORDER, - DARK_SUCCESS_TEXT, - FONT_FINE, - FONT_HERO, - FONT_HINT, - FONT_LABEL, - FONT_VALUE, - WHITE, - qcolor, -) -from aare.gui.styles import DARK_MUTED as SUBTEXT -from aare.gui.styles import DARK_SURFACE as CARD_BG -from aare.gui.styles import DARK_TEXT as TEXT -from aare.gui.styles import WHITE as ACTIVE_STEP - -logger = setup_logger(LOGGER_NAME) - - -# --------------------------------------------------------------------------- -# LED step indicator -# --------------------------------------------------------------------------- -class LEDStages(QWidget): - STEPS: ClassVar[list[str]] = ["Mount", "Centre", "Raster", "Collect"] - - # WorkflowStateKind → LED index - _KIND_TO_INDEX: ClassVar[dict[WorkflowStateKind, int]] = { - WorkflowStateKind.MOUNT: 0, - WorkflowStateKind.LOOP_CENTRE: 1, - WorkflowStateKind.RASTER: 2, - WorkflowStateKind.DATA_COLLECTION: 3, - } - - def __init__(self, active_step: int = 0, parent=None): - super().__init__(parent) - self._active = active_step - self.setFixedHeight(72) - - def set_active_step(self, step: int) -> None: - self._active = step - self.update() - - def set_from_progress(self, progress: AutomationProgress) -> None: - """Derive active LED index from an AutomationProgress object.""" - running_index = -1 - last_success = -1 - - for step_state in progress.steps: - idx = self._KIND_TO_INDEX.get(step_state.step) - if idx is None: - continue - if step_state.status == StepStatus.RUNNING: - running_index = idx - elif step_state.status == StepStatus.SUCCESS and idx > last_success: - last_success = idx - - if running_index >= 0: - self.set_active_step(running_index) - elif last_success >= 0: - self.set_active_step(min(last_success + 1, len(self.STEPS) - 1)) - else: - self.set_active_step(0) - - def paintEvent(self, event): - p = QPainter(self) - p.setRenderHint(QPainter.Antialiasing) - - w = self.width() - n = len(self.STEPS) - step_w = w / n - led_r = 10 - cy = 44 - label_y = 16 - - for i, name in enumerate(self.STEPS): - cx = step_w * i + step_w / 2 - - if i < n - 1: - next_cx = step_w * (i + 1) + step_w / 2 - line_color = QColor(ACCENT) if i < self._active else QColor(LED_OFF) - pen = QPen(line_color, 2) - p.setPen(pen) - p.drawLine(QPointF(cx + led_r + 3, cy), QPointF(next_cx - led_r - 3, cy)) - - p.setPen(Qt.NoPen) - if i < self._active: - p.setBrush(QColor(ACCENT)) - p.drawEllipse(QPointF(cx, cy), led_r, led_r) - pen = QPen(QColor(BG), 2) - pen.setCapStyle(Qt.RoundCap) - p.setPen(pen) - p.drawLine(QPointF(cx - 4, cy), QPointF(cx - 1, cy + 3)) - p.drawLine(QPointF(cx - 1, cy + 3), QPointF(cx + 4, cy - 3)) - elif i == self._active: - # was QColor(ACCENT + "55"), which mis-parsed as #AARRGGBB - glow_pen = QPen(qcolor(ACCENT, 0x55), 4) - p.setPen(glow_pen) - p.setBrush(Qt.NoBrush) - p.drawEllipse(QPointF(cx, cy), led_r + 4, led_r + 4) - p.setPen(Qt.NoPen) - p.setBrush(QColor(ACTIVE_STEP)) - p.drawEllipse(QPointF(cx, cy), led_r, led_r) - else: - p.setBrush(QColor(LED_OFF)) - p.drawEllipse(QPointF(cx, cy), led_r, led_r) - - label_color = QColor(ACCENT) if i <= self._active else QColor(SUBTEXT) - p.setPen(label_color) - font = QFont("Inter", 10) - if i == self._active: - font.setWeight(QFont.Weight.DemiBold) - p.setFont(font) - fm = QFontMetrics(font) - text_w = fm.horizontalAdvance(name) - p.drawText( - QRectF(cx - text_w / 2 - 4, 0, text_w + 8, label_y + 2), Qt.AlignCenter, name - ) - - -# --------------------------------------------------------------------------- -# Play/Pause primary button -# --------------------------------------------------------------------------- -class PlayPauseButton(QPushButton): - def __init__(self, parent=None): - super().__init__(parent) - self._running = False - self.setFixedSize(64, 64) - self.setMouseTracking(True) - - def set_running(self, running: bool) -> None: - self._running = running - self.update() - - def paintEvent(self, event): - p = QPainter(self) - p.setRenderHint(QPainter.Antialiasing) - rect = self.rect() - cx, cy = rect.width() / 2, rect.height() / 2 - r = min(rect.width(), rect.height()) / 2 - 2 - - # underMouse() instead of enter/leave overrides tracking a _hovered - # flag: QPushButton already repaints on hover (WA_Hover), so the - # two extra Qt→Python callbacks bought nothing. - bg_color = qcolor(WHITE) if self.underMouse() else QColor(ACCENT) - p.setBrush(bg_color) - p.setPen(Qt.NoPen) - p.drawEllipse(QPointF(cx, cy), r, r) - - sym = "⏸" if self._running else "▶" - p.setPen(QPen(QColor(BG), 2)) - font = QFont("Arial", 16) - p.setFont(font) - p.drawText(rect, Qt.AlignCenter, sym) - - -# --------------------------------------------------------------------------- -# Queue item card -# --------------------------------------------------------------------------- -class QueueItemCard(QFrame): - def __init__( - self, index: int | str, title: str, subtitle: str, is_next: bool = False, parent=None - ): - super().__init__(parent) - self.setFixedHeight(72) - self.setStyleSheet(f""" - QFrame {{ - background: {BUTTON_BG if is_next else CARD_BG}; - border-radius: 14px; - border: {"1px solid " + ACCENT_DIM if is_next else "none"}; - }} - """) - - layout = QHBoxLayout(self) - layout.setContentsMargins(14, 0, 14, 0) - layout.setSpacing(12) - - badge = QLabel() - badge.setFixedSize(32, 32) - badge.setAlignment(Qt.AlignCenter) - if is_next: - badge.setText("▶") - badge.setStyleSheet(f""" - color: {ACCENT}; background: {ACCENT_DIM}; - border-radius: 16px; font-size: {FONT_HINT}; font-weight: bold; - """) - else: - badge.setText(str(index)) - badge.setStyleSheet(f""" - color: {SUBTEXT}; background: {BUTTON_BG}; - border-radius: 16px; font-size: {FONT_HINT}; - """) - layout.addWidget(badge) - - text_col = QVBoxLayout() - text_col.setSpacing(2) - text_col.setContentsMargins(0, 0, 0, 0) - - title_lbl = QLabel(title) - title_lbl.setStyleSheet( - f"color: {TEXT}; font-size: {FONT_LABEL}; font-weight: 700; background: transparent;" - ) - title_lbl.setWordWrap(False) - sub_lbl = QLabel(subtitle) - sub_lbl.setStyleSheet(f"color: {SUBTEXT}; font-size: {FONT_FINE}; background: transparent;") - text_col.addWidget(title_lbl) - text_col.addWidget(sub_lbl) - layout.addLayout(text_col, stretch=1) - - -# --------------------------------------------------------------------------- -# Main portrait-mode panel -# --------------------------------------------------------------------------- -class PortraitModePanel(QWidget): - PORTRAIT_WIDTH = 420 - grab_session_requested = Signal() - - def __init__(self, sample_camera_widget: QWidget, parent=None): - super().__init__(parent) - self.setObjectName("portraitRoot") - self.setMaximumWidth(self.PORTRAIT_WIDTH) - self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding) - - # Created here rather than in the build helper so the attributes are - # initialized in __init__ (basedpyright gate); the helper styles and - # mounts them. - self._name_lbl = QLabel("—") - self._sub_lbl = QLabel("No sample queued") - - 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) - self._pending_alerts: list[tuple[str, bool]] = [] - - self._stack = QStackedWidget() - self._player_page = self._build_player(sample_camera_widget) - self._queue_page = self._build_queue() - self._stack.addWidget(self._player_page) - self._stack.addWidget(self._queue_page) - - root = QVBoxLayout(self) - root.setContentsMargins(12, 12, 12, 12) - root.addWidget(self._stack) - - # ------------------------------------------------------------------ - # Player page - # ------------------------------------------------------------------ - def _build_player(self, cam_widget: QWidget) -> QWidget: - page = QWidget() - layout = QVBoxLayout(page) - layout.setSpacing(10) - layout.setContentsMargins(0, 0, 0, 0) - - # Title - title = QLabel("S A M C A M E R A") - title.setAlignment(Qt.AlignCenter) - title.setStyleSheet( - f"color: {ACCENT}; font-size: {FONT_LABEL}; letter-spacing: 3px; font-weight: 700;" - ) - layout.addWidget(title) - - # ── Portrait alert toast (hidden by default) ─────────────────────── - self._alert_toast = QFrame() - self._alert_toast.setVisible(False) - self._alert_toast.setStyleSheet(f""" - QFrame {{ - background: {DARK_ERROR_BG}; - border: 1px solid {DARK_ERROR_BORDER}; - border-radius: 10px; - }} - """) - toast_layout = QHBoxLayout(self._alert_toast) - toast_layout.setContentsMargins(12, 8, 12, 8) - self._alert_toast_label = QLabel("") - self._alert_toast_label.setWordWrap(True) - self._alert_toast_label.setStyleSheet( - f"color: {DARK_ERROR_TEXT}; font-size: {FONT_FINE}; font-weight: 600; background: transparent;" - ) - toast_layout.addWidget(self._alert_toast_label) - # Dismiss button - dismiss_btn = QPushButton("✕") - dismiss_btn.setFixedSize(20, 20) - dismiss_btn.setStyleSheet(f""" - QPushButton {{ - color: {SUBTEXT}; - background: transparent; - border: none; - font-size: {FONT_FINE}; - }} - QPushButton:hover {{ color: {TEXT}; }} - """) - dismiss_btn.clicked.connect(self._dismiss_portrait_alert) - toast_layout.addWidget(dismiss_btn) - layout.addWidget(self._alert_toast) - - self._alert_toast_timer = QTimer(self) - self._alert_toast_timer.setSingleShot(True) - self._alert_toast_timer.timeout.connect(self._dismiss_portrait_alert) - - # Camera card — wraps the real compact_sample_camera - cam_card = QFrame() - cam_card.setStyleSheet(f"QFrame {{ background: {CARD_BG}; border-radius: 18px; }}") - cam_card_layout = QVBoxLayout(cam_card) - cam_card_layout.setContentsMargins(4, 4, 4, 4) - cam_card_layout.setSpacing(0) - cam_widget.setMinimumHeight(220) - cam_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) - cam_card_layout.addWidget(cam_widget) - layout.addWidget(cam_card) - - # Sample name labels (created in __init__) - self._name_lbl.setStyleSheet(f"color: {TEXT}; font-size: {FONT_VALUE}; font-weight: 700;") - self._sub_lbl.setStyleSheet(f"color: {SUBTEXT}; font-size: {FONT_HINT};") - layout.addWidget(self._name_lbl) - layout.addWidget(self._sub_lbl) - - # LED step indicator - self._leds = LEDStages(active_step=0) - layout.addWidget(self._leds) - - # Play/Pause button (single, centred) - ctrl_frame = QFrame() - ctrl_frame.setStyleSheet(f"QFrame {{ background: {CARD_BG}; border-radius: 18px; }}") - - ctrl_layout = QHBoxLayout(ctrl_frame) - 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) - - # 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: {FONT_HERO}; - 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 - up_next_row = QHBoxLayout() - up_next_lbl = QLabel("UP NEXT") - up_next_lbl.setStyleSheet( - f"color: {ACCENT}; font-size: {FONT_FINE}; letter-spacing: 2px; font-weight: 700;" - ) - self._samples_count_lbl = QLabel("0 SAMPLES") - self._samples_count_lbl.setStyleSheet( - f"color: {SUBTEXT}; font-size: {FONT_FINE}; letter-spacing: 1px;" - ) - up_next_row.addWidget(up_next_lbl) - up_next_row.addStretch() - up_next_row.addWidget(self._samples_count_lbl) - layout.addLayout(up_next_row) - - # Preview card container (up to 4 cards) - self._preview_container = QWidget() - self._preview_layout = QVBoxLayout(self._preview_container) - self._preview_layout.setSpacing(6) - self._preview_layout.setContentsMargins(0, 0, 0, 0) - layout.addWidget(self._preview_container) - - # View full queue button - view_btn = self._accent_button("VIEW FULL QUEUE ☰") - view_btn.clicked.connect(self._on_view_full_queue) - layout.addWidget(view_btn) - - # ── Session / utility row ────────────────────────────────────────── - util_row = QHBoxLayout() - util_row.setSpacing(8) - - self._grab_session_btn = self._accent_button("⚡ GRAB SESSION") - self._grab_session_btn.clicked.connect(self.grab_session_requested) - util_row.addWidget(self._grab_session_btn) - - layout.addLayout(util_row) - - # Back to main view button - self._back_btn = self._accent_button("← MAIN VIEW") - layout.addWidget(self._back_btn) - # Connected externally by MainWindow - - return page - - # ------------------------------------------------------------------ - # Queue / full list page - # ------------------------------------------------------------------ - def _build_queue(self) -> QWidget: - page = QWidget() - layout = QVBoxLayout(page) - layout.setSpacing(10) - layout.setContentsMargins(0, 0, 0, 0) - - title = QLabel("SAMPLE QUEUE") - title.setAlignment(Qt.AlignCenter) - title.setStyleSheet( - f"color: {ACCENT}; font-size: {FONT_LABEL}; letter-spacing: 3px; font-weight: 700;" - ) - layout.addWidget(title) - - self._queue_scroll = QScrollArea() - self._queue_scroll.setWidgetResizable(True) - self._queue_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - - self._queue_inner = QWidget() - self._queue_inner_layout = QVBoxLayout(self._queue_inner) - self._queue_inner_layout.setSpacing(6) - self._queue_inner_layout.setContentsMargins(0, 4, 0, 4) - - self._queue_scroll.setWidget(self._queue_inner) - layout.addWidget(self._queue_scroll, stretch=1) - - back_btn = self._accent_button("← BACK TO CAMERA") - back_btn.clicked.connect(lambda: self._stack.setCurrentWidget(self._player_page)) - layout.addWidget(back_btn) - - return page - - # ------------------------------------------------------------------ - # Wiring - # ------------------------------------------------------------------ - def wire_to_main_window(self, job_list_panel, tell_samples) -> None: - """Call this from MainWindow after both panels are constructed.""" - 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 - # ------------------------------------------------------------------ - @Slot(object) - def set_progress(self, progress: AutomationProgress) -> None: - """Driven by daq.automation_progress signal.""" - self._leds.set_from_progress(progress) - - @Slot(bool) - def set_running(self, running: bool) -> None: - self._is_running = running - self._play_pause_btn.set_running(running) - - @Slot(int) - def set_samples_in_queue(self, count: int) -> None: - label = f"{count} SAMPLE{'S' if count != 1 else ''}" - self._samples_count_lbl.setText(label) - - def refresh_queue_preview(self) -> None: - """ - Refresh sample name, subtitle, and preview cards from queue_preview(). - Falls back to tell_samples sorted by location when the queue is empty. - """ - if self._job_list_panel is None: - return - - current, nxt, nxt2 = self._job_list_panel.queue_preview() - - # --- sample name / subtitle --- - if current is not None: - name = str(getattr(current, "sample_name", "") or "—") - puck = str(getattr(current, "puck_name", "") or "") - pin = getattr(current, "pin", None) - subtitle_parts = [puck] - if pin is not None: - subtitle_parts.append(f"Pin {pin}") - self._name_lbl.setText(name) - self._sub_lbl.setText(" · ".join(p for p in subtitle_parts if p)) - else: - self._name_lbl.setText("—") - self._sub_lbl.setText("No sample queued") - - # --- preview cards --- - # Clear existing - while self._preview_layout.count(): - item = self._preview_layout.takeAt(0) - if item.widget(): - item.widget().deleteLater() - - previews = [s for s in [current, nxt, nxt2] if s is not None] - - # If queue is empty, suggest from tell_samples sorted by location - if not previews and self._tell_samples is not None: - raw = list(getattr(self._tell_samples.table_model, "samples", [])) - suggested = sorted( - [s for s in raw if getattr(s, "location", None) is not None], - key=lambda s: s.loc_str_sort() if hasattr(s, "loc_str_sort") else "", - )[:4] - for i, s in enumerate(suggested): - name = str(getattr(s, "sample_name", "") or f"Sample {i + 1}") - puck = str(getattr(s, "puck_name", "") or "") - card = QueueItemCard(index=i + 1, title=name, subtitle=puck, is_next=False) - self._preview_layout.addWidget(card) - return - - for i, sample in enumerate(previews): - name = str(getattr(sample, "sample_name", "") or f"Sample {i + 1}") - puck = str(getattr(sample, "puck_name", "") or "") - card = QueueItemCard( - index="▶" if i == 0 else i + 1, title=name, subtitle=puck, is_next=(i == 0) - ) - self._preview_layout.addWidget(card) - - # ------------------------------------------------------------------ - # Full queue page population - # ------------------------------------------------------------------ - def _on_view_full_queue(self) -> None: - self._rebuild_full_queue() - self._stack.setCurrentWidget(self._queue_page) - - def _rebuild_full_queue(self) -> None: - # Clear existing cards - while self._queue_inner_layout.count(): - item = self._queue_inner_layout.takeAt(0) - if item.widget(): - item.widget().deleteLater() - - samples = [] - - if self._job_list_panel is not None: - raw = list(getattr(self._job_list_panel.table_model, "samples", [])) - samples = raw - - # Fall back to tell_samples sorted by location if queue is empty - if not samples and self._tell_samples is not None: - raw = list(getattr(self._tell_samples.table_model, "samples", [])) - samples = sorted( - [s for s in raw if getattr(s, "location", None) is not None], - key=lambda s: s.loc_str_sort() if hasattr(s, "loc_str_sort") else "", - ) - - if not samples: - placeholder = QLabel("No samples in queue") - placeholder.setStyleSheet( - f"color: {SUBTEXT}; font-size: {FONT_LABEL}; font-weight: 700;" - ) - placeholder.setAlignment(Qt.AlignmentFlag.AlignCenter) - self._queue_inner_layout.addWidget(placeholder) - return - - for i, sample in enumerate(samples): - name = str(getattr(sample, "sample_name", "") or f"Sample {i + 1}") - puck = str(getattr(sample, "puck_name", "") or "") - card = QueueItemCard(index=i + 1, title=name, subtitle=puck, is_next=False) - self._queue_inner_layout.addWidget(card) - - # ------------------------------------------------------------------ - # Play/Pause handler - # ------------------------------------------------------------------ - @Slot() - def _on_play_pause_clicked(self) -> None: - if self._job_list_panel is None: - return - if self._is_running: - self._job_list_panel.pause_automation() - else: - self._job_list_panel.run() - - # ------------------------------------------------------------------ - # Helper - # ------------------------------------------------------------------ - @staticmethod - def _accent_button(text: str) -> QPushButton: - btn = QPushButton(text) - btn.setFixedHeight(48) - btn.setStyleSheet(f""" - QPushButton {{ - background: transparent; - border: 1.5px solid {ACCENT}; - border-radius: 14px; - color: {ACCENT}; - font-size: {FONT_HINT}; - font-weight: 700; - letter-spacing: 1.5px; - }} - QPushButton:hover {{ - background: {ACCENT_DIM}; - }} - QPushButton:pressed {{ - background: {ACCENT}; - color: {BG}; - }} - """) - return btn - - # ------------------------------------------------------------------ - # Portrait alert toast - # ------------------------------------------------------------------ - - @Slot(str, bool) - def show_portrait_alert(self, msg: str, is_error: bool) -> None: - """Show a compact dark-themed alert inside the portrait panel.""" - if not msg: - self._dismiss_portrait_alert() - return - - # Store for replay when returning to main view - self._pending_alerts.append((msg, is_error)) - - icon = "🛑" if is_error else "✅" - self._alert_toast_label.setText(f"{icon} {msg}") - - border_color = DARK_ERROR_BORDER if is_error else DARK_SUCCESS_BORDER - text_color = DARK_ERROR_TEXT if is_error else DARK_SUCCESS_TEXT - bg_color = DARK_ERROR_BG if is_error else DARK_SUCCESS_BG - - self._alert_toast.setStyleSheet(f""" - QFrame {{ - background: {bg_color}; - border: 1px solid {border_color}; - border-radius: 10px; - }} - """) - self._alert_toast_label.setStyleSheet( - f"color: {text_color}; font-size: {FONT_FINE}; font-weight: 600; background: transparent;" - ) - self._alert_toast.setVisible(True) - - # Auto-dismiss success after 5 s; errors persist until dismissed - self._alert_toast_timer.stop() - if not is_error: - self._alert_toast_timer.start(5000) - - @Slot() - def _dismiss_portrait_alert(self) -> None: - self._alert_toast_timer.stop() - self._alert_toast.setVisible(False) - self._alert_toast_label.clear() - - def _flush_portrait_alerts_to_banners(self, primary_banner, secondary_banner) -> None: - """ - Called when returning to main view — replay any error alerts that - arrived during portrait mode so the operator doesn't miss them. - Only the last error (if any) is surfaced to avoid flooding. - """ - errors = [(m, e) for m, e in self._pending_alerts if e] - if errors: - last_msg, last_is_error = errors[-1] - primary_banner.show_message(last_msg, last_is_error) - self._pending_alerts.clear() - 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, "_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) diff --git a/src/aare/gui/styles.py b/src/aare/gui/styles.py index 1f05bd8c..d2a64dbb 100644 --- a/src/aare/gui/styles.py +++ b/src/aare/gui/styles.py @@ -97,7 +97,6 @@ DARK_GRIP_IDLE_H = (_GRAPHICS_DIR / "resize_grip_h_idle_dark.png").as_posix() # Borders (all can be "transparent" to hide the line): BORDER = "transparent" # main dividers, e.g. the beamline state bar top line CARD_BORDER = "transparent" # cards / group boxes (Local Contact, automation) -COMPACT_BORDER = "transparent" # compact-automation cards and buttons # -- Box-frame borders by nesting level ------------------------------------- # One knob per level so the amount of "boxing" is tunable in one place: @@ -176,13 +175,6 @@ TAB_FACE_BG = "#ffffff" SELECTION_BG = BANNER SELECTION_TEXT = "#263043" # same as TEXT — readable on the banner blue -# Compact-automation page: -COMPACT_CARD_BG = "#e6eefc" -COMPACT_TITLE = "#17324d" # section titles + menu/secondary button text -COMPACT_HINT = "#51657d" # secondary text: hints, queue titles -COMPACT_VALUE = "#10263a" -COMPACT_MENU_BG = "#cbdcf8" -COMPACT_MENU_BG_HOVER = "#bfd4f6" PRIMARY = "#2563eb" # main action button PRIMARY_HOVER = "#1d4ed8" PRIMARY_TEXT = "white" @@ -602,7 +594,6 @@ TUTORIAL_BORDER = "#555555" TUTORIAL_HIGHLIGHT = "#ffff00" # widget spotlight pen (was Qt.yellow) # -- Typography ladder (role-named; tune sizes here, not in widgets) -------- -FONT_HERO = "28px" # portrait main action button FONT_ALERT = "18px" # alert banner text FONT_VALUE = "18px" # prominent values, big glyph buttons FONT_TITLE = "16px" # section / panel titles @@ -908,15 +899,10 @@ def _sunrise_stylesheet(overrides: dict[str, str] | None = None) -> str: } QWidget#mainContentRoot, - QWidget#standardMainPage, - QWidget#compactAutomationPage { + QWidget#standardMainPage { background-color: transparent; } - QWidget#portraitModePage { - background-color: $dark_bg; - } - /* Borderless hover hints. The transparent border is required — QToolTip only honours the stylesheet background once a border is set. */ QToolTip { @@ -926,93 +912,6 @@ def _sunrise_stylesheet(overrides: dict[str, str] | None = None) -> str: padding: 4px 6px; } - QFrame#compactAutomationPanel { - background: transparent; - border: none; - border-radius: 18px; - } - - QFrame#compactCameraCard, - QFrame#compactControlsCard, - QFrame#compactProgressCard, - QFrame#compactQueueCard, - QFrame#compactQueueItem { - background: $compact_card_bg; - border: 1px solid $compact_border; - border-radius: 16px; - } - - QLabel#compactSectionTitle { - background: transparent; - color: $compact_title; - font-size: $font_body; - font-weight: 700; - } - - QLabel#compactSectionHint { - background: transparent; - color: $compact_hint; - font-size: $font_hint; - } - - QLabel#compactQueueTitle { - background: transparent; - color: $compact_hint; - font-size: $font_fine; - font-weight: 700; - } - - QLabel#compactQueueValue { - background: transparent; - color: $compact_value; - font-size: $font_body; - font-weight: 700; - } - - QToolButton#compactMenuButton { - background: $compact_menu_bg; - color: $compact_title; - border: 1px solid $compact_border; - border-radius: 14px; - padding: 10px 14px; - font-size: $font_value; - font-weight: 700; - } - - QToolButton#compactMenuButton:hover { - background: $compact_menu_bg_hover; - } - - QPushButton#compactPrimaryButton { - background: $primary; - color: $primary_text; - border: none; - border-radius: 14px; - padding: 14px 18px; - font-size: $font_body_lg; - font-weight: 700; - } - - QPushButton#compactPrimaryButton:hover { - background: $primary_hover; - } - - QPushButton#compactSecondaryButton, - QToolButton#compactSecondaryButton { - background: $secondary_bg; - color: $compact_title; - border: 1px solid $compact_border; - border-radius: 14px; - padding: 14px 18px; - font-size: $font_body; - font-weight: 700; - } - - QPushButton#compactSecondaryButton:hover, - QToolButton#compactSecondaryButton:hover { - background: $secondary_bg_hover; - } - QFrame#alertBanner[alertKind="error"] { background-color: $error_bg; border: 2px solid $error_border; @@ -1397,35 +1296,6 @@ def _sunrise_stylesheet(overrides: dict[str, str] | None = None) -> str: border: none; } - QWidget#portraitRoot, - QWidget#portraitRoot QWidget { - background: $dark_bg; - color: $dark_text; - font-family: 'Inter', 'SF Pro Display', Arial, sans-serif; - font-size: $font_body; - } - - QWidget#portraitRoot QScrollArea { - border: none; - background: transparent; - } - - QWidget#portraitRoot QScrollBar:vertical { - background: $dark_surface; - width: 4px; - border-radius: 2px; - } - - QWidget#portraitRoot QScrollBar::handle:vertical { - background: $dark_border; - border-radius: 2px; - min-height: 20px; - } - - QWidget#portraitRoot QScrollBar::add-line:vertical, - QWidget#portraitRoot QScrollBar::sub-line:vertical { - height: 0px; - } """).substitute(mapping) @@ -1444,10 +1314,6 @@ def _sunset_stylesheet() -> str: background: $dark_app_background; } - QWidget#portraitModePage { - background: $dark_bg; - } - /* Interactive faces sit one step above the backdrop (site: glass2) with the faint gold hairline. Same pinned height as the light sheet so buttons and entry boxes match in both themes. */ @@ -1634,7 +1500,7 @@ def _sunset_stylesheet() -> str: QPushButton#filterChip[status_key="flagged"]:checked { background: $sample_status_flagged_bg; } QPushButton#filterChip[status_key="measured"]:checked { background: $sample_status_measured_bg; } - /* In-panel section headings — gold, matching the compact page titles. */ + /* In-panel section headings — gold. */ QLabel#sectionTitle { background: transparent; color: $dark_accent; @@ -1695,93 +1561,6 @@ def _sunset_stylesheet() -> str: padding: 4px 6px; } - QFrame#compactAutomationPanel { - background: transparent; - border: none; - border-radius: 18px; - } - - QFrame#compactCameraCard, - QFrame#compactControlsCard, - QFrame#compactProgressCard, - QFrame#compactQueueCard, - QFrame#compactQueueItem { - background: $dark_surface; - border: 1px solid $dark_border; - border-radius: 16px; - } - - QLabel#compactSectionTitle { - background: transparent; - color: $dark_accent; - font-size: $font_body; - font-weight: 700; - } - - QLabel#compactSectionHint { - background: transparent; - color: $dark_subtext; - font-size: $font_hint; - } - - QLabel#compactQueueTitle { - background: transparent; - color: $dark_subtext; - font-size: $font_fine; - font-weight: 700; - } - - QLabel#compactQueueValue { - background: transparent; - color: $dark_text; - font-size: $font_body; - font-weight: 700; - } - - QToolButton#compactMenuButton { - background: $dark_elevated; - color: $dark_accent; - border: 1px solid $dark_border; - border-radius: 14px; - padding: 10px 14px; - font-size: $font_value; - font-weight: 700; - } - - QToolButton#compactMenuButton:hover { - background: $dark_border; - } - - QPushButton#compactPrimaryButton { - background: $dark_accent_fill; - color: $dark_on_accent; - border: none; - border-radius: 14px; - padding: 14px 18px; - font-size: $font_body_lg; - font-weight: 700; - } - - QPushButton#compactPrimaryButton:hover { - background: $dark_accent_fill_hover; - } - - QPushButton#compactSecondaryButton, - QToolButton#compactSecondaryButton { - background: $dark_elevated; - color: $dark_text; - border: 1px solid $dark_border; - border-radius: 14px; - padding: 14px 18px; - font-size: $font_body; - font-weight: 700; - } - - QPushButton#compactSecondaryButton:hover, - QToolButton#compactSecondaryButton:hover { - background: $dark_border; - } - QFrame#alertBanner[alertKind="error"] { background: $dark_error_bg; border: 2px solid $dark_error_border; @@ -2029,35 +1808,6 @@ def _sunset_stylesheet() -> str: border: none; } - QWidget#portraitRoot, - QWidget#portraitRoot QWidget { - background: $dark_bg; - color: $dark_text; - font-family: 'Inter', 'SF Pro Display', Arial, sans-serif; - font-size: $font_body; - } - - QWidget#portraitRoot QScrollArea { - border: none; - background: transparent; - } - - QWidget#portraitRoot QScrollBar:vertical { - background: $dark_surface; - width: 4px; - border-radius: 2px; - } - - QWidget#portraitRoot QScrollBar::handle:vertical { - background: $dark_border; - border-radius: 2px; - min-height: 20px; - } - - QWidget#portraitRoot QScrollBar::add-line:vertical, - QWidget#portraitRoot QScrollBar::sub-line:vertical { - height: 0px; - } """).substitute(_palette()) diff --git a/src/aare/gui/widgets/alert_banner.py b/src/aare/gui/widgets/alert_banner.py index 5fb4c5b0..2198f178 100644 --- a/src/aare/gui/widgets/alert_banner.py +++ b/src/aare/gui/widgets/alert_banner.py @@ -65,7 +65,7 @@ class AlertBanner(QFrame): """Render as a compact toast under `widget`'s bottom edge (must be a descendant of the float host) instead of a full-width top bar — the baton messages sit below the sample camera view this way. Falls back - to the top bar while the anchor is hidden (e.g. portrait mode). + to the top bar while the anchor is hidden. ponytail: position goes stale if a splitter drag moves the anchor while the toast is up; it self-corrects on the next show.""" self._float_anchor = widget diff --git a/src/aare/gui/widgets/automation_progress.py b/src/aare/gui/widgets/automation_progress.py deleted file mode 100644 index af5ccfda..00000000 --- a/src/aare/gui/widgets/automation_progress.py +++ /dev/null @@ -1,198 +0,0 @@ -from __future__ import annotations - -import time -from datetime import datetime - -from aarecommon.models.automation import AutomationProgress, StepStatus, WorkflowStateKind -from PySide6.QtCore import QTimer, Slot -from PySide6.QtWidgets import QFrame, QHBoxLayout, QLabel, QVBoxLayout, QWidget - -from aare.gui.styles import ( - FAINT_TEXT, - FONT_HINT, - FONT_VALUE, - STEP_FAILED_TEXT, - STEP_PAUSED_TEXT, - STEP_RUNNING_TEXT, - STEP_SUCCESS_TEXT, -) - - -class CompactAutomationProgressStrip(QFrame): - DEFAULT_SAMPLE_ESTIMATE_S = 150.0 - - def __init__(self, parent: QWidget | None = None): - super().__init__(parent) - self._progress: AutomationProgress | None = None - self._queue_count = 0 - self._running = False - self._step_labels: dict[WorkflowStateKind, QLabel] = {} - - self._timer = QTimer(self) - self._timer.setInterval(1000) - self._timer.timeout.connect(self._refresh_live_view) - - self.setObjectName("compactAutomationProgressStrip") - self.setStyleSheet( - """ - QFrame#compactAutomationProgressStrip { - background: transparent; - border: none; - } - """ - ) - - layout = QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(8) - - self._summary_label = QLabel("Automation idle", self) - self._summary_label.setObjectName("compactProgressSummary") - self._summary_label.setWordWrap(True) - layout.addWidget(self._summary_label) - - steps_row = QHBoxLayout() - steps_row.setSpacing(8) - - for step in ( - WorkflowStateKind.MOUNT, - WorkflowStateKind.LOOP_CENTRE, - WorkflowStateKind.RASTER, - WorkflowStateKind.DATA_COLLECTION, - WorkflowStateKind.FINAL, - ): - label = QLabel(self) - label.setObjectName("compactProgressStep") - label.setWordWrap(True) - steps_row.addWidget(label, 1) - self._step_labels[step] = label - - layout.addLayout(steps_row) - self._apply_empty_state() - - def _apply_empty_state(self) -> None: - self._summary_label.setText("Automation idle") - for step, label in self._step_labels.items(): - label.setText(self._format_step_html(step, StepStatus.PENDING)) - - @staticmethod - def _step_title(step: WorkflowStateKind) -> str: - return { - WorkflowStateKind.MOUNT: "Mount", - WorkflowStateKind.LOOP_CENTRE: "Center", - WorkflowStateKind.RASTER: "Raster", - WorkflowStateKind.DATA_COLLECTION: "Collect", - WorkflowStateKind.FINAL: "Finish", - }.get(step, str(step.value)) - - @staticmethod - def _step_icon(status: StepStatus) -> str: - return { - StepStatus.PENDING: "○", - StepStatus.RUNNING: "◉", - StepStatus.SUCCESS: "✓", - StepStatus.FAILED: "✕", - StepStatus.SKIPPED: "↷", - StepStatus.PAUSED: "⏸", - }.get(status, "○") - - @staticmethod - def _step_color(status: StepStatus) -> str: - return { - StepStatus.PENDING: FAINT_TEXT, - StepStatus.RUNNING: STEP_RUNNING_TEXT, - StepStatus.SUCCESS: STEP_SUCCESS_TEXT, - StepStatus.FAILED: STEP_FAILED_TEXT, - StepStatus.SKIPPED: FAINT_TEXT, - StepStatus.PAUSED: STEP_PAUSED_TEXT, - }.get(status, FAINT_TEXT) - - def _format_step_html(self, step: WorkflowStateKind, status: StepStatus) -> str: - color = self._step_color(status) - icon = self._step_icon(status) - title = self._step_title(step) - return ( - f"
" - f"
{icon}
" - f"
{title}
" - f"
" - ) - - @staticmethod - def _format_duration(seconds: float | None) -> str: - if seconds is None or seconds <= 0: - return "0m 00s" - total = round(seconds) - minutes, secs = divmod(total, 60) - if minutes < 60: - return f"{minutes}m {secs:02d}s" - hours, minutes = divmod(minutes, 60) - return f"{hours}h {minutes:02d}m" - - @staticmethod - def _format_eta(epoch_seconds: float | None) -> str: - if epoch_seconds is None: - return "N/A" - return datetime.fromtimestamp(epoch_seconds).strftime("%H:%M:%S") - - @Slot() - def _refresh_live_view(self) -> None: - if self._progress is not None: - self.set_progress(self._progress) - - @Slot(bool) - def set_running(self, running: bool) -> None: - self._running = bool(running) - if self._progress is not None: - self.set_progress(self._progress) - - @Slot(int) - def set_samples_in_queue(self, count: int) -> None: - self._queue_count = max(0, int(count)) - if self._progress is not None: - self.set_progress(self._progress) - - @Slot(object) - def set_progress(self, progress: AutomationProgress) -> None: - self._progress = progress - - any_running = any(step.status == StepStatus.RUNNING for step in progress.steps) - if any_running and self._running: - if not self._timer.isActive(): - self._timer.start() - else: - self._timer.stop() - - current_sample = progress.current_sample_name or "None" - avg_time = ( - progress.avg_time_per_sample - if progress.avg_time_per_sample > 0 - else self.DEFAULT_SAMPLE_ESTIMATE_S - ) - queue_remaining = avg_time * self._queue_count - eta = time.time() + queue_remaining if queue_remaining > 0 else None - - state_text = "Paused" - state_color = STEP_PAUSED_TEXT - if progress.finished and progress.success is True: - state_text = "Completed" - state_color = STEP_SUCCESS_TEXT - elif progress.finished and progress.success is False: - state_text = "Failed" - state_color = STEP_FAILED_TEXT - elif self._running: - state_text = "Running" - state_color = STEP_RUNNING_TEXT - - self._summary_label.setText( - f"Status: " - f"{state_text}" - f"    Now: {current_sample}" - f"    Queue: {self._queue_count}" - f"    ETA: {self._format_duration(queue_remaining)}" - f"    Done: {self._format_eta(eta)}" - ) - - states = {step.step: step.status for step in progress.steps} - for step, label in self._step_labels.items(): - label.setText(self._format_step_html(step, states.get(step, StepStatus.PENDING))) diff --git a/tests/unit/gui/test_main_window.py b/tests/unit/gui/test_main_window.py index da5e175e..6a901043 100644 --- a/tests/unit/gui/test_main_window.py +++ b/tests/unit/gui/test_main_window.py @@ -87,6 +87,35 @@ def test_main_window_init(qtbot, mock_ui_state, daq_status_factory): win._show_right_panel_action.trigger() assert not win.beamline_controls_scroll.isHidden() + # Alert routers land straight in the runtime notification dock (the + # portrait-mode interceptors are gone with Playlist Mode). + with patch.object(win, "_show_runtime_notification") as note: + win._runtime_alert_primary("beam lost", True) + win._runtime_alert_secondary("shutter", False) + win._on_http_error("boom") + win._on_sample_camera_error("no frames") + assert note.call_count == 4 + + assert ( + win._detector_error_banner_text(automation=True, message="x") + == "Automation halted: detector error." + ) + assert ( + win._detector_error_banner_text(automation=False, message="x") + == "Manual collection stopped: detector error." + ) + + # Manual unmount respects the hutch PSS gate. + with ( + patch.object(win, "_hutch_blocks_mount", return_value="hutch open"), + patch("aare.gui.main_window.QMessageBox"), + ): + win._on_manual_unmount_requested() + win.daq.unmount.assert_not_called() + with patch.object(win, "_hutch_blocks_mount", return_value=None): + win._on_manual_unmount_requested() + win.daq.unmount.assert_called_once() + # Motion watch: only the robot station switches to the combined # beamline view. Moving no longer does (users kept losing the sample # camera on short gonio moves), and busy alone never does — Sample @@ -383,80 +412,6 @@ def test_idle_timeout_does_not_close_while_automation_active(qtbot, mock_ui_stat win.close.assert_not_called() -def test_cleanup_returns_from_portrait_mode(qtbot, mock_ui_state): - with ( - patch("requests.get"), - patch("aare.gui.main_window.DAQWorker"), - patch("aare.gui.main_window.PredictionSubscriber"), - patch("aare.gui.main_window.VideoThread"), - patch("aare.gui.main_window.JFJochDBusClient"), - patch("aare.gui.main_window.jwt.decode") as mock_jwt, - ): - mock_jwt.return_value = { - "sub": "testuser", - "staff": True, - "pgroups": ["p123"], - "session": 15, - } - fake_token = "header.payload.signature" - - win = MainWindow( - base_url=None, - token=fake_token, - default_image=None, - zmq_addr=None, - pred_zmq_addr=None, - beamline_cam_addr=None, - gonio_cam_addr=None, - gonio_cam_id=None, - ) - qtbot.addWidget(win) - - win.enter_portrait_mode() - assert win.content_stack.currentWidget() is win.portrait_mode_page - - win.cleanup() - - assert win.content_stack.currentWidget() is win._standard_main_page - - -def test_cleanup_returns_from_compact_automation_view(qtbot, mock_ui_state): - with ( - patch("requests.get"), - patch("aare.gui.main_window.DAQWorker"), - patch("aare.gui.main_window.PredictionSubscriber"), - patch("aare.gui.main_window.VideoThread"), - patch("aare.gui.main_window.JFJochDBusClient"), - patch("aare.gui.main_window.jwt.decode") as mock_jwt, - ): - mock_jwt.return_value = { - "sub": "testuser", - "staff": True, - "pgroups": ["p123"], - "session": 15, - } - fake_token = "header.payload.signature" - - win = MainWindow( - base_url=None, - token=fake_token, - default_image=None, - zmq_addr=None, - pred_zmq_addr=None, - beamline_cam_addr=None, - gonio_cam_addr=None, - gonio_cam_id=None, - ) - qtbot.addWidget(win) - - win.enter_compact_automation_view() - assert win.content_stack.currentWidget() is win.compact_automation_page - - win.cleanup() - - assert win.content_stack.currentWidget() is win._standard_main_page - - def _make_window(qtbot): win = MainWindow( base_url=None, @@ -695,15 +650,14 @@ def test_sample_camera_frame_paints_visible_views_and_acks(qtbot, mock_ui_state, } win = _make_window(qtbot) - views = (win.sample_camera, win.compact_sample_camera, win.portrait_sample_camera) - # Nothing is shown in tests, so pretend every view is on screen to reach + # Nothing is shown in tests, so pretend the view is on screen to reach # the paint branch. monkeypatch.setattr(type(win.sample_camera), "isVisible", lambda self: True) win.prediction_thread = MagicMock() win._on_sample_camera_frame(QImage(4, 6, QImage.Format.Format_RGB888)) - assert all(v.pixmap_item.pixmap().width() == 4 for v in views) + assert win.sample_camera.pixmap_item.pixmap().width() == 4 # The ack is what paces the subscriber; it must fire after every frame. win.prediction_thread.notify_frame_displayed.assert_called_once() diff --git a/tests/unit/gui/test_qt_override_reduction.py b/tests/unit/gui/test_qt_override_reduction.py index 83934a53..99231ec8 100644 --- a/tests/unit/gui/test_qt_override_reduction.py +++ b/tests/unit/gui/test_qt_override_reduction.py @@ -6,7 +6,6 @@ from PySide6.QtCore import QEvent, QPointF, Qt from PySide6.QtGui import QMouseEvent from aare.gui.panels.fluorescence_panel import FluorescencePanel -from aare.gui.panels.portrait_mode import PlayPauseButton from aare.gui.widgets.baton_request_dialog import BatonPendingDialog, BatonRequestDialog from aare.gui.widgets.value_label import ValueLabel from aare.gui.widgets.video_image import VideoGraphicsView @@ -86,14 +85,6 @@ def test_video_view_shortcuts_replace_keypress_override(qtbot): qtbot.keyClick(view, Qt.Key.Key_F) # fit_to_view: just must not raise -def test_play_pause_button_paints_without_hover_overrides(qtbot): - btn = PlayPauseButton() - qtbot.addWidget(btn) - btn.set_running(True) - # grab() forces a real paintEvent pass over the underMouse() branch - assert not btn.grab().isNull() - - def test_value_label_inherits_click(qtbot): label = ValueLabel("Energy", "keV") qtbot.addWidget(label)