diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index 2ca59df5..91aecd36 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -12,16 +12,21 @@ from aarecommon.models.models import ( BeamlineStateEnum, DAQStatusModel, SampleShortInfoList, + SessionsStateEnum, TokenData, ) from PySide6.QtCore import QEvent, QSettings, Qt, QTimer, Signal, Slot -from PySide6.QtGui import QAction, QActionGroup, QGuiApplication, QKeySequence +from PySide6.QtGui import QAction, QActionGroup, QColor, QCursor, QGuiApplication, QKeySequence from PySide6.QtWidgets import ( + QApplication, + QCheckBox, QDockWidget, QFrame, + QGraphicsColorizeEffect, QHBoxLayout, QMainWindow, QMessageBox, + QPushButton, QScrollArea, QSizePolicy, QStackedWidget, @@ -38,7 +43,8 @@ from aare.gui.constants import LOGGER_NAME from aare.gui.models.gui_state_manager import UIStateManager from aare.gui.panels.automation_panel import AutomationProgressWidget from aare.gui.panels.axis_video_panel import AxisVideoPanel -from aare.gui.panels.beamline_controls import BeamlineControls +from aare.gui.panels.abr_tweak_panel import AbrTweakWidget +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 @@ -50,10 +56,11 @@ from aare.gui.panels.local_contact_panel import LocalContactDialog # panels from aare.gui.panels.log_panel import LogDock -from aare.gui.panels.loop_centering_panel import LoopCenteringPanel +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 from aare.gui.panels.sample_queue_panel import SampleQueuePanel from aare.gui.panels.smargon_trace_panel import SmargonTracePanel from aare.gui.panels.target_stability_panel import TargetStabilityPanel @@ -63,7 +70,13 @@ from aare.gui.panels.tell_sample_panel import TellSamplePanel from aare.gui.scan_logic.raster_grid_manager import RasterGridManager from aare.gui.scan_logic.rotation_scan_manager import RotationScanManager from aare.gui.scan_logic.sample_mount_logic import SampleMountLogic -from aare.gui.styles import BACKGROUND, THEME_ORIGINAL, THEME_PORTRAIT, build_app_stylesheet +from aare.gui.styles import ( + BACKGROUND, + DOCK_CONTENT_LEFT_PAD, + THEME_ORIGINAL, + THEME_PORTRAIT, + build_app_stylesheet, +) # Threads from aare.gui.threads.axis_video_thread import VideoThread @@ -71,6 +84,8 @@ from aare.gui.threads.daq_worker import DAQWorker from aare.gui.threads.jfjoch_viewer import JFJochDBusClient from aare.gui.threads.prediction_subscriber import PredictionSubscriber from aare.gui.tutorials.controls_help_dialog import ControlsHelpDialog +from aare.gui.widgets.popout_window import DockTitleBar, PopoutWindow +from aare.gui.widgets.wheel_value_guard import WheelValueGuard # Tutorials from aare.gui.tutorials.tutorial_actions import TutorialActionExecutor @@ -88,7 +103,7 @@ from aare.gui.widgets.camera_image import SampleCameraImageLabel from aare.gui.widgets.message_box import precondition_check from aare.gui.widgets.no_wheel_scroll_area import NoWheelScrollArea from aare.gui.widgets.status_bar import StatusBar -from aare.gui.widgets.title_label import tighten_column +from aare.gui.widgets.title_label import TitleLabel, tighten_column from aare.gui.widgets.video_image import VideoGraphicsView logger = setup_logger(LOGGER_NAME) @@ -129,7 +144,6 @@ class MainWindow(QMainWindow): self._cleanup_done = False self._default_window_state = None self._pre_automation_window_state = None - self._pre_automation_ref_tools_visible = False self._pre_automation_left_column_visible = True self._pre_automation_right_column_visible = True self._in_compact_automation_view = False @@ -159,6 +173,14 @@ class MainWindow(QMainWindow): self._tutorial_event_bus = TutorialEventBus(self) self._tutorial_text_resolver = DictionaryTextResolver(MANUAL_MOUNT_TUTORIAL) self.state_manager = UIStateManager("PSI", "AareGUI") + + # Wheel safety: sliders/spin boxes/combos only react to the wheel + # while the right mouse button is held; a bare wheel just scrolls + # the page — it can never nudge a value or move a motor. + self._wheel_value_guard = WheelValueGuard(self) + app_instance = QApplication.instance() + if app_instance is not None: + app_instance.installEventFilter(self._wheel_value_guard) self.viewer = JFJochDBusClient() try: @@ -239,17 +261,80 @@ class MainWindow(QMainWindow): s=geom, parent=self.left_column, raster_mgr=self.raster, diffraction=diffraction ) - self.loop_centering = LoopCenteringPanel(parent=self.left_column) - # The beamline state strip lives in a bottom toolbar row (created # after the docks), not in the left column. Always visible. self.beamline_state_panel = BeamlineStatePanel(parent=self) - self.left_column_layout.addWidget(self.data_collection) - self.left_column_layout.addWidget(self.loop_centering) + # Beamline / Experiment as tabs (like the Dewar samples dock) instead + # of two stacked banner groups; the pages keep their banner children. + # documentMode: no pane frame, so the fixed-width panels aren't inset. + self.left_column_tabs = QTabWidget(self.left_column) + self.left_column_tabs.setDocumentMode(True) + # documentMode draws a grey base line across the bar's full width. + self.left_column_tabs.tabBar().setDrawBase(False) + + beamline_page = QWidget() + beamline_layout = QVBoxLayout(beamline_page) + beamline_layout.setContentsMargins(0, 0, 0, 0) + self.samcam = SamcamPanel(beamline_page) + if self._decoded_token.staff: + self.monochromator_panel = MonochromatorPanel(beamline_page) + self.abr_tweak = AbrTweakWidget(beamline_page) + self.beam_config = BeamConfigPanel(beamline_page) + self.beam_mark = self.beam_config.beam_mark + self.beam_center = self.beam_config.beam_center + self.beam_size = self.beam_config.beam_size + beamline_layout.addWidget(self.monochromator_panel) + beamline_layout.addWidget(self.abr_tweak) + beamline_layout.addWidget(self.beam_config) + # Samcam last: the beam panels are the ones tweaked most. + beamline_layout.addWidget(self.samcam) + beamline_layout.addStretch() + tighten_column(beamline_layout) + + experiment_page = QWidget() + experiment_layout = QVBoxLayout(experiment_page) + experiment_layout.setContentsMargins(0, 0, 0, 0) + experiment_layout.addWidget(self.data_collection) + experiment_layout.addStretch() + tighten_column(experiment_layout) + + self.left_column_tabs.addTab(beamline_page, "Beamline") + self.left_column_tabs.addTab(experiment_page, "Experiment") + + # Only the visible page counts toward the height — same trick as the + # content stack below, else the taller page pads the other tab. + def _only_current_left_tab_counts(index: int) -> None: + for i in range(self.left_column_tabs.count()): + page = self.left_column_tabs.widget(i) + vertical = ( + QSizePolicy.Policy.Preferred if i == index else QSizePolicy.Policy.Ignored + ) + page.setSizePolicy(QSizePolicy.Policy.Preferred, vertical) + + self.left_column_tabs.currentChanged.connect(_only_current_left_tab_counts) + _only_current_left_tab_counts(self.left_column_tabs.currentIndex()) + + # Dewar-tabs look: first banner flush under the tab bar (no top + # margin) and the tab row starting at the banners' left edge. The + # Experiment side needs two levels: the frame AND its first panel. + beamline_first = ( + self.monochromator_panel if self._decoded_token.staff else self.samcam + ) + for first in ( + beamline_first, + self.data_collection, + self.data_collection.file_path_panel, + ): + m = first.layout().contentsMargins() + first.layout().setContentsMargins(m.left(), 0, m.right(), m.bottom()) + self.left_column_tabs.setStyleSheet( + "QTabWidget::tab-bar {" + f" left: {self.samcam.layout().contentsMargins().left()}px; }}" + ) + + self.left_column_layout.addWidget(self.left_column_tabs) self.left_column_layout.addStretch() - # Same universal banner gap as inside the panel columns. - tighten_column(self.left_column_layout) top_widget_layout.addWidget(self.collection_controls_scroll) self.collection_controls_scroll.setWidget(self.left_column) @@ -257,9 +342,14 @@ class MainWindow(QMainWindow): Qt.ScrollBarPolicy.ScrollBarAlwaysOff ) self.collection_controls_scroll.setWidgetResizable(True) - self.collection_controls_scroll.setFixedWidth( - max(self.data_collection.set_width, self.loop_centering.sizeHint().width()) + 10 - ) + # No frame: its border drew a line above the tab bar (Dewar tabs have + # none). Freeze the inner column width: widgetResizable makes it track + # the viewport, so the scrollbar appearing used to re-flow every + # banner. Fixed width + a permanent 10px scrollbar gutter means the + # scrollbar pops into spare space and nothing moves. + self.collection_controls_scroll.setFrameShape(QFrame.Shape.NoFrame) + self.left_column.setFixedWidth(self.data_collection.set_width) + self.collection_controls_scroll.setFixedWidth(self.data_collection.set_width + 10) self.video_tab = QTabWidget(parent=top_widget) @@ -341,9 +431,7 @@ class MainWindow(QMainWindow): self.beamline_controls_scroll = NoWheelScrollArea(top_widget) - self.beamline = BeamlineControls( - self.beamline_controls_scroll, staff=self._decoded_token.staff - ) + self.beamline = BeamlineControls(self.beamline_controls_scroll) top_widget_layout.addWidget(self.beamline_controls_scroll) self.beamline_controls_scroll.setWidget(self.beamline) # Resizable so the column shrinks when panels collapse; without it the @@ -352,7 +440,9 @@ class MainWindow(QMainWindow): self.beamline_controls_scroll.setHorizontalScrollBarPolicy( Qt.ScrollBarPolicy.ScrollBarAlwaysOff ) - self.beamline_controls_scroll.setFixedWidth(self.beamline.set_width + 10) + # Same gutter math as the left column: 10px scrollbar + 2px frame, so + # the fixed-width controls are never clipped when the scrollbar shows. + self.beamline_controls_scroll.setFixedWidth(self.beamline.set_width + 12) self.tell_samples = TellSamplePanel(samples=SampleShortInfoList(s=[])) self.ref_tools_panel = ReferenceToolsPanel(samples=SampleShortInfoList(s=[])) @@ -368,31 +458,85 @@ class MainWindow(QMainWindow): ) 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 + # that always travel together, and the dewar table doubles as the + # queue view (status tints), so the automation controls sit under it + # and the Automation list dock is gone. SampleQueuePanel stays alive, + # hidden, as the queue engine; its buttons are reparented here so all + # their existing wiring keeps working. + dewar_tab = QWidget() + dewar_layout = QVBoxLayout(dewar_tab) + dewar_layout.setContentsMargins(0, 0, 0, 0) + dewar_layout.setSpacing(2) + dewar_layout.addWidget(self.tell_samples) + + self.quick_unmount_button = QPushButton("⏏ Unmount", dewar_tab) + self.quick_unmount_button.clicked.connect(lambda: self._on_manual_unmount_requested()) + + automation_row = QHBoxLayout() + for w in ( + self.job_list_panel.play_button, + self.job_list_panel.remove_button, + self.job_list_panel.clear_button, + self.quick_unmount_button, + self.job_list_panel.park_and_dry_when_cleared, + self.job_list_panel.pause_on_conditions_cb, + ): + automation_row.addWidget(w) + dewar_layout.addLayout(automation_row) + + # "Remove selected" now unqueues the dewar-table selection — the + # queue's own table is no longer displayed. + self.job_list_panel.remove_button.clicked.disconnect( + self.job_list_panel.remove_selected_samples + ) + self.job_list_panel.remove_button.clicked.connect(self._remove_selected_from_queue) + self.job_list_panel.hide() + + self.sample_lists_tabs = QTabWidget() + self.sample_lists_tabs.addTab(dewar_tab, "Dewar samples") + self.sample_lists_tabs.addTab(self.ref_tools_panel, "Auxiliary puck") + # Non-staff never get reference-tools data (the DAQ connect below is + # staff-gated). Grey the tab out instead of hiding it: every role sees + # the same view, and clicking the locked tab says why it is locked. + if not self._decoded_token.staff: + self.sample_lists_tabs.setTabEnabled(1, False) + self.sample_lists_tabs.setTabToolTip(1, "Staff only") + # Disabled tabs are skipped by tabBarClicked's hit-test, so the + # click is caught in eventFilter via the geometric tabAt() instead. + self.sample_lists_tabs.tabBar().installEventFilter(self) + + # Wrapper for the left inset: QTabWidget ignores its own contents + # margins for the tab bar, so the padding lives one level up. Aligns + # the panel's left edge with the left column above (Loop centering). + sample_lists_wrap = QWidget() + sample_lists_wrap_layout = QVBoxLayout(sample_lists_wrap) + sample_lists_wrap_layout.setContentsMargins(DOCK_CONTENT_LEFT_PAD, 0, 0, 0) + sample_lists_wrap_layout.setSpacing(0) + sample_lists_wrap_layout.addWidget(self.sample_lists_tabs) + self.tell_samples_dock = QDockWidget("Sample List", self) self.tell_samples_dock.setObjectName("tell_samples_dock") - self.tell_samples_dock.setWidget(self.tell_samples) + self.tell_samples_dock.setWidget(sample_lists_wrap) self.tell_samples_dock.setAllowedAreas(Qt.DockWidgetArea.BottomDockWidgetArea) self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, self.tell_samples_dock) - self.ref_tools_dock = QDockWidget("Reference Tools", self) - self.ref_tools_dock.setObjectName("ref_tools_dock") - self.ref_tools_dock.setWidget(self.ref_tools_panel) - self.ref_tools_dock.setAllowedAreas(Qt.DockWidgetArea.BottomDockWidgetArea) - self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, self.ref_tools_dock) - self.tabifyDockWidget(self.ref_tools_dock, self.tell_samples_dock) - if self._decoded_token.staff: - self.ref_tools_dock.show() - else: - self.ref_tools_dock.hide() + # No floating: popping the dock out ripped the panel from the row and + # reshuffled the rest. The ⤢ button (in the title bar, next to ✕) + # opens an ADDITIONAL fully-wired window; closing it changes nothing. + self.tell_samples_dock.setFeatures( + QDockWidget.DockWidgetFeature.DockWidgetMovable + | QDockWidget.DockWidgetFeature.DockWidgetClosable + ) + self._sample_popout: PopoutWindow | None = None + self.tell_samples_dock.setTitleBarWidget( + DockTitleBar(self.tell_samples_dock, self._open_sample_popout) + ) self.sample_logic = SampleMountLogic() - self.job_list_dock = QDockWidget("Automation list", self) - self.job_list_dock.setObjectName("job_list_dock") - self.job_list_dock.setWidget(self.job_list_panel) - self.job_list_dock.setAllowedAreas(Qt.DockWidgetArea.BottomDockWidgetArea) - self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, self.job_list_dock) - # Manual sample lives in the left column (DataCollectionSettings) # between Dataset path and Exp. Config., collapsible like its # neighbors — it is no longer a bottom dock. @@ -401,9 +545,20 @@ class MainWindow(QMainWindow): self.automation_progress_panel = AutomationProgressWidget() self.automation_progress_dock = QDockWidget("Automation progress", self) self.automation_progress_dock.setObjectName("automation_progress_dock") - self.automation_progress_dock.setWidget(self.automation_progress_panel) + # Scroll host: the panel's ~420px minimum otherwise dictates the whole + # bottom row's height and squeezes the Beamline column into a scrollbar. + automation_scroll = NoWheelScrollArea(self.automation_progress_dock) + automation_scroll.setWidget(self.automation_progress_panel) + automation_scroll.setWidgetResizable(True) + automation_scroll.setFrameShape(QFrame.Shape.NoFrame) + self.automation_progress_dock.setWidget(automation_scroll) self.automation_progress_dock.setAllowedAreas(Qt.DockWidgetArea.BottomDockWidgetArea) self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, self.automation_progress_dock) + # Same title-bar icons (⤢ pop-out + ✕) as Sample List / Console Log. + self._automation_popout: PopoutWindow | None = None + self.automation_progress_dock.setTitleBarWidget( + DockTitleBar(self.automation_progress_dock, self._open_automation_popout) + ) self.face_panel = FaceDetectionPanel() self.face_panel_dock = QDockWidget("Face detection", self) @@ -560,6 +715,19 @@ class MainWindow(QMainWindow): 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 + # the default-state capture so "reset layout" gets it too; a saved + # user layout (restored below) still wins. + self.resizeDocks( + [self.tell_samples_dock, self.log_dock], [240, 240], Qt.Orientation.Vertical + ) + # Equal oversized requests -> Qt distributes proportionally = 50/50. + self.resizeDocks( + [self.tell_samples_dock, self.automation_progress_dock], + [10000, 10000], + Qt.Orientation.Horizontal, + ) self._capture_default_window_state() self._restore_window_state() @@ -623,15 +791,20 @@ class MainWindow(QMainWindow): if self._decoded_token.staff: self.daq.reference_tools.connect(self.ref_tools_panel.new_list) - self.beamline.samcam.changed.connect(self.daq.samcam_settings) - self.beamline.samcam.screenshot_requested.connect(self.daq.send_screenshot_db) - self.beamline.samcam.save_beam_location_setting.connect( + self.samcam.changed.connect(self.daq.samcam_settings) + self.samcam.screenshot_requested.connect(self.daq.send_screenshot_db) + self.samcam.save_beam_location_setting.connect( self.daq.save_beam_location_camera_setting ) - self.loop_centering.find_tip.clicked.connect(self.daq.center_loop) - self.loop_centering.bounding_box.clicked.connect(self.daq.ml_bounding_box) + self.data_collection.find_tip.clicked.connect(self.daq.center_loop) + self.data_collection.bounding_box.clicked.connect(self.daq.ml_bounding_box) self.daq.raster_generated_by_ml.connect(self.raster.update_active_grid_request) + # Clicking the big session badge (SESSION VACANT / Guest Mode) opens + # the grab/request menu right at the cursor. + self.sample_camera.session_badge_clicked.connect( + lambda: self.status_bar.show_session_menu(QCursor.pos()) + ) self.sample_camera.smargon.connect(self.daq.move_smargon) self.beamline.smargon_panel.smargon.connect(self.daq.move_smargon) self.sample_camera.samcam_updated.connect(self.daq.samcam_settings) @@ -647,36 +820,36 @@ class MainWindow(QMainWindow): self.beamline.illumination_panel.back_light.connect(self.daq.back_light) if self._decoded_token.staff: - self.beamline.monochromator_panel.mono_pitch_scan.connect(self.daq.mono_pitch_scan) - self.beamline.monochromator_panel.change_energy.connect(self.daq.change_energy) - self.beamline.abr_tweak.abr_tweak.connect(self.daq.abr_tweak) - self.beamline.abr_tweak.abr_save.connect(self.daq.abr_save) - self.beamline.abr_tweak.abr_goto_meas.connect(self.daq.abr_goto_meas) + self.monochromator_panel.mono_pitch_scan.connect(self.daq.mono_pitch_scan) + self.monochromator_panel.change_energy.connect(self.daq.change_energy) + self.abr_tweak.abr_tweak.connect(self.daq.abr_tweak) + self.abr_tweak.abr_save.connect(self.daq.abr_save) + self.abr_tweak.abr_goto_meas.connect(self.daq.abr_goto_meas) - self.beamline.beam_mark.beam_mark_clear.connect(self.daq.beam_mark_clear) + self.beam_mark.beam_mark_clear.connect(self.daq.beam_mark_clear) self.sample_camera.update_beam_mark.connect(self.daq.beam_mark_add) - self.beamline.beam_center.beam_center.connect(self.daq.beam_center) - self.beamline.beam_size.beam_size.connect(self.daq.beam_size_mm) + self.beam_center.beam_center.connect(self.daq.beam_center) + self.beam_size.beam_size.connect(self.daq.beam_size_mm) self.sample_camera.load_image.connect(self.raster.load_image) self.sample_camera.switch_raster_grid.connect(self.data_collection.switch_to_raster) - self.beamline.samcam.show_detections_changed.connect(self.sample_camera.set_show_detections) - self.beamline.samcam.show_detection_polygons_changed.connect( + self.samcam.show_detections_changed.connect(self.sample_camera.set_show_detections) + self.samcam.show_detection_polygons_changed.connect( self.sample_camera.set_show_detection_polygons ) - self.beamline.samcam.show_target_point_changed.connect( + self.samcam.show_target_point_changed.connect( self.sample_camera.set_show_target_point ) - self.beamline.samcam.show_target_coordinates_changed.connect( + self.samcam.show_target_coordinates_changed.connect( self.sample_camera.set_show_target_coordinates ) - self.beamline.samcam.show_overlay_legend_changed.connect( + self.samcam.show_overlay_legend_changed.connect( self.sample_camera.set_show_overlay_legend ) - self.beamline.samcam.compact_overlay_legend_changed.connect( + self.samcam.compact_overlay_legend_changed.connect( self.sample_camera.set_compact_overlay_legend ) - self.beamline.samcam.target_color_changed.connect(self.sample_camera.set_target_color) + self.samcam.target_color_changed.connect(self.sample_camera.set_target_color) self._restore_samcam_overlay_settings() sample_feed_addr = pred_zmq_addr or zmq_addr @@ -757,6 +930,16 @@ class MainWindow(QMainWindow): self.ref_tools_panel.mount.connect(self._on_manual_mount_requested) self.ref_tools_panel.unmount.connect(self._on_manual_unmount_requested) + # Dewar table doubles as the queue view: right-click edits queue + # membership, and every queue change repaints the status tints. + self.tell_samples.add_to_queue.connect( + lambda lst: self.job_list_panel.queue_samples(lst.s, replace=False) + ) + self.tell_samples.remove_from_queue.connect( + lambda lst: self.job_list_panel.remove_samples([s.db_id for s in lst.s]) + ) + self.job_list_panel.table_model.modelReset.connect(self._sync_queue_row_tints) + self.data_collection.raster.grid_size_updated.connect(self.raster.update_grid_size) self.data_collection.raster.exp_time_updated.connect(self.raster.update_exposure_time) self.data_collection.raster.transmission_updated.connect(self.raster.update_transmission) @@ -833,13 +1016,13 @@ class MainWindow(QMainWindow): self.daq.update.connect(self.prediction_thread.update_daq_status) if self._decoded_token.staff: - self.daq.update.connect(self.beamline.monochromator_panel.update_daq_status) - self.daq.update.connect(self.beamline.beam_size.update_daq_status) - self.daq.update.connect(self.beamline.beam_center.update_daq_status) - self.daq.update.connect(self.beamline.abr_tweak.update_daq_status) - self.daq.update.connect(self.beamline.beam_mark.update_daq_status) + self.daq.update.connect(self.monochromator_panel.update_daq_status) + self.daq.update.connect(self.beam_size.update_daq_status) + self.daq.update.connect(self.beam_center.update_daq_status) + self.daq.update.connect(self.abr_tweak.update_daq_status) + self.daq.update.connect(self.beam_mark.update_daq_status) self.daq.update.connect(self.status_bar.update_daq_status) - self.daq.update.connect(self.beamline.samcam.update_daq_status) + self.daq.update.connect(self.samcam.update_daq_status) self.daq.update.connect(self.beamline.zoom_panel.update_daq_status) self.daq.update.connect(self.sample_logic.update_daq_status) @@ -859,6 +1042,7 @@ class MainWindow(QMainWindow): self.daq.raster_scan_completed.connect(self.raster.grid_scan_completed) self.daq.automated_scan_done.connect(self.job_list_panel.automated_scan_done) + self.daq.automated_scan_done.connect(self._mark_scan_result) self.daq.automation_critical_failure.connect(self._on_automation_critical_failure) self.daq.manual_collection_critical_failure.connect( self._on_manual_collection_critical_failure @@ -902,21 +1086,30 @@ class MainWindow(QMainWindow): self._shortcut_raise_sample_list = QAction("Raise sample list", self) self._shortcut_raise_sample_list.setShortcut(QKeySequence("Ctrl+L")) self._shortcut_raise_sample_list.triggered.connect( - lambda: (self.tell_samples_dock.setVisible(True), self.tell_samples_dock.raise_()) + lambda: ( + self.tell_samples_dock.setVisible(True), + self.tell_samples_dock.raise_(), + self.sample_lists_tabs.setCurrentIndex(0), + ) ) self.addAction(self._shortcut_raise_sample_list) + # Reference tools now live inside the Sample List dock as the + # Auxiliary-puck tab — Ctrl+R raises the dock on that tab. self._shortcut_raise_reference_tools_list = QAction("Raise reference tools", self) self._shortcut_raise_reference_tools_list.setShortcut(QKeySequence("Ctrl+R")) - self._shortcut_raise_reference_tools_list.triggered.connect( - lambda: (self.ref_tools_dock.setVisible(True), self.ref_tools_dock.raise_()) - ) + self._shortcut_raise_reference_tools_list.triggered.connect(self._raise_reference_tools) self.addAction(self._shortcut_raise_reference_tools_list) + # The automation controls live under the Dewar samples tab now. self._shortcut_raise_job_list = QAction("Raise automation list", self) self._shortcut_raise_job_list.setShortcut(QKeySequence("Ctrl+J")) self._shortcut_raise_job_list.triggered.connect( - lambda: (self.job_list_dock.setVisible(True), self.job_list_dock.raise_()) + lambda: ( + self.tell_samples_dock.setVisible(True), + self.tell_samples_dock.raise_(), + self.sample_lists_tabs.setCurrentIndex(0), + ) ) self.addAction(self._shortcut_raise_job_list) @@ -952,6 +1145,139 @@ class MainWindow(QMainWindow): ) self.addAction(self._shortcut_console_log) + @Slot() + def _sync_queue_row_tints(self) -> None: + self.tell_samples.table_model.set_queued_ids( + sample.db_id for sample in self.job_list_panel.table_model.samples + ) + + @Slot() + def _remove_selected_from_queue(self) -> None: + self._unqueue_panel_selection(self.tell_samples) + + def _unqueue_panel_selection(self, panel: TellSamplePanel) -> None: + rows = panel.table_view.selectionModel().selectedRows() + db_ids = [panel.table_model.get_id(index.row()).db_id for index in rows] + if db_ids: + self.job_list_panel.remove_samples(db_ids) + + @Slot(int, bool, str) + def _mark_scan_result(self, db_id: int, success: bool, reply: str) -> None: + # Display only: a failed run tints the row pale red until a later + # success clears it. Auth errors are not the sample's fault — skip. + if reply == "Authentication Error": + return + self.tell_samples.table_model.set_flagged(db_id, not success) + + @Slot() + def _open_sample_popout(self) -> None: + if self._sample_popout is None: + # Fully operational duplicate of the Sample List tab: second panel + # instances SHARING the docked panels' models, wired to the same + # slots — mounts, queue edits, chips and filters all work here. + dewar_panel = TellSamplePanel(model=self.tell_samples.table_model) + aux_panel = ReferenceToolsPanel(model=self.ref_tools_panel.table_model) + dewar_panel.mount.connect(self._on_manual_mount_requested) + dewar_panel.unmount.connect(self._on_manual_unmount_requested) + dewar_panel.add_to_queue.connect( + lambda lst: self.job_list_panel.queue_samples(lst.s, replace=False) + ) + dewar_panel.remove_from_queue.connect( + lambda lst: self.job_list_panel.remove_samples([s.db_id for s in lst.s]) + ) + aux_panel.mount.connect(self._on_manual_mount_requested) + aux_panel.unmount.connect(self._on_manual_unmount_requested) + + # One shared filter state → keep the two chip rows visually in sync. + self.tell_samples.status_chips.buttonClicked.connect( + lambda chip: dewar_panel.set_status_chip(chip.property("status_key")) + ) + dewar_panel.status_chips.buttonClicked.connect( + lambda chip: self.tell_samples.set_status_chip(chip.property("status_key")) + ) + dewar_panel.set_status_chip(self.tell_samples.table_model.status_filter) + + dewar_tab = QWidget() + dewar_layout = QVBoxLayout(dewar_tab) + dewar_layout.setContentsMargins(0, 0, 0, 0) + dewar_layout.setSpacing(2) + dewar_layout.addWidget(dewar_panel) + dewar_layout.addLayout(self._clone_automation_row(dewar_panel)) + + tabs = QTabWidget() + tabs.addTab(dewar_tab, "Dewar samples") + tabs.addTab(aux_panel, "Auxiliary puck") + if not self._decoded_token.staff: + tabs.setTabEnabled(1, False) + tabs.setTabToolTip(1, "Staff only") + self._sample_popout = PopoutWindow("Sample List", tabs, parent=self) + self._sample_popout.resize(1200, 500) + self._sample_popout.show() + self._sample_popout.raise_() + self._sample_popout.activateWindow() + + def _open_automation_popout(self) -> None: + if self._automation_popout is None: + # Mirror wired to the same feeds as the docked panel. + panel = AutomationProgressWidget() + self.job_list_panel.samples_in_queue_changed.connect(panel.set_samples_in_queue) + self.job_list_panel.automation_running_changed.connect(panel.set_running) + self.daq.automation_progress.connect(panel.set_progress) + panel.set_samples_in_queue(len(self.job_list_panel.table_model.samples)) + panel.set_running(self.job_list_panel.is_running()) + self._automation_popout = PopoutWindow("Automation progress", panel, parent=self) + self._automation_popout.resize(420, 520) + self._automation_popout.show() + self._automation_popout.raise_() + self._automation_popout.activateWindow() + + def _clone_automation_row(self, dewar_panel: TellSamplePanel) -> QHBoxLayout: + """Pop-out copy of the automation controls, driving the same queue + engine. Run/Pause text and checkbox states stay mirrored; 'Remove + selected' acts on the pop-out's own table selection.""" + jl = self.job_list_panel + run_button = QPushButton(jl.play_button.text()) + run_button.clicked.connect(jl.run) + jl.automation_running_changed.connect( + lambda running: run_button.setText("⏸ Pause" if running else "▶ Run") + ) + remove_button = QPushButton("🗑 Remove selected") + remove_button.clicked.connect(lambda: self._unqueue_panel_selection(dewar_panel)) + clear_button = QPushButton("✖ Clear list") + clear_button.clicked.connect(jl.clear) + unmount_button = QPushButton("⏏ Unmount") + unmount_button.clicked.connect(lambda: self._on_manual_unmount_requested()) + + row = QHBoxLayout() + for button in (run_button, remove_button, clear_button, unmount_button): + row.addWidget(button) + for source in (jl.park_and_dry_when_cleared, jl.pause_on_conditions_cb): + clone = QCheckBox(source.text()) + clone.setToolTip(source.toolTip()) + clone.setChecked(source.isChecked()) + # setChecked with an unchanged value emits nothing, so the + # cross-connection cannot loop. + clone.toggled.connect(source.setChecked) + source.toggled.connect(clone.setChecked) + row.addWidget(clone) + return row + + def _show_reference_tools_staff_only_popup(self) -> None: + QMessageBox.information( + self, + "Staff only", + "The Auxiliary puck (reference tools) view is available to staff accounts only.", + ) + + @Slot() + def _raise_reference_tools(self) -> None: + if not self._decoded_token.staff: + self._show_reference_tools_staff_only_popup() + return + self.tell_samples_dock.setVisible(True) + 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: @@ -979,7 +1305,7 @@ class MainWindow(QMainWindow): compact_overlay_legend = settings.value("samcam/compact_overlay_legend", False, type=bool) target_color = settings.value("samcam/target_color", "Cyan", type=str) - self.beamline.samcam.apply_overlay_settings( + self.samcam.apply_overlay_settings( show_detections=show_detections, show_detection_polygons=show_detection_polygons, show_target_point=show_target_point, @@ -1112,12 +1438,10 @@ class MainWindow(QMainWindow): if not self._in_compact_automation_view: self._pre_automation_window_state = self.saveState() - self._pre_automation_ref_tools_visible = self.ref_tools_dock.isVisible() 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.job_list_dock.setVisible(False) self.automation_progress_dock.setVisible(False) self.face_panel_dock.setVisible(False) self.fluor_panel_dock.setVisible(False) @@ -1126,9 +1450,6 @@ class MainWindow(QMainWindow): self.prediction_metrics_dock.setVisible(False) self.log_dock.setVisible(False) - if self._decoded_token.staff: - self.ref_tools_dock.setVisible(False) - self.collection_controls_scroll.setVisible(False) self.beamline_controls_scroll.setVisible(False) @@ -1146,15 +1467,11 @@ class MainWindow(QMainWindow): self.collection_controls_scroll.setVisible(self._pre_automation_left_column_visible) self.beamline_controls_scroll.setVisible(self._pre_automation_right_column_visible) - if self._decoded_token.staff: - self.ref_tools_dock.setVisible(self._pre_automation_ref_tools_visible) - self._in_compact_automation_view = False self._update_view_mode_actions() - self.job_list_dock.setVisible(True) self.tell_samples_dock.setVisible(True) - self.job_list_dock.raise_() + self.tell_samples_dock.raise_() @Slot() def _refresh_compact_queue_preview(self) -> None: @@ -1194,7 +1511,6 @@ class MainWindow(QMainWindow): # Hide all dock widgets for dock_attr in ( "tell_samples_dock", - "job_list_dock", "automation_progress_dock", "face_panel_dock", "fluor_panel_dock", @@ -1202,7 +1518,6 @@ class MainWindow(QMainWindow): "target_stability_dock", "prediction_metrics_dock", "log_dock", - "ref_tools_dock", ): dock = getattr(self, dock_attr, None) if dock is not None: @@ -1261,7 +1576,6 @@ class MainWindow(QMainWindow): self._pre_portrait_geometry = None self.tell_samples_dock.setVisible(True) - self.job_list_dock.setVisible(True) self.automation_progress_dock.setVisible(False) self.face_panel_dock.setVisible(False) self.fluor_panel_dock.setVisible(False) @@ -1435,25 +1749,6 @@ class MainWindow(QMainWindow): self.tell_samples_dock.visibilityChanged.connect(show_samples_action.setChecked) view_menu.addAction(show_samples_action) - if self._decoded_token.staff: - show_reference_tools_action = QAction("Show Reference Tools", self) - show_reference_tools_action.setCheckable(True) - show_reference_tools_action.setChecked(True) - show_reference_tools_action.triggered.connect( - lambda checked: self.ref_tools_dock.setVisible(checked) - ) - self.ref_tools_dock.visibilityChanged.connect(show_reference_tools_action.setChecked) - view_menu.addAction(show_reference_tools_action) - - show_job_list_action = QAction("Show job List", self) - show_job_list_action.setCheckable(True) - show_job_list_action.setChecked(True) - show_job_list_action.triggered.connect( - lambda checked: self.job_list_dock.setVisible(checked) - ) - self.job_list_dock.visibilityChanged.connect(show_job_list_action.setChecked) - view_menu.addAction(show_job_list_action) - show_face_panel_action = QAction("Show face detection", self) show_face_panel_action.setCheckable(True) show_face_panel_action.setChecked(False) @@ -1593,7 +1888,6 @@ class MainWindow(QMainWindow): self.beamline_controls_scroll.setVisible(True) self.tell_samples_dock.setVisible(True) - self.job_list_dock.setVisible(True) self.face_panel_dock.setVisible(False) self.fluor_panel_dock.setVisible(False) @@ -1602,12 +1896,7 @@ class MainWindow(QMainWindow): self.prediction_metrics_dock.setVisible(False) self.log_dock.setVisible(False) - if self._decoded_token.staff: - self.ref_tools_dock.setVisible(True) - self.ref_tools_dock.raise_() - else: - self.ref_tools_dock.setVisible(False) - self.tell_samples_dock.raise_() + self.tell_samples_dock.raise_() self.video_tab.setCurrentIndex(0) @@ -1998,9 +2287,51 @@ class MainWindow(QMainWindow): def sample_view(self): self.video_tab.setCurrentWidget(self.sample_camera) + def _apply_session_gate(self, session_state) -> None: + # No baton -> watching only: camera views stay live, every operating + # surface is greyed. The SESSION VACANT badge (and the status bar + # session menu) remain the way back in. + owned = session_state in ( + SessionsStateEnum.OwnedByYou, + SessionsStateEnum.PendingElseToYou, + ) + if owned == getattr(self, "_session_operations_enabled", None): + return + self._session_operations_enabled = owned + for widget in ( + self.left_column_tabs, + self.beamline, + self.tell_samples_dock.widget(), + ): + widget.setEnabled(owned) + if owned: + widget.setGraphicsEffect(None) + else: + # Full grayscale, banners included — QSS :disabled alone + # can't reach the custom-painted TitleLabels/inline styles. + effect = QGraphicsColorizeEffect(widget) + effect.setColor(QColor("#808080")) + widget.setGraphicsEffect(effect) + self.sample_camera.set_operations_enabled(owned) + + # Vacant folds every panel shut; grabbing reopens exactly the ones + # that were open before. Transient (persist=False) so the fold never + # overwrites the user's saved per-panel choices. + banners = self.left_column_tabs.findChildren(TitleLabel) + self.beamline.findChildren( + TitleLabel + ) + if owned: + for banner in getattr(self, "_pre_vacancy_open_banners", []): + banner.set_collapsed(False, persist=False) + else: + self._pre_vacancy_open_banners = [b for b in banners if not b.is_collapsed()] + for banner in banners: + banner.set_collapsed(True, persist=False) + @Slot(DAQStatusModel) def update_daq_status(self, s: DAQStatusModel): self._latest_daq_status = s + self._apply_session_gate(getattr(getattr(s, "session", None), "session", None)) if self._is_automation_active(): self._refresh_idle_activity(report_backend=False) @@ -2293,6 +2624,27 @@ class MainWindow(QMainWindow): self.state_manager.restore_window(self) self._restore_panel_visibility_settings() + def showEvent(self, event): + super().showEvent(event) + # First show only, and only without a saved layout: re-apply the + # default dock split AFTER the real (maximized) geometry exists — + # the __init__ resizeDocks ran on the pre-show size and Qt hands the + # scale-up surplus to the sample list, skewing 50/50 into ~80/20. + if not getattr(self, "_default_dock_split_done", False): + self._default_dock_split_done = True + if not self.state_manager.settings.value("main_window/state"): + QTimer.singleShot(0, self._apply_default_dock_split) + + def _apply_default_dock_split(self) -> None: + self.resizeDocks( + [self.tell_samples_dock, self.log_dock], [240, 240], Qt.Orientation.Vertical + ) + self.resizeDocks( + [self.tell_samples_dock, self.automation_progress_dock], + [10000, 10000], + Qt.Orientation.Horizontal, + ) + def closeEvent(self, event) -> None: try: self._return_to_main_view_for_shutdown() @@ -2464,6 +2816,16 @@ class MainWindow(QMainWindow): self._mark_user_interaction() except Exception as e: logger.debug(f"GUI interaction event filter error: {e}", exc_info=True) + # Non-staff click on the greyed-out Auxiliary-puck tab: only installed + # for non-staff, and tabAt() is geometric so it still sees the + # disabled tab — explain the lock instead of silently eating the click. + if ( + event.type() == QEvent.Type.MouseButtonPress + and obj is self.sample_lists_tabs.tabBar() + and obj.tabAt(event.position().toPoint()) == 1 + ): + self._show_reference_tools_staff_only_popup() + return True return super().eventFilter(obj, event) def _start_remote_close_countdown( diff --git a/src/aare/gui/models/user_sample_model.py b/src/aare/gui/models/user_sample_model.py index b57b3f37..c9e08e2e 100644 --- a/src/aare/gui/models/user_sample_model.py +++ b/src/aare/gui/models/user_sample_model.py @@ -6,34 +6,45 @@ from PySide6.QtCore import QAbstractTableModel, QMimeData, Qt from PySide6.QtGui import QBrush from aare.gui.constants import LOGGER_NAME -from aare.gui.styles import SAMPLE_ROW_HIGHLIGHT_BG, SAMPLE_ROW_QUEUED_BG, WHITE, qcolor +from aare.gui.styles import ( + SAMPLE_ROW_QUEUED_BG, + SAMPLE_STATUS_FLAGGED_BG, + SAMPLE_STATUS_MEASURED_BG, + SAMPLE_STATUS_QUEUED_BG, + qcolor, +) logger = setup_logger(LOGGER_NAME) +# Column 0 is display-only: the row position ("#") drawn over the status +# color fill. Data attributes start at column 1. +COL_STATUS = 0 + def get_entry(sample: SampleShortInfo, column: int): - if column == 0: + if column == 1: return sample.sample_name - elif column == 1: - return sample.puck_name elif column == 2: - return sample.dewar_name + return sample.puck_name elif column == 3: - return sample.loc_str() + return sample.dewar_name elif column == 4: - return sample.priority + return sample.loc_str() elif column == 5: - return sample.user + return sample.priority elif column == 6: - return sample.mount_count + return sample.user elif column == 7: - return sample.raster_count + return sample.mount_count elif column == 8: - return sample.rotation_count + return sample.raster_count elif column == 9: - return sample.screening_count + return sample.rotation_count elif column == 10: + return sample.screening_count + elif column == 11: return sample.comment + return "" class UserSampleSpreadsheet(QAbstractTableModel): @@ -49,6 +60,7 @@ class UserSampleSpreadsheet(QAbstractTableModel): samples = [] self.samples: list[SampleShortInfo] = samples self.header = [ + "#", "Sample name", "Puck", "Dewar", @@ -63,15 +75,23 @@ class UserSampleSpreadsheet(QAbstractTableModel): ] self.current_sample = current_sample self.current_puck = current_puck - self._sort_col = 3 + self._sort_col = 4 # Location self._sort_order = Qt.SortOrder.AscendingOrder self._filters: dict[int, str] = {} - self._filter_col: int | None = 5 + self._filter_col: int | None = 6 # User self._filter_value: str | None = None self.current_pgroup: str | None = None self.show_all_pgroups: bool = False + # Display-only status tints (aaregui2 concept: the dewar table doubles + # as the queue view). Fed from outside; the queue itself stays in the + # SampleQueueSpreadsheet. + self.queued_ids: set[int] = set() + self.flagged_ids: set[int] = set() + # None = All; otherwise "queued" | "flagged" | "measured" (chip row). + self.status_filter: str | None = None + self._sort() def to_list(self) -> list[dict]: @@ -90,17 +110,88 @@ class UserSampleSpreadsheet(QAbstractTableModel): def data(self, index, role=None): if role == Qt.ItemDataRole.DisplayRole: + if index.column() == COL_STATUS: + return index.row() + 1 return get_entry(self._sorted_samples[index.row()], index.column()) + elif role == Qt.ItemDataRole.BackgroundRole: + # Status lives in the "#" column, as a full cell fill under the + # row number — rows themselves alternate grey/white (view-level) + # and selection stays the pale blue tint. + if index.column() == COL_STATUS: + color = self._status_color(self._sorted_samples[index.row()]) + if color is not None: + return QBrush(qcolor(color)) elif role == Qt.ItemDataRole.TextAlignmentRole: # Align text to center return Qt.AlignmentFlag.AlignCenter - elif role == Qt.ItemDataRole.BackgroundRole: - if self._sorted_samples[index.row()].db_id == self.current_sample: - return QBrush(qcolor(SAMPLE_ROW_QUEUED_BG)) - if self._sorted_samples[index.row()].puck_name == self.current_puck: - return QBrush(qcolor(SAMPLE_ROW_HIGHLIGHT_BG)) - return QBrush(qcolor(WHITE)) return None # For other roles, return None + def _status_color(self, sample: SampleShortInfo) -> str | None: + # Mounted always wins; below that the dot depends on the active chip: + # inside a filtered view every row carries that status, so its own + # color is redundant — only cross-status marks show (queued view: + # red = also flagged; flagged view: orange = put back in the queue). + # The All view keeps the full priority queued > flagged > measured. + if sample.db_id == self.current_sample: + return SAMPLE_ROW_QUEUED_BG + queued = sample.db_id in self.queued_ids + flagged = sample.db_id in self.flagged_ids + if self.status_filter == "queued": + return SAMPLE_STATUS_FLAGGED_BG if flagged else None + if self.status_filter == "flagged": + return SAMPLE_STATUS_QUEUED_BG if queued else None + if self.status_filter == "measured": + if queued: + return SAMPLE_STATUS_QUEUED_BG + return SAMPLE_STATUS_FLAGGED_BG if flagged else None + if queued: + return SAMPLE_STATUS_QUEUED_BG + if flagged: + return SAMPLE_STATUS_FLAGGED_BG + if self._measured(sample): + return SAMPLE_STATUS_MEASURED_BG + return None + + @staticmethod + def _measured(sample: SampleShortInfo) -> bool: + # Automatic status, never relabelled by hand: a sample counts as + # measured once its rotation count exceeds 1. + return isinstance(sample.rotation_count, (int, float)) and sample.rotation_count > 1 + + def set_queued_ids(self, db_ids) -> None: + self.queued_ids = set(db_ids) + self._status_sets_changed() + + def set_flagged(self, db_id: int, flagged: bool) -> None: + if flagged: + self.flagged_ids.add(db_id) + else: + self.flagged_ids.discard(db_id) + self._status_sets_changed() + + def set_status_filter(self, status: str | None) -> None: + self.layoutAboutToBeChanged.emit() + self.status_filter = status + self._sort() + self.layoutChanged.emit() + + def _status_sets_changed(self) -> None: + # With a status chip active the row SET depends on the sets, not just + # the tint — refilter; otherwise a background repaint is enough. + if self.status_filter: + self.layoutAboutToBeChanged.emit() + self._sort() + self.layoutChanged.emit() + else: + self._emit_tints_changed() + + def _emit_tints_changed(self) -> None: + if self.rowCount() > 0: + self.dataChanged.emit( + self.index(0, COL_STATUS), + self.index(self.rowCount() - 1, COL_STATUS), + [Qt.ItemDataRole.BackgroundRole], + ) + def headerData(self, section, orientation, role=None): if role == Qt.ItemDataRole.DisplayRole: if orientation == Qt.Orientation.Horizontal: # Column header @@ -114,6 +205,7 @@ class UserSampleSpreadsheet(QAbstractTableModel): ): self.current_puck = current_puck self.current_sample = current_sample + self._emit_tints_changed() def updateData(self, samples: list[SampleShortInfo]): if samples != self.samples: @@ -123,6 +215,9 @@ class UserSampleSpreadsheet(QAbstractTableModel): self.endResetModel() def sort(self, column, order): + # The "#"/status column is display-only — nothing to sort by. + if column == COL_STATUS: + return self.layoutAboutToBeChanged.emit() self._sort_order = order self._sort_col = column @@ -131,7 +226,7 @@ class UserSampleSpreadsheet(QAbstractTableModel): def _sort(self): filtered = self._apply_filter(self.samples) - if self._sort_col == 3: + if self._sort_col == 4: # Location self._sorted_samples = sorted( filtered, key=lambda row: row.loc_str_sort(), @@ -150,6 +245,14 @@ class UserSampleSpreadsheet(QAbstractTableModel): ) def _apply_filter(self, rows: list[SampleShortInfo]) -> list[SampleShortInfo]: + # Status chip filter first (All/Queued/Flagged/Measured row). + if self.status_filter == "queued": + rows = [r for r in rows if r.db_id in self.queued_ids] + elif self.status_filter == "flagged": + rows = [r for r in rows if r.db_id in self.flagged_ids] + elif self.status_filter == "measured": + rows = [r for r in rows if self._measured(r)] + # Default filter by User using current p-group if no explicit filter set filters: dict[int, str] = { col: v for col, v in (self._filters or {}).items() if (v or "").strip() @@ -158,8 +261,8 @@ class UserSampleSpreadsheet(QAbstractTableModel): if self._filter_col is not None and (self._filter_value or "").strip(): filters[self._filter_col] = self._filter_value - if 5 not in filters and self.current_pgroup and not self.show_all_pgroups: - filters[5] = self.current_pgroup + if 6 not in filters and self.current_pgroup and not self.show_all_pgroups: + filters[6] = self.current_pgroup # User if not filters: return rows @@ -271,7 +374,7 @@ class UserSampleSpreadsheet(QAbstractTableModel): break # Sort appropriately - if column == 5: # User/pgroup column + if column == 6: # User/pgroup column try: out.sort( key=lambda x: ( @@ -290,10 +393,10 @@ class UserSampleSpreadsheet(QAbstractTableModel): def suggested_prefixes_for_sample_name(self, limit: int = 200) -> list[str]: """Get sample name prefixes from currently filtered samples (excluding column 0 filter).""" # Get currently filtered samples, excluding the sample name filter - temp_filter = self._filters.pop(0, None) + temp_filter = self._filters.pop(1, None) filtered_samples = self._apply_filter(self.samples) if temp_filter is not None: - self._filters[0] = temp_filter + self._filters[1] = temp_filter rx = re.compile(r"^([A-Za-z]+)") counts: dict[str, int] = {} @@ -314,10 +417,10 @@ class UserSampleSpreadsheet(QAbstractTableModel): def suggested_prefixes_for_location(self, limit: int = 200) -> tuple[list[str], list[str]]: """Get location prefixes from currently filtered samples (excluding column 3 filter).""" # Get currently filtered samples, excluding the location filter - temp_filter = self._filters.pop(3, None) + temp_filter = self._filters.pop(4, None) filtered_samples = self._apply_filter(self.samples) if temp_filter is not None: - self._filters[3] = temp_filter + self._filters[4] = temp_filter seg_seen: set[str] = set() segpos_seen: set[str] = set() diff --git a/src/aare/gui/panels/beamline_controls.py b/src/aare/gui/panels/beamline_controls.py index f98070de..7266cd50 100644 --- a/src/aare/gui/panels/beamline_controls.py +++ b/src/aare/gui/panels/beamline_controls.py @@ -1,13 +1,10 @@ from PySide6.QtWidgets import QFrame, QVBoxLayout, QWidget -from aare.gui.panels.abr_tweak_panel import AbrTweakWidget from aare.gui.panels.beam_center_panel import BeamCenterWidget from aare.gui.panels.beam_mark_panel import BeamMarkWidget from aare.gui.panels.beam_size_panel import BeamSizeWidget from aare.gui.panels.illumination_panel import IlluminationPanel -from aare.gui.panels.monochromator_panel import MonochromatorPanel from aare.gui.panels.omega_panel import OmegaPanel -from aare.gui.panels.samcam_panel import SamcamPanel from aare.gui.panels.smargon_panel import SmargonPanel from aare.gui.panels.zoom_panel import ZoomPanel from aare.gui.widgets.title_label import TitleLabel, tighten_column @@ -23,7 +20,7 @@ class BeamConfigPanel(QWidget): # panels' banners in the column. layout = QVBoxLayout(self) layout.setSpacing(0) - layout.addWidget(TitleLabel("Beam Config.", self, collapsible=True)) + layout.addWidget(TitleLabel("Beam configuration", self, collapsible=True, default_collapsed=False)) self.beam_mark = BeamMarkWidget(self) self.beam_center = BeamCenterWidget(self) self.beam_size = BeamSizeWidget(self) @@ -39,13 +36,15 @@ class BeamConfigPanel(QWidget): class BeamlineControls(QFrame): set_width = 250 - def __init__(self, parent=None, staff: bool = True): + def __init__(self, parent=None): super().__init__(parent) self.setObjectName("beamlineControls") self.setFixedWidth(self.set_width) self.setFrameShape(QFrame.Shape.StyledPanel) self.setFrameShadow(QFrame.Shadow.Raised) + # Samcam / monochromator / ABR / beam config moved to the left + # column's "Beamline" group (main_window builds it). self.v_layout = QVBoxLayout(self) self.zoom_panel = ZoomPanel(self) self.v_layout.addWidget(self.zoom_panel) @@ -59,22 +58,6 @@ class BeamlineControls(QFrame): self.smargon_panel = SmargonPanel(parent=self) self.v_layout.addWidget(self.smargon_panel) - self.samcam = SamcamPanel(self) - self.v_layout.addWidget(self.samcam) - - if staff: - self.monochromator_panel = MonochromatorPanel(self) - self.abr_tweak = AbrTweakWidget(self) - self.beam_config = BeamConfigPanel(self) - # Aliases: main_window wires signals via beamline.beam_mark etc. - self.beam_mark = self.beam_config.beam_mark - self.beam_center = self.beam_config.beam_center - self.beam_size = self.beam_config.beam_size - - self.v_layout.addWidget(self.monochromator_panel) - self.v_layout.addWidget(self.abr_tweak) - self.v_layout.addWidget(self.beam_config) - self.v_layout.addStretch() tighten_column(self.v_layout) self.setLayout(self.v_layout) diff --git a/src/aare/gui/panels/reference_tools_panel.py b/src/aare/gui/panels/reference_tools_panel.py index d056235a..0c22e4fc 100644 --- a/src/aare/gui/panels/reference_tools_panel.py +++ b/src/aare/gui/panels/reference_tools_panel.py @@ -9,9 +9,7 @@ from PySide6.QtWidgets import ( QFrame, QGridLayout, QHeaderView, - QLabel, QMenu, - QPushButton, QTableView, ) @@ -174,11 +172,13 @@ class ReferenceToolsPanel(QFrame): samples: SampleShortInfoList | None = None, parent=None, refresh_interval_ms: int = 5000, + model: ReferenceToolsModel | None = None, ): """ :param samples: optional initial SampleShortInfoList to populate the table :param parent: Qt parent :param refresh_interval_ms: how often to call request_refresh (panel doesn't implement the request itself) + :param model: share an existing model instead of owning one (pop-out window) """ super().__init__(parent) @@ -190,26 +190,21 @@ class ReferenceToolsPanel(QFrame): layout = QGridLayout(self) self.setLayout(layout) + # Flush layout, matching TellSamplePanel: full-width banner, no + # padding ring, no gap to the dock's tab row. + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) layout.addWidget(TitleLabel("Reference tools", parent=self), 0, 0, 1, 4) self.table_view = QTableView(parent=self) + # Row colors carry the separation — no grid lines. + self.table_view.setShowGrid(False) layout.addWidget(self.table_view, 1, 0, 1, 4) - self.curr_sample_label = QLabel("No sample mounted", parent=self) - layout.addWidget(self.curr_sample_label, 2, 0) - - self.unmount_button = QPushButton("Unmount", parent=self) - layout.addWidget(self.unmount_button, 2, 1) - self.unmount_button.clicked.connect(self._on_unmount_clicked) - - layout.setColumnStretch(0, 1) - layout.setColumnStretch(1, 0) - - # initialize model with provided samples - self.table_model = ReferenceToolsModel(rows=samples.s) + # initialize model with provided samples (or adopt the shared one) + self.table_model = model if model is not None else ReferenceToolsModel(rows=samples.s) self.table_view.setModel(self.table_model) - self.table_view.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) self.table_view.setEditTriggers(QTableView.EditTrigger.NoEditTriggers) logger.debug("Setting up table header") @@ -218,8 +213,13 @@ class ReferenceToolsPanel(QFrame): header.setSectionResizeMode(QHeaderView.ResizeMode.Interactive) logger.debug("Setting up table header") header.setStretchLastSection(True) + # No bold column titles when cells are selected. + header.setHighlightSections(False) self.table_view.verticalHeader().setVisible(True) logger.debug("Setting up table view sorting") + # Adopt the model's current order first — a second panel on a shared + # model must not re-sort it on open. + header.setSortIndicator(self.table_model._sort_col, self.table_model._sort_order) self.table_view.setSortingEnabled(True) logger.debug("Setting up table view context menu") self.table_view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) @@ -227,15 +227,18 @@ class ReferenceToolsPanel(QFrame): self.table_view.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self.table_view.setSelectionMode(QTableView.SelectionMode.SingleSelection) + # Columns at full content width, sized once (see TellSamplePanel). + self._columns_autosized = False + if self.table_model.rowCount() > 0: + self.table_view.resizeColumnsToContents() + self._columns_autosized = True + def _selected_item(self) -> SampleShortInfo | None: idx = self.table_view.currentIndex() if not idx.isValid(): return None return self.table_model.get_item(idx.row()) - def _on_unmount_clicked(self): - self.unmount.emit() - def _context_menu(self, position): idx = self.table_view.indexAt(position) @@ -263,22 +266,13 @@ class ReferenceToolsPanel(QFrame): def new_list(self, samples: SampleShortInfoList): # signal from DAQWorker will call this with the model self.table_model.update_rows(rows=samples.s) + if not self._columns_autosized and self.table_model.rowCount() > 0: + self.table_view.resizeColumnsToContents() + self._columns_autosized = True @Slot(DAQStatusModel) def update_daq_status(self, status: DAQStatusModel): + # The "No sample mounted" label and Unmount button were dropped — + # mounted state now shows as the existing row highlight instead. sample = status.sample - if sample is None: - self.curr_sample_label.setText("No sample mounted") - else: - try: - if sample.location is None: - self.curr_sample_label.setText( - f"Current sample: {sample.sample_name} (Manual mount)" - ) - else: - self.curr_sample_label.setText( - f"Current sample: {sample.sample_name} ({sample.location.segment}{sample.location.pos}-{sample.pin})" - ) - except Exception as e: - logger.debug("Could not update the current sample label", exc_info=True) - self.curr_sample_label.setText(f"Confusing information :/ {e}") + self.table_model.update_current_reference(sample.db_id if sample is not None else None) diff --git a/src/aare/gui/panels/sample_queue_panel.py b/src/aare/gui/panels/sample_queue_panel.py index a7de0c68..13eeedd7 100644 --- a/src/aare/gui/panels/sample_queue_panel.py +++ b/src/aare/gui/panels/sample_queue_panel.py @@ -194,6 +194,13 @@ class SampleQueuePanel(QFrame): self._emit_samples_in_queue_changed() + def remove_samples(self, db_ids) -> None: + """Remove the given samples from the queue. Public entry point for the + combined dewar view, whose selection lives outside this panel.""" + for db_id in db_ids: + self.table_model.remove_sample(db_id) + self._emit_samples_in_queue_changed() + def remove_selected_samples(self): selected_indexes = self.table_view.selectionModel().selectedRows() if not selected_indexes: diff --git a/src/aare/gui/panels/tell_sample_panel.py b/src/aare/gui/panels/tell_sample_panel.py index 81b2d555..b085a098 100644 --- a/src/aare/gui/panels/tell_sample_panel.py +++ b/src/aare/gui/panels/tell_sample_panel.py @@ -8,30 +8,133 @@ from aarecommon.models.models import ( from PySide6.QtCore import Qt, Signal, Slot from PySide6.QtWidgets import ( QAbstractItemView, + QButtonGroup, QFrame, QGridLayout, + QHBoxLayout, QHeaderView, - QLabel, QMenu, QPushButton, QTableView, ) from aare.gui.constants import LOGGER_NAME -from aare.gui.models.user_sample_model import UserSampleSpreadsheet -from aare.gui.styles import NOTE_TEXT +from aare.gui.models.user_sample_model import COL_STATUS, UserSampleSpreadsheet +from aare.gui.styles import ( + CHIP_NEUTRAL_BG, + MUTED_TEXT, + SAMPLE_STATUS_FLAGGED_BG, + SAMPLE_STATUS_MEASURED_BG, + SAMPLE_STATUS_QUEUED_BG, + TAB_FACE_BG, + TEXT, +) from aare.gui.widgets.title_label import TitleLabel logger = setup_logger(LOGGER_NAME) +class FrozenColumnTableView(QTableView): + """QTableView with the "#"/status column frozen (Qt frozen-column + pattern): an overlay view shares the model and selection, sits on top of + column 0, and stays put while the rest scrolls horizontally.""" + + FROZEN_WIDTH = 36 + + def __init__(self, parent=None): + super().__init__(parent) + self.frozen = QTableView(self) + self.frozen.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self.frozen.verticalHeader().hide() + self.frozen.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.frozen.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.frozen.setShowGrid(False) + self.frozen.setAlternatingRowColors(True) + self.frozen.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + self.frozen.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) + self.frozen.setEditTriggers(QTableView.EditTrigger.NoEditTriggers) + self.frozen.setDragEnabled(True) + self.frozen.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Fixed) + self.frozen.horizontalHeader().setHighlightSections(False) + # The overlay must not carry the L3 frame — it sits INSIDE the view. + self.frozen.setStyleSheet("QTableView { border: none; }") + self.viewport().stackUnder(self.frozen) + self.frozen.verticalScrollBar().valueChanged.connect(self.verticalScrollBar().setValue) + self.verticalScrollBar().valueChanged.connect(self.frozen.verticalScrollBar().setValue) + + def setModel(self, model): + super().setModel(model) + self.frozen.setModel(model) + # Share the selection so clicking either view highlights both. + self.frozen.setSelectionModel(self.selectionModel()) + for col in range(1, model.columnCount()): + self.frozen.setColumnHidden(col, True) + self.set_frozen_width(self.FROZEN_WIDTH) + self.frozen.show() + + def set_frozen_width(self, width: int) -> None: + self.setColumnWidth(0, width) + self.frozen.setColumnWidth(0, width) + self._update_frozen_geometry() + + def _update_frozen_geometry(self) -> None: + self.frozen.setGeometry( + self.frameWidth(), + self.frameWidth(), + self.columnWidth(0), + self.viewport().height() + self.horizontalHeader().height(), + ) + + def resizeEvent(self, event): + super().resizeEvent(event) + self._update_frozen_geometry() + + +class QueueDropChip(QPushButton): + """Filter chip that doubles as a drop target: dragging table rows onto it + relabels them (Queued adds to the automation queue, Flagged marks them + flagged — same mime the old queue dock took). Measured is deliberately + NOT one of these: it is automatic, from the rotation count.""" + + samples_dropped = Signal(SampleShortInfoList) + + def __init__(self, label: str, parent=None): + super().__init__(label, parent) + self.setAcceptDrops(True) + + def dragEnterEvent(self, event): + if event.mimeData().hasText(): + event.acceptProposedAction() + + def dropEvent(self, event): + try: + samples = SampleShortInfoList.model_validate_json(event.mimeData().text()) + except Exception: + logger.debug("Ignoring drop that is not a sample list", exc_info=True) + return + self.samples_dropped.emit(samples) + event.acceptProposedAction() + + class TellSamplePanel(QFrame): mount = Signal(SampleShortInfo) unmount = Signal() state = Signal(BeamlineStateEnum) + # Queue membership is edited from this table now (aaregui2 concept: the + # dewar list doubles as the queue view); the queue itself lives in the + # SampleQueuePanel these signals are wired to. + add_to_queue = Signal(SampleShortInfoList) + remove_from_queue = Signal(SampleShortInfoList) - def __init__(self, samples: SampleShortInfoList | None = None, parent=None): - + def __init__( + self, + samples: SampleShortInfoList | None = None, + parent=None, + model: UserSampleSpreadsheet | None = None, + ): + """`model`: share an existing spreadsheet model instead of owning one — + used by the pop-out window so both panels operate on the same data, + tints and filters with no syncing.""" super().__init__(parent) if samples is None: @@ -41,28 +144,86 @@ class TellSamplePanel(QFrame): grid_layout = QGridLayout(self) self.setLayout(grid_layout) + # Flush layout: the banner spans the full panel width and sits + # directly under the dock's tab row — no padding ring. + grid_layout.setContentsMargins(0, 0, 0, 0) + grid_layout.setSpacing(0) grid_layout.addWidget(TitleLabel("TELL sample changer", self), 0, 0, 1, 4) - self.table_view = QTableView() - grid_layout.addWidget(self.table_view, 1, 0, 1, 4) + # Status filter row (aaregui2 concept): filters the table by queue/ + # collection status, and each checked button wears its row-tint color, + # doubling as the legend. Styled like the Dewar/Auxiliary tab row + # above (square tabs, touching), not pills. Colors live in styles.py. + chip_row = QHBoxLayout() + # Left margin 0: "All" shares the table's left edge; bottom 0: the + # row sits directly on the table. + chip_row.setContentsMargins(0, 2, 6, 0) + chip_row.setSpacing(0) + self.status_chips = QButtonGroup(self) + self.status_chips.setExclusive(True) + for label, key, checked_bg in ( + ("All", None, CHIP_NEUTRAL_BG), + ("Queued", "queued", SAMPLE_STATUS_QUEUED_BG), + ("Flagged", "flagged", SAMPLE_STATUS_FLAGGED_BG), + ("Measured", "measured", SAMPLE_STATUS_MEASURED_BG), + ): + if key == "queued": + chip = QueueDropChip(label, self) + chip.samples_dropped.connect(self.add_to_queue) + # Deselect after the drop: the selection tint would otherwise + # sit on top of the fresh status color and hide it. + chip.samples_dropped.connect(lambda _: self.table_view.clearSelection()) + chip.setToolTip("Filter queued samples — or drop table rows here to queue them") + elif key == "flagged": + chip = QueueDropChip(label, self) + chip.samples_dropped.connect(self._flag_dropped_samples) + chip.samples_dropped.connect(lambda _: self.table_view.clearSelection()) + chip.setToolTip("Filter flagged samples — or drop table rows here to flag them") + else: + chip = QPushButton(label, self) + if key == "measured": + chip.setToolTip("Filter measured samples (automatic: rotation count > 1)") + chip.setCheckable(True) + chip.setChecked(key is None) + chip.setProperty("status_key", key) + chip.setCursor(Qt.CursorShape.PointingHandCursor) + chip.setStyleSheet( + # Same font, padding and hover hint as the QTabBar tabs above + # (no bold, default size): unchecked tabs sit 3px lower + # (raised-selection effect), the checked one wears its + # row-tint fill, hover underlines just the text. + f"QPushButton {{ background: {TAB_FACE_BG}; color: {MUTED_TEXT};" + f" border: none;" + f" border-top-left-radius: 4px; border-top-right-radius: 4px;" + f" margin-top: 3px; padding: 4px 14px; }}" + f"QPushButton:hover:!checked {{ color: {TEXT}; text-decoration: underline; }}" + f"QPushButton:checked {{ background: {checked_bg}; color: {TEXT};" + f" margin-top: 0px; padding: 6px 14px 5px 14px; }}" + ) + self.status_chips.addButton(chip) + chip_row.addWidget(chip) + chip_row.addStretch() + grid_layout.addLayout(chip_row, 1, 0, 1, 4) + self.status_chips.buttonClicked.connect( + lambda chip: self.table_model.set_status_filter(chip.property("status_key")) + ) - self.curr_sample_label = QLabel("No sample mounted", parent=self) - self.curr_sample_label.setTextFormat(Qt.TextFormat.RichText) - self.curr_sample_label.setWordWrap(True) - grid_layout.addWidget(self.curr_sample_label, 2, 0) + # Staggered grey/white rows tell rows apart — no grid lines, no row + # tints; status fills the frozen "#" column and selection stays blue. + self.table_view = FrozenColumnTableView() + self.table_view.setShowGrid(False) + self.table_view.setAlternatingRowColors(True) + grid_layout.addWidget(self.table_view, 2, 0, 1, 4) - self.unmount_button = QPushButton("Unmount", parent=self) - grid_layout.addWidget(self.unmount_button, 2, 1) - self.unmount_button.clicked.connect(self.unmount_button_clicked) - - grid_layout.setColumnStretch(0, 1) - grid_layout.setColumnStretch(1, 0) - - self.table_model = UserSampleSpreadsheet(samples=samples.s) + self.table_model = model if model is not None else UserSampleSpreadsheet(samples=samples.s) self.table_view.setModel(self.table_model) - self.table_view.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) + # Adopt the model's current order before enabling sorting: a second + # panel on a shared model must not re-sort it to column 0 on open. + self.table_view.horizontalHeader().setSortIndicator( + self.table_model._sort_col, self.table_model._sort_order + ) self.table_view.setSortingEnabled(True) self.table_view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.table_view.customContextMenuRequested.connect(self.context_menu) @@ -74,17 +235,44 @@ class TellSamplePanel(QFrame): header = self.table_view.horizontalHeader() header.setSectionResizeMode(QHeaderView.ResizeMode.Interactive) header.setStretchLastSection(True) - self.table_view.verticalHeader().setVisible(True) + # No bold column titles when cells are selected. + header.setHighlightSections(False) + # Row numbers + status color live in the frozen "#" column: fixed + # width, not resizable, stays visible on horizontal scroll. + self.table_view.verticalHeader().setVisible(False) + header.setSectionResizeMode(COL_STATUS, QHeaderView.ResizeMode.Fixed) + self.table_view.set_frozen_width(FrozenColumnTableView.FROZEN_WIDTH) + # Right-click on the frozen column behaves like the main table (the + # handler only uses the row, which both views share). + self.table_view.frozen.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.table_view.frozen.customContextMenuRequested.connect(self.context_menu) header.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) header.customContextMenuRequested.connect(self.header_context_menu) - def unmount_button_clicked(self): - self.unmount.emit() + # Columns at full content width (horizontal scroll instead of + # squishing); done ONCE so later data refreshes don't fight manual + # column adjustments. + self._columns_autosized = False + if self.table_model.rowCount() > 0: + self._autosize_columns() + + def _autosize_columns(self) -> None: + self.table_view.resizeColumnsToContents() + header = self.table_view.horizontalHeader() + # Cap: one long comment must not eat the whole view. + for col in range(1, self.table_model.columnCount()): + if header.sectionSize(col) > 300: + self.table_view.setColumnWidth(col, 300) + # Re-pin the frozen display column after the autosize pass. + self.table_view.set_frozen_width(FrozenColumnTableView.FROZEN_WIDTH) + self._columns_autosized = True @Slot(SampleShortInfoList) def new_sample_list(self, samples: SampleShortInfoList): self.table_model.updateData(samples=samples.s) + if not self._columns_autosized and self.table_model.rowCount() > 0: + self._autosize_columns() def annotate_sample_comment(self, db_id: int, comment: str) -> None: samples = list(self.table_model.samples) @@ -98,6 +286,29 @@ class TellSamplePanel(QFrame): self.table_model.updateData(samples=updated_samples) + @Slot(SampleShortInfoList) + def _flag_dropped_samples(self, samples: SampleShortInfoList) -> None: + # Flagged is display state owned by the (shared) model, so relabeling + # here reaches the docked panel and the pop-out alike. + for sample in samples.s: + self.table_model.set_flagged(sample.db_id, True) + + def set_status_chip(self, key: str | None) -> None: + """Check the chip for `key` without firing its filter — keeps the + main and pop-out chip rows in sync (both drive one shared model).""" + for chip in self.status_chips.buttons(): + if chip.property("status_key") == key: + chip.setChecked(True) + return + + def _selected_samples(self, clicked_row: int) -> list[SampleShortInfo]: + """Selected rows if the clicked row is part of the selection, else + just the clicked row — so right-click on an unselected row acts on it.""" + rows = sorted({i.row() for i in self.table_view.selectionModel().selectedRows()}) + if clicked_row not in rows: + rows = [clicked_row] + return [self.table_model.get_id(r) for r in rows] + def context_menu(self, position): index = self.table_view.indexAt(position) if not index.isValid(): @@ -109,18 +320,35 @@ class TellSamplePanel(QFrame): if sample.location is None: return + selected = [s for s in self._selected_samples(row) if s.location is not None] + menu = QMenu() + count = f" ({len(selected)})" if len(selected) > 1 else "" + add_queue_action = menu.addAction(f"Add to queue{count}") + remove_queue_action = menu.addAction(f"Remove from queue{count}") + menu.addSeparator() mount_action = menu.addAction("Mount") + # Unmount moved here from the removed bottom-row button — same signal. + unmount_action = menu.addAction("Unmount") action = menu.exec_(self.table_view.viewport().mapToGlobal(position)) if action == mount_action: self.mount.emit(sample) + elif action == unmount_action: + self.unmount.emit() + elif action == add_queue_action: + self.add_to_queue.emit(SampleShortInfoList(s=selected)) + self.table_view.clearSelection() + elif action == remove_queue_action: + self.remove_from_queue.emit(SampleShortInfoList(s=selected)) + self.table_view.clearSelection() def header_context_menu(self, pos): header = self.table_view.horizontalHeader() logical_index = header.logicalIndexAt(pos) - if logical_index < 0: + # The "#"/status column is display-only — no filter menus there. + if logical_index < 1: return col_name = self.table_model.header[logical_index] @@ -128,7 +356,7 @@ class TellSamplePanel(QFrame): menu = QMenu(self) # Column-specific preset submenus (existing logic) ... - if logical_index == 0: + if logical_index == 1: # Sample name presets = self.table_model.suggested_prefixes_for_sample_name() if presets: prefix_menu = menu.addMenu("Filter by name prefix") @@ -139,7 +367,7 @@ class TellSamplePanel(QFrame): logical_index, vv ) ) - elif logical_index == 3: + elif logical_index == 4: # Location segs, segpos = self.table_model.suggested_prefixes_for_location() if segs: seg_menu = menu.addMenu("Filter by segment (A..F,X,R)") @@ -176,7 +404,7 @@ class TellSamplePanel(QFrame): clear_filter_action = menu.addAction(f"Clear filter: {col_name}") clear_all_action = menu.addAction("Clear all filters") - if logical_index == 5: + if logical_index == 6: # User menu.addSeparator() toggle_all = menu.addAction("Show all pgroups (ignore current p-group)") toggle_all.setCheckable(True) @@ -204,48 +432,16 @@ class TellSamplePanel(QFrame): @Slot(DAQStatusModel) def update_daq_status(self, status: DAQStatusModel): + # Mounted state shows as the row highlight only — the "No sample + # mounted" label and Unmount button were dropped; TELL activity text + # lives in the beamline state panel already. sample = status.sample - tell_state = status.tell_state - - tell_details = "" - if tell_state is not None: - activity = tell_state.activity.display_name() - phase = tell_state.phase.display_name() if tell_state.phase is not None else "" - message = (tell_state.message or "").strip() - - tell_parts = [activity] - if phase: - tell_parts.append(phase) - - tell_details = " / ".join(tell_parts) - if message: - tell_details = f"{tell_details} — {message}" - if sample is None: - base_text = "No sample mounted" self.table_model.updateCurrentSample(current_puck=None, current_sample=None) else: - try: - if sample.location is None: - base_text = f"Current sample: {sample.sample_name} (Manual mount)" - else: - base_text = ( - f"Current sample: {sample.sample_name} " - f"({sample.location.segment}{sample.location.pos}-{sample.pin})" - ) - self.table_model.updateCurrentSample( - current_puck=sample.puck_name, current_sample=sample.db_id - ) - except Exception as e: - logger.debug("Could not build the TELL sample panel text", exc_info=True) - base_text = f"Confusing information :/ {e}" - - if tell_details: - self.curr_sample_label.setText( - f"{base_text}
TELL: {tell_details}" + self.table_model.updateCurrentSample( + current_puck=sample.puck_name, current_sample=sample.db_id ) - else: - self.curr_sample_label.setText(base_text) if status.session.current_pgroup is not None: self._current_pgroup = status.session.current_pgroup