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:
+30
-398
@@ -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()})"
|
||||
@@ -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
@@ -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())
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" <b>Now:</b> {current_sample}"
|
||||
f" <b>Queue:</b> {self._queue_count}"
|
||||
f" <b>ETA:</b> {self._format_duration(queue_remaining)}"
|
||||
f" <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)))
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user