resizable camera region, menu panel collapse, zoom ladder #231
@@ -102,6 +102,10 @@ jobs:
|
||||
git clone https://gitea.psi.ch/bec/${{ matrix.plugin_repo }}.git
|
||||
uv pip install -e ./mx_bec
|
||||
uv pip install -e ./${{ matrix.plugin_repo }}
|
||||
# The bec plugin deps drag PySide6 up to >=6.10, which segfaults
|
||||
# the unit suite in teardown (see the pyproject pin). Force the
|
||||
# project's capped version back after the plugin install.
|
||||
uv pip install "pyside6<6.10"
|
||||
|
||||
- name: Run Pytest
|
||||
env:
|
||||
|
||||
+4
-1
@@ -13,7 +13,10 @@ dependencies = [
|
||||
"pyJWT",
|
||||
"pyzmq",
|
||||
"opencv-python-headless",
|
||||
"PySide6",
|
||||
# <6.10: 6.11.2 (what the unpinned CI resolves) segfaults the unit suite
|
||||
# in teardown on ~half the runs — on plain main too, not this branch.
|
||||
# Drop the cap once the teardown crash is hunted down.
|
||||
"PySide6<6.10",
|
||||
"requests",
|
||||
"pyepics~=3.5",
|
||||
"redis",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 148 B |
Binary file not shown.
|
After Width: | Height: | Size: 137 B |
Binary file not shown.
|
After Width: | Height: | Size: 151 B |
Binary file not shown.
|
After Width: | Height: | Size: 143 B |
+91
-415
@@ -32,7 +32,6 @@ from PySide6.QtGui import (
|
||||
QColor,
|
||||
QCursor,
|
||||
QFont,
|
||||
QGuiApplication,
|
||||
QImage,
|
||||
QKeySequence,
|
||||
QPalette,
|
||||
@@ -55,7 +54,6 @@ from PySide6.QtWidgets import (
|
||||
QScrollArea,
|
||||
QSizePolicy,
|
||||
QSlider,
|
||||
QStackedWidget,
|
||||
QTabWidget,
|
||||
QToolBar,
|
||||
QToolTip,
|
||||
@@ -74,7 +72,6 @@ from aare.gui.panels.axis_video_panel import AxisVideoPanel
|
||||
from aare.gui.panels.beamline_controls import BeamConfigPanel, BeamlineControls
|
||||
from aare.gui.panels.beamline_recovery_panel import BeamlineRecoveryDialog
|
||||
from aare.gui.panels.beamline_state_panel import BeamlineStatePanel
|
||||
from aare.gui.panels.compact_automation_panel import CompactAutomationPanel
|
||||
from aare.gui.panels.data_collection_settings import DataCollectionSettings
|
||||
from aare.gui.panels.developer_help_dialog import DeveloperHelpDialog
|
||||
from aare.gui.panels.face_detection_panel import FaceDetectionPanel
|
||||
@@ -84,7 +81,6 @@ from aare.gui.panels.local_contact_panel import LocalContactDialog
|
||||
# panels
|
||||
from aare.gui.panels.log_panel import LogPanel
|
||||
from aare.gui.panels.monochromator_panel import MonochromatorPanel
|
||||
from aare.gui.panels.portrait_mode import PortraitModePanel
|
||||
from aare.gui.panels.prediction_metrics_panel import PredictionMetricsPanel
|
||||
from aare.gui.panels.reference_tools_panel import ReferenceToolsPanel
|
||||
from aare.gui.panels.samcam_panel import SamcamPanel
|
||||
@@ -101,7 +97,7 @@ from aare.gui.styles import (
|
||||
APP_BACKGROUND,
|
||||
DARK_TEXT,
|
||||
DOCK_CONTENT_LEFT_PAD,
|
||||
FONT_SCALE_STEP,
|
||||
FONT_SCALE_LADDER,
|
||||
SEPARATOR_HINT_DELAY_MS,
|
||||
THEME_BLUEBIRD,
|
||||
THEME_FADE_MS,
|
||||
@@ -218,12 +214,6 @@ class MainWindow(QMainWindow):
|
||||
self._local_contact_dialog = None
|
||||
self._controls_help_dialog = None
|
||||
self._default_window_state = None
|
||||
self._pre_automation_window_state = None
|
||||
self._pre_automation_left_column_visible = True
|
||||
self._pre_automation_right_column_visible = True
|
||||
self._in_compact_automation_view = False
|
||||
self._enter_automation_view_action = None
|
||||
self._return_main_view_action = None
|
||||
|
||||
self._beamline_cam_addr = beamline_cam_addr
|
||||
self._gonio_cam_addr = gonio_cam_addr
|
||||
@@ -312,9 +302,6 @@ class MainWindow(QMainWindow):
|
||||
self.alert_banner_secondary = AlertBanner(parent=root_widget)
|
||||
self.alert_banner_secondary.float_over(root_widget)
|
||||
|
||||
self.content_stack = QStackedWidget(parent=root_widget)
|
||||
root_layout.addWidget(self.content_stack, 1)
|
||||
|
||||
top_widget = QWidget(parent=root_widget)
|
||||
top_widget.setObjectName("standardMainPage")
|
||||
top_widget_layout = QHBoxLayout(top_widget)
|
||||
@@ -460,6 +447,9 @@ class MainWindow(QMainWindow):
|
||||
self.left_column_layout.addWidget(self.left_column_tabs)
|
||||
self.left_column_layout.addStretch()
|
||||
|
||||
# Plain HBox, no splitter: drag-resizing the side columns was tried
|
||||
# and rejected (user: the panels don't need resizing — the View-menu
|
||||
# toggles collapse them, and the camera takes whatever is left).
|
||||
top_widget_layout.addWidget(self.collection_controls_scroll)
|
||||
self.collection_controls_scroll.setWidget(self.left_column)
|
||||
self.collection_controls_scroll.setHorizontalScrollBarPolicy(
|
||||
@@ -469,11 +459,9 @@ class MainWindow(QMainWindow):
|
||||
# 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)
|
||||
# banner. Fixed inner width + a 10px scrollbar gutter means the
|
||||
# scrollbar pops into spare space and nothing moves; the widths are
|
||||
# zoom-dependent and live in _apply_zoom_widths.
|
||||
|
||||
self.video_tab = QTabWidget(parent=top_widget)
|
||||
|
||||
@@ -514,6 +502,14 @@ class MainWindow(QMainWindow):
|
||||
self.video_tab.addTab(self.beamline_view_panel, "Beamline view")
|
||||
self.video_tab.addTab(self.beamline_combined_panel, "Beamline combined view")
|
||||
|
||||
# The camera region's natural minimum (~284px) came from the widest
|
||||
# page's controls row (title + refresh button) and the tab labels —
|
||||
# not from the video views, which rescale freely like they do in
|
||||
# height. Clamp to 1/3 so a narrow window can shrink all four camera
|
||||
# views that far; past the natural width the tab bar scrolls and the
|
||||
# controls rows clip.
|
||||
self.video_tab.setMinimumWidth(self.video_tab.minimumSizeHint().width() // 3)
|
||||
|
||||
# if cfg_get("gui.cameras.secondary_beamline_camera_url", None):
|
||||
# self.secondary_beamline_view = VideoGraphicsView()
|
||||
# self.secondary_beamline_view_panel = AxisVideoPanel("Secondary view", self.secondary_beamline_view,
|
||||
@@ -521,39 +517,7 @@ class MainWindow(QMainWindow):
|
||||
# self.secondary_beamline_view_panel.refresh_requested.connect(self.refresh_axis_cameras)
|
||||
# self.video_tab.addTab(self.secondary_beamline_view_panel, "Secondary view")
|
||||
|
||||
self.compact_sample_camera = SampleCameraImageLabel(
|
||||
geom=geom, raster=self.raster, parent=root_widget, default_image=default_image
|
||||
)
|
||||
self.compact_automation_panel = CompactAutomationPanel(
|
||||
self.compact_sample_camera, parent=root_widget
|
||||
)
|
||||
|
||||
self.compact_automation_page = QWidget(parent=root_widget)
|
||||
self.compact_automation_page.setObjectName("compactAutomationPage")
|
||||
self.compact_automation_page_layout = QVBoxLayout(self.compact_automation_page)
|
||||
self.compact_automation_page_layout.setContentsMargins(18, 18, 18, 18)
|
||||
self.compact_automation_page_layout.setSpacing(0)
|
||||
self.compact_automation_page_layout.addWidget(self.compact_automation_panel)
|
||||
|
||||
# ── Portrait mode page ──────────────────────────────────────────
|
||||
self.portrait_sample_camera = SampleCameraImageLabel(
|
||||
geom=geom, raster=self.raster, parent=root_widget, default_image=default_image
|
||||
)
|
||||
self.portrait_mode_panel = PortraitModePanel(
|
||||
sample_camera_widget=self.portrait_sample_camera, parent=root_widget
|
||||
)
|
||||
|
||||
self.portrait_mode_page = QWidget(parent=root_widget)
|
||||
self.portrait_mode_page.setObjectName("portraitModePage")
|
||||
portrait_page_layout = QHBoxLayout(self.portrait_mode_page)
|
||||
portrait_page_layout.setContentsMargins(0, 0, 0, 0)
|
||||
portrait_page_layout.setSpacing(0)
|
||||
self.portrait_mode_page.setFixedWidth(self.portrait_mode_panel.PORTRAIT_WIDTH + 24)
|
||||
portrait_page_layout.addWidget(
|
||||
self.portrait_mode_panel, alignment=Qt.AlignmentFlag.AlignHCenter
|
||||
)
|
||||
|
||||
top_widget_layout.addWidget(self.video_tab)
|
||||
top_widget_layout.addWidget(self.video_tab, 1)
|
||||
self._start_axis_camera_threads()
|
||||
|
||||
self.beamline_controls_scroll = NoWheelScrollArea(top_widget)
|
||||
@@ -567,24 +531,13 @@ class MainWindow(QMainWindow):
|
||||
self.beamline_controls_scroll.setHorizontalScrollBarPolicy(
|
||||
Qt.ScrollBarPolicy.ScrollBarAlwaysOff
|
||||
)
|
||||
# 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)
|
||||
# Gutter math per column (10px scrollbar + frame) lives here.
|
||||
self._apply_zoom_widths()
|
||||
|
||||
self.tell_samples = TellSamplePanel(samples=SampleShortInfoList(s=[]))
|
||||
self.ref_tools_panel = ReferenceToolsPanel(samples=SampleShortInfoList(s=[]))
|
||||
self.job_list_panel = SampleQueuePanel(show_user=self._decoded_token.staff)
|
||||
|
||||
self.compact_automation_panel.play_pause_clicked.connect(self.job_list_panel.run)
|
||||
self.compact_automation_panel.skip_clicked.connect(self.job_list_panel.skip_current_sample)
|
||||
self.compact_automation_panel.step_through_toggled.connect(
|
||||
self.job_list_panel.set_step_through
|
||||
)
|
||||
self.compact_automation_panel.show_full_view_requested.connect(
|
||||
self._return_from_compact_automation_view
|
||||
)
|
||||
self.compact_automation_panel.annotation_selected.connect(self._handle_compact_annotation)
|
||||
|
||||
# One dock for both lists: the old tabified Reference Tools dock was
|
||||
# staff-only and hid behind the Sample List tab, so it "sometimes"
|
||||
# showed. aaregui2 concept: one panel, Dewar + Auxiliary-puck tabs
|
||||
@@ -624,7 +577,14 @@ class MainWindow(QMainWindow):
|
||||
# and the buttons balloon; five buttons plus two long checkboxes need
|
||||
# them at their natural size.
|
||||
automation_row.addStretch(1)
|
||||
dewar_layout.addLayout(automation_row)
|
||||
# Host widget with Ignored horizontal policy: the row's ~1000px
|
||||
# natural minimum used to propagate dock -> window and pin the whole
|
||||
# window wider than small screens (unshrinkable). Now the row clips
|
||||
# from the right instead.
|
||||
automation_row_host = QWidget(dewar_tab)
|
||||
automation_row_host.setLayout(automation_row)
|
||||
automation_row_host.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Fixed)
|
||||
dewar_layout.addWidget(automation_row_host)
|
||||
|
||||
# "Remove selected" now unqueues the dewar-table selection — the
|
||||
# queue's own table is no longer displayed.
|
||||
@@ -778,33 +738,11 @@ class MainWindow(QMainWindow):
|
||||
self.automation_progress_panel.set_running
|
||||
)
|
||||
self.job_list_panel.automation_running_changed.connect(self._on_automation_running_changed)
|
||||
self.job_list_panel.automation_running_changed.connect(
|
||||
self.compact_automation_panel.set_running
|
||||
)
|
||||
self.job_list_panel.step_through_changed.connect(
|
||||
self.compact_automation_panel.set_step_through
|
||||
)
|
||||
self.job_list_panel.samples_in_queue_changed.connect(
|
||||
self.compact_automation_panel.set_samples_in_queue
|
||||
)
|
||||
self.job_list_panel.samples_in_queue_changed.connect(self._refresh_compact_queue_preview)
|
||||
# Portrait mode: queue size + running state + preview
|
||||
self.job_list_panel.automation_running_changed.connect(self.portrait_mode_panel.set_running)
|
||||
self.job_list_panel.samples_in_queue_changed.connect(
|
||||
self.portrait_mode_panel.set_samples_in_queue
|
||||
)
|
||||
self.job_list_panel.samples_in_queue_changed.connect(self._refresh_portrait_queue_preview)
|
||||
|
||||
self.automation_progress_panel.set_samples_in_queue(
|
||||
len(self.job_list_panel.table_model.samples)
|
||||
)
|
||||
self.automation_progress_panel.set_running(self.job_list_panel.is_running())
|
||||
self.compact_automation_panel.set_running(self.job_list_panel.is_running())
|
||||
self.compact_automation_panel.set_step_through(self.job_list_panel.is_step_through())
|
||||
self.compact_automation_panel.set_samples_in_queue(
|
||||
len(self.job_list_panel.table_model.samples)
|
||||
)
|
||||
self._refresh_compact_queue_preview()
|
||||
|
||||
# smargon trace panel
|
||||
self.smargon_trace_panel = SmargonTracePanel()
|
||||
@@ -867,33 +805,20 @@ class MainWindow(QMainWindow):
|
||||
standard_page_scroll.setWidgetResizable(True)
|
||||
standard_page_scroll.setFrameShape(QFrame.Shape.NoFrame)
|
||||
standard_page_scroll.setWidget(top_widget)
|
||||
|
||||
self.content_stack.addWidget(standard_page_scroll)
|
||||
self.content_stack.addWidget(self.compact_automation_page)
|
||||
self.content_stack.addWidget(self.portrait_mode_page)
|
||||
self.content_stack.setCurrentWidget(standard_page_scroll)
|
||||
self._standard_main_page = standard_page_scroll
|
||||
|
||||
# QStackedWidget's minimum size is the max over ALL pages, so the hidden
|
||||
# portrait/compact pages inflated the window minimum past small monitors
|
||||
# (portrait alone demands 720px height). Only the visible page should
|
||||
# count — the mode-switch code resizes the window explicitly anyway.
|
||||
def _only_current_page_counts(index: int) -> None:
|
||||
for i in range(self.content_stack.count()):
|
||||
page = self.content_stack.widget(i)
|
||||
policy = QSizePolicy.Policy.Preferred if i == index else QSizePolicy.Policy.Ignored
|
||||
page.setSizePolicy(policy, policy)
|
||||
|
||||
self.content_stack.currentChanged.connect(_only_current_page_counts)
|
||||
_only_current_page_counts(self.content_stack.currentIndex())
|
||||
root_layout.addWidget(standard_page_scroll, 1)
|
||||
|
||||
self.setCentralWidget(root_widget)
|
||||
|
||||
# Qt auto-pins a top-level window's minimum size to its layout
|
||||
# minimum, which the bottom dock row pushed past small monitors.
|
||||
# An explicit minimum disables that auto-pin: the window shrinks to
|
||||
# this floor and the dock contents clip/scroll instead.
|
||||
self.setMinimumSize(320, 240)
|
||||
|
||||
self.setWindowTitle("AareGUI")
|
||||
self._restore_theme_settings()
|
||||
self._apply_theme()
|
||||
self.create_menu_bar()
|
||||
self._update_view_mode_actions()
|
||||
self._setup_global_shortcuts()
|
||||
# Default bottom-dock height: the sample list used to grab ~40% of the
|
||||
# window and squeeze the Beamline column behind a scrollbar. Before
|
||||
@@ -955,15 +880,8 @@ class MainWindow(QMainWindow):
|
||||
self._axis_camera_refresh_timer.timeout.connect(self.refresh_axis_cameras)
|
||||
self._axis_camera_refresh_timer.start()
|
||||
|
||||
self.portrait_mode_panel.wire_to_main_window(
|
||||
job_list_panel=self.job_list_panel, tell_samples=self.tell_samples
|
||||
)
|
||||
self.portrait_mode_panel._back_btn.clicked.connect(self._return_from_portrait_mode)
|
||||
self.portrait_mode_panel.grab_session_requested.connect(self.status_bar.request_baton)
|
||||
|
||||
# Route alert banner signals through portrait-aware interceptors
|
||||
self.daq.polled_devices_status.connect(self._portrait_alert_primary)
|
||||
self.daq.detector_error.connect(self._portrait_alert_secondary)
|
||||
self.daq.polled_devices_status.connect(self._runtime_alert_primary)
|
||||
self.daq.detector_error.connect(self._runtime_alert_secondary)
|
||||
|
||||
if hasattr(self, "monochromator_panel"):
|
||||
self.daq.steer_beam_available.connect(
|
||||
@@ -976,8 +894,6 @@ class MainWindow(QMainWindow):
|
||||
self.daq.baton_response_result.connect(self._on_baton_response_result)
|
||||
self.daq.baton_timeout_checked.connect(self._on_baton_timeout_checked)
|
||||
self.daq.automation_progress.connect(self.automation_progress_panel.set_progress)
|
||||
self.daq.automation_progress.connect(self.compact_automation_panel.set_progress)
|
||||
self.daq.automation_progress.connect(self.portrait_mode_panel.set_progress)
|
||||
|
||||
self.status_bar.baton_request_received.connect(self._show_baton_request_dialog)
|
||||
self.status_bar.baton_request_accepted.connect(self._accept_baton_request)
|
||||
@@ -1057,18 +973,10 @@ class MainWindow(QMainWindow):
|
||||
self.prediction_thread = PredictionSubscriber(pred_zmq_url=sample_feed_addr, topic=b"")
|
||||
self.prediction_thread.image.connect(self._on_sample_camera_frame)
|
||||
self.prediction_thread.prediction.connect(self.sample_camera.update_detections)
|
||||
self.prediction_thread.prediction.connect(self.compact_sample_camera.update_detections)
|
||||
self.prediction_thread.prediction.connect(self.portrait_sample_camera.update_detections)
|
||||
self.prediction_thread.prediction.connect(
|
||||
self.prediction_metrics_panel.update_from_prediction
|
||||
)
|
||||
self.prediction_thread.target_point.connect(self.sample_camera.update_target_point)
|
||||
self.prediction_thread.target_point.connect(
|
||||
self.compact_sample_camera.update_target_point
|
||||
)
|
||||
self.prediction_thread.target_point.connect(
|
||||
self.portrait_sample_camera.update_target_point
|
||||
)
|
||||
self.prediction_thread.target_point.connect(
|
||||
self.target_stability_panel.update_target_point
|
||||
)
|
||||
@@ -1077,12 +985,6 @@ class MainWindow(QMainWindow):
|
||||
self.prediction_thread.camera_availability_changed.connect(
|
||||
self.sample_camera.set_camera_available
|
||||
)
|
||||
self.prediction_thread.camera_availability_changed.connect(
|
||||
self.compact_sample_camera.set_camera_available
|
||||
)
|
||||
self.prediction_thread.camera_availability_changed.connect(
|
||||
self.portrait_sample_camera.set_camera_available
|
||||
)
|
||||
self.prediction_thread.camera_availability_changed.connect(
|
||||
self._on_sample_camera_availability_changed
|
||||
)
|
||||
@@ -1091,8 +993,6 @@ class MainWindow(QMainWindow):
|
||||
else:
|
||||
self.prediction_thread = None
|
||||
self.sample_camera.set_camera_available(False)
|
||||
self.compact_sample_camera.set_camera_available(False)
|
||||
self.portrait_sample_camera.set_camera_available(False)
|
||||
self._show_samcam_feed_banner("Sample camera feed unavailable: no stream configured")
|
||||
|
||||
#
|
||||
@@ -1204,10 +1104,7 @@ class MainWindow(QMainWindow):
|
||||
self.daq.update.connect(self.beamline.illumination_panel.update_daq_status)
|
||||
self.daq.update.connect(self.raster.update_daq_status)
|
||||
self.daq.update.connect(self.sample_camera.update_daq_status)
|
||||
self.daq.update.connect(self.compact_sample_camera.update_daq_status)
|
||||
self.daq.update.connect(self.portrait_sample_camera.update_daq_status)
|
||||
for camera in (self.sample_camera, self.compact_sample_camera, self.portrait_sample_camera):
|
||||
self.daq.auto_centering.connect(camera.set_auto_centering)
|
||||
self.daq.auto_centering.connect(self.sample_camera.set_auto_centering)
|
||||
self.daq.update.connect(self.tell_samples.update_daq_status)
|
||||
self.daq.update.connect(self.ref_tools_panel.update_daq_status)
|
||||
if self.prediction_thread is not None:
|
||||
@@ -1546,23 +1443,6 @@ class MainWindow(QMainWindow):
|
||||
self.tell_samples_dock.raise_()
|
||||
self.sample_lists_tabs.setCurrentIndex(1)
|
||||
|
||||
def _return_to_main_view_for_shutdown(self) -> None:
|
||||
try:
|
||||
if getattr(self, "content_stack", None) is None:
|
||||
return
|
||||
|
||||
current_widget = self.content_stack.currentWidget()
|
||||
|
||||
if hasattr(self, "portrait_mode_page") and current_widget is self.portrait_mode_page:
|
||||
self._return_from_portrait_mode()
|
||||
elif bool(getattr(self, "_in_compact_automation_view", False)):
|
||||
self._return_from_compact_automation_view()
|
||||
|
||||
if hasattr(self, "content_stack") and hasattr(self, "_standard_main_page"):
|
||||
self.content_stack.setCurrentWidget(self._standard_main_page)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to return to main view during shutdown: {e}", exc_info=True)
|
||||
|
||||
def _restore_samcam_overlay_settings(self) -> None:
|
||||
settings = QSettings("PSI", "AareGUI")
|
||||
# bool()/str() wraps: QSettings.value is typed "object" even with
|
||||
@@ -1627,13 +1507,8 @@ class MainWindow(QMainWindow):
|
||||
"""
|
||||
try:
|
||||
pixmap = QPixmap.fromImage(image)
|
||||
for view in (
|
||||
self.sample_camera,
|
||||
self.compact_sample_camera,
|
||||
self.portrait_sample_camera,
|
||||
):
|
||||
if view.isVisible():
|
||||
view.update_pixmap(pixmap)
|
||||
if self.sample_camera.isVisible():
|
||||
self.sample_camera.update_pixmap(pixmap)
|
||||
finally:
|
||||
# The slot is only connected once the subscriber exists, but the
|
||||
# attribute is Optional so the guard is what the type checker needs.
|
||||
@@ -1653,13 +1528,7 @@ class MainWindow(QMainWindow):
|
||||
logger.warning(message)
|
||||
self._show_samcam_feed_banner(message or "Sample camera feed unavailable")
|
||||
# The camera overlays show the reason too, not just the boolean.
|
||||
for cam in (
|
||||
self.sample_camera,
|
||||
getattr(self, "compact_sample_camera", None),
|
||||
getattr(self, "portrait_sample_camera", None),
|
||||
):
|
||||
if cam is not None:
|
||||
cam.set_camera_error_message(message or "Sample camera feed unavailable")
|
||||
self.sample_camera.set_camera_error_message(message or "Sample camera feed unavailable")
|
||||
|
||||
def _show_samcam_feed_banner(self, message: str) -> None:
|
||||
self._samcam_feed_banner_message = message
|
||||
@@ -1731,200 +1600,27 @@ class MainWindow(QMainWindow):
|
||||
key=lambda sample: sample.loc_str_sort(),
|
||||
)
|
||||
|
||||
def _update_view_mode_actions(self) -> None:
|
||||
in_automation_view = bool(self._in_compact_automation_view)
|
||||
|
||||
if self._enter_automation_view_action is not None:
|
||||
self._enter_automation_view_action.setVisible(not in_automation_view)
|
||||
self._enter_automation_view_action.setEnabled(not in_automation_view)
|
||||
|
||||
if self._return_main_view_action is not None:
|
||||
self._return_main_view_action.setVisible(in_automation_view)
|
||||
self._return_main_view_action.setEnabled(in_automation_view)
|
||||
|
||||
@Slot()
|
||||
def enter_compact_automation_view(self) -> None:
|
||||
self.job_list_panel.ensure_default_queue_from_samples(
|
||||
self._all_tell_samples_in_default_order()
|
||||
)
|
||||
self._refresh_compact_queue_preview()
|
||||
|
||||
if not self._in_compact_automation_view:
|
||||
self._pre_automation_window_state = self.saveState()
|
||||
self._pre_automation_left_column_visible = self.collection_controls_scroll.isVisible()
|
||||
self._pre_automation_right_column_visible = self.beamline_controls_scroll.isVisible()
|
||||
|
||||
self.tell_samples_dock.setVisible(False)
|
||||
self.information_dock.setVisible(False)
|
||||
self.face_panel_dock.setVisible(False)
|
||||
self.fluor_panel_dock.setVisible(False)
|
||||
self.smargon_trace_dock.setVisible(False)
|
||||
self.target_stability_dock.setVisible(False)
|
||||
self.prediction_metrics_dock.setVisible(False)
|
||||
|
||||
self.collection_controls_scroll.setVisible(False)
|
||||
self.beamline_controls_scroll.setVisible(False)
|
||||
|
||||
self.content_stack.setCurrentWidget(self.compact_automation_page)
|
||||
self._in_compact_automation_view = True
|
||||
self._update_view_mode_actions()
|
||||
|
||||
@Slot()
|
||||
def _return_from_compact_automation_view(self) -> None:
|
||||
self.content_stack.setCurrentWidget(self._standard_main_page)
|
||||
|
||||
if self._pre_automation_window_state is not None:
|
||||
self.restoreState(self._pre_automation_window_state)
|
||||
|
||||
self.collection_controls_scroll.setVisible(self._pre_automation_left_column_visible)
|
||||
self.beamline_controls_scroll.setVisible(self._pre_automation_right_column_visible)
|
||||
|
||||
self._in_compact_automation_view = False
|
||||
self._update_view_mode_actions()
|
||||
|
||||
self.tell_samples_dock.setVisible(True)
|
||||
self.tell_samples_dock.raise_()
|
||||
|
||||
@Slot()
|
||||
def _refresh_compact_queue_preview(self) -> None:
|
||||
current_sample, next_sample, next_next_sample = self.job_list_panel.queue_preview()
|
||||
self.compact_automation_panel.set_samples(current_sample, next_sample, next_next_sample)
|
||||
|
||||
@Slot()
|
||||
def enter_portrait_mode(self) -> None:
|
||||
"""Switch to the portrait / phone-screen view and resize the window."""
|
||||
self._pre_portrait_geometry = self.saveGeometry()
|
||||
|
||||
self.portrait_mode_panel.set_running(self.job_list_panel.is_running())
|
||||
self.portrait_mode_panel.set_samples_in_queue(len(self.job_list_panel.table_model.samples))
|
||||
self._refresh_portrait_queue_preview()
|
||||
self.content_stack.setCurrentWidget(self.portrait_mode_page)
|
||||
|
||||
# ── Camera: scale-to-fit + hide legend ─────────────────────────────
|
||||
self.portrait_sample_camera.set_show_overlay_legend(False)
|
||||
try:
|
||||
self.portrait_sample_camera._autoscale = True
|
||||
self.portrait_sample_camera._scaling()
|
||||
except Exception:
|
||||
logger.debug("Could not autoscale the portrait-mode camera", exc_info=True)
|
||||
|
||||
# ── Hide all chrome that contributes to window width ────────────────
|
||||
if self.status_bar is not None:
|
||||
self.status_bar.setVisible(False)
|
||||
self.menuBar().setVisible(False)
|
||||
|
||||
# Alert banners take up horizontal space even when hidden via QFrame
|
||||
# — force them to zero height so they cannot influence the minimum width.
|
||||
self.alert_banner.setVisible(False)
|
||||
self.alert_banner.setMaximumHeight(0)
|
||||
self.alert_banner_secondary.setVisible(False)
|
||||
self.alert_banner_secondary.setMaximumHeight(0)
|
||||
|
||||
# Hide all dock widgets
|
||||
for dock_attr in (
|
||||
"tell_samples_dock",
|
||||
"information_dock",
|
||||
"face_panel_dock",
|
||||
"fluor_panel_dock",
|
||||
"smargon_trace_dock",
|
||||
"target_stability_dock",
|
||||
"prediction_metrics_dock",
|
||||
):
|
||||
dock = getattr(self, dock_attr, None)
|
||||
if dock is not None:
|
||||
dock.setVisible(False)
|
||||
|
||||
# ── Resize to phone footprint ───────────────────────────────────────
|
||||
screen = QGuiApplication.screenAt(self.geometry().center())
|
||||
if screen is None:
|
||||
screen = QGuiApplication.primaryScreen()
|
||||
|
||||
available = screen.availableGeometry()
|
||||
portrait_w = self.portrait_mode_panel.PORTRAIT_WIDTH + 24
|
||||
portrait_h = min(860, available.height() - 40)
|
||||
|
||||
new_x = available.x() + (available.width() - portrait_w) // 2
|
||||
new_y = available.y() + (available.height() - portrait_h) // 2
|
||||
|
||||
self.setMinimumWidth(portrait_w)
|
||||
self.setMaximumWidth(portrait_w)
|
||||
self.resize(portrait_w, portrait_h)
|
||||
self.move(new_x, new_y)
|
||||
|
||||
@Slot()
|
||||
def _return_from_portrait_mode(self) -> None:
|
||||
"""Restore the window to its pre-portrait geometry and switch page."""
|
||||
# ── Lift hard width cap before restoring geometry ───────────────────
|
||||
self.setMinimumWidth(0)
|
||||
self.setMaximumWidth(16777215) # Qt's QWIDGETSIZE_MAX
|
||||
|
||||
self.content_stack.setCurrentWidget(self._standard_main_page)
|
||||
|
||||
# ── Restore chrome ──────────────────────────────────────────────────
|
||||
if self.status_bar is not None:
|
||||
self.status_bar.setVisible(True)
|
||||
self.menuBar().setVisible(True)
|
||||
|
||||
# Restore alert banners to normal operation
|
||||
self.alert_banner.setMaximumHeight(16777215)
|
||||
self.alert_banner_secondary.setMaximumHeight(16777215)
|
||||
# Replay any pending messages that arrived during portrait mode
|
||||
self.portrait_mode_panel._flush_portrait_alerts_to_banners(
|
||||
self.alert_banner, self.alert_banner_secondary
|
||||
)
|
||||
|
||||
# ── Restore camera legend ───────────────────────────────────────────
|
||||
try:
|
||||
settings = self.portrait_sample_camera.target_overlay_settings()
|
||||
self.portrait_sample_camera.set_show_overlay_legend(
|
||||
settings.get("show_overlay_legend", False)
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Could not restore the camera overlay legend", exc_info=True)
|
||||
|
||||
if hasattr(self, "_pre_portrait_geometry") and self._pre_portrait_geometry:
|
||||
self.restoreGeometry(self._pre_portrait_geometry)
|
||||
self._pre_portrait_geometry = None
|
||||
|
||||
self.tell_samples_dock.setVisible(True)
|
||||
self.information_dock.setVisible(False)
|
||||
self.face_panel_dock.setVisible(False)
|
||||
self.fluor_panel_dock.setVisible(False)
|
||||
self.smargon_trace_dock.setVisible(False)
|
||||
self.target_stability_dock.setVisible(False)
|
||||
self.prediction_metrics_dock.setVisible(False)
|
||||
|
||||
@Slot(str, bool)
|
||||
def _portrait_alert_primary(self, msg: str, is_error: bool) -> None:
|
||||
def _runtime_alert_primary(self, msg: str, is_error: bool) -> None:
|
||||
"""Route primary alerts into the runtime dock."""
|
||||
if self.content_stack.currentWidget() is self.portrait_mode_page:
|
||||
self.portrait_mode_panel.show_portrait_alert(msg, is_error)
|
||||
else:
|
||||
self._show_runtime_notification(
|
||||
title="Runtime alert" if is_error else "Runtime update",
|
||||
message=msg,
|
||||
level="error" if is_error else "success",
|
||||
sticky=is_error,
|
||||
auto_clear_ms=None if is_error else 4000,
|
||||
)
|
||||
self._show_runtime_notification(
|
||||
title="Runtime alert" if is_error else "Runtime update",
|
||||
message=msg,
|
||||
level="error" if is_error else "success",
|
||||
sticky=is_error,
|
||||
auto_clear_ms=None if is_error else 4000,
|
||||
)
|
||||
|
||||
@Slot(str, bool)
|
||||
def _portrait_alert_secondary(self, msg: str, is_error: bool) -> None:
|
||||
def _runtime_alert_secondary(self, msg: str, is_error: bool) -> None:
|
||||
"""Route secondary alerts into the runtime dock."""
|
||||
if self.content_stack.currentWidget() is self.portrait_mode_page:
|
||||
self.portrait_mode_panel.show_portrait_alert(msg, is_error)
|
||||
else:
|
||||
self._show_runtime_notification(
|
||||
title="Device alert" if is_error else "Device update",
|
||||
message=msg,
|
||||
level="warning" if is_error else "info",
|
||||
sticky=is_error,
|
||||
auto_clear_ms=None if is_error else 4000,
|
||||
)
|
||||
|
||||
@Slot()
|
||||
def _refresh_portrait_queue_preview(self) -> None:
|
||||
self.portrait_mode_panel.refresh_queue_preview()
|
||||
self._show_runtime_notification(
|
||||
title="Device alert" if is_error else "Device update",
|
||||
message=msg,
|
||||
level="warning" if is_error else "info",
|
||||
sticky=is_error,
|
||||
auto_clear_ms=None if is_error else 4000,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _annotation_token(annotation: str) -> str:
|
||||
@@ -1951,26 +1647,6 @@ class MainWindow(QMainWindow):
|
||||
|
||||
return f"{current} | {token}"
|
||||
|
||||
@Slot(str)
|
||||
def _handle_compact_annotation(self, annotation: str) -> None:
|
||||
current_sample, _, _ = self.job_list_panel.queue_preview()
|
||||
if current_sample is None:
|
||||
self.status_bar.show_connection_message("No sample selected for annotation.", True)
|
||||
return
|
||||
|
||||
updated_comment = self._append_annotation_to_comment(
|
||||
getattr(current_sample, "comment", None), annotation
|
||||
)
|
||||
|
||||
self.job_list_panel.annotate_sample_comment(current_sample.db_id, updated_comment)
|
||||
self.tell_samples.annotate_sample_comment(current_sample.db_id, updated_comment)
|
||||
self._refresh_compact_queue_preview()
|
||||
|
||||
self.status_bar.show_connection_message(
|
||||
f"Annotation added: {self._annotation_token(annotation)} — {current_sample.sample_name}",
|
||||
False,
|
||||
)
|
||||
|
||||
@Slot()
|
||||
def refresh_axis_cameras(self) -> None:
|
||||
logger.info("Refreshing Axis camera threads")
|
||||
@@ -2059,15 +1735,29 @@ class MainWindow(QMainWindow):
|
||||
settings.setValue("appearance/font_scale", font_scale())
|
||||
|
||||
def _change_font_zoom(self, direction: int) -> None:
|
||||
"""Ctrl+plus / Ctrl+minus / Ctrl+0 accessibility zoom: step the FONT_*
|
||||
ladder scale and rebuild the app stylesheet (same repolish path as a
|
||||
theme switch, so the whole UI rescales in one pass)."""
|
||||
# round: repeated 0.1 float steps otherwise drift (1.2000000000000002)
|
||||
set_font_scale(
|
||||
1.0 if direction == 0 else round(font_scale() + direction * FONT_SCALE_STEP, 2)
|
||||
)
|
||||
"""Ctrl+plus / Ctrl+minus / Ctrl+0 accessibility zoom: walk the
|
||||
100/125/150% ladder and rebuild the app stylesheet (same repolish
|
||||
path as a theme switch, so the whole UI rescales in one pass)."""
|
||||
# index() is safe: set_font_scale snaps every value onto the ladder.
|
||||
i = FONT_SCALE_LADDER.index(font_scale())
|
||||
i = 0 if direction == 0 else max(0, min(len(FONT_SCALE_LADDER) - 1, i + direction))
|
||||
set_font_scale(FONT_SCALE_LADDER[i])
|
||||
self._save_theme_settings()
|
||||
self._apply_theme()
|
||||
self._apply_zoom_widths()
|
||||
|
||||
def _apply_zoom_widths(self) -> None:
|
||||
# The side columns are fixed-width designs; when the ladder scales the
|
||||
# text, the boxes must widen with it or the zoomed text clips. Runs
|
||||
# once at construction and again on every zoom change.
|
||||
s = font_scale()
|
||||
left = round(self.data_collection.set_width * s)
|
||||
self.data_collection.setFixedWidth(left)
|
||||
self.left_column.setFixedWidth(left)
|
||||
self.collection_controls_scroll.setFixedWidth(left + 10)
|
||||
right = round(self.beamline.set_width * s)
|
||||
self.beamline.setFixedWidth(right)
|
||||
self.beamline_controls_scroll.setFixedWidth(right + 12)
|
||||
|
||||
@Slot()
|
||||
def use_legacy_theme(self) -> None:
|
||||
@@ -2094,24 +1784,6 @@ class MainWindow(QMainWindow):
|
||||
quit_action.triggered.connect(self.close)
|
||||
file_menu.addAction(quit_action)
|
||||
|
||||
# Prototype views live in the View menu (below the themes), not on the
|
||||
# menubar; only their in-progress labels changed, the internal
|
||||
# action/slot names stay.
|
||||
self._enter_automation_view_action = QAction("Automation View (prototyping)", self)
|
||||
self._enter_automation_view_action.setShortcut(QKeySequence("Ctrl+5"))
|
||||
self._enter_automation_view_action.triggered.connect(self.enter_compact_automation_view)
|
||||
|
||||
# Stays top-level: it only shows while INSIDE the automation view,
|
||||
# where the way back must not hide in a menu.
|
||||
self._return_main_view_action = QAction("Return to Main View", self)
|
||||
self._return_main_view_action.setShortcut(QKeySequence("Ctrl+Shift+5"))
|
||||
self._return_main_view_action.triggered.connect(self._return_from_compact_automation_view)
|
||||
menu_bar.addAction(self._return_main_view_action)
|
||||
|
||||
self._portrait_mode_action = QAction("Playlist Mode (work in progress)", self)
|
||||
self._portrait_mode_action.setShortcut(QKeySequence("Ctrl+6"))
|
||||
self._portrait_mode_action.triggered.connect(self.enter_portrait_mode)
|
||||
|
||||
view_menu = menu_bar.addMenu("View")
|
||||
|
||||
self._theme_action_group = QActionGroup(self)
|
||||
@@ -2157,8 +1829,18 @@ class MainWindow(QMainWindow):
|
||||
reset_text_action.triggered.connect(lambda: self._change_font_zoom(0))
|
||||
view_menu.addAction(reset_text_action)
|
||||
view_menu.addSeparator()
|
||||
view_menu.addAction(self._portrait_mode_action)
|
||||
view_menu.addAction(self._enter_automation_view_action)
|
||||
|
||||
# Side-panel collapse lives here, not as on-screen buttons.
|
||||
self._show_left_panel_action = QAction("Show Left Panel", self)
|
||||
self._show_left_panel_action.setCheckable(True)
|
||||
self._show_left_panel_action.setChecked(True)
|
||||
self._show_left_panel_action.toggled.connect(self.collection_controls_scroll.setVisible)
|
||||
view_menu.addAction(self._show_left_panel_action)
|
||||
self._show_right_panel_action = QAction("Show Right Panel", self)
|
||||
self._show_right_panel_action.setCheckable(True)
|
||||
self._show_right_panel_action.setChecked(True)
|
||||
self._show_right_panel_action.toggled.connect(self.beamline_controls_scroll.setVisible)
|
||||
view_menu.addAction(self._show_right_panel_action)
|
||||
view_menu.addSeparator()
|
||||
|
||||
show_samples_action = QAction("Show Sample List", self)
|
||||
@@ -2297,10 +1979,6 @@ class MainWindow(QMainWindow):
|
||||
if self._default_window_state is not None:
|
||||
self.restoreState(self._default_window_state)
|
||||
|
||||
self.content_stack.setCurrentWidget(self._standard_main_page)
|
||||
self._in_compact_automation_view = False
|
||||
self._update_view_mode_actions()
|
||||
|
||||
self.collection_controls_scroll.setVisible(True)
|
||||
self.beamline_controls_scroll.setVisible(True)
|
||||
|
||||
@@ -2415,7 +2093,7 @@ class MainWindow(QMainWindow):
|
||||
These come from polling, resync and other background requests; a modal
|
||||
pop-up would be too intrusive, so they go to the runtime dock/banner.
|
||||
"""
|
||||
self._portrait_alert_primary(message, True)
|
||||
self._runtime_alert_primary(message, True)
|
||||
|
||||
@Slot(bool)
|
||||
def _on_pss_alarm_changed(self, active: bool) -> None:
|
||||
@@ -2878,8 +2556,6 @@ class MainWindow(QMainWindow):
|
||||
s.geom.beam_location_pxl.x, s.geom.beam_location_pxl.y
|
||||
)
|
||||
|
||||
self._refresh_compact_queue_preview()
|
||||
|
||||
current_session = int(getattr(self._decoded_token, "session", -1))
|
||||
for gui in getattr(s, "open_guis", []) or []:
|
||||
try:
|
||||
@@ -3171,7 +2847,7 @@ class MainWindow(QMainWindow):
|
||||
if not self._default_dock_split_done:
|
||||
self._default_dock_split_done = True
|
||||
if not self.state_manager.settings.value("main_window/state"):
|
||||
QTimer.singleShot(0, self._apply_default_dock_split)
|
||||
QTimer.singleShot(0, self, self._apply_default_dock_split)
|
||||
|
||||
def _apply_default_dock_split(self) -> None:
|
||||
self.resizeDocks(
|
||||
@@ -3328,7 +3004,7 @@ class MainWindow(QMainWindow):
|
||||
# Pop-outs open enlarged instead of keeping the cramped docked
|
||||
# size. Deferred: the window is mid-reparent while the signal
|
||||
# fires.
|
||||
QTimer.singleShot(0, lambda d=dock: self._enlarge_floating_dock(d))
|
||||
QTimer.singleShot(0, dock, lambda d=dock: self._enlarge_floating_dock(d))
|
||||
|
||||
def _enlarge_floating_dock(self, dock: QDockWidget) -> None:
|
||||
if not dock.isFloating():
|
||||
|
||||
@@ -1,227 +0,0 @@
|
||||
from aarecommon.models.automation import AutomationProgress
|
||||
from aarecommon.models.models import SampleShortInfo
|
||||
from PySide6.QtCore import Qt, Signal, Slot
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMenu,
|
||||
QPushButton,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from aare.gui.widgets.automation_progress import CompactAutomationProgressStrip
|
||||
|
||||
|
||||
class CompactAutomationPanel(QFrame):
|
||||
play_pause_clicked = Signal()
|
||||
skip_clicked = Signal()
|
||||
step_through_toggled = Signal(bool)
|
||||
annotation_selected = Signal(str)
|
||||
show_full_view_requested = Signal()
|
||||
ensure_default_queue_requested = Signal()
|
||||
|
||||
def __init__(self, camera_widget: QWidget, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
self._running = False
|
||||
self._step_through = False
|
||||
self._current_sample: SampleShortInfo | None = None
|
||||
self._next_sample: SampleShortInfo | None = None
|
||||
self._next_next_sample: SampleShortInfo | None = None
|
||||
|
||||
self.setFrameShape(QFrame.Shape.NoFrame)
|
||||
self.setObjectName("compactAutomationPanel")
|
||||
|
||||
main_layout = QVBoxLayout(self)
|
||||
main_layout.setContentsMargins(18, 18, 18, 18)
|
||||
main_layout.setSpacing(12)
|
||||
|
||||
camera_card = QFrame(self)
|
||||
camera_card.setObjectName("compactCameraCard")
|
||||
camera_layout = QVBoxLayout(camera_card)
|
||||
camera_layout.setContentsMargins(12, 12, 12, 12)
|
||||
camera_layout.setSpacing(8)
|
||||
|
||||
camera_title = QLabel("Camera", self)
|
||||
camera_title.setObjectName("compactSectionTitle")
|
||||
camera_layout.addWidget(camera_title)
|
||||
camera_layout.addWidget(camera_widget, 1)
|
||||
|
||||
main_layout.addWidget(camera_card, 1)
|
||||
|
||||
controls_card = QFrame(self)
|
||||
controls_card.setObjectName("compactControlsCard")
|
||||
controls_layout = QVBoxLayout(controls_card)
|
||||
controls_layout.setContentsMargins(12, 12, 12, 12)
|
||||
controls_layout.setSpacing(10)
|
||||
|
||||
controls_header = QHBoxLayout()
|
||||
controls_header.setSpacing(10)
|
||||
|
||||
controls_title_wrap = QVBoxLayout()
|
||||
controls_title_wrap.setContentsMargins(0, 0, 0, 0)
|
||||
controls_title_wrap.setSpacing(2)
|
||||
|
||||
controls_title = QLabel("Controls", self)
|
||||
controls_title.setObjectName("compactSectionTitle")
|
||||
controls_hint = QLabel("Run and manage the automation queue.", self)
|
||||
controls_hint.setObjectName("compactSectionHint")
|
||||
|
||||
controls_title_wrap.addWidget(controls_title)
|
||||
controls_title_wrap.addWidget(controls_hint)
|
||||
|
||||
self.menu_button = QToolButton(self)
|
||||
self.menu_button.setObjectName("compactMenuButton")
|
||||
self.menu_button.setText("☰")
|
||||
self.menu_button.setToolTip("Return to main view")
|
||||
self.menu_button.clicked.connect(self.show_full_view_requested.emit)
|
||||
|
||||
controls_header.addLayout(controls_title_wrap, 1)
|
||||
controls_header.addWidget(self.menu_button, 0, Qt.AlignmentFlag.AlignTop)
|
||||
|
||||
controls_layout.addLayout(controls_header)
|
||||
|
||||
button_row = QHBoxLayout()
|
||||
button_row.setSpacing(10)
|
||||
|
||||
self.play_pause_button = QPushButton("▶ Play", self)
|
||||
self.play_pause_button.setObjectName("compactPrimaryButton")
|
||||
self.play_pause_button.clicked.connect(self.play_pause_clicked.emit)
|
||||
|
||||
self.skip_button = QPushButton("⏭ Skip", self)
|
||||
self.skip_button.setObjectName("compactSecondaryButton")
|
||||
self.skip_button.clicked.connect(self.skip_clicked.emit)
|
||||
|
||||
self.step_button = QPushButton("Step", self)
|
||||
self.step_button.setObjectName("compactSecondaryButton")
|
||||
self.step_button.setCheckable(True)
|
||||
self.step_button.toggled.connect(self._on_step_toggled)
|
||||
|
||||
self.annotation_button = QToolButton(self)
|
||||
self.annotation_button.setObjectName("compactSecondaryButton")
|
||||
self.annotation_button.setText("Annotate")
|
||||
self.annotation_button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
|
||||
|
||||
annotation_menu = QMenu(self.annotation_button)
|
||||
for label in ["Heart", "Thumbs Up", "Thumbs Down", "Eyes", "Scan Again"]:
|
||||
action = annotation_menu.addAction(label)
|
||||
action.triggered.connect(
|
||||
lambda checked=False, value=label: self.annotation_selected.emit(value)
|
||||
)
|
||||
self.annotation_button.setMenu(annotation_menu)
|
||||
|
||||
button_row.addWidget(self.play_pause_button, 2)
|
||||
button_row.addWidget(self.skip_button, 1)
|
||||
button_row.addWidget(self.step_button, 1)
|
||||
button_row.addWidget(self.annotation_button, 1)
|
||||
|
||||
controls_layout.addLayout(button_row)
|
||||
main_layout.addWidget(controls_card)
|
||||
|
||||
progress_card = QFrame(self)
|
||||
progress_card.setObjectName("compactProgressCard")
|
||||
progress_layout = QVBoxLayout(progress_card)
|
||||
progress_layout.setContentsMargins(12, 12, 12, 12)
|
||||
progress_layout.setSpacing(8)
|
||||
|
||||
progress_title = QLabel("Progress", self)
|
||||
progress_title.setObjectName("compactSectionTitle")
|
||||
progress_layout.addWidget(progress_title)
|
||||
|
||||
self.progress_strip = CompactAutomationProgressStrip(self)
|
||||
progress_layout.addWidget(self.progress_strip)
|
||||
|
||||
main_layout.addWidget(progress_card)
|
||||
|
||||
queue_card = QFrame(self)
|
||||
queue_card.setObjectName("compactQueueCard")
|
||||
queue_layout = QVBoxLayout(queue_card)
|
||||
queue_layout.setContentsMargins(12, 12, 12, 12)
|
||||
queue_layout.setSpacing(10)
|
||||
|
||||
queue_title = QLabel("Queue Preview", self)
|
||||
queue_title.setObjectName("compactSectionTitle")
|
||||
queue_layout.addWidget(queue_title)
|
||||
|
||||
queue_row = QHBoxLayout()
|
||||
queue_row.setSpacing(10)
|
||||
|
||||
self.current_card = self._build_queue_item("Now", "—")
|
||||
self.next_card = self._build_queue_item("Next", "—")
|
||||
self.next_next_card = self._build_queue_item("Later", "—")
|
||||
|
||||
queue_row.addWidget(self.current_card["frame"], 1)
|
||||
queue_row.addWidget(self.next_card["frame"], 1)
|
||||
queue_row.addWidget(self.next_next_card["frame"], 1)
|
||||
|
||||
queue_layout.addLayout(queue_row)
|
||||
main_layout.addWidget(queue_card)
|
||||
|
||||
def _build_queue_item(self, title: str, value: str) -> dict[str, QWidget | QLabel]:
|
||||
frame = QFrame(self)
|
||||
frame.setObjectName("compactQueueItem")
|
||||
|
||||
layout = QVBoxLayout(frame)
|
||||
layout.setContentsMargins(12, 10, 12, 10)
|
||||
layout.setSpacing(4)
|
||||
|
||||
title_label = QLabel(title, self)
|
||||
title_label.setObjectName("compactQueueTitle")
|
||||
|
||||
value_label = QLabel(value, self)
|
||||
value_label.setObjectName("compactQueueValue")
|
||||
value_label.setWordWrap(True)
|
||||
|
||||
layout.addWidget(title_label)
|
||||
layout.addWidget(value_label)
|
||||
|
||||
return {"frame": frame, "title": title_label, "value": value_label}
|
||||
|
||||
@Slot(bool)
|
||||
def set_running(self, running: bool) -> None:
|
||||
self._running = bool(running)
|
||||
self.play_pause_button.setText("⏸ Pause" if self._running else "▶ Play")
|
||||
self.progress_strip.set_running(running)
|
||||
|
||||
@Slot(bool)
|
||||
def set_step_through(self, enabled: bool) -> None:
|
||||
self._step_through = bool(enabled)
|
||||
self.step_button.blockSignals(True)
|
||||
self.step_button.setChecked(self._step_through)
|
||||
self.step_button.blockSignals(False)
|
||||
|
||||
@Slot(int)
|
||||
def set_samples_in_queue(self, count: int) -> None:
|
||||
self.progress_strip.set_samples_in_queue(count)
|
||||
|
||||
@Slot(object)
|
||||
def set_progress(self, progress: AutomationProgress) -> None:
|
||||
self.progress_strip.set_progress(progress)
|
||||
|
||||
@Slot(object, object, object)
|
||||
def set_samples(
|
||||
self,
|
||||
current_sample: SampleShortInfo | None,
|
||||
next_sample: SampleShortInfo | None,
|
||||
next_next_sample: SampleShortInfo | None,
|
||||
) -> None:
|
||||
self._current_sample = current_sample
|
||||
self._next_sample = next_sample
|
||||
self._next_next_sample = next_next_sample
|
||||
|
||||
self.current_card["value"].setText(self._format_sample(current_sample))
|
||||
self.next_card["value"].setText(self._format_sample(next_sample))
|
||||
self.next_next_card["value"].setText(self._format_sample(next_next_sample))
|
||||
|
||||
def _on_step_toggled(self, checked: bool) -> None:
|
||||
self._step_through = checked
|
||||
self.step_through_toggled.emit(checked)
|
||||
|
||||
@staticmethod
|
||||
def _format_sample(sample: SampleShortInfo | None) -> str:
|
||||
if sample is None:
|
||||
return "—"
|
||||
return f"{sample.sample_name} ({sample.loc_str()})"
|
||||
@@ -1,805 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import ClassVar
|
||||
|
||||
from aarecommon.config.logger import setup_logger
|
||||
from aarecommon.models.automation import AutomationProgress, StepStatus, WorkflowStateKind
|
||||
from PySide6.QtCore import QPointF, QRectF, Qt, QTimer, Signal, Slot
|
||||
from PySide6.QtGui import QColor, QFont, QFontMetrics, QPainter, QPen
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QSizePolicy,
|
||||
QSpinBox,
|
||||
QStackedWidget,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from aare.gui.constants import LOGGER_NAME
|
||||
from aare.gui.styles import DARK_ACCENT as ACCENT
|
||||
from aare.gui.styles import DARK_BG as BG
|
||||
from aare.gui.styles import DARK_BORDER as ACCENT_DIM
|
||||
from aare.gui.styles import DARK_BORDER as LED_OFF
|
||||
from aare.gui.styles import DARK_ELEVATED as BUTTON_BG
|
||||
from aare.gui.styles import (
|
||||
DARK_ERROR_BG,
|
||||
DARK_ERROR_BORDER,
|
||||
DARK_ERROR_TEXT,
|
||||
DARK_SUCCESS_BG,
|
||||
DARK_SUCCESS_BORDER,
|
||||
DARK_SUCCESS_TEXT,
|
||||
FONT_FINE,
|
||||
FONT_HERO,
|
||||
FONT_HINT,
|
||||
FONT_LABEL,
|
||||
FONT_VALUE,
|
||||
WHITE,
|
||||
qcolor,
|
||||
)
|
||||
from aare.gui.styles import DARK_MUTED as SUBTEXT
|
||||
from aare.gui.styles import DARK_SURFACE as CARD_BG
|
||||
from aare.gui.styles import DARK_TEXT as TEXT
|
||||
from aare.gui.styles import WHITE as ACTIVE_STEP
|
||||
|
||||
logger = setup_logger(LOGGER_NAME)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LED step indicator
|
||||
# ---------------------------------------------------------------------------
|
||||
class LEDStages(QWidget):
|
||||
STEPS: ClassVar[list[str]] = ["Mount", "Centre", "Raster", "Collect"]
|
||||
|
||||
# WorkflowStateKind → LED index
|
||||
_KIND_TO_INDEX: ClassVar[dict[WorkflowStateKind, int]] = {
|
||||
WorkflowStateKind.MOUNT: 0,
|
||||
WorkflowStateKind.LOOP_CENTRE: 1,
|
||||
WorkflowStateKind.RASTER: 2,
|
||||
WorkflowStateKind.DATA_COLLECTION: 3,
|
||||
}
|
||||
|
||||
def __init__(self, active_step: int = 0, parent=None):
|
||||
super().__init__(parent)
|
||||
self._active = active_step
|
||||
self.setFixedHeight(72)
|
||||
|
||||
def set_active_step(self, step: int) -> None:
|
||||
self._active = step
|
||||
self.update()
|
||||
|
||||
def set_from_progress(self, progress: AutomationProgress) -> None:
|
||||
"""Derive active LED index from an AutomationProgress object."""
|
||||
running_index = -1
|
||||
last_success = -1
|
||||
|
||||
for step_state in progress.steps:
|
||||
idx = self._KIND_TO_INDEX.get(step_state.step)
|
||||
if idx is None:
|
||||
continue
|
||||
if step_state.status == StepStatus.RUNNING:
|
||||
running_index = idx
|
||||
elif step_state.status == StepStatus.SUCCESS and idx > last_success:
|
||||
last_success = idx
|
||||
|
||||
if running_index >= 0:
|
||||
self.set_active_step(running_index)
|
||||
elif last_success >= 0:
|
||||
self.set_active_step(min(last_success + 1, len(self.STEPS) - 1))
|
||||
else:
|
||||
self.set_active_step(0)
|
||||
|
||||
def paintEvent(self, event):
|
||||
p = QPainter(self)
|
||||
p.setRenderHint(QPainter.Antialiasing)
|
||||
|
||||
w = self.width()
|
||||
n = len(self.STEPS)
|
||||
step_w = w / n
|
||||
led_r = 10
|
||||
cy = 44
|
||||
label_y = 16
|
||||
|
||||
for i, name in enumerate(self.STEPS):
|
||||
cx = step_w * i + step_w / 2
|
||||
|
||||
if i < n - 1:
|
||||
next_cx = step_w * (i + 1) + step_w / 2
|
||||
line_color = QColor(ACCENT) if i < self._active else QColor(LED_OFF)
|
||||
pen = QPen(line_color, 2)
|
||||
p.setPen(pen)
|
||||
p.drawLine(QPointF(cx + led_r + 3, cy), QPointF(next_cx - led_r - 3, cy))
|
||||
|
||||
p.setPen(Qt.NoPen)
|
||||
if i < self._active:
|
||||
p.setBrush(QColor(ACCENT))
|
||||
p.drawEllipse(QPointF(cx, cy), led_r, led_r)
|
||||
pen = QPen(QColor(BG), 2)
|
||||
pen.setCapStyle(Qt.RoundCap)
|
||||
p.setPen(pen)
|
||||
p.drawLine(QPointF(cx - 4, cy), QPointF(cx - 1, cy + 3))
|
||||
p.drawLine(QPointF(cx - 1, cy + 3), QPointF(cx + 4, cy - 3))
|
||||
elif i == self._active:
|
||||
# was QColor(ACCENT + "55"), which mis-parsed as #AARRGGBB
|
||||
glow_pen = QPen(qcolor(ACCENT, 0x55), 4)
|
||||
p.setPen(glow_pen)
|
||||
p.setBrush(Qt.NoBrush)
|
||||
p.drawEllipse(QPointF(cx, cy), led_r + 4, led_r + 4)
|
||||
p.setPen(Qt.NoPen)
|
||||
p.setBrush(QColor(ACTIVE_STEP))
|
||||
p.drawEllipse(QPointF(cx, cy), led_r, led_r)
|
||||
else:
|
||||
p.setBrush(QColor(LED_OFF))
|
||||
p.drawEllipse(QPointF(cx, cy), led_r, led_r)
|
||||
|
||||
label_color = QColor(ACCENT) if i <= self._active else QColor(SUBTEXT)
|
||||
p.setPen(label_color)
|
||||
font = QFont("Inter", 10)
|
||||
if i == self._active:
|
||||
font.setWeight(QFont.Weight.DemiBold)
|
||||
p.setFont(font)
|
||||
fm = QFontMetrics(font)
|
||||
text_w = fm.horizontalAdvance(name)
|
||||
p.drawText(
|
||||
QRectF(cx - text_w / 2 - 4, 0, text_w + 8, label_y + 2), Qt.AlignCenter, name
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Play/Pause primary button
|
||||
# ---------------------------------------------------------------------------
|
||||
class PlayPauseButton(QPushButton):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._running = False
|
||||
self.setFixedSize(64, 64)
|
||||
self.setMouseTracking(True)
|
||||
|
||||
def set_running(self, running: bool) -> None:
|
||||
self._running = running
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, event):
|
||||
p = QPainter(self)
|
||||
p.setRenderHint(QPainter.Antialiasing)
|
||||
rect = self.rect()
|
||||
cx, cy = rect.width() / 2, rect.height() / 2
|
||||
r = min(rect.width(), rect.height()) / 2 - 2
|
||||
|
||||
# underMouse() instead of enter/leave overrides tracking a _hovered
|
||||
# flag: QPushButton already repaints on hover (WA_Hover), so the
|
||||
# two extra Qt→Python callbacks bought nothing.
|
||||
bg_color = qcolor(WHITE) if self.underMouse() else QColor(ACCENT)
|
||||
p.setBrush(bg_color)
|
||||
p.setPen(Qt.NoPen)
|
||||
p.drawEllipse(QPointF(cx, cy), r, r)
|
||||
|
||||
sym = "⏸" if self._running else "▶"
|
||||
p.setPen(QPen(QColor(BG), 2))
|
||||
font = QFont("Arial", 16)
|
||||
p.setFont(font)
|
||||
p.drawText(rect, Qt.AlignCenter, sym)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Queue item card
|
||||
# ---------------------------------------------------------------------------
|
||||
class QueueItemCard(QFrame):
|
||||
def __init__(
|
||||
self, index: int | str, title: str, subtitle: str, is_next: bool = False, parent=None
|
||||
):
|
||||
super().__init__(parent)
|
||||
self.setFixedHeight(72)
|
||||
self.setStyleSheet(f"""
|
||||
QFrame {{
|
||||
background: {BUTTON_BG if is_next else CARD_BG};
|
||||
border-radius: 14px;
|
||||
border: {"1px solid " + ACCENT_DIM if is_next else "none"};
|
||||
}}
|
||||
""")
|
||||
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(14, 0, 14, 0)
|
||||
layout.setSpacing(12)
|
||||
|
||||
badge = QLabel()
|
||||
badge.setFixedSize(32, 32)
|
||||
badge.setAlignment(Qt.AlignCenter)
|
||||
if is_next:
|
||||
badge.setText("▶")
|
||||
badge.setStyleSheet(f"""
|
||||
color: {ACCENT}; background: {ACCENT_DIM};
|
||||
border-radius: 16px; font-size: {FONT_HINT}; font-weight: bold;
|
||||
""")
|
||||
else:
|
||||
badge.setText(str(index))
|
||||
badge.setStyleSheet(f"""
|
||||
color: {SUBTEXT}; background: {BUTTON_BG};
|
||||
border-radius: 16px; font-size: {FONT_HINT};
|
||||
""")
|
||||
layout.addWidget(badge)
|
||||
|
||||
text_col = QVBoxLayout()
|
||||
text_col.setSpacing(2)
|
||||
text_col.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
title_lbl = QLabel(title)
|
||||
title_lbl.setStyleSheet(
|
||||
f"color: {TEXT}; font-size: {FONT_LABEL}; font-weight: 700; background: transparent;"
|
||||
)
|
||||
title_lbl.setWordWrap(False)
|
||||
sub_lbl = QLabel(subtitle)
|
||||
sub_lbl.setStyleSheet(f"color: {SUBTEXT}; font-size: {FONT_FINE}; background: transparent;")
|
||||
text_col.addWidget(title_lbl)
|
||||
text_col.addWidget(sub_lbl)
|
||||
layout.addLayout(text_col, stretch=1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main portrait-mode panel
|
||||
# ---------------------------------------------------------------------------
|
||||
class PortraitModePanel(QWidget):
|
||||
PORTRAIT_WIDTH = 420
|
||||
grab_session_requested = Signal()
|
||||
|
||||
def __init__(self, sample_camera_widget: QWidget, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setObjectName("portraitRoot")
|
||||
self.setMaximumWidth(self.PORTRAIT_WIDTH)
|
||||
self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding)
|
||||
|
||||
# Created here rather than in the build helper so the attributes are
|
||||
# initialized in __init__ (basedpyright gate); the helper styles and
|
||||
# mounts them.
|
||||
self._name_lbl = QLabel("—")
|
||||
self._sub_lbl = QLabel("No sample queued")
|
||||
|
||||
self._job_list_panel = None
|
||||
self._tell_samples = None
|
||||
self._is_running = False
|
||||
self._loop_enabled = False
|
||||
self._loop_remaining = 0
|
||||
self._loop_samples = []
|
||||
|
||||
self._loop_restart_timer = QTimer(self)
|
||||
self._loop_restart_timer.setInterval(1000)
|
||||
self._loop_restart_timer.timeout.connect(self._try_loop_restart)
|
||||
|
||||
self._loop_restart_deadline = None
|
||||
|
||||
# Pending alert messages received while in portrait mode
|
||||
# Each entry: (msg, is_error)
|
||||
self._pending_alerts: list[tuple[str, bool]] = []
|
||||
|
||||
self._stack = QStackedWidget()
|
||||
self._player_page = self._build_player(sample_camera_widget)
|
||||
self._queue_page = self._build_queue()
|
||||
self._stack.addWidget(self._player_page)
|
||||
self._stack.addWidget(self._queue_page)
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(12, 12, 12, 12)
|
||||
root.addWidget(self._stack)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Player page
|
||||
# ------------------------------------------------------------------
|
||||
def _build_player(self, cam_widget: QWidget) -> QWidget:
|
||||
page = QWidget()
|
||||
layout = QVBoxLayout(page)
|
||||
layout.setSpacing(10)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
# Title
|
||||
title = QLabel("S A M C A M E R A")
|
||||
title.setAlignment(Qt.AlignCenter)
|
||||
title.setStyleSheet(
|
||||
f"color: {ACCENT}; font-size: {FONT_LABEL}; letter-spacing: 3px; font-weight: 700;"
|
||||
)
|
||||
layout.addWidget(title)
|
||||
|
||||
# ── Portrait alert toast (hidden by default) ───────────────────────
|
||||
self._alert_toast = QFrame()
|
||||
self._alert_toast.setVisible(False)
|
||||
self._alert_toast.setStyleSheet(f"""
|
||||
QFrame {{
|
||||
background: {DARK_ERROR_BG};
|
||||
border: 1px solid {DARK_ERROR_BORDER};
|
||||
border-radius: 10px;
|
||||
}}
|
||||
""")
|
||||
toast_layout = QHBoxLayout(self._alert_toast)
|
||||
toast_layout.setContentsMargins(12, 8, 12, 8)
|
||||
self._alert_toast_label = QLabel("")
|
||||
self._alert_toast_label.setWordWrap(True)
|
||||
self._alert_toast_label.setStyleSheet(
|
||||
f"color: {DARK_ERROR_TEXT}; font-size: {FONT_FINE}; font-weight: 600; background: transparent;"
|
||||
)
|
||||
toast_layout.addWidget(self._alert_toast_label)
|
||||
# Dismiss button
|
||||
dismiss_btn = QPushButton("✕")
|
||||
dismiss_btn.setFixedSize(20, 20)
|
||||
dismiss_btn.setStyleSheet(f"""
|
||||
QPushButton {{
|
||||
color: {SUBTEXT};
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: {FONT_FINE};
|
||||
}}
|
||||
QPushButton:hover {{ color: {TEXT}; }}
|
||||
""")
|
||||
dismiss_btn.clicked.connect(self._dismiss_portrait_alert)
|
||||
toast_layout.addWidget(dismiss_btn)
|
||||
layout.addWidget(self._alert_toast)
|
||||
|
||||
self._alert_toast_timer = QTimer(self)
|
||||
self._alert_toast_timer.setSingleShot(True)
|
||||
self._alert_toast_timer.timeout.connect(self._dismiss_portrait_alert)
|
||||
|
||||
# Camera card — wraps the real compact_sample_camera
|
||||
cam_card = QFrame()
|
||||
cam_card.setStyleSheet(f"QFrame {{ background: {CARD_BG}; border-radius: 18px; }}")
|
||||
cam_card_layout = QVBoxLayout(cam_card)
|
||||
cam_card_layout.setContentsMargins(4, 4, 4, 4)
|
||||
cam_card_layout.setSpacing(0)
|
||||
cam_widget.setMinimumHeight(220)
|
||||
cam_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
cam_card_layout.addWidget(cam_widget)
|
||||
layout.addWidget(cam_card)
|
||||
|
||||
# Sample name labels (created in __init__)
|
||||
self._name_lbl.setStyleSheet(f"color: {TEXT}; font-size: {FONT_VALUE}; font-weight: 700;")
|
||||
self._sub_lbl.setStyleSheet(f"color: {SUBTEXT}; font-size: {FONT_HINT};")
|
||||
layout.addWidget(self._name_lbl)
|
||||
layout.addWidget(self._sub_lbl)
|
||||
|
||||
# LED step indicator
|
||||
self._leds = LEDStages(active_step=0)
|
||||
layout.addWidget(self._leds)
|
||||
|
||||
# Play/Pause button (single, centred)
|
||||
ctrl_frame = QFrame()
|
||||
ctrl_frame.setStyleSheet(f"QFrame {{ background: {CARD_BG}; border-radius: 18px; }}")
|
||||
|
||||
ctrl_layout = QHBoxLayout(ctrl_frame)
|
||||
ctrl_layout.setContentsMargins(12, 12, 12, 12)
|
||||
ctrl_layout.setSpacing(12)
|
||||
|
||||
ctrl_layout.addStretch()
|
||||
|
||||
self._play_pause_btn = PlayPauseButton()
|
||||
self._play_pause_btn.clicked.connect(self._on_play_pause_clicked)
|
||||
ctrl_layout.addWidget(self._play_pause_btn)
|
||||
|
||||
# Loop arrow button
|
||||
self._loop_btn = QPushButton("↻")
|
||||
self._loop_btn.setFixedSize(52, 52)
|
||||
self._loop_btn.setCheckable(True)
|
||||
self._loop_btn.setToolTip("Loop queue")
|
||||
self._loop_btn.setStyleSheet(f"""
|
||||
QPushButton {{
|
||||
border-radius: 26px;
|
||||
background: {BUTTON_BG};
|
||||
color: {ACCENT};
|
||||
font-size: {FONT_HERO};
|
||||
font-weight: bold;
|
||||
}}
|
||||
QPushButton:checked {{
|
||||
background: {ACCENT};
|
||||
color: {BG};
|
||||
}}
|
||||
""")
|
||||
self._loop_btn.toggled.connect(self._toggle_loop)
|
||||
ctrl_layout.addWidget(self._loop_btn)
|
||||
|
||||
self._loop_count = QSpinBox()
|
||||
self._loop_count.setRange(1, 999)
|
||||
self._loop_count.setValue(2)
|
||||
self._loop_count.setPrefix("× ")
|
||||
self._loop_count.setFixedHeight(40)
|
||||
self._loop_count.setToolTip("Number of queue repeats")
|
||||
ctrl_layout.addWidget(self._loop_count)
|
||||
|
||||
ctrl_layout.addStretch()
|
||||
|
||||
layout.addWidget(ctrl_frame)
|
||||
|
||||
# "UP NEXT" header
|
||||
up_next_row = QHBoxLayout()
|
||||
up_next_lbl = QLabel("UP NEXT")
|
||||
up_next_lbl.setStyleSheet(
|
||||
f"color: {ACCENT}; font-size: {FONT_FINE}; letter-spacing: 2px; font-weight: 700;"
|
||||
)
|
||||
self._samples_count_lbl = QLabel("0 SAMPLES")
|
||||
self._samples_count_lbl.setStyleSheet(
|
||||
f"color: {SUBTEXT}; font-size: {FONT_FINE}; letter-spacing: 1px;"
|
||||
)
|
||||
up_next_row.addWidget(up_next_lbl)
|
||||
up_next_row.addStretch()
|
||||
up_next_row.addWidget(self._samples_count_lbl)
|
||||
layout.addLayout(up_next_row)
|
||||
|
||||
# Preview card container (up to 4 cards)
|
||||
self._preview_container = QWidget()
|
||||
self._preview_layout = QVBoxLayout(self._preview_container)
|
||||
self._preview_layout.setSpacing(6)
|
||||
self._preview_layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.addWidget(self._preview_container)
|
||||
|
||||
# View full queue button
|
||||
view_btn = self._accent_button("VIEW FULL QUEUE ☰")
|
||||
view_btn.clicked.connect(self._on_view_full_queue)
|
||||
layout.addWidget(view_btn)
|
||||
|
||||
# ── Session / utility row ──────────────────────────────────────────
|
||||
util_row = QHBoxLayout()
|
||||
util_row.setSpacing(8)
|
||||
|
||||
self._grab_session_btn = self._accent_button("⚡ GRAB SESSION")
|
||||
self._grab_session_btn.clicked.connect(self.grab_session_requested)
|
||||
util_row.addWidget(self._grab_session_btn)
|
||||
|
||||
layout.addLayout(util_row)
|
||||
|
||||
# Back to main view button
|
||||
self._back_btn = self._accent_button("← MAIN VIEW")
|
||||
layout.addWidget(self._back_btn)
|
||||
# Connected externally by MainWindow
|
||||
|
||||
return page
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Queue / full list page
|
||||
# ------------------------------------------------------------------
|
||||
def _build_queue(self) -> QWidget:
|
||||
page = QWidget()
|
||||
layout = QVBoxLayout(page)
|
||||
layout.setSpacing(10)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
title = QLabel("SAMPLE QUEUE")
|
||||
title.setAlignment(Qt.AlignCenter)
|
||||
title.setStyleSheet(
|
||||
f"color: {ACCENT}; font-size: {FONT_LABEL}; letter-spacing: 3px; font-weight: 700;"
|
||||
)
|
||||
layout.addWidget(title)
|
||||
|
||||
self._queue_scroll = QScrollArea()
|
||||
self._queue_scroll.setWidgetResizable(True)
|
||||
self._queue_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
|
||||
self._queue_inner = QWidget()
|
||||
self._queue_inner_layout = QVBoxLayout(self._queue_inner)
|
||||
self._queue_inner_layout.setSpacing(6)
|
||||
self._queue_inner_layout.setContentsMargins(0, 4, 0, 4)
|
||||
|
||||
self._queue_scroll.setWidget(self._queue_inner)
|
||||
layout.addWidget(self._queue_scroll, stretch=1)
|
||||
|
||||
back_btn = self._accent_button("← BACK TO CAMERA")
|
||||
back_btn.clicked.connect(lambda: self._stack.setCurrentWidget(self._player_page))
|
||||
layout.addWidget(back_btn)
|
||||
|
||||
return page
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Wiring
|
||||
# ------------------------------------------------------------------
|
||||
def wire_to_main_window(self, job_list_panel, tell_samples) -> None:
|
||||
"""Call this from MainWindow after both panels are constructed."""
|
||||
self._job_list_panel = job_list_panel
|
||||
self._tell_samples = tell_samples
|
||||
|
||||
self._job_list_panel.loop_restart_requested = self._restart_loop_if_needed
|
||||
|
||||
self._populate_queue_from_tell_samples_if_empty()
|
||||
self.refresh_queue_preview()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public update slots
|
||||
# ------------------------------------------------------------------
|
||||
@Slot(object)
|
||||
def set_progress(self, progress: AutomationProgress) -> None:
|
||||
"""Driven by daq.automation_progress signal."""
|
||||
self._leds.set_from_progress(progress)
|
||||
|
||||
@Slot(bool)
|
||||
def set_running(self, running: bool) -> None:
|
||||
self._is_running = running
|
||||
self._play_pause_btn.set_running(running)
|
||||
|
||||
@Slot(int)
|
||||
def set_samples_in_queue(self, count: int) -> None:
|
||||
label = f"{count} SAMPLE{'S' if count != 1 else ''}"
|
||||
self._samples_count_lbl.setText(label)
|
||||
|
||||
def refresh_queue_preview(self) -> None:
|
||||
"""
|
||||
Refresh sample name, subtitle, and preview cards from queue_preview().
|
||||
Falls back to tell_samples sorted by location when the queue is empty.
|
||||
"""
|
||||
if self._job_list_panel is None:
|
||||
return
|
||||
|
||||
current, nxt, nxt2 = self._job_list_panel.queue_preview()
|
||||
|
||||
# --- sample name / subtitle ---
|
||||
if current is not None:
|
||||
name = str(getattr(current, "sample_name", "") or "—")
|
||||
puck = str(getattr(current, "puck_name", "") or "")
|
||||
pin = getattr(current, "pin", None)
|
||||
subtitle_parts = [puck]
|
||||
if pin is not None:
|
||||
subtitle_parts.append(f"Pin {pin}")
|
||||
self._name_lbl.setText(name)
|
||||
self._sub_lbl.setText(" · ".join(p for p in subtitle_parts if p))
|
||||
else:
|
||||
self._name_lbl.setText("—")
|
||||
self._sub_lbl.setText("No sample queued")
|
||||
|
||||
# --- preview cards ---
|
||||
# Clear existing
|
||||
while self._preview_layout.count():
|
||||
item = self._preview_layout.takeAt(0)
|
||||
if item.widget():
|
||||
item.widget().deleteLater()
|
||||
|
||||
previews = [s for s in [current, nxt, nxt2] if s is not None]
|
||||
|
||||
# If queue is empty, suggest from tell_samples sorted by location
|
||||
if not previews and self._tell_samples is not None:
|
||||
raw = list(getattr(self._tell_samples.table_model, "samples", []))
|
||||
suggested = sorted(
|
||||
[s for s in raw if getattr(s, "location", None) is not None],
|
||||
key=lambda s: s.loc_str_sort() if hasattr(s, "loc_str_sort") else "",
|
||||
)[:4]
|
||||
for i, s in enumerate(suggested):
|
||||
name = str(getattr(s, "sample_name", "") or f"Sample {i + 1}")
|
||||
puck = str(getattr(s, "puck_name", "") or "")
|
||||
card = QueueItemCard(index=i + 1, title=name, subtitle=puck, is_next=False)
|
||||
self._preview_layout.addWidget(card)
|
||||
return
|
||||
|
||||
for i, sample in enumerate(previews):
|
||||
name = str(getattr(sample, "sample_name", "") or f"Sample {i + 1}")
|
||||
puck = str(getattr(sample, "puck_name", "") or "")
|
||||
card = QueueItemCard(
|
||||
index="▶" if i == 0 else i + 1, title=name, subtitle=puck, is_next=(i == 0)
|
||||
)
|
||||
self._preview_layout.addWidget(card)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Full queue page population
|
||||
# ------------------------------------------------------------------
|
||||
def _on_view_full_queue(self) -> None:
|
||||
self._rebuild_full_queue()
|
||||
self._stack.setCurrentWidget(self._queue_page)
|
||||
|
||||
def _rebuild_full_queue(self) -> None:
|
||||
# Clear existing cards
|
||||
while self._queue_inner_layout.count():
|
||||
item = self._queue_inner_layout.takeAt(0)
|
||||
if item.widget():
|
||||
item.widget().deleteLater()
|
||||
|
||||
samples = []
|
||||
|
||||
if self._job_list_panel is not None:
|
||||
raw = list(getattr(self._job_list_panel.table_model, "samples", []))
|
||||
samples = raw
|
||||
|
||||
# Fall back to tell_samples sorted by location if queue is empty
|
||||
if not samples and self._tell_samples is not None:
|
||||
raw = list(getattr(self._tell_samples.table_model, "samples", []))
|
||||
samples = sorted(
|
||||
[s for s in raw if getattr(s, "location", None) is not None],
|
||||
key=lambda s: s.loc_str_sort() if hasattr(s, "loc_str_sort") else "",
|
||||
)
|
||||
|
||||
if not samples:
|
||||
placeholder = QLabel("No samples in queue")
|
||||
placeholder.setStyleSheet(
|
||||
f"color: {SUBTEXT}; font-size: {FONT_LABEL}; font-weight: 700;"
|
||||
)
|
||||
placeholder.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self._queue_inner_layout.addWidget(placeholder)
|
||||
return
|
||||
|
||||
for i, sample in enumerate(samples):
|
||||
name = str(getattr(sample, "sample_name", "") or f"Sample {i + 1}")
|
||||
puck = str(getattr(sample, "puck_name", "") or "")
|
||||
card = QueueItemCard(index=i + 1, title=name, subtitle=puck, is_next=False)
|
||||
self._queue_inner_layout.addWidget(card)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Play/Pause handler
|
||||
# ------------------------------------------------------------------
|
||||
@Slot()
|
||||
def _on_play_pause_clicked(self) -> None:
|
||||
if self._job_list_panel is None:
|
||||
return
|
||||
if self._is_running:
|
||||
self._job_list_panel.pause_automation()
|
||||
else:
|
||||
self._job_list_panel.run()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helper
|
||||
# ------------------------------------------------------------------
|
||||
@staticmethod
|
||||
def _accent_button(text: str) -> QPushButton:
|
||||
btn = QPushButton(text)
|
||||
btn.setFixedHeight(48)
|
||||
btn.setStyleSheet(f"""
|
||||
QPushButton {{
|
||||
background: transparent;
|
||||
border: 1.5px solid {ACCENT};
|
||||
border-radius: 14px;
|
||||
color: {ACCENT};
|
||||
font-size: {FONT_HINT};
|
||||
font-weight: 700;
|
||||
letter-spacing: 1.5px;
|
||||
}}
|
||||
QPushButton:hover {{
|
||||
background: {ACCENT_DIM};
|
||||
}}
|
||||
QPushButton:pressed {{
|
||||
background: {ACCENT};
|
||||
color: {BG};
|
||||
}}
|
||||
""")
|
||||
return btn
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Portrait alert toast
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@Slot(str, bool)
|
||||
def show_portrait_alert(self, msg: str, is_error: bool) -> None:
|
||||
"""Show a compact dark-themed alert inside the portrait panel."""
|
||||
if not msg:
|
||||
self._dismiss_portrait_alert()
|
||||
return
|
||||
|
||||
# Store for replay when returning to main view
|
||||
self._pending_alerts.append((msg, is_error))
|
||||
|
||||
icon = "🛑" if is_error else "✅"
|
||||
self._alert_toast_label.setText(f"{icon} {msg}")
|
||||
|
||||
border_color = DARK_ERROR_BORDER if is_error else DARK_SUCCESS_BORDER
|
||||
text_color = DARK_ERROR_TEXT if is_error else DARK_SUCCESS_TEXT
|
||||
bg_color = DARK_ERROR_BG if is_error else DARK_SUCCESS_BG
|
||||
|
||||
self._alert_toast.setStyleSheet(f"""
|
||||
QFrame {{
|
||||
background: {bg_color};
|
||||
border: 1px solid {border_color};
|
||||
border-radius: 10px;
|
||||
}}
|
||||
""")
|
||||
self._alert_toast_label.setStyleSheet(
|
||||
f"color: {text_color}; font-size: {FONT_FINE}; font-weight: 600; background: transparent;"
|
||||
)
|
||||
self._alert_toast.setVisible(True)
|
||||
|
||||
# Auto-dismiss success after 5 s; errors persist until dismissed
|
||||
self._alert_toast_timer.stop()
|
||||
if not is_error:
|
||||
self._alert_toast_timer.start(5000)
|
||||
|
||||
@Slot()
|
||||
def _dismiss_portrait_alert(self) -> None:
|
||||
self._alert_toast_timer.stop()
|
||||
self._alert_toast.setVisible(False)
|
||||
self._alert_toast_label.clear()
|
||||
|
||||
def _flush_portrait_alerts_to_banners(self, primary_banner, secondary_banner) -> None:
|
||||
"""
|
||||
Called when returning to main view — replay any error alerts that
|
||||
arrived during portrait mode so the operator doesn't miss them.
|
||||
Only the last error (if any) is surfaced to avoid flooding.
|
||||
"""
|
||||
errors = [(m, e) for m, e in self._pending_alerts if e]
|
||||
if errors:
|
||||
last_msg, last_is_error = errors[-1]
|
||||
primary_banner.show_message(last_msg, last_is_error)
|
||||
self._pending_alerts.clear()
|
||||
self._dismiss_portrait_alert()
|
||||
|
||||
@Slot(bool)
|
||||
def _toggle_loop(self, enabled: bool) -> None:
|
||||
self._loop_enabled = enabled
|
||||
|
||||
if enabled and self._job_list_panel:
|
||||
self._loop_samples = list(self._job_list_panel.table_model.samples)
|
||||
self._loop_remaining = self._loop_count.value()
|
||||
else:
|
||||
self._loop_remaining = 0
|
||||
|
||||
def _try_loop_restart(self):
|
||||
"""
|
||||
Polls beamline state until safe to restart.
|
||||
"""
|
||||
|
||||
if self._job_list_panel is None:
|
||||
self._loop_restart_timer.stop()
|
||||
return
|
||||
|
||||
# Timeout protection
|
||||
if self._loop_restart_deadline is not None and self._loop_restart_deadline.hasExpired():
|
||||
self._loop_restart_timer.stop()
|
||||
|
||||
self._loop_enabled = False
|
||||
self._loop_btn.setChecked(False)
|
||||
|
||||
self.show_portrait_alert("Loop stopped: beamline remained busy too long", True)
|
||||
return
|
||||
|
||||
# Still busy, wait
|
||||
if getattr(self._job_list_panel, "_busy", False):
|
||||
return
|
||||
|
||||
# Safe to restart
|
||||
self._loop_restart_timer.stop()
|
||||
|
||||
self._job_list_panel.run()
|
||||
|
||||
def _restart_loop_if_needed(self) -> bool:
|
||||
"""
|
||||
Restore queue and wait for beamline idle before restarting.
|
||||
Has timeout protection.
|
||||
"""
|
||||
|
||||
if not self._loop_enabled:
|
||||
return False
|
||||
|
||||
if not self._loop_samples:
|
||||
return False
|
||||
|
||||
if self._loop_remaining <= 0:
|
||||
return False
|
||||
|
||||
self._job_list_panel.queue_samples(list(self._loop_samples), replace=True)
|
||||
|
||||
self._loop_remaining -= 1
|
||||
|
||||
# Start waiting for idle
|
||||
self._loop_restart_deadline = QTimer().remainingTime()
|
||||
|
||||
# 30 second safety timeout
|
||||
from PySide6.QtCore import QDeadlineTimer
|
||||
|
||||
self._loop_restart_deadline = QDeadlineTimer(30000)
|
||||
|
||||
self._loop_restart_timer.start()
|
||||
|
||||
return True
|
||||
|
||||
def _populate_queue_from_tell_samples_if_empty(self) -> None:
|
||||
"""
|
||||
Fill the automation queue from TELL samples if no queue exists.
|
||||
Samples are ordered by physical sample position.
|
||||
"""
|
||||
|
||||
if self._job_list_panel is None or self._tell_samples is None:
|
||||
logger.info("Job list panel or tell_samples not ready")
|
||||
return
|
||||
|
||||
# Do not overwrite an existing queue
|
||||
if self._job_list_panel.table_model.samples:
|
||||
logger.info("Queue already exists, not populating from tell_samples")
|
||||
return
|
||||
|
||||
samples = list(getattr(self._tell_samples.table_model, "samples", []))
|
||||
|
||||
ordered = sorted(
|
||||
[s for s in samples if getattr(s, "location", None) is not None],
|
||||
key=lambda s: s.loc_str_sort() if hasattr(s, "loc_str_sort") else "",
|
||||
)
|
||||
|
||||
if ordered:
|
||||
self._job_list_panel.queue_samples(ordered, replace=True)
|
||||
@@ -15,7 +15,9 @@ from PySide6.QtWidgets import (
|
||||
QHeaderView,
|
||||
QMenu,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QTableView,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from aare.gui.constants import LOGGER_NAME
|
||||
@@ -196,7 +198,14 @@ class TellSamplePanel(QFrame):
|
||||
self.status_chips.addButton(chip)
|
||||
chip_row.addWidget(chip)
|
||||
chip_row.addStretch()
|
||||
grid_layout.addLayout(chip_row, 1, 0, 1, 4)
|
||||
# Ignored-width host (same trick as the automation row in
|
||||
# main_window): the five chips' ~380px natural minimum otherwise
|
||||
# propagates dock -> window and blocks shrinking the window on small
|
||||
# screens. The row clips from the right instead.
|
||||
chip_host = QWidget(self)
|
||||
chip_host.setLayout(chip_row)
|
||||
chip_host.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Fixed)
|
||||
grid_layout.addWidget(chip_host, 1, 0, 1, 4)
|
||||
self.status_chips.buttonClicked.connect(
|
||||
lambda chip: self.table_model.set_status_filter(chip.property("status_key"))
|
||||
)
|
||||
|
||||
+55
-284
@@ -82,11 +82,21 @@ DARK_SLIDER_GRIP = (_GRAPHICS_DIR / "slider_grip_dark.png").as_posix()
|
||||
# Check marks (PRIMARY blue / dusk gold at generation time):
|
||||
CHECK_MARK = (_GRAPHICS_DIR / "check_mark_light.png").as_posix()
|
||||
DARK_CHECK_MARK = (_GRAPHICS_DIR / "check_mark_dark.png").as_posix()
|
||||
# Resize-bar grips: IDLE paints a short (66px) centered bar instead of a
|
||||
# full-length line — the full idle line read as a wall between panels; the
|
||||
# hover/drag hint keeps the full-length gradient (see _separator_gradient).
|
||||
# QSS can't paint a fixed-length centered segment, so PNGs like the arrows
|
||||
# above; used by BOTH QMainWindow::separator and QSplitter::handle, so this
|
||||
# is the one place. Colors baked in (SEPARATOR_IDLE / DARK_SEPARATOR_IDLE)
|
||||
# — regenerate if those knobs change.
|
||||
GRIP_IDLE_V = (_GRAPHICS_DIR / "resize_grip_v_idle_light.png").as_posix()
|
||||
GRIP_IDLE_H = (_GRAPHICS_DIR / "resize_grip_h_idle_light.png").as_posix()
|
||||
DARK_GRIP_IDLE_V = (_GRAPHICS_DIR / "resize_grip_v_idle_dark.png").as_posix()
|
||||
DARK_GRIP_IDLE_H = (_GRAPHICS_DIR / "resize_grip_h_idle_dark.png").as_posix()
|
||||
|
||||
# Borders (all can be "transparent" to hide the line):
|
||||
BORDER = "transparent" # main dividers, e.g. the beamline state bar top line
|
||||
CARD_BORDER = "transparent" # cards / group boxes (Local Contact, automation)
|
||||
COMPACT_BORDER = "transparent" # compact-automation cards and buttons
|
||||
|
||||
# -- Box-frame borders by nesting level -------------------------------------
|
||||
# One knob per level so the amount of "boxing" is tunable in one place:
|
||||
@@ -124,18 +134,20 @@ SEPARATOR_HINT = "#3f4a5f"
|
||||
SEPARATOR_IDLE = "#47536a"
|
||||
SEPARATOR_HINT_DELAY_MS = 66 # int, used in code, not QSS
|
||||
|
||||
# The gutter keeps this full width for the mouse; only a 2px line + 1px
|
||||
# shadow is painted inside it (a solid 5px bar read too heavy). 5px is a
|
||||
# first guess — adjust here if the grab target feels off.
|
||||
# The gutter keeps this full width for the mouse; idle paints only the
|
||||
# short GRIP_* bar (a full-length idle line read as a wall), hover/drag
|
||||
# brings back the full-length hint gradient so the grabbed bar reads over
|
||||
# its whole run. 5px is a first guess — adjust here if the grab target
|
||||
# feels off.
|
||||
SEPARATOR_REGION = "5px"
|
||||
SEPARATOR_SHADOW = "rgba(31, 41, 59, 25%)"
|
||||
|
||||
|
||||
def _separator_gradient(line: str, shadow: str, axis: str) -> str:
|
||||
"""Paint of a resize gutter: transparent 1px, shadow 1px, line 2px,
|
||||
shadow 1px — hard gradient stops at 1/5 steps of SEPARATOR_REGION.
|
||||
axis "x" runs the gradient left->right (an upright line), "y" top->down
|
||||
(a lying line)."""
|
||||
"""Full-length hover/drag hint of a resize gutter: transparent 1px,
|
||||
shadow 1px, line 2px, shadow 1px — hard gradient stops at 1/5 steps of
|
||||
SEPARATOR_REGION. axis "x" runs the gradient left->right (an upright
|
||||
line), "y" top->down (a lying line)."""
|
||||
x2, y2 = ("1", "0") if axis == "x" else ("0", "1")
|
||||
return (
|
||||
f"qlineargradient(x1:0, y1:0, x2:{x2}, y2:{y2},"
|
||||
@@ -146,8 +158,6 @@ def _separator_gradient(line: str, shadow: str, axis: str) -> str:
|
||||
)
|
||||
|
||||
|
||||
SEP_IDLE_X = _separator_gradient(SEPARATOR_IDLE, SEPARATOR_SHADOW, "x")
|
||||
SEP_IDLE_Y = _separator_gradient(SEPARATOR_IDLE, SEPARATOR_SHADOW, "y")
|
||||
SEP_HINT_X = _separator_gradient(SEPARATOR_HINT, SEPARATOR_SHADOW, "x")
|
||||
SEP_HINT_Y = _separator_gradient(SEPARATOR_HINT, SEPARATOR_SHADOW, "y")
|
||||
|
||||
@@ -165,13 +175,6 @@ TAB_FACE_BG = "#ffffff"
|
||||
SELECTION_BG = BANNER
|
||||
SELECTION_TEXT = "#263043" # same as TEXT — readable on the banner blue
|
||||
|
||||
# Compact-automation page:
|
||||
COMPACT_CARD_BG = "#e6eefc"
|
||||
COMPACT_TITLE = "#17324d" # section titles + menu/secondary button text
|
||||
COMPACT_HINT = "#51657d" # secondary text: hints, queue titles
|
||||
COMPACT_VALUE = "#10263a"
|
||||
COMPACT_MENU_BG = "#cbdcf8"
|
||||
COMPACT_MENU_BG_HOVER = "#bfd4f6"
|
||||
PRIMARY = "#2563eb" # main action button
|
||||
PRIMARY_HOVER = "#1d4ed8"
|
||||
PRIMARY_TEXT = "white"
|
||||
@@ -258,8 +261,6 @@ DARK_ACCENT = "#e0913f" # gold
|
||||
# >=3.74:1 — same WCAG 1.4.11 floor as the light-theme separators.
|
||||
DARK_SEPARATOR_IDLE = "rgba(224, 145, 63, 70%)"
|
||||
DARK_SEPARATOR_SHADOW = "rgba(0, 0, 0, 45%)"
|
||||
DARK_SEP_IDLE_X = _separator_gradient(DARK_SEPARATOR_IDLE, DARK_SEPARATOR_SHADOW, "x")
|
||||
DARK_SEP_IDLE_Y = _separator_gradient(DARK_SEPARATOR_IDLE, DARK_SEPARATOR_SHADOW, "y")
|
||||
DARK_SEP_HINT_X = _separator_gradient(DARK_ACCENT, DARK_SEPARATOR_SHADOW, "x")
|
||||
DARK_SEP_HINT_Y = _separator_gradient(DARK_ACCENT, DARK_SEPARATOR_SHADOW, "y")
|
||||
DARK_ACCENT_HOVER = "#eaa253" # accent2 — brighter gold
|
||||
@@ -593,7 +594,6 @@ TUTORIAL_BORDER = "#555555"
|
||||
TUTORIAL_HIGHLIGHT = "#ffff00" # widget spotlight pen (was Qt.yellow)
|
||||
|
||||
# -- Typography ladder (role-named; tune sizes here, not in widgets) --------
|
||||
FONT_HERO = "28px" # portrait main action button
|
||||
FONT_ALERT = "18px" # alert banner text
|
||||
FONT_VALUE = "18px" # prominent values, big glyph buttons
|
||||
FONT_TITLE = "16px" # section / panel titles
|
||||
@@ -607,9 +607,10 @@ FONT_FINE = "11px" # fine print, queue titles
|
||||
# Whole-app text scale applied to the FONT_* ladder when the QSS is built, so
|
||||
# one re-apply of the stylesheet rescales every rule. Floats, so _palette()'s
|
||||
# str filter never picks them up as colors.
|
||||
FONT_SCALE_MIN = 0.8
|
||||
FONT_SCALE_MAX = 1.6
|
||||
FONT_SCALE_STEP = 0.1
|
||||
# Discrete ladder: 100% / 125% / 150% only. Free ±10% steps produced
|
||||
# in-between sizes where the fixed-width side boxes clipped their text;
|
||||
# three vetted stops keep fonts and box widths in lockstep.
|
||||
FONT_SCALE_LADDER = (1.0, 1.25, 1.5)
|
||||
_font_scale = 1.0
|
||||
|
||||
|
||||
@@ -618,10 +619,10 @@ def font_scale() -> float:
|
||||
|
||||
|
||||
def set_font_scale(scale: float) -> None:
|
||||
# Clamped: below 0.8 the fine-print sizes fall under 9px (unreadable),
|
||||
# above 1.6 the fixed-height banner rows start clipping their text.
|
||||
# Snap to the ladder so old saved settings (e.g. 1.2 from the former
|
||||
# free-step zoom) land on the nearest vetted stop.
|
||||
global _font_scale
|
||||
_font_scale = max(FONT_SCALE_MIN, min(FONT_SCALE_MAX, scale))
|
||||
_font_scale = min(FONT_SCALE_LADDER, key=lambda s: abs(s - scale))
|
||||
|
||||
|
||||
# -- Hover tooltips (the QToolTip popup; styled borderless) -----------------
|
||||
@@ -898,15 +899,10 @@ def _sunrise_stylesheet(overrides: dict[str, str] | None = None) -> str:
|
||||
}
|
||||
|
||||
QWidget#mainContentRoot,
|
||||
QWidget#standardMainPage,
|
||||
QWidget#compactAutomationPage {
|
||||
QWidget#standardMainPage {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
QWidget#portraitModePage {
|
||||
background-color: $dark_bg;
|
||||
}
|
||||
|
||||
/* Borderless hover hints. The transparent border is required — QToolTip
|
||||
only honours the stylesheet background once a border is set. */
|
||||
QToolTip {
|
||||
@@ -916,93 +912,6 @@ def _sunrise_stylesheet(overrides: dict[str, str] | None = None) -> str:
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
QFrame#compactAutomationPanel {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
QFrame#compactCameraCard,
|
||||
QFrame#compactControlsCard,
|
||||
QFrame#compactProgressCard,
|
||||
QFrame#compactQueueCard,
|
||||
QFrame#compactQueueItem {
|
||||
background: $compact_card_bg;
|
||||
border: 1px solid $compact_border;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
QLabel#compactSectionTitle {
|
||||
background: transparent;
|
||||
color: $compact_title;
|
||||
font-size: $font_body;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
QLabel#compactSectionHint {
|
||||
background: transparent;
|
||||
color: $compact_hint;
|
||||
font-size: $font_hint;
|
||||
}
|
||||
|
||||
QLabel#compactQueueTitle {
|
||||
background: transparent;
|
||||
color: $compact_hint;
|
||||
font-size: $font_fine;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
QLabel#compactQueueValue {
|
||||
background: transparent;
|
||||
color: $compact_value;
|
||||
font-size: $font_body;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
QToolButton#compactMenuButton {
|
||||
background: $compact_menu_bg;
|
||||
color: $compact_title;
|
||||
border: 1px solid $compact_border;
|
||||
border-radius: 14px;
|
||||
padding: 10px 14px;
|
||||
font-size: $font_value;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
QToolButton#compactMenuButton:hover {
|
||||
background: $compact_menu_bg_hover;
|
||||
}
|
||||
|
||||
QPushButton#compactPrimaryButton {
|
||||
background: $primary;
|
||||
color: $primary_text;
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
padding: 14px 18px;
|
||||
font-size: $font_body_lg;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
QPushButton#compactPrimaryButton:hover {
|
||||
background: $primary_hover;
|
||||
}
|
||||
|
||||
QPushButton#compactSecondaryButton,
|
||||
QToolButton#compactSecondaryButton {
|
||||
background: $secondary_bg;
|
||||
color: $compact_title;
|
||||
border: 1px solid $compact_border;
|
||||
border-radius: 14px;
|
||||
padding: 14px 18px;
|
||||
font-size: $font_body;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
QPushButton#compactSecondaryButton:hover,
|
||||
QToolButton#compactSecondaryButton:hover {
|
||||
background: $secondary_bg_hover;
|
||||
}
|
||||
|
||||
QFrame#alertBanner[alertKind="error"] {
|
||||
background-color: $error_bg;
|
||||
border: 2px solid $error_border;
|
||||
@@ -1169,20 +1078,21 @@ def _sunrise_stylesheet(overrides: dict[str, str] | None = None) -> str:
|
||||
}
|
||||
|
||||
/* Resize gutters: the mouse keeps the full $separator_region, but only
|
||||
a 2px line + 1px shadow paints (gradients in _separator_gradient).
|
||||
An upright line needs the x-gradient: that is a :vertical main-window
|
||||
separator but a :horizontal splitter handle (handle orientation
|
||||
follows the splitter, not the bar). */
|
||||
the short centered GRIP_* bar paints (image: never stretches, it
|
||||
centers). An upright grip is the _v file: that is a :vertical
|
||||
main-window separator but a :horizontal splitter handle (handle
|
||||
orientation follows the splitter, not the bar). */
|
||||
QMainWindow::separator {
|
||||
width: $separator_region;
|
||||
height: $separator_region;
|
||||
background: transparent;
|
||||
}
|
||||
QSplitter::handle { background: transparent; }
|
||||
QMainWindow::separator:vertical, QSplitter::handle:horizontal {
|
||||
background: $sep_idle_x;
|
||||
image: url($grip_idle_v);
|
||||
}
|
||||
QMainWindow::separator:horizontal, QSplitter::handle:vertical {
|
||||
background: $sep_idle_y;
|
||||
image: url($grip_idle_h);
|
||||
}
|
||||
QSplitter::handle:horizontal { width: $separator_region; }
|
||||
QSplitter::handle:vertical { height: $separator_region; }
|
||||
@@ -1191,18 +1101,23 @@ def _sunrise_stylesheet(overrides: dict[str, str] | None = None) -> str:
|
||||
MainWindow.event() after a 1s hover rest or on press; :hover limits
|
||||
the fill to the exact separator being dragged. */
|
||||
QMainWindow[separatorHint="true"]::separator:vertical:hover {
|
||||
image: none;
|
||||
background: $sep_hint_x;
|
||||
}
|
||||
QMainWindow[separatorHint="true"]::separator:horizontal:hover {
|
||||
image: none;
|
||||
background: $sep_hint_y;
|
||||
}
|
||||
|
||||
/* Splitter handles (prediction metrics) are plain child widgets the
|
||||
property gate above doesn't reach — immediate hover/press hint. */
|
||||
/* Splitter handles (camera region, prediction metrics) are plain child
|
||||
widgets the property gate above doesn't reach — immediate hover/press
|
||||
hint. */
|
||||
QSplitter::handle:horizontal:hover, QSplitter::handle:horizontal:pressed {
|
||||
image: none;
|
||||
background: $sep_hint_x;
|
||||
}
|
||||
QSplitter::handle:vertical:hover, QSplitter::handle:vertical:pressed {
|
||||
image: none;
|
||||
background: $sep_hint_y;
|
||||
}
|
||||
|
||||
@@ -1381,35 +1296,6 @@ def _sunrise_stylesheet(overrides: dict[str, str] | None = None) -> str:
|
||||
border: none;
|
||||
}
|
||||
|
||||
QWidget#portraitRoot,
|
||||
QWidget#portraitRoot QWidget {
|
||||
background: $dark_bg;
|
||||
color: $dark_text;
|
||||
font-family: 'Inter', 'SF Pro Display', Arial, sans-serif;
|
||||
font-size: $font_body;
|
||||
}
|
||||
|
||||
QWidget#portraitRoot QScrollArea {
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
QWidget#portraitRoot QScrollBar:vertical {
|
||||
background: $dark_surface;
|
||||
width: 4px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
QWidget#portraitRoot QScrollBar::handle:vertical {
|
||||
background: $dark_border;
|
||||
border-radius: 2px;
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
QWidget#portraitRoot QScrollBar::add-line:vertical,
|
||||
QWidget#portraitRoot QScrollBar::sub-line:vertical {
|
||||
height: 0px;
|
||||
}
|
||||
""").substitute(mapping)
|
||||
|
||||
|
||||
@@ -1428,10 +1314,6 @@ def _sunset_stylesheet() -> str:
|
||||
background: $dark_app_background;
|
||||
}
|
||||
|
||||
QWidget#portraitModePage {
|
||||
background: $dark_bg;
|
||||
}
|
||||
|
||||
/* Interactive faces sit one step above the backdrop (site: glass2)
|
||||
with the faint gold hairline. Same pinned height as the light sheet
|
||||
so buttons and entry boxes match in both themes. */
|
||||
@@ -1618,7 +1500,7 @@ def _sunset_stylesheet() -> str:
|
||||
QPushButton#filterChip[status_key="flagged"]:checked { background: $sample_status_flagged_bg; }
|
||||
QPushButton#filterChip[status_key="measured"]:checked { background: $sample_status_measured_bg; }
|
||||
|
||||
/* In-panel section headings — gold, matching the compact page titles. */
|
||||
/* In-panel section headings — gold. */
|
||||
QLabel#sectionTitle {
|
||||
background: transparent;
|
||||
color: $dark_accent;
|
||||
@@ -1679,93 +1561,6 @@ def _sunset_stylesheet() -> str:
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
QFrame#compactAutomationPanel {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
QFrame#compactCameraCard,
|
||||
QFrame#compactControlsCard,
|
||||
QFrame#compactProgressCard,
|
||||
QFrame#compactQueueCard,
|
||||
QFrame#compactQueueItem {
|
||||
background: $dark_surface;
|
||||
border: 1px solid $dark_border;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
QLabel#compactSectionTitle {
|
||||
background: transparent;
|
||||
color: $dark_accent;
|
||||
font-size: $font_body;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
QLabel#compactSectionHint {
|
||||
background: transparent;
|
||||
color: $dark_subtext;
|
||||
font-size: $font_hint;
|
||||
}
|
||||
|
||||
QLabel#compactQueueTitle {
|
||||
background: transparent;
|
||||
color: $dark_subtext;
|
||||
font-size: $font_fine;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
QLabel#compactQueueValue {
|
||||
background: transparent;
|
||||
color: $dark_text;
|
||||
font-size: $font_body;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
QToolButton#compactMenuButton {
|
||||
background: $dark_elevated;
|
||||
color: $dark_accent;
|
||||
border: 1px solid $dark_border;
|
||||
border-radius: 14px;
|
||||
padding: 10px 14px;
|
||||
font-size: $font_value;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
QToolButton#compactMenuButton:hover {
|
||||
background: $dark_border;
|
||||
}
|
||||
|
||||
QPushButton#compactPrimaryButton {
|
||||
background: $dark_accent_fill;
|
||||
color: $dark_on_accent;
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
padding: 14px 18px;
|
||||
font-size: $font_body_lg;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
QPushButton#compactPrimaryButton:hover {
|
||||
background: $dark_accent_fill_hover;
|
||||
}
|
||||
|
||||
QPushButton#compactSecondaryButton,
|
||||
QToolButton#compactSecondaryButton {
|
||||
background: $dark_elevated;
|
||||
color: $dark_text;
|
||||
border: 1px solid $dark_border;
|
||||
border-radius: 14px;
|
||||
padding: 14px 18px;
|
||||
font-size: $font_body;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
QPushButton#compactSecondaryButton:hover,
|
||||
QToolButton#compactSecondaryButton:hover {
|
||||
background: $dark_border;
|
||||
}
|
||||
|
||||
QFrame#alertBanner[alertKind="error"] {
|
||||
background: $dark_error_bg;
|
||||
border: 2px solid $dark_error_border;
|
||||
@@ -1846,32 +1641,37 @@ def _sunset_stylesheet() -> str:
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Idle + hover resize lines, dark flavor — see the light-theme note. */
|
||||
/* Idle + hover resize grips, dark flavor — see the light-theme note. */
|
||||
QMainWindow::separator {
|
||||
width: $separator_region;
|
||||
height: $separator_region;
|
||||
background: transparent;
|
||||
}
|
||||
QSplitter::handle { background: transparent; }
|
||||
QMainWindow::separator:vertical, QSplitter::handle:horizontal {
|
||||
background: $dark_sep_idle_x;
|
||||
image: url($dark_grip_idle_v);
|
||||
}
|
||||
QMainWindow::separator:horizontal, QSplitter::handle:vertical {
|
||||
background: $dark_sep_idle_y;
|
||||
image: url($dark_grip_idle_h);
|
||||
}
|
||||
QSplitter::handle:horizontal { width: $separator_region; }
|
||||
QSplitter::handle:vertical { height: $separator_region; }
|
||||
|
||||
QMainWindow[separatorHint="true"]::separator:vertical:hover {
|
||||
image: none;
|
||||
background: $dark_sep_hint_x;
|
||||
}
|
||||
QMainWindow[separatorHint="true"]::separator:horizontal:hover {
|
||||
image: none;
|
||||
background: $dark_sep_hint_y;
|
||||
}
|
||||
|
||||
QSplitter::handle:horizontal:hover, QSplitter::handle:horizontal:pressed {
|
||||
image: none;
|
||||
background: $dark_sep_hint_x;
|
||||
}
|
||||
QSplitter::handle:vertical:hover, QSplitter::handle:vertical:pressed {
|
||||
image: none;
|
||||
background: $dark_sep_hint_y;
|
||||
}
|
||||
|
||||
@@ -2008,35 +1808,6 @@ def _sunset_stylesheet() -> str:
|
||||
border: none;
|
||||
}
|
||||
|
||||
QWidget#portraitRoot,
|
||||
QWidget#portraitRoot QWidget {
|
||||
background: $dark_bg;
|
||||
color: $dark_text;
|
||||
font-family: 'Inter', 'SF Pro Display', Arial, sans-serif;
|
||||
font-size: $font_body;
|
||||
}
|
||||
|
||||
QWidget#portraitRoot QScrollArea {
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
QWidget#portraitRoot QScrollBar:vertical {
|
||||
background: $dark_surface;
|
||||
width: 4px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
QWidget#portraitRoot QScrollBar::handle:vertical {
|
||||
background: $dark_border;
|
||||
border-radius: 2px;
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
QWidget#portraitRoot QScrollBar::add-line:vertical,
|
||||
QWidget#portraitRoot QScrollBar::sub-line:vertical {
|
||||
height: 0px;
|
||||
}
|
||||
""").substitute(_palette())
|
||||
|
||||
|
||||
@@ -2047,12 +1818,12 @@ if __name__ == "__main__":
|
||||
assert APP_BACKGROUND not in build_app_stylesheet(THEME_BLUEBIRD)
|
||||
# Font zoom: the FONT_* ladder must follow the scale, and the scale must
|
||||
# clamp to its documented bounds.
|
||||
set_font_scale(1.3)
|
||||
assert "font-size: 21px" in build_app_stylesheet(THEME_SUNRISE) # title 16->21
|
||||
set_font_scale(1.3) # snaps to the 1.25 ladder stop
|
||||
assert "font-size: 20px" in build_app_stylesheet(THEME_SUNRISE) # title 16->20
|
||||
set_font_scale(99.0)
|
||||
assert font_scale() == FONT_SCALE_MAX
|
||||
assert font_scale() == FONT_SCALE_LADDER[-1]
|
||||
set_font_scale(0.0)
|
||||
assert font_scale() == FONT_SCALE_MIN
|
||||
assert font_scale() == FONT_SCALE_LADDER[0]
|
||||
set_font_scale(1.0)
|
||||
assert "font-size: 16px" in build_app_stylesheet(THEME_SUNRISE)
|
||||
# This line was added by Claude. But I would do the same. So all gude.
|
||||
|
||||
@@ -65,7 +65,7 @@ class AlertBanner(QFrame):
|
||||
"""Render as a compact toast under `widget`'s bottom edge (must be a
|
||||
descendant of the float host) instead of a full-width top bar — the
|
||||
baton messages sit below the sample camera view this way. Falls back
|
||||
to the top bar while the anchor is hidden (e.g. portrait mode).
|
||||
to the top bar while the anchor is hidden.
|
||||
ponytail: position goes stale if a splitter drag moves the anchor while
|
||||
the toast is up; it self-corrects on the next show."""
|
||||
self._float_anchor = widget
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
from aarecommon.models.automation import AutomationProgress, StepStatus, WorkflowStateKind
|
||||
from PySide6.QtCore import QTimer, Slot
|
||||
from PySide6.QtWidgets import QFrame, QHBoxLayout, QLabel, QVBoxLayout, QWidget
|
||||
|
||||
from aare.gui.styles import (
|
||||
FAINT_TEXT,
|
||||
FONT_HINT,
|
||||
FONT_VALUE,
|
||||
STEP_FAILED_TEXT,
|
||||
STEP_PAUSED_TEXT,
|
||||
STEP_RUNNING_TEXT,
|
||||
STEP_SUCCESS_TEXT,
|
||||
)
|
||||
|
||||
|
||||
class CompactAutomationProgressStrip(QFrame):
|
||||
DEFAULT_SAMPLE_ESTIMATE_S = 150.0
|
||||
|
||||
def __init__(self, parent: QWidget | None = None):
|
||||
super().__init__(parent)
|
||||
self._progress: AutomationProgress | None = None
|
||||
self._queue_count = 0
|
||||
self._running = False
|
||||
self._step_labels: dict[WorkflowStateKind, QLabel] = {}
|
||||
|
||||
self._timer = QTimer(self)
|
||||
self._timer.setInterval(1000)
|
||||
self._timer.timeout.connect(self._refresh_live_view)
|
||||
|
||||
self.setObjectName("compactAutomationProgressStrip")
|
||||
self.setStyleSheet(
|
||||
"""
|
||||
QFrame#compactAutomationProgressStrip {
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(8)
|
||||
|
||||
self._summary_label = QLabel("Automation idle", self)
|
||||
self._summary_label.setObjectName("compactProgressSummary")
|
||||
self._summary_label.setWordWrap(True)
|
||||
layout.addWidget(self._summary_label)
|
||||
|
||||
steps_row = QHBoxLayout()
|
||||
steps_row.setSpacing(8)
|
||||
|
||||
for step in (
|
||||
WorkflowStateKind.MOUNT,
|
||||
WorkflowStateKind.LOOP_CENTRE,
|
||||
WorkflowStateKind.RASTER,
|
||||
WorkflowStateKind.DATA_COLLECTION,
|
||||
WorkflowStateKind.FINAL,
|
||||
):
|
||||
label = QLabel(self)
|
||||
label.setObjectName("compactProgressStep")
|
||||
label.setWordWrap(True)
|
||||
steps_row.addWidget(label, 1)
|
||||
self._step_labels[step] = label
|
||||
|
||||
layout.addLayout(steps_row)
|
||||
self._apply_empty_state()
|
||||
|
||||
def _apply_empty_state(self) -> None:
|
||||
self._summary_label.setText("Automation idle")
|
||||
for step, label in self._step_labels.items():
|
||||
label.setText(self._format_step_html(step, StepStatus.PENDING))
|
||||
|
||||
@staticmethod
|
||||
def _step_title(step: WorkflowStateKind) -> str:
|
||||
return {
|
||||
WorkflowStateKind.MOUNT: "Mount",
|
||||
WorkflowStateKind.LOOP_CENTRE: "Center",
|
||||
WorkflowStateKind.RASTER: "Raster",
|
||||
WorkflowStateKind.DATA_COLLECTION: "Collect",
|
||||
WorkflowStateKind.FINAL: "Finish",
|
||||
}.get(step, str(step.value))
|
||||
|
||||
@staticmethod
|
||||
def _step_icon(status: StepStatus) -> str:
|
||||
return {
|
||||
StepStatus.PENDING: "○",
|
||||
StepStatus.RUNNING: "◉",
|
||||
StepStatus.SUCCESS: "✓",
|
||||
StepStatus.FAILED: "✕",
|
||||
StepStatus.SKIPPED: "↷",
|
||||
StepStatus.PAUSED: "⏸",
|
||||
}.get(status, "○")
|
||||
|
||||
@staticmethod
|
||||
def _step_color(status: StepStatus) -> str:
|
||||
return {
|
||||
StepStatus.PENDING: FAINT_TEXT,
|
||||
StepStatus.RUNNING: STEP_RUNNING_TEXT,
|
||||
StepStatus.SUCCESS: STEP_SUCCESS_TEXT,
|
||||
StepStatus.FAILED: STEP_FAILED_TEXT,
|
||||
StepStatus.SKIPPED: FAINT_TEXT,
|
||||
StepStatus.PAUSED: STEP_PAUSED_TEXT,
|
||||
}.get(status, FAINT_TEXT)
|
||||
|
||||
def _format_step_html(self, step: WorkflowStateKind, status: StepStatus) -> str:
|
||||
color = self._step_color(status)
|
||||
icon = self._step_icon(status)
|
||||
title = self._step_title(step)
|
||||
return (
|
||||
f"<div style='text-align:center; background:transparent;'>"
|
||||
f"<div style='font-size:{FONT_VALUE}; color:{color}; font-weight:700; background:transparent;'>{icon}</div>"
|
||||
f"<div style='font-size:{FONT_HINT}; color:{color}; font-weight:700; background:transparent;'>{title}</div>"
|
||||
f"</div>"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _format_duration(seconds: float | None) -> str:
|
||||
if seconds is None or seconds <= 0:
|
||||
return "0m 00s"
|
||||
total = round(seconds)
|
||||
minutes, secs = divmod(total, 60)
|
||||
if minutes < 60:
|
||||
return f"{minutes}m {secs:02d}s"
|
||||
hours, minutes = divmod(minutes, 60)
|
||||
return f"{hours}h {minutes:02d}m"
|
||||
|
||||
@staticmethod
|
||||
def _format_eta(epoch_seconds: float | None) -> str:
|
||||
if epoch_seconds is None:
|
||||
return "N/A"
|
||||
return datetime.fromtimestamp(epoch_seconds).strftime("%H:%M:%S")
|
||||
|
||||
@Slot()
|
||||
def _refresh_live_view(self) -> None:
|
||||
if self._progress is not None:
|
||||
self.set_progress(self._progress)
|
||||
|
||||
@Slot(bool)
|
||||
def set_running(self, running: bool) -> None:
|
||||
self._running = bool(running)
|
||||
if self._progress is not None:
|
||||
self.set_progress(self._progress)
|
||||
|
||||
@Slot(int)
|
||||
def set_samples_in_queue(self, count: int) -> None:
|
||||
self._queue_count = max(0, int(count))
|
||||
if self._progress is not None:
|
||||
self.set_progress(self._progress)
|
||||
|
||||
@Slot(object)
|
||||
def set_progress(self, progress: AutomationProgress) -> None:
|
||||
self._progress = progress
|
||||
|
||||
any_running = any(step.status == StepStatus.RUNNING for step in progress.steps)
|
||||
if any_running and self._running:
|
||||
if not self._timer.isActive():
|
||||
self._timer.start()
|
||||
else:
|
||||
self._timer.stop()
|
||||
|
||||
current_sample = progress.current_sample_name or "None"
|
||||
avg_time = (
|
||||
progress.avg_time_per_sample
|
||||
if progress.avg_time_per_sample > 0
|
||||
else self.DEFAULT_SAMPLE_ESTIMATE_S
|
||||
)
|
||||
queue_remaining = avg_time * self._queue_count
|
||||
eta = time.time() + queue_remaining if queue_remaining > 0 else None
|
||||
|
||||
state_text = "Paused"
|
||||
state_color = STEP_PAUSED_TEXT
|
||||
if progress.finished and progress.success is True:
|
||||
state_text = "Completed"
|
||||
state_color = STEP_SUCCESS_TEXT
|
||||
elif progress.finished and progress.success is False:
|
||||
state_text = "Failed"
|
||||
state_color = STEP_FAILED_TEXT
|
||||
elif self._running:
|
||||
state_text = "Running"
|
||||
state_color = STEP_RUNNING_TEXT
|
||||
|
||||
self._summary_label.setText(
|
||||
f"<span style='background:transparent;'><b>Status:</b> "
|
||||
f"<span style='color:{state_color}; font-weight:700; background:transparent;'>{state_text}</span>"
|
||||
f" <b>Now:</b> {current_sample}"
|
||||
f" <b>Queue:</b> {self._queue_count}"
|
||||
f" <b>ETA:</b> {self._format_duration(queue_remaining)}"
|
||||
f" <b>Done:</b> {self._format_eta(eta)}</span>"
|
||||
)
|
||||
|
||||
states = {step.step: step.status for step in progress.steps}
|
||||
for step, label in self._step_labels.items():
|
||||
label.setText(self._format_step_html(step, states.get(step, StepStatus.PENDING)))
|
||||
@@ -0,0 +1,95 @@
|
||||
from PySide6.QtCore import QPoint, QRect, QSize, Qt
|
||||
from PySide6.QtWidgets import QLayout, QWidget
|
||||
|
||||
|
||||
class FlowLayout(QLayout):
|
||||
"""Left-to-right layout that wraps to new rows when the width runs out —
|
||||
Qt ships no built-in for this, so this is the canonical Qt example
|
||||
trimmed down. Added for the status-bar readouts: on narrow windows they
|
||||
were simply clipped; now they wrap and stay readable.
|
||||
"""
|
||||
|
||||
def __init__(self, parent=None, hspacing: int = 10, vspacing: int = 2):
|
||||
super().__init__(parent)
|
||||
self._items = []
|
||||
self._hspace = hspacing
|
||||
self._vspace = vspacing
|
||||
|
||||
def addItem(self, item):
|
||||
self._items.append(item)
|
||||
|
||||
def count(self):
|
||||
return len(self._items)
|
||||
|
||||
def itemAt(self, index):
|
||||
return self._items[index] if 0 <= index < len(self._items) else None
|
||||
|
||||
# Newer PySide stubs declare a non-Optional return, but Qt's own takeAt
|
||||
# contract is "nullptr if out of range" — suppress, don't lie.
|
||||
def takeAt(self, index): # pyright: ignore[reportIncompatibleMethodOverride]
|
||||
return self._items.pop(index) if 0 <= index < len(self._items) else None
|
||||
|
||||
def expandingDirections(self):
|
||||
return Qt.Orientation(0)
|
||||
|
||||
def hasHeightForWidth(self):
|
||||
return True
|
||||
|
||||
def heightForWidth(self, width):
|
||||
return self._do_layout(QRect(0, 0, width, 0), test_only=True)
|
||||
|
||||
def setGeometry(self, rect):
|
||||
super().setGeometry(rect)
|
||||
self._do_layout(rect, test_only=False)
|
||||
|
||||
def sizeHint(self):
|
||||
return self.minimumSize()
|
||||
|
||||
def minimumSize(self):
|
||||
size = QSize()
|
||||
for item in self._items:
|
||||
size = size.expandedTo(item.minimumSize())
|
||||
margins = self.contentsMargins()
|
||||
return size + QSize(margins.left() + margins.right(), margins.top() + margins.bottom())
|
||||
|
||||
def _do_layout(self, rect, test_only: bool) -> int:
|
||||
margins = self.contentsMargins()
|
||||
x = rect.x() + margins.left()
|
||||
y = rect.y() + margins.top()
|
||||
right = rect.right() - margins.right()
|
||||
line_height = 0
|
||||
for item in self._items:
|
||||
# Hidden widgets must not leave gaps in the row.
|
||||
if item.isEmpty():
|
||||
continue
|
||||
hint = item.sizeHint()
|
||||
next_x = x + hint.width()
|
||||
if next_x - 1 > right and line_height > 0:
|
||||
x = rect.x() + margins.left()
|
||||
y += line_height + self._vspace
|
||||
next_x = x + hint.width()
|
||||
line_height = 0
|
||||
if not test_only:
|
||||
item.setGeometry(QRect(QPoint(x, y), hint))
|
||||
x = next_x + self._hspace
|
||||
line_height = max(line_height, hint.height())
|
||||
return y + line_height + margins.bottom() - rect.y()
|
||||
|
||||
|
||||
class FlowHost(QWidget):
|
||||
"""Widget owning a FlowLayout. Parents like QStatusBar ignore
|
||||
heightForWidth, so the host pins its own minimum height to the wrapped
|
||||
height on every resize — the bar then grows to fit the rows.
|
||||
"""
|
||||
|
||||
def __init__(self, parent=None, hspacing: int = 10, vspacing: int = 2):
|
||||
super().__init__(parent)
|
||||
self._flow = FlowLayout(self, hspacing=hspacing, vspacing=vspacing)
|
||||
self._flow.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
def add_widget(self, widget: QWidget) -> None:
|
||||
self._flow.addWidget(widget)
|
||||
|
||||
def resizeEvent(self, event):
|
||||
super().resizeEvent(event)
|
||||
self.setMinimumHeight(self._flow.heightForWidth(self.width()))
|
||||
@@ -11,6 +11,7 @@ from aare.gui.constants import LOGGER_NAME
|
||||
from aare.gui.styles import THEME_SUNRISE, status_colors
|
||||
from aare.gui.widgets.baton_request_dialog import BatonRequestDialog
|
||||
from aare.gui.widgets.clickable_label import ClickableLabel
|
||||
from aare.gui.widgets.flow_layout import FlowHost
|
||||
from aare.gui.widgets.pgroup_dialog import PGroupDialog
|
||||
from aare.gui.widgets.value_label import ValueLabel
|
||||
|
||||
@@ -92,13 +93,19 @@ class StatusBar(QStatusBar):
|
||||
|
||||
self.exp_shutter_label = ValueLabel("ExpHutch Shutter", "", self)
|
||||
|
||||
# Passive readouts group left (addWidget), operable controls group
|
||||
# right (addPermanentWidget) wearing the shared ClickableLabel hover
|
||||
# affordance — fixed order Cryo | Fast Shutter | State | p-group |
|
||||
# Session. No showMessage is ever used here, so the left section is
|
||||
# never hidden by temporary messages.
|
||||
self.addWidget(self.message_label)
|
||||
# Every readout lives in ONE wrapping flow host: on narrow windows
|
||||
# QStatusBar used to clip/hide the labels outright, now they wrap to
|
||||
# extra rows and stay readable. Fixed order: passives first, then the
|
||||
# operables (shared ClickableLabel hover affordance) — Cryo | Fast
|
||||
# Shutter | State | p-group | Session at the end. The old left/right
|
||||
# split (addWidget vs addPermanentWidget) is gone with the wrap; no
|
||||
# showMessage is ever used here, so nothing hides the host.
|
||||
# TODO(cryo): passive for now, but placed with the operables because a
|
||||
# cryo operation (fill/anneal menu) is planned; when it gets a click
|
||||
# handler, swap it to a ClickableLabel so it inherits the affordance.
|
||||
self.info_host = FlowHost(self)
|
||||
for widget in (
|
||||
self.message_label,
|
||||
self.sharpness,
|
||||
self.samcam_fps,
|
||||
self.flux,
|
||||
@@ -108,19 +115,14 @@ class StatusBar(QStatusBar):
|
||||
self.exp_shutter_label,
|
||||
self.tell_state_label,
|
||||
self.busy_label,
|
||||
):
|
||||
self.addWidget(widget)
|
||||
# TODO(cryo): passive for now, but placed with the operables because a
|
||||
# cryo operation (fill/anneal menu) is planned; when it gets a click
|
||||
# handler, swap it to a ClickableLabel so it inherits the affordance.
|
||||
for widget in (
|
||||
self.cryo_label,
|
||||
self.shutter_label,
|
||||
self.state_label,
|
||||
self.pgroup_label,
|
||||
self.session_label,
|
||||
):
|
||||
self.addPermanentWidget(widget)
|
||||
self.info_host.add_widget(widget)
|
||||
self.addWidget(self.info_host, 1)
|
||||
|
||||
def set_theme(self, theme: str) -> None:
|
||||
"""Adopt the theme's flag colors: recolor the connection message and
|
||||
|
||||
@@ -89,8 +89,12 @@ class TitleLabel(QLabel):
|
||||
if settings.value(self._settings_key, default_collapsed, type=bool):
|
||||
self._collapsed = True
|
||||
# Deferred: the panel adds its other widgets after constructing
|
||||
# the TitleLabel, so siblings don't exist yet.
|
||||
QTimer.singleShot(0, self._apply_collapsed)
|
||||
# the TitleLabel, so siblings don't exist yet. `self` as the
|
||||
# receiver context, or the pending timer outlives a deleted
|
||||
# banner and fires into the dead C++ object — the source of the
|
||||
# long-standing "QPushButton returned NULL" SystemError flake
|
||||
# poisoning whatever test runs next.
|
||||
QTimer.singleShot(0, self, self._apply_collapsed)
|
||||
|
||||
def paintEvent(self, event):
|
||||
# QSS has no text-shadow, so paint by hand: the QSS background box
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from typing import cast
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -72,6 +73,50 @@ def test_main_window_init(qtbot, mock_ui_state, daq_status_factory):
|
||||
win.data_collection._emit_change_energy()
|
||||
assert sent and abs(sent[0] - 12400.0) < 1e-6
|
||||
|
||||
# Side panels collapse from the View menu (no on-screen buttons).
|
||||
# The clamp lets a narrow window shrink the camera region well below
|
||||
# the natural minimum the tab labels + controls rows would demand.
|
||||
assert 0 < win.video_tab.minimumWidth() < win.video_tab.minimumSizeHint().width()
|
||||
win._show_left_panel_action.trigger()
|
||||
assert win.collection_controls_scroll.isHidden()
|
||||
win._show_left_panel_action.trigger()
|
||||
assert not win.collection_controls_scroll.isHidden()
|
||||
win._show_right_panel_action.trigger()
|
||||
assert win.beamline_controls_scroll.isHidden()
|
||||
win._show_right_panel_action.trigger()
|
||||
assert not win.beamline_controls_scroll.isHidden()
|
||||
|
||||
# Alert routers land straight in the runtime notification dock (the
|
||||
# portrait-mode interceptors are gone with Playlist Mode).
|
||||
with patch.object(win, "_show_runtime_notification") as note:
|
||||
win._runtime_alert_primary("beam lost", True)
|
||||
win._runtime_alert_secondary("shutter", False)
|
||||
win._on_http_error("boom")
|
||||
win._on_sample_camera_error("no frames")
|
||||
assert note.call_count == 4
|
||||
|
||||
assert (
|
||||
win._detector_error_banner_text(automation=True, message="x")
|
||||
== "Automation halted: detector error."
|
||||
)
|
||||
assert (
|
||||
win._detector_error_banner_text(automation=False, message="x")
|
||||
== "Manual collection stopped: detector error."
|
||||
)
|
||||
|
||||
# Manual unmount respects the hutch PSS gate. cast: win.daq is a
|
||||
# patched-in MagicMock, but pyright sees the real DAQWorker type.
|
||||
unmount_mock = cast(MagicMock, win.daq.unmount)
|
||||
with (
|
||||
patch.object(win, "_hutch_blocks_mount", return_value="hutch open"),
|
||||
patch("aare.gui.main_window.QMessageBox"),
|
||||
):
|
||||
win._on_manual_unmount_requested()
|
||||
unmount_mock.assert_not_called()
|
||||
with patch.object(win, "_hutch_blocks_mount", return_value=None):
|
||||
win._on_manual_unmount_requested()
|
||||
unmount_mock.assert_called_once()
|
||||
|
||||
# Motion watch: only the robot station switches to the combined
|
||||
# beamline view. Moving no longer does (users kept losing the sample
|
||||
# camera on short gonio moves), and busy alone never does — Sample
|
||||
@@ -368,80 +413,6 @@ def test_idle_timeout_does_not_close_while_automation_active(qtbot, mock_ui_stat
|
||||
win.close.assert_not_called()
|
||||
|
||||
|
||||
def test_cleanup_returns_from_portrait_mode(qtbot, mock_ui_state):
|
||||
with (
|
||||
patch("requests.get"),
|
||||
patch("aare.gui.main_window.DAQWorker"),
|
||||
patch("aare.gui.main_window.PredictionSubscriber"),
|
||||
patch("aare.gui.main_window.VideoThread"),
|
||||
patch("aare.gui.main_window.JFJochDBusClient"),
|
||||
patch("aare.gui.main_window.jwt.decode") as mock_jwt,
|
||||
):
|
||||
mock_jwt.return_value = {
|
||||
"sub": "testuser",
|
||||
"staff": True,
|
||||
"pgroups": ["p123"],
|
||||
"session": 15,
|
||||
}
|
||||
fake_token = "header.payload.signature"
|
||||
|
||||
win = MainWindow(
|
||||
base_url=None,
|
||||
token=fake_token,
|
||||
default_image=None,
|
||||
zmq_addr=None,
|
||||
pred_zmq_addr=None,
|
||||
beamline_cam_addr=None,
|
||||
gonio_cam_addr=None,
|
||||
gonio_cam_id=None,
|
||||
)
|
||||
qtbot.addWidget(win)
|
||||
|
||||
win.enter_portrait_mode()
|
||||
assert win.content_stack.currentWidget() is win.portrait_mode_page
|
||||
|
||||
win.cleanup()
|
||||
|
||||
assert win.content_stack.currentWidget() is win._standard_main_page
|
||||
|
||||
|
||||
def test_cleanup_returns_from_compact_automation_view(qtbot, mock_ui_state):
|
||||
with (
|
||||
patch("requests.get"),
|
||||
patch("aare.gui.main_window.DAQWorker"),
|
||||
patch("aare.gui.main_window.PredictionSubscriber"),
|
||||
patch("aare.gui.main_window.VideoThread"),
|
||||
patch("aare.gui.main_window.JFJochDBusClient"),
|
||||
patch("aare.gui.main_window.jwt.decode") as mock_jwt,
|
||||
):
|
||||
mock_jwt.return_value = {
|
||||
"sub": "testuser",
|
||||
"staff": True,
|
||||
"pgroups": ["p123"],
|
||||
"session": 15,
|
||||
}
|
||||
fake_token = "header.payload.signature"
|
||||
|
||||
win = MainWindow(
|
||||
base_url=None,
|
||||
token=fake_token,
|
||||
default_image=None,
|
||||
zmq_addr=None,
|
||||
pred_zmq_addr=None,
|
||||
beamline_cam_addr=None,
|
||||
gonio_cam_addr=None,
|
||||
gonio_cam_id=None,
|
||||
)
|
||||
qtbot.addWidget(win)
|
||||
|
||||
win.enter_compact_automation_view()
|
||||
assert win.content_stack.currentWidget() is win.compact_automation_page
|
||||
|
||||
win.cleanup()
|
||||
|
||||
assert win.content_stack.currentWidget() is win._standard_main_page
|
||||
|
||||
|
||||
def _make_window(qtbot):
|
||||
win = MainWindow(
|
||||
base_url=None,
|
||||
@@ -525,20 +496,28 @@ def test_font_zoom_steps_clamps_and_resets(qtbot, mock_ui_state):
|
||||
try:
|
||||
base_pt = win._default_app_font.pointSizeF()
|
||||
win._change_font_zoom(1)
|
||||
assert styles.font_scale() == pytest.approx(1.1)
|
||||
assert styles.font_scale() == pytest.approx(1.25)
|
||||
# Part 2 of the zoom: the app default font scales with the ladder.
|
||||
app = QApplication.instance()
|
||||
assert isinstance(app, QApplication) # narrow from QCoreApplication|None
|
||||
assert app.font().pointSizeF() == pytest.approx(base_pt * 1.1)
|
||||
for _ in range(20):
|
||||
assert app.font().pointSizeF() == pytest.approx(base_pt * 1.25)
|
||||
# Part 3: the fixed-width side boxes follow the ladder, else the
|
||||
# zoomed text clips inside them.
|
||||
assert win.data_collection.minimumWidth() == round(win.data_collection.set_width * 1.25)
|
||||
assert win.collection_controls_scroll.maximumWidth() == (
|
||||
round(win.data_collection.set_width * 1.25) + 10
|
||||
)
|
||||
assert win.beamline.minimumWidth() == round(win.beamline.set_width * 1.25)
|
||||
for _ in range(5):
|
||||
win._change_font_zoom(1)
|
||||
assert styles.font_scale() == styles.FONT_SCALE_MAX
|
||||
assert styles.font_scale() == styles.FONT_SCALE_LADDER[-1]
|
||||
win._change_font_zoom(0)
|
||||
assert styles.font_scale() == 1.0
|
||||
assert app.font().pointSizeF() == pytest.approx(base_pt)
|
||||
for _ in range(20):
|
||||
assert win.data_collection.minimumWidth() == win.data_collection.set_width
|
||||
for _ in range(5):
|
||||
win._change_font_zoom(-1)
|
||||
assert styles.font_scale() == styles.FONT_SCALE_MIN
|
||||
assert styles.font_scale() == styles.FONT_SCALE_LADDER[0] # floor is 100%
|
||||
finally:
|
||||
styles.set_font_scale(1.0)
|
||||
if saved is None:
|
||||
@@ -672,15 +651,14 @@ def test_sample_camera_frame_paints_visible_views_and_acks(qtbot, mock_ui_state,
|
||||
}
|
||||
win = _make_window(qtbot)
|
||||
|
||||
views = (win.sample_camera, win.compact_sample_camera, win.portrait_sample_camera)
|
||||
# Nothing is shown in tests, so pretend every view is on screen to reach
|
||||
# Nothing is shown in tests, so pretend the view is on screen to reach
|
||||
# the paint branch.
|
||||
monkeypatch.setattr(type(win.sample_camera), "isVisible", lambda self: True)
|
||||
win.prediction_thread = MagicMock()
|
||||
|
||||
win._on_sample_camera_frame(QImage(4, 6, QImage.Format.Format_RGB888))
|
||||
|
||||
assert all(v.pixmap_item.pixmap().width() == 4 for v in views)
|
||||
assert win.sample_camera.pixmap_item.pixmap().width() == 4
|
||||
# The ack is what paces the subscriber; it must fire after every frame.
|
||||
win.prediction_thread.notify_frame_displayed.assert_called_once()
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ from PySide6.QtCore import QEvent, QPointF, Qt
|
||||
from PySide6.QtGui import QMouseEvent
|
||||
|
||||
from aare.gui.panels.fluorescence_panel import FluorescencePanel
|
||||
from aare.gui.panels.portrait_mode import PlayPauseButton
|
||||
from aare.gui.widgets.baton_request_dialog import BatonPendingDialog, BatonRequestDialog
|
||||
from aare.gui.widgets.value_label import ValueLabel
|
||||
from aare.gui.widgets.video_image import VideoGraphicsView
|
||||
@@ -86,14 +85,6 @@ def test_video_view_shortcuts_replace_keypress_override(qtbot):
|
||||
qtbot.keyClick(view, Qt.Key.Key_F) # fit_to_view: just must not raise
|
||||
|
||||
|
||||
def test_play_pause_button_paints_without_hover_overrides(qtbot):
|
||||
btn = PlayPauseButton()
|
||||
qtbot.addWidget(btn)
|
||||
btn.set_running(True)
|
||||
# grab() forces a real paintEvent pass over the underMouse() branch
|
||||
assert not btn.grab().isNull()
|
||||
|
||||
|
||||
def test_value_label_inherits_click(qtbot):
|
||||
label = ValueLabel("Energy", "keV")
|
||||
qtbot.addWidget(label)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""Status bar layout contract: passive readouts left, operable controls
|
||||
right, and every operable label wears the shared hover affordance."""
|
||||
"""Status bar layout contract: every readout lives in one wrapping flow
|
||||
host (narrow windows wrap instead of clipping), and every operable label
|
||||
wears the shared hover affordance."""
|
||||
|
||||
from aarecommon.models.models import TokenData
|
||||
from PySide6.QtCore import QEvent, QPointF, Qt
|
||||
from PySide6.QtGui import QEnterEvent
|
||||
|
||||
from aare.gui.widgets.flow_layout import FlowLayout
|
||||
from aare.gui.widgets.status_bar import StatusBar
|
||||
|
||||
|
||||
@@ -62,18 +64,34 @@ def test_state_menu_gates_beam_location_for_non_staff(qtbot, daq_status_factory,
|
||||
assert not _state_menu_entries(non_staff)["Beam location (admin mode only)"]
|
||||
|
||||
|
||||
def test_passives_left_operables_right(qtbot):
|
||||
def test_readouts_wrap_instead_of_clipping(qtbot):
|
||||
bar = _bar(qtbot)
|
||||
# QStatusBar hides only the non-permanent (left) section behind a
|
||||
# temporary message — probe the grouping through that behavior.
|
||||
bar.showMessage("probe")
|
||||
assert not bar.flux.isVisible()
|
||||
assert not bar.busy_label.isVisible()
|
||||
host = bar.info_host
|
||||
flow = host.layout()
|
||||
assert isinstance(flow, FlowLayout) # narrows Optional for the checker
|
||||
# Every readout is in the flow host, none clipped away by QStatusBar.
|
||||
for label in (
|
||||
bar.flux,
|
||||
bar.busy_label,
|
||||
bar.cryo_label,
|
||||
bar.shutter_label,
|
||||
bar.state_label,
|
||||
bar.pgroup_label,
|
||||
bar.session_label,
|
||||
):
|
||||
assert label.isVisible()
|
||||
assert label.parentWidget() is host
|
||||
# Narrow width -> the flow reports a taller (multi-row) height than one
|
||||
# row, and resizing the host pins its minimum height to the wrapped
|
||||
# height so the bar grows instead of cutting labels off.
|
||||
one_row = flow.heightForWidth(100_000)
|
||||
wrapped = flow.heightForWidth(200)
|
||||
assert wrapped > one_row
|
||||
host.resize(200, wrapped)
|
||||
assert host.minimumHeight() >= wrapped
|
||||
# Layout bookkeeping the canonical example demands.
|
||||
assert flow.count() > 0
|
||||
assert flow.itemAt(0) is not None
|
||||
assert flow.itemAt(9999) is None
|
||||
taken = flow.takeAt(flow.count() - 1)
|
||||
assert taken is not None
|
||||
assert flow.takeAt(9999) is None
|
||||
|
||||
@@ -73,3 +73,19 @@ def test_not_collapsible_by_default(qtbot):
|
||||
title = TitleLabel("Plain", panel)
|
||||
grid.addWidget(title, 0, 0)
|
||||
assert not hasattr(title, "toggle_button")
|
||||
|
||||
|
||||
def test_pending_collapse_timer_dies_with_the_banner():
|
||||
"""The deferred _apply_collapsed must not fire into a deleted banner —
|
||||
that was the historic "QPushButton returned NULL" / SystemError flake
|
||||
poisoning whichever test ran next (singleShot without receiver context
|
||||
outlives the widget)."""
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
_remove_key()
|
||||
host = QWidget()
|
||||
TitleLabel("DoomedBanner", host, collapsible=True, default_collapsed=True)
|
||||
# Python-owned host: del cascades the C++ delete to the banner while
|
||||
# its singleShot(0) is still pending.
|
||||
del host
|
||||
QApplication.processEvents() # must not raise into the event loop
|
||||
|
||||
@@ -11,7 +11,7 @@ overrides = [{ name = "opencv-python", marker = "sys_platform == 'nonexistent'"
|
||||
|
||||
[[package]]
|
||||
name = "aarecommon"
|
||||
version = "0.7.3"
|
||||
version = "0.9.1"
|
||||
source = { registry = "https://gitea.psi.ch/api/packages/mx/pypi/simple" }
|
||||
dependencies = [
|
||||
{ name = "jfjoch-client" },
|
||||
@@ -24,14 +24,14 @@ dependencies = [
|
||||
{ name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
|
||||
{ name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
|
||||
]
|
||||
sdist = { url = "https://gitea.psi.ch/api/packages/mx/pypi/files/aarecommon/0.7.3/aarecommon-0.7.3.tar.gz", hash = "sha256:e85ddf9d3b740ca7536597b26cf839b159e96efc68845a6efb667a7dca5c4e0f" }
|
||||
sdist = { url = "https://gitea.psi.ch/api/packages/mx/pypi/files/aarecommon/0.9.1/aarecommon-0.9.1.tar.gz", hash = "sha256:64bb008516e1d08851aa7febaaafacb56fd5285f773275c9fc4da17a2a660fb8" }
|
||||
wheels = [
|
||||
{ url = "https://gitea.psi.ch/api/packages/mx/pypi/files/aarecommon/0.7.3/aarecommon-0.7.3-py3-none-any.whl", hash = "sha256:22e8c87e2800cbe9223341b43b448f4d0a4f2c199e59c27d17eed2a4b2e11531" },
|
||||
{ url = "https://gitea.psi.ch/api/packages/mx/pypi/files/aarecommon/0.9.1/aarecommon-0.9.1-py3-none-any.whl", hash = "sha256:0b052077236d68b2dbdd4e4dcdd28ed5a180ed1c5f4e0e92390c14d8337ec463" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aaredaq"
|
||||
version = "0.22.1"
|
||||
version = "0.32.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aarecommon" },
|
||||
@@ -41,7 +41,6 @@ dependencies = [
|
||||
{ name = "bec-ipython-client" },
|
||||
{ name = "bec-lib" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "gunicorn" },
|
||||
{ name = "httpx" },
|
||||
{ name = "jfjoch-client" },
|
||||
{ name = "matplotlib" },
|
||||
@@ -54,7 +53,6 @@ dependencies = [
|
||||
{ name = "pyjwt" },
|
||||
{ name = "pyside6" },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "python-redis-lock" },
|
||||
{ name = "pyzmq" },
|
||||
{ name = "redis" },
|
||||
{ name = "requests" },
|
||||
@@ -71,6 +69,7 @@ docs = [
|
||||
test = [
|
||||
{ name = "basedpyright" },
|
||||
{ name = "diff-cover" },
|
||||
{ name = "fakeredis", extra = ["lua"] },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "pytest-cov" },
|
||||
@@ -82,18 +81,18 @@ test = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "aarecommon", specifier = ">=0.7.3" },
|
||||
{ name = "aarecommon", specifier = ">=0.9.0" },
|
||||
{ name = "aaredb", specifier = ">=0.83.1", index = "https://gitea.psi.ch/api/packages/mx/pypi/simple" },
|
||||
{ name = "aarelcinfer-client", specifier = "==0.1.1a8" },
|
||||
{ name = "aarescan-client", specifier = "==1.0.0rc5" },
|
||||
{ name = "aarescan-client", specifier = ">=1.0.0rc15" },
|
||||
{ name = "basedpyright", marker = "extra == 'test'" },
|
||||
{ name = "bec-ipython-client", specifier = ">=3.130.3" },
|
||||
{ name = "bec-lib", specifier = ">=3.130.3" },
|
||||
{ name = "diff-cover", marker = "extra == 'test'" },
|
||||
{ name = "fakeredis", extras = ["lua"], marker = "extra == 'test'" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "gunicorn" },
|
||||
{ name = "httpx", specifier = ">=0.28.1" },
|
||||
{ name = "jfjoch-client", specifier = ">=1.0.0rc165" },
|
||||
{ name = "jfjoch-client", specifier = ">=1.0.0rc166" },
|
||||
{ name = "matplotlib", specifier = ">=3.10.3" },
|
||||
{ name = "numpy" },
|
||||
{ name = "opencv-python-headless" },
|
||||
@@ -101,7 +100,7 @@ requires-dist = [
|
||||
{ name = "pydantic", specifier = ">=2.11" },
|
||||
{ name = "pyepics", specifier = "~=3.5" },
|
||||
{ name = "pyjwt" },
|
||||
{ name = "pyside6", specifier = "==6.9.0" },
|
||||
{ name = "pyside6", specifier = "<6.10" },
|
||||
{ name = "pytest", marker = "extra == 'test'" },
|
||||
{ name = "pytest-asyncio", marker = "extra == 'test'" },
|
||||
{ name = "pytest-cov", marker = "extra == 'test'" },
|
||||
@@ -109,7 +108,6 @@ requires-dist = [
|
||||
{ name = "pytest-qt", marker = "extra == 'test'" },
|
||||
{ name = "pytest-timeout", marker = "extra == 'test'" },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "python-redis-lock" },
|
||||
{ name = "pyzmq" },
|
||||
{ name = "redis" },
|
||||
{ name = "requests" },
|
||||
@@ -152,7 +150,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "aarescan-client"
|
||||
version = "1.0.0rc5"
|
||||
version = "1.0.0rc15"
|
||||
source = { registry = "https://gitea.psi.ch/api/packages/mx/pypi/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
@@ -160,9 +158,9 @@ dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://gitea.psi.ch/api/packages/mx/pypi/files/aarescan-client/1.0.0rc5/aarescan_client-1.0.0rc5.tar.gz", hash = "sha256:b31ec4a4809cf48828a498d45080a053270802c142c9a584b06d5467265fb3d6" }
|
||||
sdist = { url = "https://gitea.psi.ch/api/packages/mx/pypi/files/aarescan-client/1.0.0rc15/aarescan_client-1.0.0rc15.tar.gz", hash = "sha256:2e578da285415880dfe4adfe633493b5f85037b4837031576b544a8a4f64e615" }
|
||||
wheels = [
|
||||
{ url = "https://gitea.psi.ch/api/packages/mx/pypi/files/aarescan-client/1.0.0rc5/aarescan_client-1.0.0rc5-py3-none-any.whl", hash = "sha256:571f0863b321962fda12943198a4b269aced818548c276006e7b1d553b043c49" },
|
||||
{ url = "https://gitea.psi.ch/api/packages/mx/pypi/files/aarescan-client/1.0.0rc15/aarescan_client-1.0.0rc15-py3-none-any.whl", hash = "sha256:1aa42cec49f850203cf7141d895b9ef8f496c6f56dc71b0223ad8cd920ae35a3" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -817,6 +815,24 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fakeredis"
|
||||
version = "2.38.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "redis" },
|
||||
{ name = "sortedcontainers" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bf/6b/ad7db311fea3a62f7e359720cc8c064f06f8f0006e65272f50d3a23d37f6/fakeredis-2.38.0.tar.gz", hash = "sha256:d2abfd24652f86501044499bf08c9d639db050f695eefc06b8b8b6f0bb24dbd6", size = 271116, upload-time = "2026-09-08T21:15:04.806Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/8e/9a80a8808e8a723aacb3a5d9df07890b46b363a056b372d3cd2e655259fe/fakeredis-2.38.0-py3-none-any.whl", hash = "sha256:d9fb0518c4eaa35f1f2c94df6b4a4c97ff3ca9f43d6cc8112317a9037d244301", size = 167360, upload-time = "2026-09-08T21:15:03.219Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
lua = [
|
||||
{ name = "lupa" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.141.1"
|
||||
@@ -929,18 +945,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/be/af012eda9507494f28b99b077423806c43a11573eb6225dd46f19ae2d263/fpdf2-2.8.8-py3-none-any.whl", hash = "sha256:3557a478fc577a929c94aace9666aed4dcc432b5ab6764232e6a59f1ccd75f17", size = 337000, upload-time = "2026-08-09T23:32:43.728Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gunicorn"
|
||||
version = "26.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "packaging" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6d/b7/a4a3f632f823e432ce6bc65f62961b7980c898c77f075a2f7118cb3846fe/gunicorn-26.0.0.tar.gz", hash = "sha256:ca9346f85e3a4aeeb64d491045c16b9a35647abd37ea15efe53080eb8b090baf", size = 727286, upload-time = "2026-05-05T06:38:25.529Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/40/9c2384fc2be4ad25dd4a49decd5ad9ea5a3639814c11bd40ab77cb9f0a14/gunicorn-26.0.0-py3-none-any.whl", hash = "sha256:40233d26a5f0d1872916188c276e21641155111c2853f0c2cd55260aec0d24fc", size = 212009, upload-time = "2026-05-05T06:38:23.007Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
@@ -1362,6 +1366,65 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/24/4f/ea47579b38165bab7985d666ec49181883599399266ad42ff14e999ac493/Louie-2.0.1-py3-none-any.whl", hash = "sha256:4c6f385bb5084285eab72bf6d5af9576a4e8d37af34e61bd01bcb18dea02c1db", size = 17889, upload-time = "2023-07-22T07:07:53.424Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lupa"
|
||||
version = "2.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/a6/0f869fbb07c393f15473b1eefefb7b5bec162fb7481803d040ed4dc46002/lupa-2.8.tar.gz", hash = "sha256:d8022641b9ec8ecf2c5ecbe9f47e5a70e0b87c4b5ae921b92cb02a638e0acd08", size = 6156370, upload-time = "2026-04-15T20:08:30.534Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/09/21/9be4516ddd22f8eadba336d9ba065d17d79108465ae1b7f71424ab99b9d0/lupa-2.8-cp310-abi3-win32.whl", hash = "sha256:c2a5fd15dc62374e1661a55f01744c9ec1c56f291ba4a0749d3af2174556e78f", size = 1594887, upload-time = "2026-04-15T20:05:23.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/99/1557c9685d7034d9ce8dd2b54c40a26d6deb7c67c1fdb5c801abd1a02c3f/lupa-2.8-cp310-abi3-win_arm64.whl", hash = "sha256:9e304fb1c50cf23fd8882afbe1aa87525ef8a72667bcab3b37b2bbb2bc542269", size = 1371742, upload-time = "2026-04-15T20:05:27.417Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/0a/5a740717f27aa77481e6a61b97cf79d1e0c1ede729b1268caacded915326/lupa-2.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b12e43c1fb787189dfc28cd604aef0baa2cb95e27da19498d520361d0ace070a", size = 1202376, upload-time = "2026-04-15T20:05:44.049Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/75/6b64d0098c64275a801896cb7a6a30e7e653d25fa102c64e747292afcdbb/lupa-2.8-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f6f603391dffb256e36a79fd2044084d5f4b8a0a4c0e5ad291cd3ab3aaf1fd0a", size = 1839271, upload-time = "2026-04-15T20:05:47.399Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/2f/0d4f00563046ff616ef6a421f8b776a5ffb327f7b32ed69e856d52b917a8/lupa-2.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f6f41c91366e7d0d474f87d81c1274af861f40812bf729c9f97ab4c8f3c7ac8", size = 2376251, upload-time = "2026-04-15T20:05:49.891Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/8e/caa83237f427d9e85b7f02c816e7270c9c9571dec1673e06b0180402f70e/lupa-2.8-cp311-cp311-win_amd64.whl", hash = "sha256:f5a6af145b0ea818f01d27bfe2583a4b538570bef61d22c8773e0eccf011234c", size = 1923488, upload-time = "2026-04-15T20:05:52.954Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/0b/368f2f0bc750b25c69d4563e44f677925ab5dd3d2887f9b0c15465d21a2a/lupa-2.8-cp312-abi3-macosx_10_13_x86_64.whl", hash = "sha256:f4342f4de76ae7ce2ab0672d36003bdb7e1a33252f293b569298ddd792e70e33", size = 1194056, upload-time = "2026-04-15T20:05:55.794Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/0f/c89eb8dd36fdea4e50ae3f7f5275bea3b0cc5d4057b8ee7b3bbc78010422/lupa-2.8-cp312-abi3-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:4203fa1659315e939a5304e75001b8cc14234fb3cbb3ed86c049b0cc5d90fcee", size = 1434278, upload-time = "2026-04-15T20:05:57.94Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/30/c3b4d2cd8733621b404b8a4214e5f852955c4ba632546dc84123bea9ee89/lupa-2.8-cp312-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:81f2d843ce668b653146c007467570210ae44be51dac6926666c51d49536f307", size = 1150068, upload-time = "2026-04-15T20:06:01.04Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/d2/bac12c398519efafc6af84be1974edd0d7a4895fb4735b5c8d615d298595/lupa-2.8-cp312-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d3d0cde2c77588d1c60875a4f34f059513476c6e1775351897195b51e0f3df08", size = 1409532, upload-time = "2026-04-15T20:06:03.592Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/6a/18b52e11962014026e07813530b0b108ee8bc0a2a13ef0eaea5d41dce023/lupa-2.8-cp312-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9e0d11b8f3a8dac6413f704fef7161d048bb10c58bdac6cbffa5e60efa56e9a3", size = 1242687, upload-time = "2026-04-15T20:06:06.863Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/8e/7fd4eb049875f61429b96780d2eae4700f0e78fe0a52db8edb231b1cd09f/lupa-2.8-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:54cff414f21f8cd8c6be4aae52541f3b9cd39602b59e3a3db9b5c9f9f674ff18", size = 1856038, upload-time = "2026-04-15T20:06:09.358Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/f9/37ad9d2773d30f2931890d310a4bdce28d45484206e6f48bc18b0325eabd/lupa-2.8-cp312-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:24b4d8af5558e549b70daf1547f5c1c1d664ecea9fc790f83efe5d75e9a93797", size = 1128982, upload-time = "2026-04-15T20:06:12.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/31/c0fd7984c24844ea79caa45c0235f61a06b38fd69a839f6c62770f8d684a/lupa-2.8-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:ce86dff1ee7f7cf45f5622065ae991949dd7bb1703581cbc58a630137bb7ccf9", size = 1457594, upload-time = "2026-04-15T20:06:15.881Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/f5/a28e411be30ec1bf0db1eb0c087eebc73be9e7a1adcfe6ac209861ccc446/lupa-2.8-cp312-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:f4d01b2a08c70bbb883a9e082b6b36b89121ed5910b710f1ba11c73295ff4fba", size = 1425721, upload-time = "2026-04-15T20:06:18.009Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/c1/359f767c4ae024be30d909fe8a9f0e9af266bad47ce2bd2ed248fb986fcf/lupa-2.8-cp312-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:7f210d5a8353e510ea1199c42cf3cbdd630553bf2bc8fb4c00fea06fdec7c798", size = 1253258, upload-time = "2026-04-15T20:06:21.17Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/52/473f11790c261fd02bbf318a546fe040e9ec9f677181272fa78d3b4112a4/lupa-2.8-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4f81a02806e7c7ad26d8c6fa222c8bef1b0c1b124347c879be880b41339d41e4", size = 2395272, upload-time = "2026-04-15T20:06:24.137Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/bf/75c8795655a8836eab6a11a630352c4b7c5dc5c54d075077bc9bffdeee45/lupa-2.8-cp312-abi3-win32.whl", hash = "sha256:360056453a7a4eaa4ac5a204c31a5a014b1eb2ee5490603234d2ba831684f1f2", size = 1606136, upload-time = "2026-04-15T20:06:27.815Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/29/11a2cdd612b6f55e506292dfb6ba343216e80a693e7fe3f876ef204ce9c6/lupa-2.8-cp312-abi3-win_arm64.whl", hash = "sha256:1628371c6592a6d5650497a9e31fb2bb3a7e9883c1f301d1111265e484045af9", size = 1364495, upload-time = "2026-04-15T20:06:30.254Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/17/fa834b6b09ad17e7df5d0f7715d64877a125a3776ada689751a1f9dc2959/lupa-2.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:450650f91c48c2415b0d59ab3abfcfda3b6efb5b858205f4d4bda8ad141fa529", size = 1190111, upload-time = "2026-04-15T20:06:32.84Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/43/45589901b7d1a0e3a9d91d19a311fb6a56924e8571536c3f2212160fd953/lupa-2.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:27044f3363047f946b3d3aab9157cbd172b3538ada9ec1baef43432bf7d03a78", size = 1812999, upload-time = "2026-04-15T20:06:35.664Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/ac/4ade7d15ff5c61758d7943ac6f0a496bf1cc65b6c09f842b52a0702e664c/lupa-2.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8cf4f064a0e5531afce2d7d750120c10c10f9529139af6ca6150d13151034398", size = 2368731, upload-time = "2026-04-15T20:06:37.959Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/27/05f950d15b8ab120b39c43588b438ff3ace70c1b1b0225a960393a497483/lupa-2.8-cp312-cp312-win_amd64.whl", hash = "sha256:281bedc5deb92d31e649a3552edd662449365a635904fa4d5cb4509c7245e34e", size = 1941809, upload-time = "2026-04-15T20:06:40.302Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/3f/19f83c3a0c84dc8bea8a58e7416dca6a3ede662c33c8d1ec758e5afc754a/lupa-2.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45fc9da0145ecb0083ef5ff9975116cc784bd0258bdc2bd131ba15483ce18398", size = 1201203, upload-time = "2026-04-15T20:06:42.169Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/0f/a14f0073f09610158038582e230618a48c14da6bd88185289461aa4cb854/lupa-2.8-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:58e18afed57955b41130e269c78f53d4123ab86e236b53816f4cbffa25cb5d30", size = 1806210, upload-time = "2026-04-15T20:06:45.486Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/14/48fff156c63a136001a7620878af7d31aa07e66b495ed621e3eddd73c294/lupa-2.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc47f536ac13a79cef47d29a2b205576a22841f042a2bcec1676b95806e7706a", size = 2359005, upload-time = "2026-04-15T20:06:47.819Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/18/3ac638ec90edf178242b8a2b2f00f8adae694248c03a26341ef941bb746e/lupa-2.8-cp313-cp313-win_amd64.whl", hash = "sha256:ce9404c661dbac65cc9bed351ad45e797af93d30d70be309a3fa8209ac86d93b", size = 1936754, upload-time = "2026-04-15T20:06:50.448Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/ef/5ee5fed6ea7459a671196359ce04bfeeaf26be1dac8ff24bf28e5c7a6e81/lupa-2.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:348c3f8ecabb6324dcbc05c2740d762ef8fcec7b06c79e45262ab97a217684e3", size = 1209388, upload-time = "2026-04-15T20:06:53.022Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/b1/67a940d5542cb0384b443fe951b5a83ea9340d1333a733a258fdd1c619ba/lupa-2.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:951496471056061598a7d1729a6cdf48d662fec777a9f2d8aa5a1e62fd30e5a5", size = 1826821, upload-time = "2026-04-15T20:06:55.699Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/a2/b354e5ba3b911ec50686003dc8897e892b9e8c5c036b33219b03d54c4daf/lupa-2.8-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a591b9947ca347b41a63370e121d6e2b1458fe6dde9ae065029ec10a37f25ff4", size = 2366893, upload-time = "2026-04-15T20:06:58.9Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/52/d76066401f29539df5352f70ecded66576f32933b6045cd0bfc56cb770b9/lupa-2.8-cp314-cp314-win_amd64.whl", hash = "sha256:3903c9cf628dae2f56405503247b77a61a3a61bd2dda470e336950c74776d55d", size = 1994716, upload-time = "2026-04-15T20:07:19.194Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/bd/3efc437a4361c16d25e66478c50357c9a8e8ecfb718fe749eb9ca3176ef6/lupa-2.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f711a8ab0486b9ac6fdda94a22ddcfbc9f0d4a27e3a8cf1bf79c6e48b33017c1", size = 1251217, upload-time = "2026-04-15T20:07:01.64Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/f4/2e9f8ecbaca854bfdf14af8a9b505ec0cbc640377b3b218921594b7563cd/lupa-2.8-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc51250e76367a3e27fcd01dc769b9bfcbbc34f48df48dde53d6af6e75b7eaa5", size = 1814701, upload-time = "2026-04-15T20:07:04.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/53/4000b1acaa8b1f3827fcff0cfcdff44d3befddda42cab7e685a49689b5a1/lupa-2.8-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f8a22088a552828958603323f0a5c4b3e11e03b75d0bf4c965ef879de9b60a8d", size = 2348414, upload-time = "2026-04-15T20:07:07.285Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/78/26ee48d3890cddf03cefb65f433e3492759c0b3c0582180755bddbaab7bd/lupa-2.8-cp314-cp314t-win32.whl", hash = "sha256:4f7c553c1d8cfffbe85d81daef730d12cae4b6002d457542914da0ac8a1145b3", size = 1831611, upload-time = "2026-04-15T20:07:09.752Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/d1/4a5cc64a3cad22821ae4c3f7a90456a08ca19457d8354f4abf46ad03c7e8/lupa-2.8-cp314-cp314t-win_amd64.whl", hash = "sha256:d8766aff03a78c80ad2d188a8bdb216de5ec838359cd87e05bbdfa56394a6105", size = 2209250, upload-time = "2026-04-15T20:07:11.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/7c/cdcb654daf668192aaf36b0aeb94f2281dad092aaa5003688691131736ea/lupa-2.8-cp314-cp314t-win_arm64.whl", hash = "sha256:91d622777febda3ab1bed1d45295f2f32a4680c7b3d7caf8c669998ed5c44118", size = 1126735, upload-time = "2026-04-15T20:07:15.434Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/44/de1961ad38e17cd326a53c246c7e3b91178ed578f4cf22ffcd5e7e11b041/lupa-2.8-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b036738282a5acd2e71fdddb317c9df8b87c1673aa57f403d05fcc2be8abc4ba", size = 1186020, upload-time = "2026-04-15T20:07:35.017Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/c2/276f0b9dc8bcc5a8a58af5316dfa0e6f56be3613dd6dbcc8d3d2cb6559ba/lupa-2.8-cp39-abi3-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:ac6b6e8d0e617e26a98cbb44880bcd75de5d32b3ad7b3b3793583909292b47ed", size = 1468944, upload-time = "2026-04-15T20:07:37.782Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/38/52934e52a5180dc6425d20284d004fe4b27a4f9171a82dc99fb67af250bf/lupa-2.8-cp39-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ba3a7dd839f90c3d2e53bebe3c192b1f3f9fd720a6781256405123211fd0dce6", size = 1172998, upload-time = "2026-04-15T20:07:40.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/82/76b3809bd0839d9b3b4ec58d06591e08f17337b6d9576877cb9d48b34e94/lupa-2.8-cp39-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d7edb13a7a5250b5c6c22d1495d9e842b5c9fc5081c8fe6b5efe2112fe3e41f9", size = 1449975, upload-time = "2026-04-15T20:07:44.262Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/07/2f89d54f747c67c23b4b9ae4aa8c8dd06bb409155dedcf406157f2736b66/lupa-2.8-cp39-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:891f72e0bffbed1e4175f975aeb2a083956586a100066525e1be485f617f7b25", size = 1281944, upload-time = "2026-04-15T20:07:46.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/bd/7375d2b0fcae79d806baf52a76f26c96964593f58e1372d13ae5ac09c676/lupa-2.8-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a295f87b5b7ebbfd5191932e8cb0e51df3c7769101ac6b6c7d7c9fb27bfd1307", size = 1910455, upload-time = "2026-04-15T20:07:49.75Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/0c/8abb3bc0e08b311fc01db05b6e9f9ff31a8f65e4fc3f0aeb05cfef75c8ac/lupa-2.8-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:4fe5d7a810b64ea8511eb885fc8cdde042ee5ff7b7d08ae78f32449756acb177", size = 1155548, upload-time = "2026-04-15T20:07:52.657Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/2e/9eeecd3f493099721c1d3f31beeca23a4237db1a54223684df4dc96aa1bd/lupa-2.8-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:bfc470012ef66ad064c7bd77416af03a3452ef630b04b9012595ea13f2e54518", size = 1489232, upload-time = "2026-04-15T20:07:54.92Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/13/731c99dc2e7652ae818a6de45bdf0142049f7cb566049061c898355f1891/lupa-2.8-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:250e035fdaffe8c87093e3ebc206ac29a26131b1568ea711d780c26001ce96e7", size = 1466321, upload-time = "2026-04-15T20:07:57.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/71/3ad8cc4fc05a77dc0d3f7079348bd1cad4675a0d14c24f8e6a3ce5f008f7/lupa-2.8-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:b9bddb09acfffb4f828f790f444b11dc0cca591afea1a244d9329eea2d20c003", size = 1288577, upload-time = "2026-04-15T20:07:59.913Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/b2/1175f6d0aa7b68627fbe2f58bd1e8bea36a89d10dfd67671d2b024c96162/lupa-2.8-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2e64acbbd47e9b82a64405a39e0d2b36a5a7dad8ab41c0f3437f572f7d282ba3", size = 2444866, upload-time = "2026-04-15T20:08:02.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/f7/e78df680c7a0ea452daac07467ca188d63c2c00ca1c884c0a50e27eb83b5/lupa-2.8-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32e4e5103bbddcdd2458fb2ccae6c8ba11c9997c711d7e379e0d45551d109c76", size = 1778509, upload-time = "2026-04-15T20:08:21.784Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/23/0e53cabb16b2a8aa9cf1fde499c097d8942c5dab709fc8e921f3b824b18b/lupa-2.8-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7667001804657496dee9feced2daae5000b4604a3218dd8e6b7b754982ba88b8", size = 2300480, upload-time = "2026-04-15T20:08:24.394Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/85/0271227eab939921a12ebba5d17aa4cd18346aa534ca7f5da09cd0b63dd4/lupa-2.8-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:86f6f668966965b15247dc32d064cfe7be67b71e584ccfacbe2f637575296878", size = 1847445, upload-time = "2026-04-15T20:08:27.031Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markdown"
|
||||
version = "3.10.3"
|
||||
@@ -2379,18 +2442,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-redis-lock"
|
||||
version = "4.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "redis" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/49/a3/1c5872ce7dafef9b4f31fbbf54938d9be632e3b7d53146b0eac26150ec97/python_redis_lock-4.0.1.tar.gz", hash = "sha256:57995e13dce196301138d710d4f132d667c200cd814718e081bf299d6cf11d70", size = 164986, upload-time = "2026-04-08T15:41:18.551Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/b5/d2bb0d46b31620753ba2c976f97fb24d7e0f2fb1390a911f632d43fe1c1b/python_redis_lock-4.0.1-py3-none-any.whl", hash = "sha256:1d39f579ff3f96d7c14e523680951aa60eb4d397f4ad1bc72a3440b7460d18d5", size = 12358, upload-time = "2026-04-08T15:41:16.822Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-slugify"
|
||||
version = "8.0.4"
|
||||
@@ -2742,6 +2793,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sortedcontainers"
|
||||
version = "2.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "soupsieve"
|
||||
version = "2.9.2"
|
||||
|
||||
Reference in New Issue
Block a user