resizable camera region, menu panel collapse, zoom ladder #231

Merged
duan_j merged 12 commits from feat/gui-panel-resize into main 2026-09-16 16:16:48 +02:00
21 changed files with 484 additions and 2087 deletions
+4
View File
@@ -102,6 +102,10 @@ jobs:
git clone https://gitea.psi.ch/bec/${{ matrix.plugin_repo }}.git
uv pip install -e ./mx_bec
uv pip install -e ./${{ matrix.plugin_repo }}
# The bec plugin deps drag PySide6 up to >=6.10, which segfaults
# the unit suite in teardown (see the pyproject pin). Force the
# project's capped version back after the plugin install.
uv pip install "pyside6<6.10"
- name: Run Pytest
env:
+4 -1
View File
@@ -13,7 +13,10 @@ dependencies = [
"pyJWT",
"pyzmq",
"opencv-python-headless",
"PySide6",
# <6.10: 6.11.2 (what the unpinned CI resolves) segfaults the unit suite
# in teardown on ~half the runs — on plain main too, not this branch.
# Drop the cap once the teardown crash is hunted down.
"PySide6<6.10",
"requests",
"pyepics~=3.5",
"redis",
Binary file not shown.

After

Width:  |  Height:  |  Size: 148 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 B

+91 -415
View File
@@ -32,7 +32,6 @@ from PySide6.QtGui import (
QColor,
QCursor,
QFont,
QGuiApplication,
QImage,
QKeySequence,
QPalette,
@@ -55,7 +54,6 @@ from PySide6.QtWidgets import (
QScrollArea,
QSizePolicy,
QSlider,
QStackedWidget,
QTabWidget,
QToolBar,
QToolTip,
@@ -74,7 +72,6 @@ from aare.gui.panels.axis_video_panel import AxisVideoPanel
from aare.gui.panels.beamline_controls import BeamConfigPanel, BeamlineControls
from aare.gui.panels.beamline_recovery_panel import BeamlineRecoveryDialog
from aare.gui.panels.beamline_state_panel import BeamlineStatePanel
from aare.gui.panels.compact_automation_panel import CompactAutomationPanel
from aare.gui.panels.data_collection_settings import DataCollectionSettings
from aare.gui.panels.developer_help_dialog import DeveloperHelpDialog
from aare.gui.panels.face_detection_panel import FaceDetectionPanel
@@ -84,7 +81,6 @@ from aare.gui.panels.local_contact_panel import LocalContactDialog
# panels
from aare.gui.panels.log_panel import LogPanel
from aare.gui.panels.monochromator_panel import MonochromatorPanel
from aare.gui.panels.portrait_mode import PortraitModePanel
from aare.gui.panels.prediction_metrics_panel import PredictionMetricsPanel
from aare.gui.panels.reference_tools_panel import ReferenceToolsPanel
from aare.gui.panels.samcam_panel import SamcamPanel
@@ -101,7 +97,7 @@ from aare.gui.styles import (
APP_BACKGROUND,
DARK_TEXT,
DOCK_CONTENT_LEFT_PAD,
FONT_SCALE_STEP,
FONT_SCALE_LADDER,
SEPARATOR_HINT_DELAY_MS,
THEME_BLUEBIRD,
THEME_FADE_MS,
@@ -218,12 +214,6 @@ class MainWindow(QMainWindow):
self._local_contact_dialog = None
self._controls_help_dialog = None
self._default_window_state = None
self._pre_automation_window_state = None
self._pre_automation_left_column_visible = True
self._pre_automation_right_column_visible = True
self._in_compact_automation_view = False
self._enter_automation_view_action = None
self._return_main_view_action = None
self._beamline_cam_addr = beamline_cam_addr
self._gonio_cam_addr = gonio_cam_addr
@@ -312,9 +302,6 @@ class MainWindow(QMainWindow):
self.alert_banner_secondary = AlertBanner(parent=root_widget)
self.alert_banner_secondary.float_over(root_widget)
self.content_stack = QStackedWidget(parent=root_widget)
root_layout.addWidget(self.content_stack, 1)
top_widget = QWidget(parent=root_widget)
top_widget.setObjectName("standardMainPage")
top_widget_layout = QHBoxLayout(top_widget)
@@ -460,6 +447,9 @@ class MainWindow(QMainWindow):
self.left_column_layout.addWidget(self.left_column_tabs)
self.left_column_layout.addStretch()
# Plain HBox, no splitter: drag-resizing the side columns was tried
# and rejected (user: the panels don't need resizing — the View-menu
# toggles collapse them, and the camera takes whatever is left).
top_widget_layout.addWidget(self.collection_controls_scroll)
self.collection_controls_scroll.setWidget(self.left_column)
self.collection_controls_scroll.setHorizontalScrollBarPolicy(
@@ -469,11 +459,9 @@ class MainWindow(QMainWindow):
# No frame: its border drew a line above the tab bar (Dewar tabs have
# none). Freeze the inner column width: widgetResizable makes it track
# the viewport, so the scrollbar appearing used to re-flow every
# banner. Fixed width + a permanent 10px scrollbar gutter means the
# scrollbar pops into spare space and nothing moves.
self.collection_controls_scroll.setFrameShape(QFrame.Shape.NoFrame)
self.left_column.setFixedWidth(self.data_collection.set_width)
self.collection_controls_scroll.setFixedWidth(self.data_collection.set_width + 10)
# banner. Fixed inner width + a 10px scrollbar gutter means the
# scrollbar pops into spare space and nothing moves; the widths are
# zoom-dependent and live in _apply_zoom_widths.
self.video_tab = QTabWidget(parent=top_widget)
@@ -514,6 +502,14 @@ class MainWindow(QMainWindow):
self.video_tab.addTab(self.beamline_view_panel, "Beamline view")
self.video_tab.addTab(self.beamline_combined_panel, "Beamline combined view")
# The camera region's natural minimum (~284px) came from the widest
# page's controls row (title + refresh button) and the tab labels —
# not from the video views, which rescale freely like they do in
# height. Clamp to 1/3 so a narrow window can shrink all four camera
# views that far; past the natural width the tab bar scrolls and the
# controls rows clip.
self.video_tab.setMinimumWidth(self.video_tab.minimumSizeHint().width() // 3)
# if cfg_get("gui.cameras.secondary_beamline_camera_url", None):
# self.secondary_beamline_view = VideoGraphicsView()
# self.secondary_beamline_view_panel = AxisVideoPanel("Secondary view", self.secondary_beamline_view,
@@ -521,39 +517,7 @@ class MainWindow(QMainWindow):
# self.secondary_beamline_view_panel.refresh_requested.connect(self.refresh_axis_cameras)
# self.video_tab.addTab(self.secondary_beamline_view_panel, "Secondary view")
self.compact_sample_camera = SampleCameraImageLabel(
geom=geom, raster=self.raster, parent=root_widget, default_image=default_image
)
self.compact_automation_panel = CompactAutomationPanel(
self.compact_sample_camera, parent=root_widget
)
self.compact_automation_page = QWidget(parent=root_widget)
self.compact_automation_page.setObjectName("compactAutomationPage")
self.compact_automation_page_layout = QVBoxLayout(self.compact_automation_page)
self.compact_automation_page_layout.setContentsMargins(18, 18, 18, 18)
self.compact_automation_page_layout.setSpacing(0)
self.compact_automation_page_layout.addWidget(self.compact_automation_panel)
# ── Portrait mode page ──────────────────────────────────────────
self.portrait_sample_camera = SampleCameraImageLabel(
geom=geom, raster=self.raster, parent=root_widget, default_image=default_image
)
self.portrait_mode_panel = PortraitModePanel(
sample_camera_widget=self.portrait_sample_camera, parent=root_widget
)
self.portrait_mode_page = QWidget(parent=root_widget)
self.portrait_mode_page.setObjectName("portraitModePage")
portrait_page_layout = QHBoxLayout(self.portrait_mode_page)
portrait_page_layout.setContentsMargins(0, 0, 0, 0)
portrait_page_layout.setSpacing(0)
self.portrait_mode_page.setFixedWidth(self.portrait_mode_panel.PORTRAIT_WIDTH + 24)
portrait_page_layout.addWidget(
self.portrait_mode_panel, alignment=Qt.AlignmentFlag.AlignHCenter
)
top_widget_layout.addWidget(self.video_tab)
top_widget_layout.addWidget(self.video_tab, 1)
self._start_axis_camera_threads()
self.beamline_controls_scroll = NoWheelScrollArea(top_widget)
@@ -567,24 +531,13 @@ class MainWindow(QMainWindow):
self.beamline_controls_scroll.setHorizontalScrollBarPolicy(
Qt.ScrollBarPolicy.ScrollBarAlwaysOff
)
# Same gutter math as the left column: 10px scrollbar + 2px frame, so
# the fixed-width controls are never clipped when the scrollbar shows.
self.beamline_controls_scroll.setFixedWidth(self.beamline.set_width + 12)
# Gutter math per column (10px scrollbar + frame) lives here.
self._apply_zoom_widths()
self.tell_samples = TellSamplePanel(samples=SampleShortInfoList(s=[]))
self.ref_tools_panel = ReferenceToolsPanel(samples=SampleShortInfoList(s=[]))
self.job_list_panel = SampleQueuePanel(show_user=self._decoded_token.staff)
self.compact_automation_panel.play_pause_clicked.connect(self.job_list_panel.run)
self.compact_automation_panel.skip_clicked.connect(self.job_list_panel.skip_current_sample)
self.compact_automation_panel.step_through_toggled.connect(
self.job_list_panel.set_step_through
)
self.compact_automation_panel.show_full_view_requested.connect(
self._return_from_compact_automation_view
)
self.compact_automation_panel.annotation_selected.connect(self._handle_compact_annotation)
# One dock for both lists: the old tabified Reference Tools dock was
# staff-only and hid behind the Sample List tab, so it "sometimes"
# showed. aaregui2 concept: one panel, Dewar + Auxiliary-puck tabs
@@ -624,7 +577,14 @@ class MainWindow(QMainWindow):
# and the buttons balloon; five buttons plus two long checkboxes need
# them at their natural size.
automation_row.addStretch(1)
dewar_layout.addLayout(automation_row)
# Host widget with Ignored horizontal policy: the row's ~1000px
# natural minimum used to propagate dock -> window and pin the whole
# window wider than small screens (unshrinkable). Now the row clips
# from the right instead.
automation_row_host = QWidget(dewar_tab)
automation_row_host.setLayout(automation_row)
automation_row_host.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Fixed)
dewar_layout.addWidget(automation_row_host)
# "Remove selected" now unqueues the dewar-table selection — the
# queue's own table is no longer displayed.
@@ -778,33 +738,11 @@ class MainWindow(QMainWindow):
self.automation_progress_panel.set_running
)
self.job_list_panel.automation_running_changed.connect(self._on_automation_running_changed)
self.job_list_panel.automation_running_changed.connect(
self.compact_automation_panel.set_running
)
self.job_list_panel.step_through_changed.connect(
self.compact_automation_panel.set_step_through
)
self.job_list_panel.samples_in_queue_changed.connect(
self.compact_automation_panel.set_samples_in_queue
)
self.job_list_panel.samples_in_queue_changed.connect(self._refresh_compact_queue_preview)
# Portrait mode: queue size + running state + preview
self.job_list_panel.automation_running_changed.connect(self.portrait_mode_panel.set_running)
self.job_list_panel.samples_in_queue_changed.connect(
self.portrait_mode_panel.set_samples_in_queue
)
self.job_list_panel.samples_in_queue_changed.connect(self._refresh_portrait_queue_preview)
self.automation_progress_panel.set_samples_in_queue(
len(self.job_list_panel.table_model.samples)
)
self.automation_progress_panel.set_running(self.job_list_panel.is_running())
self.compact_automation_panel.set_running(self.job_list_panel.is_running())
self.compact_automation_panel.set_step_through(self.job_list_panel.is_step_through())
self.compact_automation_panel.set_samples_in_queue(
len(self.job_list_panel.table_model.samples)
)
self._refresh_compact_queue_preview()
# smargon trace panel
self.smargon_trace_panel = SmargonTracePanel()
@@ -867,33 +805,20 @@ class MainWindow(QMainWindow):
standard_page_scroll.setWidgetResizable(True)
standard_page_scroll.setFrameShape(QFrame.Shape.NoFrame)
standard_page_scroll.setWidget(top_widget)
self.content_stack.addWidget(standard_page_scroll)
self.content_stack.addWidget(self.compact_automation_page)
self.content_stack.addWidget(self.portrait_mode_page)
self.content_stack.setCurrentWidget(standard_page_scroll)
self._standard_main_page = standard_page_scroll
# QStackedWidget's minimum size is the max over ALL pages, so the hidden
# portrait/compact pages inflated the window minimum past small monitors
# (portrait alone demands 720px height). Only the visible page should
# count — the mode-switch code resizes the window explicitly anyway.
def _only_current_page_counts(index: int) -> None:
for i in range(self.content_stack.count()):
page = self.content_stack.widget(i)
policy = QSizePolicy.Policy.Preferred if i == index else QSizePolicy.Policy.Ignored
page.setSizePolicy(policy, policy)
self.content_stack.currentChanged.connect(_only_current_page_counts)
_only_current_page_counts(self.content_stack.currentIndex())
root_layout.addWidget(standard_page_scroll, 1)
self.setCentralWidget(root_widget)
# Qt auto-pins a top-level window's minimum size to its layout
# minimum, which the bottom dock row pushed past small monitors.
# An explicit minimum disables that auto-pin: the window shrinks to
# this floor and the dock contents clip/scroll instead.
self.setMinimumSize(320, 240)
self.setWindowTitle("AareGUI")
self._restore_theme_settings()
self._apply_theme()
self.create_menu_bar()
self._update_view_mode_actions()
self._setup_global_shortcuts()
# Default bottom-dock height: the sample list used to grab ~40% of the
# window and squeeze the Beamline column behind a scrollbar. Before
@@ -955,15 +880,8 @@ class MainWindow(QMainWindow):
self._axis_camera_refresh_timer.timeout.connect(self.refresh_axis_cameras)
self._axis_camera_refresh_timer.start()
self.portrait_mode_panel.wire_to_main_window(
job_list_panel=self.job_list_panel, tell_samples=self.tell_samples
)
self.portrait_mode_panel._back_btn.clicked.connect(self._return_from_portrait_mode)
self.portrait_mode_panel.grab_session_requested.connect(self.status_bar.request_baton)
# Route alert banner signals through portrait-aware interceptors
self.daq.polled_devices_status.connect(self._portrait_alert_primary)
self.daq.detector_error.connect(self._portrait_alert_secondary)
self.daq.polled_devices_status.connect(self._runtime_alert_primary)
self.daq.detector_error.connect(self._runtime_alert_secondary)
if hasattr(self, "monochromator_panel"):
self.daq.steer_beam_available.connect(
@@ -976,8 +894,6 @@ class MainWindow(QMainWindow):
self.daq.baton_response_result.connect(self._on_baton_response_result)
self.daq.baton_timeout_checked.connect(self._on_baton_timeout_checked)
self.daq.automation_progress.connect(self.automation_progress_panel.set_progress)
self.daq.automation_progress.connect(self.compact_automation_panel.set_progress)
self.daq.automation_progress.connect(self.portrait_mode_panel.set_progress)
self.status_bar.baton_request_received.connect(self._show_baton_request_dialog)
self.status_bar.baton_request_accepted.connect(self._accept_baton_request)
@@ -1057,18 +973,10 @@ class MainWindow(QMainWindow):
self.prediction_thread = PredictionSubscriber(pred_zmq_url=sample_feed_addr, topic=b"")
self.prediction_thread.image.connect(self._on_sample_camera_frame)
self.prediction_thread.prediction.connect(self.sample_camera.update_detections)
self.prediction_thread.prediction.connect(self.compact_sample_camera.update_detections)
self.prediction_thread.prediction.connect(self.portrait_sample_camera.update_detections)
self.prediction_thread.prediction.connect(
self.prediction_metrics_panel.update_from_prediction
)
self.prediction_thread.target_point.connect(self.sample_camera.update_target_point)
self.prediction_thread.target_point.connect(
self.compact_sample_camera.update_target_point
)
self.prediction_thread.target_point.connect(
self.portrait_sample_camera.update_target_point
)
self.prediction_thread.target_point.connect(
self.target_stability_panel.update_target_point
)
@@ -1077,12 +985,6 @@ class MainWindow(QMainWindow):
self.prediction_thread.camera_availability_changed.connect(
self.sample_camera.set_camera_available
)
self.prediction_thread.camera_availability_changed.connect(
self.compact_sample_camera.set_camera_available
)
self.prediction_thread.camera_availability_changed.connect(
self.portrait_sample_camera.set_camera_available
)
self.prediction_thread.camera_availability_changed.connect(
self._on_sample_camera_availability_changed
)
@@ -1091,8 +993,6 @@ class MainWindow(QMainWindow):
else:
self.prediction_thread = None
self.sample_camera.set_camera_available(False)
self.compact_sample_camera.set_camera_available(False)
self.portrait_sample_camera.set_camera_available(False)
self._show_samcam_feed_banner("Sample camera feed unavailable: no stream configured")
#
@@ -1204,10 +1104,7 @@ class MainWindow(QMainWindow):
self.daq.update.connect(self.beamline.illumination_panel.update_daq_status)
self.daq.update.connect(self.raster.update_daq_status)
self.daq.update.connect(self.sample_camera.update_daq_status)
self.daq.update.connect(self.compact_sample_camera.update_daq_status)
self.daq.update.connect(self.portrait_sample_camera.update_daq_status)
for camera in (self.sample_camera, self.compact_sample_camera, self.portrait_sample_camera):
self.daq.auto_centering.connect(camera.set_auto_centering)
self.daq.auto_centering.connect(self.sample_camera.set_auto_centering)
self.daq.update.connect(self.tell_samples.update_daq_status)
self.daq.update.connect(self.ref_tools_panel.update_daq_status)
if self.prediction_thread is not None:
@@ -1546,23 +1443,6 @@ class MainWindow(QMainWindow):
self.tell_samples_dock.raise_()
self.sample_lists_tabs.setCurrentIndex(1)
def _return_to_main_view_for_shutdown(self) -> None:
try:
if getattr(self, "content_stack", None) is None:
return
current_widget = self.content_stack.currentWidget()
if hasattr(self, "portrait_mode_page") and current_widget is self.portrait_mode_page:
self._return_from_portrait_mode()
elif bool(getattr(self, "_in_compact_automation_view", False)):
self._return_from_compact_automation_view()
if hasattr(self, "content_stack") and hasattr(self, "_standard_main_page"):
self.content_stack.setCurrentWidget(self._standard_main_page)
except Exception as e:
logger.warning(f"Failed to return to main view during shutdown: {e}", exc_info=True)
def _restore_samcam_overlay_settings(self) -> None:
settings = QSettings("PSI", "AareGUI")
# bool()/str() wraps: QSettings.value is typed "object" even with
@@ -1627,13 +1507,8 @@ class MainWindow(QMainWindow):
"""
try:
pixmap = QPixmap.fromImage(image)
for view in (
self.sample_camera,
self.compact_sample_camera,
self.portrait_sample_camera,
):
if view.isVisible():
view.update_pixmap(pixmap)
if self.sample_camera.isVisible():
self.sample_camera.update_pixmap(pixmap)
finally:
# The slot is only connected once the subscriber exists, but the
# attribute is Optional so the guard is what the type checker needs.
@@ -1653,13 +1528,7 @@ class MainWindow(QMainWindow):
logger.warning(message)
self._show_samcam_feed_banner(message or "Sample camera feed unavailable")
# The camera overlays show the reason too, not just the boolean.
for cam in (
self.sample_camera,
getattr(self, "compact_sample_camera", None),
getattr(self, "portrait_sample_camera", None),
):
if cam is not None:
cam.set_camera_error_message(message or "Sample camera feed unavailable")
self.sample_camera.set_camera_error_message(message or "Sample camera feed unavailable")
def _show_samcam_feed_banner(self, message: str) -> None:
self._samcam_feed_banner_message = message
@@ -1731,200 +1600,27 @@ class MainWindow(QMainWindow):
key=lambda sample: sample.loc_str_sort(),
)
def _update_view_mode_actions(self) -> None:
in_automation_view = bool(self._in_compact_automation_view)
if self._enter_automation_view_action is not None:
self._enter_automation_view_action.setVisible(not in_automation_view)
self._enter_automation_view_action.setEnabled(not in_automation_view)
if self._return_main_view_action is not None:
self._return_main_view_action.setVisible(in_automation_view)
self._return_main_view_action.setEnabled(in_automation_view)
@Slot()
def enter_compact_automation_view(self) -> None:
self.job_list_panel.ensure_default_queue_from_samples(
self._all_tell_samples_in_default_order()
)
self._refresh_compact_queue_preview()
if not self._in_compact_automation_view:
self._pre_automation_window_state = self.saveState()
self._pre_automation_left_column_visible = self.collection_controls_scroll.isVisible()
self._pre_automation_right_column_visible = self.beamline_controls_scroll.isVisible()
self.tell_samples_dock.setVisible(False)
self.information_dock.setVisible(False)
self.face_panel_dock.setVisible(False)
self.fluor_panel_dock.setVisible(False)
self.smargon_trace_dock.setVisible(False)
self.target_stability_dock.setVisible(False)
self.prediction_metrics_dock.setVisible(False)
self.collection_controls_scroll.setVisible(False)
self.beamline_controls_scroll.setVisible(False)
self.content_stack.setCurrentWidget(self.compact_automation_page)
self._in_compact_automation_view = True
self._update_view_mode_actions()
@Slot()
def _return_from_compact_automation_view(self) -> None:
self.content_stack.setCurrentWidget(self._standard_main_page)
if self._pre_automation_window_state is not None:
self.restoreState(self._pre_automation_window_state)
self.collection_controls_scroll.setVisible(self._pre_automation_left_column_visible)
self.beamline_controls_scroll.setVisible(self._pre_automation_right_column_visible)
self._in_compact_automation_view = False
self._update_view_mode_actions()
self.tell_samples_dock.setVisible(True)
self.tell_samples_dock.raise_()
@Slot()
def _refresh_compact_queue_preview(self) -> None:
current_sample, next_sample, next_next_sample = self.job_list_panel.queue_preview()
self.compact_automation_panel.set_samples(current_sample, next_sample, next_next_sample)
@Slot()
def enter_portrait_mode(self) -> None:
"""Switch to the portrait / phone-screen view and resize the window."""
self._pre_portrait_geometry = self.saveGeometry()
self.portrait_mode_panel.set_running(self.job_list_panel.is_running())
self.portrait_mode_panel.set_samples_in_queue(len(self.job_list_panel.table_model.samples))
self._refresh_portrait_queue_preview()
self.content_stack.setCurrentWidget(self.portrait_mode_page)
# ── Camera: scale-to-fit + hide legend ─────────────────────────────
self.portrait_sample_camera.set_show_overlay_legend(False)
try:
self.portrait_sample_camera._autoscale = True
self.portrait_sample_camera._scaling()
except Exception:
logger.debug("Could not autoscale the portrait-mode camera", exc_info=True)
# ── Hide all chrome that contributes to window width ────────────────
if self.status_bar is not None:
self.status_bar.setVisible(False)
self.menuBar().setVisible(False)
# Alert banners take up horizontal space even when hidden via QFrame
# — force them to zero height so they cannot influence the minimum width.
self.alert_banner.setVisible(False)
self.alert_banner.setMaximumHeight(0)
self.alert_banner_secondary.setVisible(False)
self.alert_banner_secondary.setMaximumHeight(0)
# Hide all dock widgets
for dock_attr in (
"tell_samples_dock",
"information_dock",
"face_panel_dock",
"fluor_panel_dock",
"smargon_trace_dock",
"target_stability_dock",
"prediction_metrics_dock",
):
dock = getattr(self, dock_attr, None)
if dock is not None:
dock.setVisible(False)
# ── Resize to phone footprint ───────────────────────────────────────
screen = QGuiApplication.screenAt(self.geometry().center())
if screen is None:
screen = QGuiApplication.primaryScreen()
available = screen.availableGeometry()
portrait_w = self.portrait_mode_panel.PORTRAIT_WIDTH + 24
portrait_h = min(860, available.height() - 40)
new_x = available.x() + (available.width() - portrait_w) // 2
new_y = available.y() + (available.height() - portrait_h) // 2
self.setMinimumWidth(portrait_w)
self.setMaximumWidth(portrait_w)
self.resize(portrait_w, portrait_h)
self.move(new_x, new_y)
@Slot()
def _return_from_portrait_mode(self) -> None:
"""Restore the window to its pre-portrait geometry and switch page."""
# ── Lift hard width cap before restoring geometry ───────────────────
self.setMinimumWidth(0)
self.setMaximumWidth(16777215) # Qt's QWIDGETSIZE_MAX
self.content_stack.setCurrentWidget(self._standard_main_page)
# ── Restore chrome ──────────────────────────────────────────────────
if self.status_bar is not None:
self.status_bar.setVisible(True)
self.menuBar().setVisible(True)
# Restore alert banners to normal operation
self.alert_banner.setMaximumHeight(16777215)
self.alert_banner_secondary.setMaximumHeight(16777215)
# Replay any pending messages that arrived during portrait mode
self.portrait_mode_panel._flush_portrait_alerts_to_banners(
self.alert_banner, self.alert_banner_secondary
)
# ── Restore camera legend ───────────────────────────────────────────
try:
settings = self.portrait_sample_camera.target_overlay_settings()
self.portrait_sample_camera.set_show_overlay_legend(
settings.get("show_overlay_legend", False)
)
except Exception:
logger.debug("Could not restore the camera overlay legend", exc_info=True)
if hasattr(self, "_pre_portrait_geometry") and self._pre_portrait_geometry:
self.restoreGeometry(self._pre_portrait_geometry)
self._pre_portrait_geometry = None
self.tell_samples_dock.setVisible(True)
self.information_dock.setVisible(False)
self.face_panel_dock.setVisible(False)
self.fluor_panel_dock.setVisible(False)
self.smargon_trace_dock.setVisible(False)
self.target_stability_dock.setVisible(False)
self.prediction_metrics_dock.setVisible(False)
@Slot(str, bool)
def _portrait_alert_primary(self, msg: str, is_error: bool) -> None:
def _runtime_alert_primary(self, msg: str, is_error: bool) -> None:
"""Route primary alerts into the runtime dock."""
if self.content_stack.currentWidget() is self.portrait_mode_page:
self.portrait_mode_panel.show_portrait_alert(msg, is_error)
else:
self._show_runtime_notification(
title="Runtime alert" if is_error else "Runtime update",
message=msg,
level="error" if is_error else "success",
sticky=is_error,
auto_clear_ms=None if is_error else 4000,
)
self._show_runtime_notification(
title="Runtime alert" if is_error else "Runtime update",
message=msg,
level="error" if is_error else "success",
sticky=is_error,
auto_clear_ms=None if is_error else 4000,
)
@Slot(str, bool)
def _portrait_alert_secondary(self, msg: str, is_error: bool) -> None:
def _runtime_alert_secondary(self, msg: str, is_error: bool) -> None:
"""Route secondary alerts into the runtime dock."""
if self.content_stack.currentWidget() is self.portrait_mode_page:
self.portrait_mode_panel.show_portrait_alert(msg, is_error)
else:
self._show_runtime_notification(
title="Device alert" if is_error else "Device update",
message=msg,
level="warning" if is_error else "info",
sticky=is_error,
auto_clear_ms=None if is_error else 4000,
)
@Slot()
def _refresh_portrait_queue_preview(self) -> None:
self.portrait_mode_panel.refresh_queue_preview()
self._show_runtime_notification(
title="Device alert" if is_error else "Device update",
message=msg,
level="warning" if is_error else "info",
sticky=is_error,
auto_clear_ms=None if is_error else 4000,
)
@staticmethod
def _annotation_token(annotation: str) -> str:
@@ -1951,26 +1647,6 @@ class MainWindow(QMainWindow):
return f"{current} | {token}"
@Slot(str)
def _handle_compact_annotation(self, annotation: str) -> None:
current_sample, _, _ = self.job_list_panel.queue_preview()
if current_sample is None:
self.status_bar.show_connection_message("No sample selected for annotation.", True)
return
updated_comment = self._append_annotation_to_comment(
getattr(current_sample, "comment", None), annotation
)
self.job_list_panel.annotate_sample_comment(current_sample.db_id, updated_comment)
self.tell_samples.annotate_sample_comment(current_sample.db_id, updated_comment)
self._refresh_compact_queue_preview()
self.status_bar.show_connection_message(
f"Annotation added: {self._annotation_token(annotation)}{current_sample.sample_name}",
False,
)
@Slot()
def refresh_axis_cameras(self) -> None:
logger.info("Refreshing Axis camera threads")
@@ -2059,15 +1735,29 @@ class MainWindow(QMainWindow):
settings.setValue("appearance/font_scale", font_scale())
def _change_font_zoom(self, direction: int) -> None:
"""Ctrl+plus / Ctrl+minus / Ctrl+0 accessibility zoom: step the FONT_*
ladder scale and rebuild the app stylesheet (same repolish path as a
theme switch, so the whole UI rescales in one pass)."""
# round: repeated 0.1 float steps otherwise drift (1.2000000000000002)
set_font_scale(
1.0 if direction == 0 else round(font_scale() + direction * FONT_SCALE_STEP, 2)
)
"""Ctrl+plus / Ctrl+minus / Ctrl+0 accessibility zoom: walk the
100/125/150% ladder and rebuild the app stylesheet (same repolish
path as a theme switch, so the whole UI rescales in one pass)."""
# index() is safe: set_font_scale snaps every value onto the ladder.
i = FONT_SCALE_LADDER.index(font_scale())
i = 0 if direction == 0 else max(0, min(len(FONT_SCALE_LADDER) - 1, i + direction))
set_font_scale(FONT_SCALE_LADDER[i])
self._save_theme_settings()
self._apply_theme()
self._apply_zoom_widths()
def _apply_zoom_widths(self) -> None:
# The side columns are fixed-width designs; when the ladder scales the
# text, the boxes must widen with it or the zoomed text clips. Runs
# once at construction and again on every zoom change.
s = font_scale()
left = round(self.data_collection.set_width * s)
self.data_collection.setFixedWidth(left)
self.left_column.setFixedWidth(left)
self.collection_controls_scroll.setFixedWidth(left + 10)
right = round(self.beamline.set_width * s)
self.beamline.setFixedWidth(right)
self.beamline_controls_scroll.setFixedWidth(right + 12)
@Slot()
def use_legacy_theme(self) -> None:
@@ -2094,24 +1784,6 @@ class MainWindow(QMainWindow):
quit_action.triggered.connect(self.close)
file_menu.addAction(quit_action)
# Prototype views live in the View menu (below the themes), not on the
# menubar; only their in-progress labels changed, the internal
# action/slot names stay.
self._enter_automation_view_action = QAction("Automation View (prototyping)", self)
self._enter_automation_view_action.setShortcut(QKeySequence("Ctrl+5"))
self._enter_automation_view_action.triggered.connect(self.enter_compact_automation_view)
# Stays top-level: it only shows while INSIDE the automation view,
# where the way back must not hide in a menu.
self._return_main_view_action = QAction("Return to Main View", self)
self._return_main_view_action.setShortcut(QKeySequence("Ctrl+Shift+5"))
self._return_main_view_action.triggered.connect(self._return_from_compact_automation_view)
menu_bar.addAction(self._return_main_view_action)
self._portrait_mode_action = QAction("Playlist Mode (work in progress)", self)
self._portrait_mode_action.setShortcut(QKeySequence("Ctrl+6"))
self._portrait_mode_action.triggered.connect(self.enter_portrait_mode)
view_menu = menu_bar.addMenu("View")
self._theme_action_group = QActionGroup(self)
@@ -2157,8 +1829,18 @@ class MainWindow(QMainWindow):
reset_text_action.triggered.connect(lambda: self._change_font_zoom(0))
view_menu.addAction(reset_text_action)
view_menu.addSeparator()
view_menu.addAction(self._portrait_mode_action)
view_menu.addAction(self._enter_automation_view_action)
# Side-panel collapse lives here, not as on-screen buttons.
self._show_left_panel_action = QAction("Show Left Panel", self)
self._show_left_panel_action.setCheckable(True)
self._show_left_panel_action.setChecked(True)
self._show_left_panel_action.toggled.connect(self.collection_controls_scroll.setVisible)
view_menu.addAction(self._show_left_panel_action)
self._show_right_panel_action = QAction("Show Right Panel", self)
self._show_right_panel_action.setCheckable(True)
self._show_right_panel_action.setChecked(True)
self._show_right_panel_action.toggled.connect(self.beamline_controls_scroll.setVisible)
view_menu.addAction(self._show_right_panel_action)
view_menu.addSeparator()
show_samples_action = QAction("Show Sample List", self)
@@ -2297,10 +1979,6 @@ class MainWindow(QMainWindow):
if self._default_window_state is not None:
self.restoreState(self._default_window_state)
self.content_stack.setCurrentWidget(self._standard_main_page)
self._in_compact_automation_view = False
self._update_view_mode_actions()
self.collection_controls_scroll.setVisible(True)
self.beamline_controls_scroll.setVisible(True)
@@ -2415,7 +2093,7 @@ class MainWindow(QMainWindow):
These come from polling, resync and other background requests; a modal
pop-up would be too intrusive, so they go to the runtime dock/banner.
"""
self._portrait_alert_primary(message, True)
self._runtime_alert_primary(message, True)
@Slot(bool)
def _on_pss_alarm_changed(self, active: bool) -> None:
@@ -2878,8 +2556,6 @@ class MainWindow(QMainWindow):
s.geom.beam_location_pxl.x, s.geom.beam_location_pxl.y
)
self._refresh_compact_queue_preview()
current_session = int(getattr(self._decoded_token, "session", -1))
for gui in getattr(s, "open_guis", []) or []:
try:
@@ -3171,7 +2847,7 @@ class MainWindow(QMainWindow):
if not self._default_dock_split_done:
self._default_dock_split_done = True
if not self.state_manager.settings.value("main_window/state"):
QTimer.singleShot(0, self._apply_default_dock_split)
QTimer.singleShot(0, self, self._apply_default_dock_split)
def _apply_default_dock_split(self) -> None:
self.resizeDocks(
@@ -3328,7 +3004,7 @@ class MainWindow(QMainWindow):
# Pop-outs open enlarged instead of keeping the cramped docked
# size. Deferred: the window is mid-reparent while the signal
# fires.
QTimer.singleShot(0, lambda d=dock: self._enlarge_floating_dock(d))
QTimer.singleShot(0, dock, lambda d=dock: self._enlarge_floating_dock(d))
def _enlarge_floating_dock(self, dock: QDockWidget) -> None:
if not dock.isFloating():
@@ -1,227 +0,0 @@
from aarecommon.models.automation import AutomationProgress
from aarecommon.models.models import SampleShortInfo
from PySide6.QtCore import Qt, Signal, Slot
from PySide6.QtWidgets import (
QFrame,
QHBoxLayout,
QLabel,
QMenu,
QPushButton,
QToolButton,
QVBoxLayout,
QWidget,
)
from aare.gui.widgets.automation_progress import CompactAutomationProgressStrip
class CompactAutomationPanel(QFrame):
play_pause_clicked = Signal()
skip_clicked = Signal()
step_through_toggled = Signal(bool)
annotation_selected = Signal(str)
show_full_view_requested = Signal()
ensure_default_queue_requested = Signal()
def __init__(self, camera_widget: QWidget, parent=None):
super().__init__(parent)
self._running = False
self._step_through = False
self._current_sample: SampleShortInfo | None = None
self._next_sample: SampleShortInfo | None = None
self._next_next_sample: SampleShortInfo | None = None
self.setFrameShape(QFrame.Shape.NoFrame)
self.setObjectName("compactAutomationPanel")
main_layout = QVBoxLayout(self)
main_layout.setContentsMargins(18, 18, 18, 18)
main_layout.setSpacing(12)
camera_card = QFrame(self)
camera_card.setObjectName("compactCameraCard")
camera_layout = QVBoxLayout(camera_card)
camera_layout.setContentsMargins(12, 12, 12, 12)
camera_layout.setSpacing(8)
camera_title = QLabel("Camera", self)
camera_title.setObjectName("compactSectionTitle")
camera_layout.addWidget(camera_title)
camera_layout.addWidget(camera_widget, 1)
main_layout.addWidget(camera_card, 1)
controls_card = QFrame(self)
controls_card.setObjectName("compactControlsCard")
controls_layout = QVBoxLayout(controls_card)
controls_layout.setContentsMargins(12, 12, 12, 12)
controls_layout.setSpacing(10)
controls_header = QHBoxLayout()
controls_header.setSpacing(10)
controls_title_wrap = QVBoxLayout()
controls_title_wrap.setContentsMargins(0, 0, 0, 0)
controls_title_wrap.setSpacing(2)
controls_title = QLabel("Controls", self)
controls_title.setObjectName("compactSectionTitle")
controls_hint = QLabel("Run and manage the automation queue.", self)
controls_hint.setObjectName("compactSectionHint")
controls_title_wrap.addWidget(controls_title)
controls_title_wrap.addWidget(controls_hint)
self.menu_button = QToolButton(self)
self.menu_button.setObjectName("compactMenuButton")
self.menu_button.setText("")
self.menu_button.setToolTip("Return to main view")
self.menu_button.clicked.connect(self.show_full_view_requested.emit)
controls_header.addLayout(controls_title_wrap, 1)
controls_header.addWidget(self.menu_button, 0, Qt.AlignmentFlag.AlignTop)
controls_layout.addLayout(controls_header)
button_row = QHBoxLayout()
button_row.setSpacing(10)
self.play_pause_button = QPushButton("▶ Play", self)
self.play_pause_button.setObjectName("compactPrimaryButton")
self.play_pause_button.clicked.connect(self.play_pause_clicked.emit)
self.skip_button = QPushButton("⏭ Skip", self)
self.skip_button.setObjectName("compactSecondaryButton")
self.skip_button.clicked.connect(self.skip_clicked.emit)
self.step_button = QPushButton("Step", self)
self.step_button.setObjectName("compactSecondaryButton")
self.step_button.setCheckable(True)
self.step_button.toggled.connect(self._on_step_toggled)
self.annotation_button = QToolButton(self)
self.annotation_button.setObjectName("compactSecondaryButton")
self.annotation_button.setText("Annotate")
self.annotation_button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
annotation_menu = QMenu(self.annotation_button)
for label in ["Heart", "Thumbs Up", "Thumbs Down", "Eyes", "Scan Again"]:
action = annotation_menu.addAction(label)
action.triggered.connect(
lambda checked=False, value=label: self.annotation_selected.emit(value)
)
self.annotation_button.setMenu(annotation_menu)
button_row.addWidget(self.play_pause_button, 2)
button_row.addWidget(self.skip_button, 1)
button_row.addWidget(self.step_button, 1)
button_row.addWidget(self.annotation_button, 1)
controls_layout.addLayout(button_row)
main_layout.addWidget(controls_card)
progress_card = QFrame(self)
progress_card.setObjectName("compactProgressCard")
progress_layout = QVBoxLayout(progress_card)
progress_layout.setContentsMargins(12, 12, 12, 12)
progress_layout.setSpacing(8)
progress_title = QLabel("Progress", self)
progress_title.setObjectName("compactSectionTitle")
progress_layout.addWidget(progress_title)
self.progress_strip = CompactAutomationProgressStrip(self)
progress_layout.addWidget(self.progress_strip)
main_layout.addWidget(progress_card)
queue_card = QFrame(self)
queue_card.setObjectName("compactQueueCard")
queue_layout = QVBoxLayout(queue_card)
queue_layout.setContentsMargins(12, 12, 12, 12)
queue_layout.setSpacing(10)
queue_title = QLabel("Queue Preview", self)
queue_title.setObjectName("compactSectionTitle")
queue_layout.addWidget(queue_title)
queue_row = QHBoxLayout()
queue_row.setSpacing(10)
self.current_card = self._build_queue_item("Now", "")
self.next_card = self._build_queue_item("Next", "")
self.next_next_card = self._build_queue_item("Later", "")
queue_row.addWidget(self.current_card["frame"], 1)
queue_row.addWidget(self.next_card["frame"], 1)
queue_row.addWidget(self.next_next_card["frame"], 1)
queue_layout.addLayout(queue_row)
main_layout.addWidget(queue_card)
def _build_queue_item(self, title: str, value: str) -> dict[str, QWidget | QLabel]:
frame = QFrame(self)
frame.setObjectName("compactQueueItem")
layout = QVBoxLayout(frame)
layout.setContentsMargins(12, 10, 12, 10)
layout.setSpacing(4)
title_label = QLabel(title, self)
title_label.setObjectName("compactQueueTitle")
value_label = QLabel(value, self)
value_label.setObjectName("compactQueueValue")
value_label.setWordWrap(True)
layout.addWidget(title_label)
layout.addWidget(value_label)
return {"frame": frame, "title": title_label, "value": value_label}
@Slot(bool)
def set_running(self, running: bool) -> None:
self._running = bool(running)
self.play_pause_button.setText("⏸ Pause" if self._running else "▶ Play")
self.progress_strip.set_running(running)
@Slot(bool)
def set_step_through(self, enabled: bool) -> None:
self._step_through = bool(enabled)
self.step_button.blockSignals(True)
self.step_button.setChecked(self._step_through)
self.step_button.blockSignals(False)
@Slot(int)
def set_samples_in_queue(self, count: int) -> None:
self.progress_strip.set_samples_in_queue(count)
@Slot(object)
def set_progress(self, progress: AutomationProgress) -> None:
self.progress_strip.set_progress(progress)
@Slot(object, object, object)
def set_samples(
self,
current_sample: SampleShortInfo | None,
next_sample: SampleShortInfo | None,
next_next_sample: SampleShortInfo | None,
) -> None:
self._current_sample = current_sample
self._next_sample = next_sample
self._next_next_sample = next_next_sample
self.current_card["value"].setText(self._format_sample(current_sample))
self.next_card["value"].setText(self._format_sample(next_sample))
self.next_next_card["value"].setText(self._format_sample(next_next_sample))
def _on_step_toggled(self, checked: bool) -> None:
self._step_through = checked
self.step_through_toggled.emit(checked)
@staticmethod
def _format_sample(sample: SampleShortInfo | None) -> str:
if sample is None:
return ""
return f"{sample.sample_name} ({sample.loc_str()})"
-805
View File
@@ -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)
+10 -1
View File
@@ -15,7 +15,9 @@ from PySide6.QtWidgets import (
QHeaderView,
QMenu,
QPushButton,
QSizePolicy,
QTableView,
QWidget,
)
from aare.gui.constants import LOGGER_NAME
@@ -196,7 +198,14 @@ class TellSamplePanel(QFrame):
self.status_chips.addButton(chip)
chip_row.addWidget(chip)
chip_row.addStretch()
grid_layout.addLayout(chip_row, 1, 0, 1, 4)
# Ignored-width host (same trick as the automation row in
# main_window): the five chips' ~380px natural minimum otherwise
# propagates dock -> window and blocks shrinking the window on small
# screens. The row clips from the right instead.
chip_host = QWidget(self)
chip_host.setLayout(chip_row)
chip_host.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Fixed)
grid_layout.addWidget(chip_host, 1, 0, 1, 4)
self.status_chips.buttonClicked.connect(
lambda chip: self.table_model.set_status_filter(chip.property("status_key"))
)
+55 -284
View File
@@ -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.
+1 -1
View File
@@ -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
-198
View File
@@ -1,198 +0,0 @@
from __future__ import annotations
import time
from datetime import datetime
from aarecommon.models.automation import AutomationProgress, StepStatus, WorkflowStateKind
from PySide6.QtCore import QTimer, Slot
from PySide6.QtWidgets import QFrame, QHBoxLayout, QLabel, QVBoxLayout, QWidget
from aare.gui.styles import (
FAINT_TEXT,
FONT_HINT,
FONT_VALUE,
STEP_FAILED_TEXT,
STEP_PAUSED_TEXT,
STEP_RUNNING_TEXT,
STEP_SUCCESS_TEXT,
)
class CompactAutomationProgressStrip(QFrame):
DEFAULT_SAMPLE_ESTIMATE_S = 150.0
def __init__(self, parent: QWidget | None = None):
super().__init__(parent)
self._progress: AutomationProgress | None = None
self._queue_count = 0
self._running = False
self._step_labels: dict[WorkflowStateKind, QLabel] = {}
self._timer = QTimer(self)
self._timer.setInterval(1000)
self._timer.timeout.connect(self._refresh_live_view)
self.setObjectName("compactAutomationProgressStrip")
self.setStyleSheet(
"""
QFrame#compactAutomationProgressStrip {
background: transparent;
border: none;
}
"""
)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(8)
self._summary_label = QLabel("Automation idle", self)
self._summary_label.setObjectName("compactProgressSummary")
self._summary_label.setWordWrap(True)
layout.addWidget(self._summary_label)
steps_row = QHBoxLayout()
steps_row.setSpacing(8)
for step in (
WorkflowStateKind.MOUNT,
WorkflowStateKind.LOOP_CENTRE,
WorkflowStateKind.RASTER,
WorkflowStateKind.DATA_COLLECTION,
WorkflowStateKind.FINAL,
):
label = QLabel(self)
label.setObjectName("compactProgressStep")
label.setWordWrap(True)
steps_row.addWidget(label, 1)
self._step_labels[step] = label
layout.addLayout(steps_row)
self._apply_empty_state()
def _apply_empty_state(self) -> None:
self._summary_label.setText("Automation idle")
for step, label in self._step_labels.items():
label.setText(self._format_step_html(step, StepStatus.PENDING))
@staticmethod
def _step_title(step: WorkflowStateKind) -> str:
return {
WorkflowStateKind.MOUNT: "Mount",
WorkflowStateKind.LOOP_CENTRE: "Center",
WorkflowStateKind.RASTER: "Raster",
WorkflowStateKind.DATA_COLLECTION: "Collect",
WorkflowStateKind.FINAL: "Finish",
}.get(step, str(step.value))
@staticmethod
def _step_icon(status: StepStatus) -> str:
return {
StepStatus.PENDING: "",
StepStatus.RUNNING: "",
StepStatus.SUCCESS: "",
StepStatus.FAILED: "",
StepStatus.SKIPPED: "",
StepStatus.PAUSED: "",
}.get(status, "")
@staticmethod
def _step_color(status: StepStatus) -> str:
return {
StepStatus.PENDING: FAINT_TEXT,
StepStatus.RUNNING: STEP_RUNNING_TEXT,
StepStatus.SUCCESS: STEP_SUCCESS_TEXT,
StepStatus.FAILED: STEP_FAILED_TEXT,
StepStatus.SKIPPED: FAINT_TEXT,
StepStatus.PAUSED: STEP_PAUSED_TEXT,
}.get(status, FAINT_TEXT)
def _format_step_html(self, step: WorkflowStateKind, status: StepStatus) -> str:
color = self._step_color(status)
icon = self._step_icon(status)
title = self._step_title(step)
return (
f"<div style='text-align:center; background:transparent;'>"
f"<div style='font-size:{FONT_VALUE}; color:{color}; font-weight:700; background:transparent;'>{icon}</div>"
f"<div style='font-size:{FONT_HINT}; color:{color}; font-weight:700; background:transparent;'>{title}</div>"
f"</div>"
)
@staticmethod
def _format_duration(seconds: float | None) -> str:
if seconds is None or seconds <= 0:
return "0m 00s"
total = round(seconds)
minutes, secs = divmod(total, 60)
if minutes < 60:
return f"{minutes}m {secs:02d}s"
hours, minutes = divmod(minutes, 60)
return f"{hours}h {minutes:02d}m"
@staticmethod
def _format_eta(epoch_seconds: float | None) -> str:
if epoch_seconds is None:
return "N/A"
return datetime.fromtimestamp(epoch_seconds).strftime("%H:%M:%S")
@Slot()
def _refresh_live_view(self) -> None:
if self._progress is not None:
self.set_progress(self._progress)
@Slot(bool)
def set_running(self, running: bool) -> None:
self._running = bool(running)
if self._progress is not None:
self.set_progress(self._progress)
@Slot(int)
def set_samples_in_queue(self, count: int) -> None:
self._queue_count = max(0, int(count))
if self._progress is not None:
self.set_progress(self._progress)
@Slot(object)
def set_progress(self, progress: AutomationProgress) -> None:
self._progress = progress
any_running = any(step.status == StepStatus.RUNNING for step in progress.steps)
if any_running and self._running:
if not self._timer.isActive():
self._timer.start()
else:
self._timer.stop()
current_sample = progress.current_sample_name or "None"
avg_time = (
progress.avg_time_per_sample
if progress.avg_time_per_sample > 0
else self.DEFAULT_SAMPLE_ESTIMATE_S
)
queue_remaining = avg_time * self._queue_count
eta = time.time() + queue_remaining if queue_remaining > 0 else None
state_text = "Paused"
state_color = STEP_PAUSED_TEXT
if progress.finished and progress.success is True:
state_text = "Completed"
state_color = STEP_SUCCESS_TEXT
elif progress.finished and progress.success is False:
state_text = "Failed"
state_color = STEP_FAILED_TEXT
elif self._running:
state_text = "Running"
state_color = STEP_RUNNING_TEXT
self._summary_label.setText(
f"<span style='background:transparent;'><b>Status:</b> "
f"<span style='color:{state_color}; font-weight:700; background:transparent;'>{state_text}</span>"
f" &nbsp;&nbsp; <b>Now:</b> {current_sample}"
f" &nbsp;&nbsp; <b>Queue:</b> {self._queue_count}"
f" &nbsp;&nbsp; <b>ETA:</b> {self._format_duration(queue_remaining)}"
f" &nbsp;&nbsp; <b>Done:</b> {self._format_eta(eta)}</span>"
)
states = {step.step: step.status for step in progress.steps}
for step, label in self._step_labels.items():
label.setText(self._format_step_html(step, states.get(step, StepStatus.PENDING)))
+95
View File
@@ -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()))
+15 -13
View File
@@ -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
+6 -2
View File
@@ -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
+61 -83
View File
@@ -1,3 +1,4 @@
from typing import cast
from unittest.mock import MagicMock, patch
import pytest
@@ -72,6 +73,50 @@ def test_main_window_init(qtbot, mock_ui_state, daq_status_factory):
win.data_collection._emit_change_energy()
assert sent and abs(sent[0] - 12400.0) < 1e-6
# Side panels collapse from the View menu (no on-screen buttons).
# The clamp lets a narrow window shrink the camera region well below
# the natural minimum the tab labels + controls rows would demand.
assert 0 < win.video_tab.minimumWidth() < win.video_tab.minimumSizeHint().width()
win._show_left_panel_action.trigger()
assert win.collection_controls_scroll.isHidden()
win._show_left_panel_action.trigger()
assert not win.collection_controls_scroll.isHidden()
win._show_right_panel_action.trigger()
assert win.beamline_controls_scroll.isHidden()
win._show_right_panel_action.trigger()
assert not win.beamline_controls_scroll.isHidden()
# Alert routers land straight in the runtime notification dock (the
# portrait-mode interceptors are gone with Playlist Mode).
with patch.object(win, "_show_runtime_notification") as note:
win._runtime_alert_primary("beam lost", True)
win._runtime_alert_secondary("shutter", False)
win._on_http_error("boom")
win._on_sample_camera_error("no frames")
assert note.call_count == 4
assert (
win._detector_error_banner_text(automation=True, message="x")
== "Automation halted: detector error."
)
assert (
win._detector_error_banner_text(automation=False, message="x")
== "Manual collection stopped: detector error."
)
# Manual unmount respects the hutch PSS gate. cast: win.daq is a
# patched-in MagicMock, but pyright sees the real DAQWorker type.
unmount_mock = cast(MagicMock, win.daq.unmount)
with (
patch.object(win, "_hutch_blocks_mount", return_value="hutch open"),
patch("aare.gui.main_window.QMessageBox"),
):
win._on_manual_unmount_requested()
unmount_mock.assert_not_called()
with patch.object(win, "_hutch_blocks_mount", return_value=None):
win._on_manual_unmount_requested()
unmount_mock.assert_called_once()
# Motion watch: only the robot station switches to the combined
# beamline view. Moving no longer does (users kept losing the sample
# camera on short gonio moves), and busy alone never does — Sample
@@ -368,80 +413,6 @@ def test_idle_timeout_does_not_close_while_automation_active(qtbot, mock_ui_stat
win.close.assert_not_called()
def test_cleanup_returns_from_portrait_mode(qtbot, mock_ui_state):
with (
patch("requests.get"),
patch("aare.gui.main_window.DAQWorker"),
patch("aare.gui.main_window.PredictionSubscriber"),
patch("aare.gui.main_window.VideoThread"),
patch("aare.gui.main_window.JFJochDBusClient"),
patch("aare.gui.main_window.jwt.decode") as mock_jwt,
):
mock_jwt.return_value = {
"sub": "testuser",
"staff": True,
"pgroups": ["p123"],
"session": 15,
}
fake_token = "header.payload.signature"
win = MainWindow(
base_url=None,
token=fake_token,
default_image=None,
zmq_addr=None,
pred_zmq_addr=None,
beamline_cam_addr=None,
gonio_cam_addr=None,
gonio_cam_id=None,
)
qtbot.addWidget(win)
win.enter_portrait_mode()
assert win.content_stack.currentWidget() is win.portrait_mode_page
win.cleanup()
assert win.content_stack.currentWidget() is win._standard_main_page
def test_cleanup_returns_from_compact_automation_view(qtbot, mock_ui_state):
with (
patch("requests.get"),
patch("aare.gui.main_window.DAQWorker"),
patch("aare.gui.main_window.PredictionSubscriber"),
patch("aare.gui.main_window.VideoThread"),
patch("aare.gui.main_window.JFJochDBusClient"),
patch("aare.gui.main_window.jwt.decode") as mock_jwt,
):
mock_jwt.return_value = {
"sub": "testuser",
"staff": True,
"pgroups": ["p123"],
"session": 15,
}
fake_token = "header.payload.signature"
win = MainWindow(
base_url=None,
token=fake_token,
default_image=None,
zmq_addr=None,
pred_zmq_addr=None,
beamline_cam_addr=None,
gonio_cam_addr=None,
gonio_cam_id=None,
)
qtbot.addWidget(win)
win.enter_compact_automation_view()
assert win.content_stack.currentWidget() is win.compact_automation_page
win.cleanup()
assert win.content_stack.currentWidget() is win._standard_main_page
def _make_window(qtbot):
win = MainWindow(
base_url=None,
@@ -525,20 +496,28 @@ def test_font_zoom_steps_clamps_and_resets(qtbot, mock_ui_state):
try:
base_pt = win._default_app_font.pointSizeF()
win._change_font_zoom(1)
assert styles.font_scale() == pytest.approx(1.1)
assert styles.font_scale() == pytest.approx(1.25)
# Part 2 of the zoom: the app default font scales with the ladder.
app = QApplication.instance()
assert isinstance(app, QApplication) # narrow from QCoreApplication|None
assert app.font().pointSizeF() == pytest.approx(base_pt * 1.1)
for _ in range(20):
assert app.font().pointSizeF() == pytest.approx(base_pt * 1.25)
# Part 3: the fixed-width side boxes follow the ladder, else the
# zoomed text clips inside them.
assert win.data_collection.minimumWidth() == round(win.data_collection.set_width * 1.25)
assert win.collection_controls_scroll.maximumWidth() == (
round(win.data_collection.set_width * 1.25) + 10
)
assert win.beamline.minimumWidth() == round(win.beamline.set_width * 1.25)
for _ in range(5):
win._change_font_zoom(1)
assert styles.font_scale() == styles.FONT_SCALE_MAX
assert styles.font_scale() == styles.FONT_SCALE_LADDER[-1]
win._change_font_zoom(0)
assert styles.font_scale() == 1.0
assert app.font().pointSizeF() == pytest.approx(base_pt)
for _ in range(20):
assert win.data_collection.minimumWidth() == win.data_collection.set_width
for _ in range(5):
win._change_font_zoom(-1)
assert styles.font_scale() == styles.FONT_SCALE_MIN
assert styles.font_scale() == styles.FONT_SCALE_LADDER[0] # floor is 100%
finally:
styles.set_font_scale(1.0)
if saved is None:
@@ -672,15 +651,14 @@ def test_sample_camera_frame_paints_visible_views_and_acks(qtbot, mock_ui_state,
}
win = _make_window(qtbot)
views = (win.sample_camera, win.compact_sample_camera, win.portrait_sample_camera)
# Nothing is shown in tests, so pretend every view is on screen to reach
# Nothing is shown in tests, so pretend the view is on screen to reach
# the paint branch.
monkeypatch.setattr(type(win.sample_camera), "isVisible", lambda self: True)
win.prediction_thread = MagicMock()
win._on_sample_camera_frame(QImage(4, 6, QImage.Format.Format_RGB888))
assert all(v.pixmap_item.pixmap().width() == 4 for v in views)
assert win.sample_camera.pixmap_item.pixmap().width() == 4
# The ack is what paces the subscriber; it must fire after every frame.
win.prediction_thread.notify_frame_displayed.assert_called_once()
@@ -6,7 +6,6 @@ from PySide6.QtCore import QEvent, QPointF, Qt
from PySide6.QtGui import QMouseEvent
from aare.gui.panels.fluorescence_panel import FluorescencePanel
from aare.gui.panels.portrait_mode import PlayPauseButton
from aare.gui.widgets.baton_request_dialog import BatonPendingDialog, BatonRequestDialog
from aare.gui.widgets.value_label import ValueLabel
from aare.gui.widgets.video_image import VideoGraphicsView
@@ -86,14 +85,6 @@ def test_video_view_shortcuts_replace_keypress_override(qtbot):
qtbot.keyClick(view, Qt.Key.Key_F) # fit_to_view: just must not raise
def test_play_pause_button_paints_without_hover_overrides(qtbot):
btn = PlayPauseButton()
qtbot.addWidget(btn)
btn.set_running(True)
# grab() forces a real paintEvent pass over the underMouse() branch
assert not btn.grab().isNull()
def test_value_label_inherits_click(qtbot):
label = ValueLabel("Energy", "keV")
qtbot.addWidget(label)
+27 -9
View File
@@ -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
+16
View File
@@ -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
Generated
+99 -39
View File
@@ -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"