feat!: remove Playlist Mode and Automation View, unpin window minimum

Why the width shrink only worked after a Playlist Mode round-trip:
Qt auto-pins a top-level window's minimum size to its layout minimum,
and returning from Playlist Mode hid the Information dock (and every
side dock), which happened to drop that layout minimum. The main view
now gets an explicit small minimum (320x240) which disables the
auto-pin outright, so the window shrinks directly and the dock
contents clip or scroll instead.

The Playlist Mode (Ctrl+6) and Automation View (Ctrl+5) prototypes
are deleted per user decision: panels, pages, the content stack (the
standard page is the only page left), menu actions, camera fan-out to
their view copies, portrait alert interceptors (now plain runtime
notification routers), and their styles (portraitRoot, compact*
cards, COMPACT_* and FONT_HERO knobs).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-16 13:28:16 +02:00
committed by duan_j
co-authored by Claude Fable 5
parent 750559fade
commit 809959df33
8 changed files with 64 additions and 1967 deletions
+30 -398
View File
@@ -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:
@@ -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()})"
-805
View File
@@ -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)
+2 -252
View File
@@ -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())
+1 -1
View File
@@ -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
-198
View File
@@ -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"<div style='text-align:center; background:transparent;'>"
f"<div style='font-size:{FONT_VALUE}; color:{color}; font-weight:700; background:transparent;'>{icon}</div>"
f"<div style='font-size:{FONT_HINT}; color:{color}; font-weight:700; background:transparent;'>{title}</div>"
f"</div>"
)
@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"<span style='background:transparent;'><b>Status:</b> "
f"<span style='color:{state_color}; font-weight:700; background:transparent;'>{state_text}</span>"
f" &nbsp;&nbsp; <b>Now:</b> {current_sample}"
f" &nbsp;&nbsp; <b>Queue:</b> {self._queue_count}"
f" &nbsp;&nbsp; <b>ETA:</b> {self._format_duration(queue_remaining)}"
f" &nbsp;&nbsp; <b>Done:</b> {self._format_eta(eta)}</span>"
)
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)))
+31 -77
View File
@@ -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()
@@ -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)