diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 6cf003b3..efe0318d 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -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: diff --git a/pyproject.toml b/pyproject.toml index a5ca8f0a..16fd6963 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/src/aare/gui/graphics/resize_grip_h_idle_dark.png b/src/aare/gui/graphics/resize_grip_h_idle_dark.png new file mode 100644 index 00000000..d3e9696d Binary files /dev/null and b/src/aare/gui/graphics/resize_grip_h_idle_dark.png differ diff --git a/src/aare/gui/graphics/resize_grip_h_idle_light.png b/src/aare/gui/graphics/resize_grip_h_idle_light.png new file mode 100644 index 00000000..ed9bffc9 Binary files /dev/null and b/src/aare/gui/graphics/resize_grip_h_idle_light.png differ diff --git a/src/aare/gui/graphics/resize_grip_v_idle_dark.png b/src/aare/gui/graphics/resize_grip_v_idle_dark.png new file mode 100644 index 00000000..3f125af8 Binary files /dev/null and b/src/aare/gui/graphics/resize_grip_v_idle_dark.png differ diff --git a/src/aare/gui/graphics/resize_grip_v_idle_light.png b/src/aare/gui/graphics/resize_grip_v_idle_light.png new file mode 100644 index 00000000..ddef85aa Binary files /dev/null and b/src/aare/gui/graphics/resize_grip_v_idle_light.png differ diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index 46423584..21e12fc4 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -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(): diff --git a/src/aare/gui/panels/compact_automation_panel.py b/src/aare/gui/panels/compact_automation_panel.py deleted file mode 100644 index cd48421b..00000000 --- a/src/aare/gui/panels/compact_automation_panel.py +++ /dev/null @@ -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()})" diff --git a/src/aare/gui/panels/portrait_mode.py b/src/aare/gui/panels/portrait_mode.py deleted file mode 100644 index 50db0af5..00000000 --- a/src/aare/gui/panels/portrait_mode.py +++ /dev/null @@ -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) diff --git a/src/aare/gui/panels/tell_sample_panel.py b/src/aare/gui/panels/tell_sample_panel.py index 1fcfcbba..cb20127d 100644 --- a/src/aare/gui/panels/tell_sample_panel.py +++ b/src/aare/gui/panels/tell_sample_panel.py @@ -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")) ) diff --git a/src/aare/gui/styles.py b/src/aare/gui/styles.py index 719a0d2e..d2a64dbb 100644 --- a/src/aare/gui/styles.py +++ b/src/aare/gui/styles.py @@ -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. diff --git a/src/aare/gui/widgets/alert_banner.py b/src/aare/gui/widgets/alert_banner.py index 5fb4c5b0..2198f178 100644 --- a/src/aare/gui/widgets/alert_banner.py +++ b/src/aare/gui/widgets/alert_banner.py @@ -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 diff --git a/src/aare/gui/widgets/automation_progress.py b/src/aare/gui/widgets/automation_progress.py deleted file mode 100644 index af5ccfda..00000000 --- a/src/aare/gui/widgets/automation_progress.py +++ /dev/null @@ -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"
" - f"
{icon}
" - f"
{title}
" - f"
" - ) - - @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"Status: " - f"{state_text}" - f"    Now: {current_sample}" - f"    Queue: {self._queue_count}" - f"    ETA: {self._format_duration(queue_remaining)}" - f"    Done: {self._format_eta(eta)}" - ) - - 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))) diff --git a/src/aare/gui/widgets/flow_layout.py b/src/aare/gui/widgets/flow_layout.py new file mode 100644 index 00000000..20378222 --- /dev/null +++ b/src/aare/gui/widgets/flow_layout.py @@ -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())) diff --git a/src/aare/gui/widgets/status_bar.py b/src/aare/gui/widgets/status_bar.py index 6d939345..4b31e3f8 100644 --- a/src/aare/gui/widgets/status_bar.py +++ b/src/aare/gui/widgets/status_bar.py @@ -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 diff --git a/src/aare/gui/widgets/title_label.py b/src/aare/gui/widgets/title_label.py index 6c5d5c28..0abc91fa 100644 --- a/src/aare/gui/widgets/title_label.py +++ b/src/aare/gui/widgets/title_label.py @@ -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 diff --git a/tests/unit/gui/test_main_window.py b/tests/unit/gui/test_main_window.py index 380424cc..36b718f3 100644 --- a/tests/unit/gui/test_main_window.py +++ b/tests/unit/gui/test_main_window.py @@ -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() diff --git a/tests/unit/gui/test_qt_override_reduction.py b/tests/unit/gui/test_qt_override_reduction.py index 83934a53..99231ec8 100644 --- a/tests/unit/gui/test_qt_override_reduction.py +++ b/tests/unit/gui/test_qt_override_reduction.py @@ -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) diff --git a/tests/unit/gui/test_status_bar.py b/tests/unit/gui/test_status_bar.py index 1f5bcbde..dc4f4e7f 100644 --- a/tests/unit/gui/test_status_bar.py +++ b/tests/unit/gui/test_status_bar.py @@ -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 diff --git a/tests/unit/gui/test_title_label.py b/tests/unit/gui/test_title_label.py index 168dc31d..daf48ecf 100644 --- a/tests/unit/gui/test_title_label.py +++ b/tests/unit/gui/test_title_label.py @@ -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 diff --git a/uv.lock b/uv.lock index 5e34f601..d754e769 100644 --- a/uv.lock +++ b/uv.lock @@ -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"