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