feat: merge Sample List, Reference Tools, and the queue into one dock

Reference Tools was a separate staff-only dock tabified behind Sample
List and kept vanishing (saved dock state, raise order); the automation
job list was a third dock for what is conceptually the same table. One
tabbed dock now holds Dewar samples + Auxiliary puck (concept from
aaregui2's QueuePanel): the dewar table doubles as the queue view with
the queue buttons and an Unmount button in a row beneath it,
SampleQueuePanel lives on hidden as the queue engine, and queue/flag
membership is edited via right-click or row drags onto the Queued and
Flagged chips. Column 0 is a frozen #+status cell (mounted > queued >
flagged > measured, tints context-dependent per chip filter); non-staff
see Auxiliary greyed out with a Staff-only popup instead of hidden.
Ctrl+L/Ctrl+R raise the Dewar/Auxiliary tab. The Sample List and log
pop-outs are fully operational second views on shared models. Riders:
MainWindow installs WheelValueGuard, wires the camera session badge,
and moves samcam/monochromator/ABR/beam-config into the left column's
Beamline group (BeamlineControls keeps only zoom/light/omega/smargon).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 17:08:51 +02:00
co-authored by Claude Fable 5
parent cae056283a
commit 4e355ca37d
6 changed files with 899 additions and 254 deletions
+470 -108
View File
@@ -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(
+131 -28
View File
@@ -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()
+4 -21
View File
@@ -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)
+27 -33
View File
@@ -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: <b>{sample.sample_name} (Manual mount)</b>"
)
else:
self.curr_sample_label.setText(
f"Current sample: <b>{sample.sample_name} ({sample.location.segment}{sample.location.pos}-{sample.pin})</b>"
)
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)
@@ -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:
+260 -64
View File
@@ -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: <b>{sample.sample_name} (Manual mount)</b>"
else:
base_text = (
f"Current sample: <b>{sample.sample_name} "
f"({sample.location.segment}{sample.location.pos}-{sample.pin})</b>"
)
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}<br/><span style='color:{NOTE_TEXT};'>TELL: {tell_details}</span>"
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