Theme overhaul: sunrise/sunset/bluebird themes and camera overlay rework #130

Merged
duan_j merged 26 commits from feat/theme-overhaul into collapsable-and-adaptive 2026-08-11 10:24:48 +02:00
44 changed files with 2423 additions and 813 deletions
+2 -2
View File
@@ -4,8 +4,8 @@
</defs>
<style>
tspan { white-space:pre }
.t0 { font-size: 84px;fill: #000000;font-weight: 400;font-family: "Asap-Regular", "Asap" }
.t1 { font-size: 20px;fill: #000000;font-weight: 400;font-family: "Asap-Regular", "Asap" }
.t0 { font-size: 84px;fill: #fcfcfc;font-weight: 400;font-family: "Asap-Regular", "Asap" }
.t1 { font-size: 20px;fill: #ffffff;font-weight: 400;font-family: "Asap-Regular", "Asap" }
</style>
<use id="Layer 1" href="#img1" x="58" y="57"/>
<text id="AARE " transform="translate(203,106)">

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 B

+5 -8
View File
@@ -191,18 +191,15 @@ def main():
splash.set_progress(100, "Ready")
splash.finish(win)
# Pre-set the "normal" (un-maximized) geometry as a fraction of the
# primary screen, centered — otherwise leaving maximized mode restores
# the size hint, which is wider than the monitor. Other panels may
# still enforce a somewhat larger minimum; the window then lands on
# that minimum instead.
# Start windowed (not maximized) at a fraction of the primary screen,
# centered — sized explicitly because the size hint is wider than the
# monitor. Other panels may still enforce a somewhat larger minimum;
# the window then lands on that minimum instead.
unmax_w, unmax_h = 0.5, 0.7
available = app.primaryScreen().availableGeometry()
win.resize(int(available.width() * unmax_w), int(available.height() * unmax_h))
win.move(available.center() - win.rect().center())
# Maximized so the window adapts to the monitor instead of its size hint,
# which is taller than a 1920x1200 console.
win.showMaximized()
win.show()
sys.exit(app.exec())
except Exception as e:
+402 -95
View File
@@ -15,20 +15,43 @@ from aarecommon.models.models import (
SessionsStateEnum,
TokenData,
)
from PySide6.QtCore import QEvent, QSettings, Qt, QTimer, Signal, Slot
from PySide6.QtGui import QAction, QActionGroup, QColor, QCursor, QGuiApplication, QKeySequence
from PySide6.QtCore import (
QByteArray,
QEvent,
QObject,
QPropertyAnimation,
QSettings,
Qt,
QTimer,
Signal,
Slot,
)
from PySide6.QtGui import (
QAction,
QActionGroup,
QColor,
QCursor,
QGuiApplication,
QKeySequence,
QPalette,
)
from PySide6.QtWidgets import (
QAbstractButton,
QApplication,
QCheckBox,
QComboBox,
QDockWidget,
QFrame,
QGraphicsColorizeEffect,
QGraphicsOpacityEffect,
QHBoxLayout,
QLabel,
QMainWindow,
QMessageBox,
QPushButton,
QScrollArea,
QSizePolicy,
QSlider,
QStackedWidget,
QTabWidget,
QToolBar,
@@ -55,7 +78,7 @@ from aare.gui.panels.fluorescence_panel import FluorescencePanel
from aare.gui.panels.local_contact_panel import LocalContactDialog
# panels
from aare.gui.panels.log_panel import LogDock
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
@@ -71,11 +94,16 @@ from aare.gui.scan_logic.raster_grid_manager import RasterGridManager
from aare.gui.scan_logic.rotation_scan_manager import RotationScanManager
from aare.gui.scan_logic.sample_mount_logic import SampleMountLogic
from aare.gui.styles import (
BACKGROUND,
APP_BACKGROUND,
DARK_TEXT,
DOCK_CONTENT_LEFT_PAD,
THEME_ORIGINAL,
THEME_PORTRAIT,
SEPARATOR_HINT_DELAY_MS,
THEME_BLUEBIRD,
THEME_FADE_MS,
THEME_SUNRISE,
THEME_SUNSET,
build_app_stylesheet,
qcolor,
)
# Threads
@@ -109,6 +137,33 @@ from aare.gui.widgets.wheel_value_guard import WheelValueGuard
logger = setup_logger(LOGGER_NAME)
class ClickableCursorFilter(QObject):
"""App-wide pointing-hand cursor on every button/combo/slider, present
and future. QSS cannot set cursors, and per-widget setCursor calls are
forgotten whenever a new widget is added — the Polish event fires once
for each widget when its style is applied, so this catches them all.
Widgets that manage their own cursor afterwards (beamline state strip's
forbidden cursor) still win: they set it later."""
def eventFilter(self, obj, event):
if event.type() == QEvent.Type.Polish and isinstance(
obj, (QAbstractButton, QComboBox, QSlider)
):
obj.setCursor(Qt.CursorShape.PointingHandCursor)
return False
class _AlertBannerHost(QWidget):
"""Content root that repositions the floating alert banners on resize.
A plain resizeEvent override instead of event filters: filters firing
during widget teardown corrupted PySide in the test suite."""
def resizeEvent(self, event):
super().resizeEvent(event)
for banner in self.findChildren(AlertBanner):
banner.reposition()
class MainWindow(QMainWindow):
sample_geometry = Signal(SampleGeometryModel)
@@ -116,6 +171,8 @@ class MainWindow(QMainWindow):
# declared for the basedpyright gate.
_session_operations_enabled: bool | None = None
_default_dock_split_done: bool = False
_pre_watch_dock_state: QByteArray | None = None
_pre_watch_visibility: list[tuple[QWidget, bool]] | None = None
def __init__(
self,
@@ -134,7 +191,7 @@ class MainWindow(QMainWindow):
# default) so the mutable list is per-instance (RUF012).
self._pre_vacancy_open_banners: list[TitleLabel] = []
self._theme_mode = THEME_ORIGINAL
self._theme_mode = THEME_SUNRISE
self._theme_action_group = None
self._use_legacy_theme_action = None
self._use_portrait_theme_action = None
@@ -183,6 +240,12 @@ class MainWindow(QMainWindow):
self._tutorial_text_resolver = DictionaryTextResolver(MANUAL_MOUNT_TUTORIAL)
self.state_manager = UIStateManager("PSI", "AareGUI")
# App-level, not window-level: dialogs and pop-outs get it too.
self._clickable_cursor_filter = ClickableCursorFilter(self)
app = QApplication.instance()
assert app is not None
app.installEventFilter(self._clickable_cursor_filter)
# Wheel safety: sliders/spin boxes/combos only react to the wheel
# while the right mouse button is held; a bare wheel just scrolls
# the page — it can never nudge a value or move a motor.
@@ -213,19 +276,31 @@ class MainWindow(QMainWindow):
)
raise
self.setStyleSheet(f"background-color: {BACKGROUND};")
self.setStyleSheet(f"background-color: {APP_BACKGROUND};")
root_widget = QWidget(parent=self)
# Resize-line hint (see SEPARATOR_HINT in styles.py): WA_Hover gives
# us HoverMove without a button held; the single-shot timer implements
# the "rested for 1s" gate. The QSS ::separator:hover rule does the
# hit-testing, so event() never needs to know where separators are.
self.setAttribute(Qt.WidgetAttribute.WA_Hover, True)
self._separator_hint_timer = QTimer(self)
self._separator_hint_timer.setSingleShot(True)
self._separator_hint_timer.setInterval(SEPARATOR_HINT_DELAY_MS)
self._separator_hint_timer.timeout.connect(lambda: self._set_separator_hint(True))
root_widget = _AlertBannerHost(parent=self)
root_widget.setObjectName("mainContentRoot")
root_layout = QVBoxLayout(root_widget)
root_layout.setContentsMargins(0, 0, 0, 0)
root_layout.setSpacing(0)
# Banners float over the content instead of sitting in root_layout, so
# messages (e.g. "Baton acquired!") no longer shift the whole UI.
self.alert_banner = AlertBanner(parent=root_widget)
root_layout.addWidget(self.alert_banner)
self.alert_banner.float_over(root_widget)
self.alert_banner_secondary = AlertBanner(parent=root_widget)
root_layout.addWidget(self.alert_banner_secondary)
self.alert_banner_secondary.float_over(root_widget)
self.content_stack = QStackedWidget(parent=root_widget)
root_layout.addWidget(self.content_stack, 1)
@@ -362,6 +437,9 @@ class MainWindow(QMainWindow):
self.sample_camera = SampleCameraImageLabel(
geom=geom, raster=self.raster, parent=top_widget, default_image=default_image
)
# Baton toasts sit as a compact pill under the camera view instead of
# a full-width bar at the top of the window.
self.alert_banner.anchor_to(self.sample_camera)
self.beamline_view = VideoGraphicsView()
self.beamline_view_panel = AxisVideoPanel(
@@ -473,6 +551,7 @@ class MainWindow(QMainWindow):
# hidden, as the queue engine; its buttons are reparented here so all
# their existing wiring keeps working.
dewar_tab = QWidget()
dewar_tab.setObjectName("dewarTab")
dewar_layout = QVBoxLayout(dewar_tab)
dewar_layout.setContentsMargins(0, 0, 0, 0)
dewar_layout.setSpacing(2)
@@ -514,6 +593,10 @@ class MainWindow(QMainWindow):
# click is caught in eventFilter via the geometric tabAt() instead.
self.sample_lists_tabs.tabBar().installEventFilter(self)
# Tracks beamline-state TRANSITIONS for the default sample-tab switch
# (see _apply_default_sample_tab).
self._last_beamline_state: BeamlineStateEnum | None = None
# Wrapper for the left inset: QTabWidget ignores its own contents
# margins for the tab bar, so the padding lives one level up. Aligns
# the panel's left edge with the left column above (Loop centering).
@@ -549,21 +632,46 @@ class MainWindow(QMainWindow):
self.manual_sample_panel = self.data_collection.manual_sample_panel
self.automation_progress_panel = AutomationProgressWidget()
self.automation_progress_dock = QDockWidget("Automation progress", self)
self.automation_progress_dock.setObjectName("automation_progress_dock")
# Scroll host: the panel's ~420px minimum otherwise dictates the whole
# bottom row's height and squeezes the Beamline column into a scrollbar.
automation_scroll = NoWheelScrollArea(self.automation_progress_dock)
automation_scroll = NoWheelScrollArea()
automation_scroll.setWidget(self.automation_progress_panel)
automation_scroll.setWidgetResizable(True)
automation_scroll.setFrameShape(QFrame.Shape.NoFrame)
self.automation_progress_dock.setWidget(automation_scroll)
self.automation_progress_dock.setAllowedAreas(Qt.DockWidgetArea.BottomDockWidgetArea)
self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, self.automation_progress_dock)
# Same title-bar icons (⤢ pop-out + ✕) as Sample List / Console Log.
self._automation_popout: PopoutWindow | None = None
self.automation_progress_dock.setTitleBarWidget(
DockTitleBar(self.automation_progress_dock, self._open_automation_popout)
# One "Information" dock with Automation progress + Console log tabs,
# mirroring the Sample List dock's Dewar/Auxiliary layout — replaces
# the two tabified docks whose switcher tabs sat at the window bottom.
self.log_panel = LogPanel()
self.log_panel.attach_logger("")
self.log_panel.attach_logger("aareGUI")
self.log_panel.reveal_requested.connect(self._reveal_console_log)
self.information_tabs = QTabWidget()
self.information_tabs.addTab(automation_scroll, "Automation progress")
self.information_tabs.addTab(self.log_panel, "Console log")
# Same wrapper trick as the Sample List dock: QTabWidget ignores its
# own contents margins for the tab bar, so the inset lives one level up.
information_wrap = QWidget()
information_wrap_layout = QVBoxLayout(information_wrap)
information_wrap_layout.setContentsMargins(DOCK_CONTENT_LEFT_PAD, 0, 0, 0)
information_wrap_layout.setSpacing(0)
information_wrap_layout.addWidget(self.information_tabs)
self.information_dock = QDockWidget("Information", self)
self.information_dock.setObjectName("information_dock")
self.information_dock.setWidget(information_wrap)
self.information_dock.setAllowedAreas(Qt.DockWidgetArea.BottomDockWidgetArea)
self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, self.information_dock)
self.information_dock.setFeatures(
QDockWidget.DockWidgetFeature.DockWidgetMovable
| QDockWidget.DockWidgetFeature.DockWidgetClosable
)
# Same title-bar icons (⤢ pop-out + ✕) as Sample List.
self._information_popout: PopoutWindow | None = None
self.information_dock.setTitleBarWidget(
DockTitleBar(self.information_dock, self._open_information_popout)
)
self.face_panel = FaceDetectionPanel()
@@ -588,15 +696,6 @@ class MainWindow(QMainWindow):
self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, self.fluor_panel_dock)
self.fluor_panel_dock.hide()
self.log_dock = LogDock("Console Log", self)
self.log_dock.setObjectName("log_dock")
self.addDockWidget(Qt.BottomDockWidgetArea, self.log_dock)
self.log_dock.attach_logger("")
self.log_dock.attach_logger("aareGUI")
self.log_dock.hide()
self.tabifyDockWidget(self.automation_progress_dock, self.log_dock)
self.job_list_panel.samples_in_queue_changed.connect(
self.automation_progress_panel.set_samples_in_queue
)
@@ -726,11 +825,11 @@ class MainWindow(QMainWindow):
# the default-state capture so "reset layout" gets it too; a saved
# user layout (restored below) still wins.
self.resizeDocks(
[self.tell_samples_dock, self.log_dock], [240, 240], Qt.Orientation.Vertical
[self.tell_samples_dock, self.information_dock], [240, 240], Qt.Orientation.Vertical
)
# Equal oversized requests -> Qt distributes proportionally = 50/50.
self.resizeDocks(
[self.tell_samples_dock, self.automation_progress_dock],
[self.tell_samples_dock, self.information_dock],
[10000, 10000],
Qt.Orientation.Horizontal,
)
@@ -750,6 +849,8 @@ class MainWindow(QMainWindow):
)
self.status_bar = StatusBar(self._decoded_token, parent=self)
# Created after the first _apply_theme, so hand it the theme directly.
self.status_bar.set_theme(self._theme_mode)
self.setStatusBar(self.status_bar)
self.daq = DAQWorker(base_url=self._base_url, token=self._token)
@@ -1138,11 +1239,9 @@ class MainWindow(QMainWindow):
)
self.addAction(self._shortcut_toggle_smargon_trace)
self._shortcut_console_log = QAction("Toggle Console Log", self)
self._shortcut_console_log = QAction("Show Console log", self)
self._shortcut_console_log.setShortcut(QKeySequence("Ctrl+Shift+L"))
self._shortcut_console_log.triggered.connect(
lambda: (self.log_dock.setVisible(True), self.log_dock.raise_())
)
self._shortcut_console_log.triggered.connect(self._reveal_console_log)
self.addAction(self._shortcut_console_log)
@Slot()
@@ -1198,6 +1297,7 @@ class MainWindow(QMainWindow):
dewar_panel.set_status_chip(self.tell_samples.table_model.status_filter)
dewar_tab = QWidget()
dewar_tab.setObjectName("dewarTab")
dewar_layout = QVBoxLayout(dewar_tab)
dewar_layout.setContentsMargins(0, 0, 0, 0)
dewar_layout.setSpacing(2)
@@ -1216,20 +1316,33 @@ class MainWindow(QMainWindow):
self._sample_popout.raise_()
self._sample_popout.activateWindow()
def _open_automation_popout(self) -> None:
if self._automation_popout is None:
# Mirror wired to the same feeds as the docked panel.
def _open_information_popout(self) -> None:
if self._information_popout is None:
# Automation mirror wired to the same feeds as the docked panel.
panel = AutomationProgressWidget()
self.job_list_panel.samples_in_queue_changed.connect(panel.set_samples_in_queue)
self.job_list_panel.automation_running_changed.connect(panel.set_running)
self.daq.automation_progress.connect(panel.set_progress)
panel.set_samples_in_queue(len(self.job_list_panel.table_model.samples))
panel.set_running(self.job_list_panel.is_running())
self._automation_popout = PopoutWindow("Automation progress", panel, parent=self)
self._automation_popout.resize(420, 520)
self._automation_popout.show()
self._automation_popout.raise_()
self._automation_popout.activateWindow()
tabs = QTabWidget()
tabs.addTab(panel, "Automation progress")
tabs.addTab(self.log_panel.make_mirror_view(), "Console log")
self._information_popout = PopoutWindow("Information", tabs, parent=self)
self._information_popout.resize(1000, 520)
self._information_popout.show()
self._information_popout.raise_()
self._information_popout.activateWindow()
@Slot()
def _reveal_console_log(self) -> None:
"""Show the Information dock with the Console log tab on top — the
one entry point for 'the user must see the log now' (notifications,
Ctrl+Shift+L)."""
self.information_dock.setVisible(True)
self.information_dock.raise_()
self.information_tabs.setCurrentWidget(self.log_panel)
def _clone_automation_row(self, dewar_panel: TellSamplePanel) -> QHBoxLayout:
"""Pop-out copy of the automation controls, driving the same queue
@@ -1307,7 +1420,9 @@ class MainWindow(QMainWindow):
show_target_coordinates = bool(
settings.value("samcam/show_target_coordinates", True, type=bool)
)
show_overlay_legend = bool(settings.value("samcam/show_overlay_legend", True, type=bool))
# Legend defaults off — it covers the camera image; users opt in and
# the choice persists via QSettings.
show_overlay_legend = bool(settings.value("samcam/show_overlay_legend", False, type=bool))
compact_overlay_legend = bool(
settings.value("samcam/compact_overlay_legend", False, type=bool)
)
@@ -1355,6 +1470,14 @@ class MainWindow(QMainWindow):
def _on_sample_camera_error(self, message: str) -> None:
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")
def _show_samcam_feed_banner(self, message: str) -> None:
self._samcam_feed_banner_message = message
@@ -1450,13 +1573,12 @@ class MainWindow(QMainWindow):
self._pre_automation_right_column_visible = self.beamline_controls_scroll.isVisible()
self.tell_samples_dock.setVisible(False)
self.automation_progress_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.log_dock.setVisible(False)
self.collection_controls_scroll.setVisible(False)
self.beamline_controls_scroll.setVisible(False)
@@ -1519,13 +1641,12 @@ class MainWindow(QMainWindow):
# Hide all dock widgets
for dock_attr in (
"tell_samples_dock",
"automation_progress_dock",
"information_dock",
"face_panel_dock",
"fluor_panel_dock",
"smargon_trace_dock",
"target_stability_dock",
"prediction_metrics_dock",
"log_dock",
):
dock = getattr(self, dock_attr, None)
if dock is not None:
@@ -1574,7 +1695,7 @@ class MainWindow(QMainWindow):
try:
settings = self.portrait_sample_camera.target_overlay_settings()
self.portrait_sample_camera.set_show_overlay_legend(
settings.get("show_overlay_legend", True)
settings.get("show_overlay_legend", False)
)
except Exception:
logger.debug("Could not restore the camera overlay legend", exc_info=True)
@@ -1584,13 +1705,12 @@ class MainWindow(QMainWindow):
self._pre_portrait_geometry = None
self.tell_samples_dock.setVisible(True)
self.automation_progress_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.log_dock.setVisible(False)
@Slot(str, bool)
def _portrait_alert_primary(self, msg: str, is_error: bool) -> None:
@@ -1682,11 +1802,61 @@ class MainWindow(QMainWindow):
self.tutorial_manager.start("manual_workflow_demo")
def _apply_theme(self) -> None:
# Screenshot cross-fade (THEME_FADE_MS in styles.py): freeze the old
# look in a click-through overlay, restyle beneath it in one normal
# repolish, fade the overlay out. QSS has no transitions, and
# animating the palette would re-polish every widget per frame.
old_look = self.grab() if self.isVisible() else None
# Native primitives QSS can't recolor (spin/combo arrow glyphs) draw
# from the app palette — flip its text roles with the theme.
app = QApplication.instance()
assert isinstance(app, QApplication) # palette() lives on QApplication
if not hasattr(self, "_default_palette"):
self._default_palette = app.palette()
if self._theme_mode == THEME_SUNSET:
palette = QPalette(self._default_palette)
for role in (
QPalette.ColorRole.ButtonText,
QPalette.ColorRole.Text,
QPalette.ColorRole.WindowText,
):
palette.setColor(role, qcolor(DARK_TEXT))
app.setPalette(palette)
else:
app.setPalette(self._default_palette)
self.setStyleSheet(build_app_stylesheet(self._theme_mode))
# State colors are painted in code per DAQ tick — QSS can't reach them.
self.beamline_state_panel.set_theme(self._theme_mode)
# Status bar flags likewise; it is created after the first
# _apply_theme call in __init__, hence the guard.
if hasattr(self, "status_bar"):
self.status_bar.set_theme(self._theme_mode)
self.sample_camera.set_theme(self._theme_mode)
if old_look is None:
return
overlay = QLabel(self)
overlay.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
overlay.setPixmap(old_look)
overlay.setGeometry(self.rect())
overlay.raise_()
overlay.show()
effect = QGraphicsOpacityEffect(overlay)
overlay.setGraphicsEffect(effect)
fade = QPropertyAnimation(effect, QByteArray(b"opacity"), overlay)
fade.setDuration(THEME_FADE_MS)
fade.setStartValue(1.0)
fade.setEndValue(0.0)
fade.finished.connect(overlay.deleteLater)
fade.start(QPropertyAnimation.DeletionPolicy.DeleteWhenStopped)
def _restore_theme_settings(self) -> None:
settings = QSettings("PSI", "AareGUI")
self._theme_mode = settings.value("appearance/theme", THEME_ORIGINAL, type=str)
# str() wrap: settings.value is typed object even with type=str.
saved = str(settings.value("appearance/theme", THEME_SUNRISE, type=str))
# Migrate the pre-rename tokens so a saved theme survives the value
# change (styles.py: "original"->"sunrise", "portrait"->"sunset").
saved = {"original": THEME_SUNRISE, "portrait": THEME_SUNSET}.get(saved, saved)
self._theme_mode = saved
def _save_theme_settings(self) -> None:
settings = QSettings("PSI", "AareGUI")
@@ -1694,12 +1864,17 @@ class MainWindow(QMainWindow):
@Slot()
def use_legacy_theme(self) -> None:
self._theme_mode = THEME_ORIGINAL
self._theme_mode = THEME_SUNRISE
self._apply_theme()
@Slot()
def use_portrait_theme(self) -> None:
self._theme_mode = THEME_PORTRAIT
self._theme_mode = THEME_SUNSET
self._apply_theme()
@Slot()
def use_bluebird_theme(self) -> None:
self._theme_mode = THEME_BLUEBIRD
self._apply_theme()
def create_menu_bar(self):
@@ -1712,41 +1887,54 @@ class MainWindow(QMainWindow):
quit_action.triggered.connect(self.close)
file_menu.addAction(quit_action)
self._enter_automation_view_action = QAction("Automation View", self)
# 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)
menu_bar.addAction(self._enter_automation_view_action)
# 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("Portrait Mode", self)
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)
menu_bar.addAction(self._portrait_mode_action)
view_menu = menu_bar.addMenu("View")
self._theme_action_group = QActionGroup(self)
self._theme_action_group.setExclusive(True)
self._use_legacy_theme_action = QAction("Legacy Theme", self)
self._use_legacy_theme_action = QAction("Sunrise Theme (default)", self)
self._use_legacy_theme_action.setCheckable(True)
self._use_legacy_theme_action.setChecked(self._theme_mode == THEME_ORIGINAL)
self._use_legacy_theme_action.setChecked(self._theme_mode == THEME_SUNRISE)
self._use_legacy_theme_action.triggered.connect(self.use_legacy_theme)
self._theme_action_group.addAction(self._use_legacy_theme_action)
self._use_portrait_theme_action = QAction("Portrait Theme", self)
self._use_portrait_theme_action = QAction("Sunset Theme (work in progress)", self)
self._use_portrait_theme_action.setCheckable(True)
self._use_portrait_theme_action.setChecked(self._theme_mode == THEME_PORTRAIT)
self._use_portrait_theme_action.setChecked(self._theme_mode == THEME_SUNSET)
self._use_portrait_theme_action.triggered.connect(self.use_portrait_theme)
self._theme_action_group.addAction(self._use_portrait_theme_action)
self._use_bluebird_theme_action = QAction("Bluebird Theme", self)
self._use_bluebird_theme_action.setCheckable(True)
self._use_bluebird_theme_action.setChecked(self._theme_mode == THEME_BLUEBIRD)
self._use_bluebird_theme_action.triggered.connect(self.use_bluebird_theme)
self._theme_action_group.addAction(self._use_bluebird_theme_action)
view_menu.addAction(self._use_legacy_theme_action)
view_menu.addAction(self._use_bluebird_theme_action)
view_menu.addAction(self._use_portrait_theme_action)
view_menu.addSeparator()
view_menu.addAction(self._portrait_mode_action)
view_menu.addAction(self._enter_automation_view_action)
view_menu.addSeparator()
show_samples_action = QAction("Show Sample List", self)
show_samples_action.setCheckable(True)
@@ -1809,12 +1997,14 @@ class MainWindow(QMainWindow):
)
view_menu.addAction(show_prediction_metrics_action)
show_log_action = QAction("Show Log", self)
show_log_action.setCheckable(True)
show_log_action.setChecked(False)
show_log_action.triggered.connect(lambda checked: self.log_dock.setVisible(checked))
self.log_dock.visibilityChanged.connect(show_log_action.setChecked)
view_menu.addAction(show_log_action)
show_information_action = QAction("Show Information", self)
show_information_action.setCheckable(True)
show_information_action.setChecked(False)
show_information_action.triggered.connect(
lambda checked: self.information_dock.setVisible(checked)
)
self.information_dock.visibilityChanged.connect(show_information_action.setChecked)
view_menu.addAction(show_information_action)
view_menu.addSeparator()
@@ -1870,15 +2060,9 @@ class MainWindow(QMainWindow):
local_contact_action.triggered.connect(self.show_local_contact)
help_menu.addAction(local_contact_action)
help_menu.addSeparator()
start_text_tutorial_action = QAction("Start Tutorial (Text)", self)
start_text_tutorial_action.triggered.connect(self.start_text_tutorial)
help_menu.addAction(start_text_tutorial_action)
start_interactive_tutorial_action = QAction("Start Tutorial (Interactive)", self)
start_interactive_tutorial_action.triggered.connect(self.start_interactive_tutorial)
help_menu.addAction(start_interactive_tutorial_action)
# Tutorial entries hidden 2026-08-10: content is out of date. The
# tutorial machinery stays wired — restore the two QActions here
# (Start Tutorial (Text)/(Interactive)) once the content is refreshed.
def _capture_default_window_state(self) -> None:
self._default_window_state = self.saveState()
@@ -1902,7 +2086,10 @@ class MainWindow(QMainWindow):
self.smargon_trace_dock.setVisible(False)
self.target_stability_dock.setVisible(False)
self.prediction_metrics_dock.setVisible(False)
self.log_dock.setVisible(False)
# Default look: Information dock open on the Automation progress tab
# (the old automation dock was visible by default, the log hidden).
self.information_dock.setVisible(True)
self.information_tabs.setCurrentIndex(0)
self.tell_samples_dock.raise_()
@@ -1939,15 +2126,15 @@ class MainWindow(QMainWindow):
sticky: bool = True,
auto_clear_ms: int | None = None,
) -> None:
self.log_dock.show_notification(
self.log_panel.show_notification(
title=title, message=message, level=level, sticky=sticky, auto_clear_ms=auto_clear_ms
)
def _show_runtime_waiting_notification(self, *, title: str, message: str) -> None:
self.log_dock.show_waiting_notification(title=title, message=message)
self.log_panel.show_waiting_notification(title=title, message=message)
def _clear_runtime_notification(self) -> None:
self.log_dock.clear_notification()
self.log_panel.clear_notification()
def _clear_automation_critical_banner(self) -> None:
if not self._automation_critical_banner_active:
@@ -2331,11 +2518,73 @@ class MainWindow(QMainWindow):
for banner in banners:
banner.set_collapsed(True, persist=False)
# Watch-only shows ONLY the camera stream: every other operating
# surface is hidden outright (not just greyed), and regaining the
# baton restores exactly the visibility each one had before. The
# status bar and its SESSION VACANT badge stay — they are the way
# back in. To later hide the camera streams as well, add
# self.video_tab to this list.
# findChildren instead of a hand list: hand-listing missed docks
# (Console Log, Fluorescence, ...) and would again for future ones.
# Covers the pop-out mirrors too — they are operating surfaces.
hide_in_watch_mode = [
self.left_column_tabs,
self.beamline,
self.beamline_state_panel,
*self.findChildren(PopoutWindow),
]
if owned:
for widget, was_visible in self._pre_watch_visibility or []:
widget.setVisible(was_visible)
# Docks restore via restoreState, not per-dock setVisible: a dock
# TABBED BEHIND another is isHidden() at snapshot time, so a
# visibility snapshot mistakes it for user-closed and loses it
# (the vanished Sample List). restoreState also brings back
# tab order, the active tab and dock sizes. Needs objectNames
# on every dock.
state = self._pre_watch_dock_state
if state is not None:
self.restoreState(state)
else:
# not isHidden(), NOT isVisible(): the vacant gate first runs
# before the window is shown, where isVisible() is False for
# everything and the restore would then "restore" all-hidden.
self._pre_watch_dock_state = self.saveState()
self._pre_watch_visibility = [(w, not w.isHidden()) for w in hide_in_watch_mode]
for widget in hide_in_watch_mode + self.findChildren(QDockWidget):
widget.hide()
# Alignment/maintenance states mount reference pins, so the sample dock
# defaults to the Auxiliary puck there; every other state defaults back
# to the Dewar list.
_AUX_PUCK_STATES = frozenset(
{
BeamlineStateEnum.Maintenance,
BeamlineStateEnum.BeamLocation,
BeamlineStateEnum.BeamstopAlignment,
BeamlineStateEnum.FluxMeasurement,
}
)
def _apply_default_sample_tab(self, state: BeamlineStateEnum | None) -> None:
# Index 1 = Auxiliary puck; disabled for non-staff, never force it.
if state in self._AUX_PUCK_STATES and self.sample_lists_tabs.isTabEnabled(1):
self.sample_lists_tabs.setCurrentIndex(1)
else:
self.sample_lists_tabs.setCurrentIndex(0)
@Slot(DAQStatusModel)
def update_daq_status(self, s: DAQStatusModel):
self._latest_daq_status = s
self._apply_session_gate(getattr(getattr(s, "session", None), "session", None))
# Default tab only on state TRANSITIONS — a manual tab choice
# survives while the state stays put.
new_beamline_state = getattr(s, "state", None)
if new_beamline_state != self._last_beamline_state:
self._last_beamline_state = new_beamline_state
self._apply_default_sample_tab(new_beamline_state)
if self._is_automation_active():
self._refresh_idle_activity(report_backend=False)
@@ -2436,10 +2685,10 @@ class MainWindow(QMainWindow):
self._close_baton_pending_dialog()
if status.you_are_holder:
self.alert_banner.show_message("Baton acquired!", False, auto_clear_ms=10000)
self.alert_banner.show_message("Baton acquired!", False, auto_clear_ms=4000)
else:
self.alert_banner.show_message(
"Request declined or cancelled", False, auto_clear_ms=10000
"Request declined or cancelled", False, auto_clear_ms=4000
)
# Manage incoming request dialog (when someone requests from us)
@@ -2457,7 +2706,7 @@ class MainWindow(QMainWindow):
"""
if result.get("granted"):
self._waiting_for_baton_response = False
self.alert_banner.show_message("Baton acquired!", False, auto_clear_ms=10000)
self.alert_banner.show_message("Baton acquired!", False, auto_clear_ms=4000)
logger.info("Baton acquired")
# Close pending dialog; StatusBar will trigger p-group selection via SSE
self._close_baton_pending_dialog()
@@ -2518,12 +2767,12 @@ class MainWindow(QMainWindow):
logger.debug(f"Baton response result: {result}")
if result.get("accepted"):
self._waiting_for_baton_response = False
self.alert_banner.show_message("Control transferred", False, auto_clear_ms=10000)
self.alert_banner.show_message("Control transferred", False, auto_clear_ms=4000)
self._close_baton_dialog()
self.status_bar.update_baton_status(self.status_bar._baton_status)
elif result.get("refused"):
self._waiting_for_baton_response = False
self.alert_banner.show_message("Request declined", False, auto_clear_ms=10000)
self.alert_banner.show_message("Request declined", False, auto_clear_ms=4000)
self._close_baton_dialog()
self.status_bar.update_baton_status(self.status_bar._baton_status)
else:
@@ -2542,7 +2791,7 @@ class MainWindow(QMainWindow):
elif result.get("granted"):
self._waiting_for_baton_response = False
self.alert_banner.show_message("Baton acquired!", False, auto_clear_ms=10000)
self.alert_banner.show_message("Baton acquired!", False, auto_clear_ms=4000)
self._close_baton_pending_dialog()
elif result.get("queued"):
@@ -2561,7 +2810,7 @@ class MainWindow(QMainWindow):
elif result.get("refused"):
self._waiting_for_baton_response = False
self.alert_banner.show_message("Request declined", False, auto_clear_ms=10000)
self.alert_banner.show_message("Request declined", False, auto_clear_ms=4000)
self._close_baton_pending_dialog()
else:
@@ -2595,7 +2844,7 @@ class MainWindow(QMainWindow):
settings.setValue("prediction_metrics", self.prediction_metrics_dock.isVisible())
settings.setValue("face_detection", self.face_panel_dock.isVisible())
settings.setValue("fluorescence", self.fluor_panel_dock.isVisible())
settings.setValue("log", self.log_dock.isVisible())
settings.setValue("information", self.information_dock.isVisible())
settings.endGroup()
def _restore_panel_visibility_settings(self) -> None:
@@ -2617,14 +2866,26 @@ class MainWindow(QMainWindow):
self.face_panel_dock.setVisible(settings.value("face_detection", False, type=bool))
if settings.contains("fluorescence"):
self.fluor_panel_dock.setVisible(settings.value("fluorescence", False, type=bool))
if settings.contains("log"):
self.log_dock.setVisible(settings.value("log", False, type=bool))
# New key: the old "log" flag described a dock that defaulted hidden;
# the merged Information dock defaults visible, so old values would
# wrongly hide it.
if settings.contains("information"):
# bool() wrap: settings.value is typed object even with type=bool.
self.information_dock.setVisible(bool(settings.value("information", True, type=bool)))
settings.endGroup()
def _restore_window_state(self) -> None:
# TODO put all setting related handlign into state_manager
self.state_manager.restore_window(self)
# Heal a poisoned layout: a state saved while watch-only mode had
# everything folded away comes back as "every dock hidden" — a user
# hides docks selectively, only the watch-mode fold hides ALL of
# them. Fall back to the default layout instead of resurrecting it
# (the vanished-Sample-List-on-restart bug).
docks = self.findChildren(QDockWidget)
if docks and all(d.isHidden() for d in docks) and self._default_window_state is not None:
self.restoreState(self._default_window_state)
self._restore_panel_visibility_settings()
def showEvent(self, event):
@@ -2640,10 +2901,10 @@ class MainWindow(QMainWindow):
def _apply_default_dock_split(self) -> None:
self.resizeDocks(
[self.tell_samples_dock, self.log_dock], [240, 240], Qt.Orientation.Vertical
[self.tell_samples_dock, self.information_dock], [240, 240], Qt.Orientation.Vertical
)
self.resizeDocks(
[self.tell_samples_dock, self.automation_progress_dock],
[self.tell_samples_dock, self.information_dock],
[10000, 10000],
Qt.Orientation.Horizontal,
)
@@ -2655,6 +2916,10 @@ class MainWindow(QMainWindow):
logger.warning(f"Failed to restore main view before close: {e}", exc_info=True)
try:
# Closing while watch-only would persist the all-hidden fold and
# poison every future start — put the pre-watch layout back first.
if self._session_operations_enabled is False and self._pre_watch_dock_state is not None:
self.restoreState(self._pre_watch_dock_state)
# TODO put all setting related handling into state_manager
self.state_manager.save_window(self)
self._save_samcam_overlay_settings()
@@ -2775,7 +3040,17 @@ class MainWindow(QMainWindow):
@Slot(bool)
def _on_dock_top_level_changed(self, floating: bool) -> None:
dock = self.sender()
if floating and isinstance(dock, QDockWidget):
if not isinstance(dock, QDockWidget):
return
# Floating = top-level: a transparent top-level renders black on the
# container's non-composited X11, so the QDockWidget[floating="true"]
# QSS rule gives it an opaque face; docked it goes transparent again
# so the main-window gradient stays continuous.
dock.setProperty("floating", floating)
dock.style().unpolish(dock)
dock.style().polish(dock)
dock.update()
if floating:
# Pop-outs open enlarged instead of keeping the cramped docked
# size. Deferred: the window is mid-reparent while the signal
# fires.
@@ -2796,6 +3071,38 @@ class MainWindow(QMainWindow):
if dx or dy:
dock.move(geo.x() + dx, geo.y() + dy)
def _set_separator_hint(self, on: bool) -> None:
if self.property("separatorHint") == on:
return
self.setProperty("separatorHint", on)
# Property selectors are only re-evaluated on repolish.
self.style().unpolish(self)
self.style().polish(self)
self.update()
def event(self, event):
# Separator press/drag never reaches mousePressEvent — QMainWindow
# eats it inside event() to start the separator move — so the hint
# gate must sit here, before super() dispatches.
t = event.type()
if t in (QEvent.Type.HoverEnter, QEvent.Type.HoverMove):
# Any move restarts the 1s countdown ("hover and rest"); during a
# drag (button held) the hint stays on instead.
if QApplication.mouseButtons() == Qt.MouseButton.NoButton:
self._set_separator_hint(False)
self._separator_hint_timer.start()
elif t == QEvent.Type.HoverLeave:
self._separator_hint_timer.stop()
self._set_separator_hint(False)
elif t == QEvent.Type.MouseButtonPress:
# Press shows the line immediately, no 1s wait.
self._separator_hint_timer.stop()
self._set_separator_hint(True)
elif t == QEvent.Type.MouseButtonRelease:
self._set_separator_hint(False)
self._separator_hint_timer.start()
return super().event(event)
def eventFilter(self, obj, event):
# Closing a floated (popped-out) dock re-docks it instead of hiding —
# otherwise the panel silently disappears and has to be restored via
+6 -2
View File
@@ -4,7 +4,7 @@ from PySide6.QtCore import QAbstractTableModel, Qt
from PySide6.QtGui import QBrush
from aare.gui.constants import LOGGER_NAME
from aare.gui.styles import SAMPLE_ROW_ACTIVE_BG, SAMPLE_ROW_QUEUED_BG, WHITE, qcolor
from aare.gui.styles import SAMPLE_ROW_ACTIVE_BG, SAMPLE_ROW_QUEUED_BG, SAMPLE_STATUS_TEXT, qcolor
logger = setup_logger(LOGGER_NAME)
@@ -59,12 +59,16 @@ class SampleQueueSpreadsheet(QAbstractTableModel):
elif role == Qt.ItemDataRole.TextAlignmentRole:
return Qt.AlignmentFlag.AlignCenter
elif role == Qt.ItemDataRole.BackgroundRole:
# Tint only the head-of-queue row; plain rows return None so the
# theme QSS paints them (hardcoded WHITE fills broke dark mode).
if index.row() == 0:
if self._running:
return QBrush(qcolor(SAMPLE_ROW_ACTIVE_BG))
else:
return QBrush(qcolor(SAMPLE_ROW_QUEUED_BG))
return QBrush(qcolor(WHITE))
elif role == Qt.ItemDataRole.ForegroundRole and index.row() == 0:
# Fixed dark ink on the tint so dark-theme white text stays legible.
return QBrush(qcolor(SAMPLE_STATUS_TEXT))
return None
def headerData(self, section, orientation, role=None):
+9
View File
@@ -11,6 +11,7 @@ from aare.gui.styles import (
SAMPLE_STATUS_FLAGGED_BG,
SAMPLE_STATUS_MEASURED_BG,
SAMPLE_STATUS_QUEUED_BG,
SAMPLE_STATUS_TEXT,
qcolor,
)
@@ -121,6 +122,14 @@ class UserSampleSpreadsheet(QAbstractTableModel):
color = self._status_color(self._sorted_samples[index.row()])
if color is not None:
return QBrush(qcolor(color))
elif role == Qt.ItemDataRole.ForegroundRole:
# Tinted cells get fixed dark ink: the tints stay light pastel in
# BOTH themes, so Sunset's white theme text would vanish on them.
if (
index.column() == COL_STATUS
and self._status_color(self._sorted_samples[index.row()]) is not None
):
return QBrush(qcolor(SAMPLE_STATUS_TEXT))
elif role == Qt.ItemDataRole.TextAlignmentRole: # Align text to center
return Qt.AlignmentFlag.AlignCenter
return None # For other roles, return None
+11 -6
View File
@@ -4,7 +4,7 @@ from PySide6.QtCore import Signal, Slot
from PySide6.QtGui import Qt
from PySide6.QtWidgets import QGridLayout, QLabel, QPushButton, QWidget
from aare.gui.styles import ALERT_TEXT, DEFAULT_TEXT
from aare.gui.styles import ALERT_TEXT
from aare.gui.widgets.button_with_payload import ButtonWithPayload
from aare.gui.widgets.number_line_edit import NumberLineEdit
from aare.gui.widgets.title_label import TitleLabel
@@ -20,10 +20,15 @@ class AbrTweakButtons(QWidget):
self._step_mm = step_mm
grid_layout = QGridLayout(self)
grid_layout.setColumnStretch(0, 1)
# Caption, arrows, and value pack tight to the left; the empty
# trailing column soaks up all dead width. The values keep AlignRight
# in their content-width column so the decimal points line up without
# drifting over next to the Step column.
grid_layout.setColumnStretch(0, 0)
grid_layout.setColumnStretch(1, 0)
grid_layout.setColumnStretch(2, 0)
grid_layout.setColumnStretch(3, 3)
grid_layout.setColumnStretch(3, 0)
grid_layout.setColumnStretch(4, 1)
grid_layout.addWidget(QLabel("GMX"), 0, 0)
button_gmx_minus = ButtonWithPayload("", payload={"x": -1, "y": 0, "z": 0})
@@ -148,15 +153,15 @@ class AbrTweakWidget(QWidget):
if abs(s.geom.aerotech.x) >= 0.001:
self._abr_buttons.gmx_label.setStyleSheet(f"color: {ALERT_TEXT};")
else:
self._abr_buttons.gmx_label.setStyleSheet(f"color: {DEFAULT_TEXT};")
self._abr_buttons.gmx_label.setStyleSheet("")
self._abr_buttons.gmy_label.setText(f"{s.geom.aerotech_meas.y:.3f}")
if abs(s.geom.aerotech.y) >= 0.001:
self._abr_buttons.gmy_label.setStyleSheet(f"color: {ALERT_TEXT};")
else:
self._abr_buttons.gmy_label.setStyleSheet(f"color: {DEFAULT_TEXT};")
self._abr_buttons.gmy_label.setStyleSheet("")
self._abr_buttons.gmz_label.setText(f"{s.geom.aerotech_meas.z:.3f}")
if abs(s.geom.aerotech.z) >= 0.001:
self._abr_buttons.gmz_label.setStyleSheet(f"color: {ALERT_TEXT};")
else:
self._abr_buttons.gmz_label.setStyleSheet(f"color: {DEFAULT_TEXT};")
self._abr_buttons.gmz_label.setStyleSheet("")
+14 -49
View File
@@ -1,4 +1,6 @@
from PySide6.QtCore import Qt, Signal
from dataclasses import replace
from PySide6.QtCore import Signal
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget
from aare.gui.widgets.busy_overlay import BusyOverlayStyle
@@ -8,32 +10,13 @@ from aare.gui.widgets.video_image import VideoGraphicsView
class AxisVideoPanel(QWidget):
refresh_requested = Signal()
def __init__(self, title: str, video_view: VideoGraphicsView | None = None, parent=None):
# video_view may be a bare VideoGraphicsView or any container holding
# them (the combined view passes a QWidget with two stacked views).
def __init__(self, title: str, video_view: QWidget | None = None, parent=None):
super().__init__(parent)
self._title_label = QLabel(title, self)
self._status_container = QWidget(self)
self._status_container.setObjectName("axisVideoStatusContainer")
self._status_container.setProperty("busyState", "idle")
self._status_dot = QLabel(self._status_container)
self._status_dot.setObjectName("axisVideoStatusDot")
self._status_dot.setFixedSize(10, 10)
self._status_label = QLabel("", self._status_container)
self._status_label.setObjectName("axisVideoStatusLabel")
self._status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
status_layout = QHBoxLayout(self._status_container)
status_layout.setContentsMargins(10, 6, 12, 6)
status_layout.setSpacing(8)
status_layout.addWidget(self._status_dot)
status_layout.addWidget(self._status_label)
self._status_container.setMinimumWidth(190)
self._status_container.hide()
self._refresh_button = QPushButton("Refresh Axis Cameras", self)
self._refresh_button.clicked.connect(self.refresh_requested.emit)
@@ -43,7 +26,6 @@ class AxisVideoPanel(QWidget):
controls_layout.setContentsMargins(0, 0, 0, 0)
controls_layout.addWidget(self._title_label)
controls_layout.addStretch()
controls_layout.addWidget(self._status_container)
controls_layout.addWidget(self._refresh_button)
root_layout = QVBoxLayout(self)
@@ -52,12 +34,6 @@ class AxisVideoPanel(QWidget):
root_layout.addLayout(controls_layout)
root_layout.addWidget(self.view)
def _refresh_status_style(self) -> None:
for widget in (self._status_container, self._status_dot, self._status_label):
widget.style().unpolish(widget)
widget.style().polish(widget)
widget.update()
def _all_video_views(self) -> list[VideoGraphicsView]:
views: list[VideoGraphicsView] = []
if isinstance(self.view, VideoGraphicsView):
@@ -70,24 +46,13 @@ class AxisVideoPanel(QWidget):
return unique_views
def set_busy_style(self, style: BusyOverlayStyle | None) -> None:
if style is None:
self._status_label.setText("")
self._status_container.setProperty("busyState", "idle")
self._status_dot.setStyleSheet("background-color: transparent;")
self._status_label.setStyleSheet("")
self._refresh_status_style()
self._status_container.hide()
else:
self._status_label.setText(style.text)
self._status_container.setProperty("busyState", "active")
self._status_dot.setStyleSheet(f"background-color: {style.accent_dot};")
self._status_label.setStyleSheet(f"color: {style.badge_fg};")
self._refresh_status_style()
self._status_container.show()
# The hint line invites a click, but only the sample-camera badge is
# a click target — strip it for these passive views.
if style is not None and style.subtext:
style = replace(style, subtext="")
for view in self._all_video_views():
# Only the first view draws the badge: the combined panel stacks two
# video views and used to show the message once per view.
for index, view in enumerate(self._all_video_views()):
if hasattr(view, "set_busy_overlay_style"):
view.set_busy_overlay_style(style)
def set_status_text(self, text: str) -> None:
self._status_label.setText(text or "")
view.set_busy_overlay_style(style if index == 0 else None)
+1 -1
View File
@@ -27,7 +27,7 @@ class BeamMarkWidget(QWidget):
# Shares the readings row instead of a full-width row below.
clear_button = QPushButton("Clear marks")
clear_button.setFixedWidth(90)
clear_button.setFixedWidth(100) # 100 is needed to see everything
grid_layout.addWidget(clear_button, 1, 5)
clear_button.pressed.connect(self.clear_button_pressed)
+39 -17
View File
@@ -5,13 +5,7 @@ from PySide6.QtCore import Qt, QTimer, Signal, Slot
from PySide6.QtGui import QCursor, QFont, QFontMetrics
from PySide6.QtWidgets import QFrame, QHBoxLayout, QLabel, QMenu, QPushButton, QSizePolicy, QToolTip
from aare.gui.styles import (
FONT_VALUE,
STATE_AVAILABLE,
STATE_MSG_ERROR,
STATE_MSG_INFO,
STATE_UNAVAILABLE,
)
from aare.gui.styles import FONT_VALUE, THEME_SUNRISE, state_colors
# Shortcut transitions from the "Available transitions" menu in
# widgets/status_bar.py show_state_menu — these come ON TOP of the one-hop
@@ -146,8 +140,9 @@ class BeamlineStatePanel(QFrame):
self._hover_hint_timer.setInterval(3000)
self._hover_hint_timer.timeout.connect(self._show_hover_hint)
self._available_color = STATE_AVAILABLE
self._unavailable_color = STATE_UNAVAILABLE
# Per-theme colors (MainWindow._apply_theme calls set_theme).
self._colors = state_colors(THEME_SUNRISE)
self._separators: list[QLabel] = []
layout = QHBoxLayout(self)
layout.setContentsMargins(10, 2, 10, 2)
@@ -159,9 +154,8 @@ class BeamlineStatePanel(QFrame):
for index, (state, label) in enumerate(self._ENTRIES):
if index:
separator = QLabel("", self)
separator.setStyleSheet(
f"color: {STATE_UNAVAILABLE}; background: transparent; border: none; font-size: {FONT_VALUE};"
)
self._style_separator(separator)
self._separators.append(separator)
layout.addWidget(separator)
button = HoverableButton(label, self)
button.setFlat(True)
@@ -310,6 +304,21 @@ class BeamlineStatePanel(QFrame):
elif state == BeamlineStateEnum.XrayFluorescence:
self.xray_fluorescence.emit()
def _style_separator(self, separator: QLabel) -> None:
separator.setStyleSheet(
f"color: {self._colors['unavailable']};"
f" background: transparent; border: none; font-size: {FONT_VALUE};"
)
def set_theme(self, theme: str) -> None:
"""Adopt the theme's state colors (MainWindow._apply_theme calls this
on every switch — the colors are painted in code, so the app QSS
alone cannot restyle them)."""
self._colors = state_colors(theme)
for separator in self._separators:
self._style_separator(separator)
self._apply_highlight()
def _apply_highlight(self) -> None:
available = self._available_targets()
for state, button in self._buttons.items():
@@ -325,14 +334,16 @@ class BeamlineStatePanel(QFrame):
# No backgrounds, no rounded corners.
if is_current or is_pending:
color = (
STATE_MSG_ERROR if state == BeamlineStateEnum.Maintenance else STATE_MSG_INFO
self._colors["error"]
if state == BeamlineStateEnum.Maintenance
else self._colors["info"]
)
bold = True
elif is_available:
color = self._available_color
color = self._colors["available"]
bold = False
else:
color = self._unavailable_color
color = self._colors["unavailable"]
bold = False
# Font set in code (not QSS) so _update_label_mode can measure
@@ -345,17 +356,28 @@ class BeamlineStatePanel(QFrame):
# Guarded updates: this runs on every DAQ tick, and re-applying an
# unchanged stylesheet repolishes the button, which drops the hover
# cursor under a resting mouse until it moves again.
# Hover underline (the app-wide tab/chip affordance) only on
# entries that mean something: the current state and reachable
# targets — not the grey dead ends.
hover_underline = (
" text-decoration: underline;" if (is_current or is_pending or is_available) else ""
)
qss = (
f"QPushButton {{ border: none; background: transparent; color: {color};"
f" padding: 1px 8px; }}"
f" QPushButton:hover {{ color: {color}; }}"
f" QPushButton:hover {{ color: {color};{hover_underline} }}"
)
if button.styleSheet() != qss:
button.setStyleSheet(qss)
# Clickability follows availability; unavailable states get the
# forbidden cursor and only the deferred 3 s explanation tooltip.
if is_available:
# The current state is not a click target, but it is not
# forbidden either — plain cursor + a "you are here" tip.
if is_current or is_pending:
cursor = Qt.CursorShape.ArrowCursor
tooltip = f"{state.display_name()}: this is the current state"
elif is_available:
cursor = Qt.CursorShape.PointingHandCursor
tooltip = self._TOOLTIPS.get(state, state.display_name())
else:
@@ -20,6 +20,7 @@ from aare.gui.panels.raster_data_collection import RasterDataCollectionPanel
from aare.gui.panels.rotation_data_collection import RotationDataCollectionPanel
from aare.gui.panels.smart_rotation_panel import SimpleRotationSettingsPanel
from aare.gui.scan_logic.raster_grid_manager import RasterGridManager
from aare.gui.styles import BANNER_TAB_GAP
from aare.gui.widgets.title_label import TitleLabel, tighten_column
@@ -102,6 +103,9 @@ class DataCollectionSettings(QFrame):
"Experiment configuration", exp_config, collapsible=True, default_collapsed=False
)
)
# Explicit spacer, not layout spacing: the tab bar must keep sitting
# flush on the pane below, only the banner gets breathing room.
exp_config_layout.addSpacing(BANNER_TAB_GAP)
exp_config_layout.addWidget(self._tab_bar)
exp_config_layout.addWidget(pane)
v_layout.addWidget(exp_config)
+20 -4
View File
@@ -32,6 +32,7 @@ from PySide6.QtWidgets import (
from aare.gui.constants import LOGGER_NAME
from aare.gui.log import QtLogEmitter, QtLogHandler
from aare.gui.styles import (
FLAT_CARD_RADIUS,
PANEL_BG_FAINT,
PANEL_BG_SOFT,
PANEL_BORDER,
@@ -69,8 +70,16 @@ class DeveloperHelpDialog(QDialog):
self._banner.setVisible(self._is_staff)
self._banner.setWordWrap(True)
self._banner.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
# Square cards throughout this dialog: the rounded corner cut-outs
# render black on the container's non-composited X11.
self._banner.setStyleSheet(
card_style(PANEL_BG_SOFT, PANEL_BORDER, selector="QLabel", extra="padding: 6px 8px;")
card_style(
PANEL_BG_SOFT,
PANEL_BORDER,
selector="QLabel",
radius=FLAT_CARD_RADIUS,
extra="padding: 6px 8px;",
)
)
root.addWidget(self._banner)
@@ -89,7 +98,6 @@ class DeveloperHelpDialog(QDialog):
"QLineEdit {"
f" background: {WHITE};"
f" border: 1px solid {PANEL_BORDER_DARK};"
" border-radius: 6px;"
" padding: 4px 8px;"
"}"
)
@@ -149,7 +157,9 @@ class DeveloperHelpDialog(QDialog):
self._details_frame = QFrame(self)
self._details_frame.setFrameShape(QFrame.Shape.StyledPanel)
self._details_frame.setStyleSheet(card_style(PANEL_BG_FAINT, PANEL_BORDER))
self._details_frame.setStyleSheet(
card_style(PANEL_BG_FAINT, PANEL_BORDER, radius=FLAT_CARD_RADIUS)
)
details_layout = QVBoxLayout(self._details_frame)
details_layout.setContentsMargins(10, 10, 10, 10)
@@ -179,7 +189,13 @@ class DeveloperHelpDialog(QDialog):
self._detail_help.setWordWrap(True)
self._detail_help.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
self._detail_help.setStyleSheet(
card_style(WHITE, PANEL_BORDER_LIGHT, selector="QLabel", extra="padding: 8px;")
card_style(
WHITE,
PANEL_BORDER_LIGHT,
selector="QLabel",
radius=FLAT_CARD_RADIUS,
extra="padding: 8px;",
)
)
details_layout.addWidget(QLabel("Help:", self))
details_layout.addWidget(self._detail_help, 1)
+4 -7
View File
@@ -7,7 +7,7 @@ from aarecommon.models.models import DAQStatusModel, SampleShortInfo
from PySide6.QtCore import Qt, Signal, Slot
from PySide6.QtWidgets import QGridLayout, QLabel, QLineEdit, QMessageBox, QSpinBox, QWidget
from aare.gui.styles import DEFAULT_TEXT, PATH_WARN_TEXT, SURFACE
from aare.gui.styles import PATH_WARN_TEXT
from aare.gui.widgets.title_label import TitleLabel
## Logic for filenames:
@@ -47,7 +47,6 @@ class FilePathPanel(QWidget):
grid_layout.addWidget(QLabel("Directory", parent=self), 1, 0)
self.directory_edit = QLineEdit("{date}/{puck}/{pos}", parent=self)
self.directory_edit.setStyleSheet(f"background-color: {SURFACE};")
self.directory_edit.setToolTip(
"Provide subdirectory for your files. The following macros are allowed: <br/>"
"<b>{date}</b> - date in format yyyymmdd <br/>"
@@ -61,7 +60,6 @@ class FilePathPanel(QWidget):
grid_layout.addWidget(QLabel("File prefix", parent=self), 2, 0)
self.file_prefix_edit = QLineEdit("{sample}", parent=self)
self.file_prefix_edit.setStyleSheet(f"background-color: {SURFACE};")
self.file_prefix_edit.setToolTip(
"Provide file prefix for your files. The following macros are allowed: <br/>"
"<b>{date}</b> - date in format yyyymmdd <br/>"
@@ -75,7 +73,6 @@ class FilePathPanel(QWidget):
grid_layout.addWidget(QLabel("Run number", parent=self), 3, 0)
self.run_number_edit = QSpinBox(parent=self)
self.run_number_edit.setStyleSheet(f"background-color: {SURFACE};")
self.run_number_edit.setValue(1)
self.run_number_edit.setRange(1, 999)
self.run_number_edit.setAlignment(Qt.AlignmentFlag.AlignRight)
@@ -163,9 +160,9 @@ class FilePathPanel(QWidget):
effective = self._effective_dataset_base(self._filename)
exists = os.path.exists(f"{effective}_master.h5") or os.path.exists(effective)
self.file_name_label.setText(effective + "_master.h5")
self.file_name_label.setStyleSheet(
f"color: {PATH_WARN_TEXT};" if exists else f"color: {DEFAULT_TEXT};"
)
# Empty stylesheet = reset to the THEME text color (a hardcoded
# "default" black would be invisible on the dark theme).
self.file_name_label.setStyleSheet(f"color: {PATH_WARN_TEXT};" if exists else "")
self.path_updated.emit(self._filename)
@Slot()
+42 -59
View File
@@ -1,7 +1,6 @@
from aarecommon.config.logger import attach_to_logger, find_existing_formatter
from PySide6.QtCore import Qt, QTimer, Signal, Slot
from PySide6.QtCore import QTimer, Signal, Slot
from PySide6.QtWidgets import (
QDockWidget,
QFrame,
QHBoxLayout,
QLabel,
@@ -25,9 +24,9 @@ from aare.gui.styles import (
LOG_SUCCESS_BORDER,
LOG_WARN_BG,
LOG_WARN_BORDER,
TEXT,
card_style,
)
from aare.gui.widgets.popout_window import DockTitleBar, PopoutWindow
class RuntimeNotificationWidget(QFrame):
@@ -60,7 +59,7 @@ class RuntimeNotificationWidget(QFrame):
self._clear_button = QPushButton("Clear", self)
self._clear_button.clicked.connect(self.clear_notification)
self._show_log_button = QPushButton("Show Log", self)
self._show_log_button = QPushButton("Show log", self)
self._show_log_button.clicked.connect(self.show_log_requested.emit)
header_layout = QHBoxLayout()
@@ -125,9 +124,12 @@ class RuntimeNotificationWidget(QFrame):
radius=FLAT_CARD_RADIUS,
)
# Transparent children: the app-wide QWidget background rule would
# otherwise paint opaque strips over the card tint.
+ " QLabel#runtimeNotificationTitle { font-weight: bold; background: transparent; }"
+ " QLabel#runtimeNotificationMessage { background: transparent; }"
# otherwise paint opaque strips over the card tint. Text color is
# pinned dark: the card fills above stay light pastel in BOTH
# themes, so theme-following text goes white-on-cream in Sunset.
+ f" QLabel#runtimeNotificationTitle {{ color: {TEXT};"
+ " font-weight: bold; background: transparent; }"
+ f" QLabel#runtimeNotificationMessage {{ color: {TEXT}; background: transparent; }}"
+ " QWidget#runtimeNotificationBody { background: transparent; }"
)
@@ -190,41 +192,30 @@ class RuntimeNotificationWidget(QFrame):
self.cleared.emit()
class LogDock(QDockWidget):
def __init__(self, title="Log", parent=None):
super().__init__(title, parent)
self.setAllowedAreas(
Qt.DockWidgetArea.BottomDockWidgetArea
| Qt.DockWidgetArea.RightDockWidgetArea
| Qt.DockWidgetArea.LeftDockWidgetArea
)
class LogPanel(QWidget):
"""Console-log card: notification banner + log view. A tab inside the
Information dock (was its own LogDock QDockWidget until the Automation
progress / Console log docks merged). Revealing the dock/tab is the
owner's job — this panel only signals when it needs to be seen."""
# No floating: popping a dock out rips it from the row and reshuffles
# the rest. The ⤢ in the title bar (next to ✕) opens an ADDITIONAL
# window on the same log instead.
self.setFeatures(
QDockWidget.DockWidgetFeature.DockWidgetMovable
| QDockWidget.DockWidgetFeature.DockWidgetClosable
)
self._popout: PopoutWindow | None = None
self._popout_view: QPlainTextEdit | None = None
self.setTitleBarWidget(DockTitleBar(self, self._open_popout))
reveal_requested = Signal()
self.container = QWidget(self)
def __init__(self, parent=None):
super().__init__(parent)
self.setObjectName("logPanel")
self.notification = RuntimeNotificationWidget(self)
self.notification.show_log_requested.connect(self._focus_log)
self.notification = RuntimeNotificationWidget(self.container)
self.notification.show_log_requested.connect(self._raise_and_focus_log)
self.view = QPlainTextEdit(self.container)
self.view = QPlainTextEdit(self)
self.view.setReadOnly(True)
layout = QVBoxLayout(self.container)
layout = QVBoxLayout(self)
layout.setContentsMargins(6, 6, 6, 6)
layout.setSpacing(6)
layout.addWidget(self.notification)
layout.addWidget(self.view, 1)
self.setWidget(self.container)
self._mirror_views: list[QPlainTextEdit] = []
self.emitter = QtLogEmitter()
self.emitter.message.connect(self._append_line)
@@ -239,29 +230,23 @@ class LogDock(QDockWidget):
def _append_line(self, text: str):
self.view.appendPlainText(text)
@Slot()
def _open_popout(self) -> None:
if self._popout is None:
# Mirror view fed by the same emitter; history is copied once at
# creation. (One QTextDocument shared by two QPlainTextEdits would
# make their layouts fight, hence the second document.)
view = QPlainTextEdit()
view.setReadOnly(True)
# Frameless inside the pop-out — no nested boxes in this window.
view.setStyleSheet("QPlainTextEdit { border: none; }")
view.setPlainText(self.view.toPlainText())
self.emitter.message.connect(view.appendPlainText)
self._popout_view = view
self._popout = PopoutWindow("Console Log", view, parent=self.window())
self._popout.resize(1000, 450)
self._popout.show()
self._popout.raise_()
self._popout.activateWindow()
def make_mirror_view(self) -> QPlainTextEdit:
"""Second view on the same emitter, for pop-out windows: history is
copied once at creation, live lines reach every mirror, clear()
empties them all. (One QTextDocument shared by two QPlainTextEdits
would make their layouts fight, hence the separate documents.)"""
view = QPlainTextEdit()
view.setReadOnly(True)
# Frameless inside the pop-out — no nested boxes in this window.
view.setStyleSheet("QPlainTextEdit { border: none; }")
view.setPlainText(self.view.toPlainText())
self.emitter.message.connect(view.appendPlainText)
self._mirror_views.append(view)
return view
@Slot()
def _raise_and_focus_log(self) -> None:
self.setVisible(True)
self.raise_()
def _focus_log(self) -> None:
self.reveal_requested.emit()
self.view.setFocus()
def show_notification(
@@ -273,15 +258,13 @@ class LogDock(QDockWidget):
sticky: bool = True,
auto_clear_ms: int | None = None,
) -> None:
self.setVisible(True)
self.raise_()
self.reveal_requested.emit()
self.notification.show_notification(
title=title, message=message, level=level, sticky=sticky, auto_clear_ms=auto_clear_ms
)
def show_waiting_notification(self, *, title: str, message: str) -> None:
self.setVisible(True)
self.raise_()
self.reveal_requested.emit()
self.notification.show_waiting(title=title, message=message)
def clear_notification(self) -> None:
@@ -289,5 +272,5 @@ class LogDock(QDockWidget):
def clear(self):
self.view.clear()
if self._popout_view is not None:
self._popout_view.clear()
for mirror in self._mirror_views:
mirror.clear()
+3 -2
View File
@@ -23,13 +23,14 @@ class MonochromatorPanel(QWidget):
# One row (label | value | button) instead of three — vertical space.
# Display in keV; the DAQ API stays in eV (converted on emit).
grid_layout.addWidget(QLabel("Energy", parent=self), 2, 0)
# Unit lives in the label, not as a spinbox suffix — the suffix ate
# field width and sat between the value and the +/- arrow.
grid_layout.addWidget(QLabel("Energy (keV)", parent=self), 2, 0)
self.energy_spin = QDoubleSpinBox(parent=self)
self.energy_spin.setDecimals(3)
self.energy_spin.setRange(1.0, 30.0)
self.energy_spin.setSingleStep(0.1)
self.energy_spin.setSuffix(" keV")
self.energy_spin.setValue(12.0)
grid_layout.addWidget(self.energy_spin, 2, 1)
+35 -11
View File
@@ -7,7 +7,7 @@ from PySide6.QtGui import QBrush
from PySide6.QtWidgets import QAbstractItemView, QFrame, QGridLayout, QHeaderView, QMenu, QTableView
from aare.gui.constants import LOGGER_NAME
from aare.gui.styles import SAMPLE_ROW_QUEUED_BG, WHITE, qcolor
from aare.gui.styles import SAMPLE_ROW_QUEUED_BG, SAMPLE_STATUS_TEXT, qcolor
from aare.gui.widgets.title_label import TitleLabel
logger = setup_logger(LOGGER_NAME)
@@ -40,7 +40,10 @@ class ReferenceToolsModel(QAbstractTableModel):
self.samples: list[SampleShortInfo] = rows or []
self.current_reference = current_reference
# Column 0 is display-only: the row position ("#"), matching the
# Dewar samples table; the vertical header is hidden in the panel.
self.header = [
"#",
"Position",
"Sample name",
"Mount count",
@@ -48,7 +51,7 @@ class ReferenceToolsModel(QAbstractTableModel):
"Rotation count",
"Screening count",
]
self._sort_col = 0
self._sort_col = 1
self._sort_order = Qt.SortOrder.AscendingOrder
self._sorted_samples: list[SampleShortInfo] = []
if self.samples:
@@ -70,13 +73,25 @@ class ReferenceToolsModel(QAbstractTableModel):
return None
if role == Qt.ItemDataRole.DisplayRole:
return get_entry(self._sorted_samples[index.row()], index.column())
if index.column() == 0:
return str(index.row() + 1)
return get_entry(self._sorted_samples[index.row()], index.column() - 1)
elif role == Qt.ItemDataRole.TextAlignmentRole:
return Qt.AlignmentFlag.AlignCenter
elif role == Qt.ItemDataRole.BackgroundRole:
# Tint only the current-reference row; plain rows return None so
# the theme QSS paints them (a hardcoded WHITE fill here was the
# big white table in dark mode and fought the light theme's
# alternating stripes).
if self._sorted_samples[index.row()].db_id == self.current_reference:
return QBrush(qcolor(SAMPLE_ROW_QUEUED_BG))
return QBrush(qcolor(WHITE))
elif (
role == Qt.ItemDataRole.ForegroundRole
and self._sorted_samples[index.row()].db_id == self.current_reference
):
# Fixed dark ink on the tint — the tint stays pale in BOTH themes,
# so the dark theme's near-white text would vanish on it.
return QBrush(qcolor(SAMPLE_STATUS_TEXT))
return None
@@ -99,6 +114,9 @@ class ReferenceToolsModel(QAbstractTableModel):
self.endResetModel()
def sort(self, column, order):
# The "#" column is display-only — nothing to sort by.
if column == 0:
return
self.layoutAboutToBeChanged.emit()
self._sort_order = order
self._sort_col = column
@@ -111,14 +129,14 @@ class ReferenceToolsModel(QAbstractTableModel):
self._sorted_samples = []
return
if self._sort_col == 0:
if self._sort_col == 1:
# Special sorting for location (Position column)
self._sorted_samples = sorted(
self.samples,
key=lambda row: row.loc_str_sort(),
reverse=(self._sort_order == Qt.SortOrder.DescendingOrder),
)
elif self._sort_col == 2:
elif self._sort_col == 3:
# Numeric sort for Mount count; place None last on ascending, first on descending
none_sentinel = (
float("inf") if self._sort_order == Qt.SortOrder.AscendingOrder else float("-inf")
@@ -131,10 +149,11 @@ class ReferenceToolsModel(QAbstractTableModel):
reverse=(self._sort_order == Qt.SortOrder.DescendingOrder),
)
else:
# String sort with empty fallback
# String sort with empty fallback (get_entry columns sit one left
# of the view columns because of the display-only "#").
self._sorted_samples = sorted(
self.samples,
key=lambda row: get_entry(row, self._sort_col) or "",
key=lambda row: get_entry(row, self._sort_col - 1) or "",
reverse=(self._sort_order == Qt.SortOrder.DescendingOrder),
)
@@ -152,7 +171,7 @@ class ReferenceToolsModel(QAbstractTableModel):
self.dataChanged.emit(
self.index(0, 0),
self.index(self.rowCount() - 1, self.columnCount() - 1),
[Qt.ItemDataRole.BackgroundRole],
[Qt.ItemDataRole.BackgroundRole, Qt.ItemDataRole.ForegroundRole],
)
@@ -191,8 +210,11 @@ class ReferenceToolsPanel(QFrame):
layout.addWidget(TitleLabel("Reference tools", parent=self), 0, 0, 1, 4)
self.table_view = QTableView(parent=self)
# Row colors carry the separation — no grid lines.
# Row colors carry the separation — no grid lines. Alternating rows
# come from the theme QSS (alternate-background-color), same as the
# Dewar sample list.
self.table_view.setShowGrid(False)
self.table_view.setAlternatingRowColors(True)
layout.addWidget(self.table_view, 1, 0, 1, 4)
# initialize model with provided samples (or adopt the shared one)
@@ -208,7 +230,9 @@ class ReferenceToolsPanel(QFrame):
header.setStretchLastSection(True)
# No bold column titles when cells are selected.
header.setHighlightSections(False)
self.table_view.verticalHeader().setVisible(True)
# Row numbers live in the display-only "#" column (like the Dewar
# table), not the vertical header.
self.table_view.verticalHeader().setVisible(False)
logger.debug("Setting up table view sorting")
# Adopt the model's current order first — a second panel on a shared
# model must not re-sort it on open.
+5 -10
View File
@@ -13,7 +13,6 @@ from PySide6.QtWidgets import (
QWidget,
)
from aare.gui.styles import INPUT_BG
from aare.gui.widgets.title_label import TitleLabel
@@ -46,7 +45,6 @@ class SamcamPanel(QWidget):
self.exposure_spinbox = QDoubleSpinBox()
self.exposure_spinbox.setRange(0, 1.0) # Adjust range as needed
self.exposure_spinbox.setSingleStep(0.001)
self.exposure_spinbox.setStyleSheet(f"QDoubleSpinBox {{ background-color: {INPUT_BG}; }}")
self.exposure_spinbox.setDecimals(3)
self.exposure_spinbox.valueChanged.connect(self._changed)
@@ -54,13 +52,14 @@ class SamcamPanel(QWidget):
self.gain_spinbox.setRange(0, 1000) # Adjust range as needed
self.gain_spinbox.setSingleStep(1)
self.gain_spinbox.setDecimals(1)
self.gain_spinbox.setStyleSheet(f"QDoubleSpinBox {{ background-color: {INPUT_BG}; }}")
self.gain_spinbox.valueChanged.connect(self._changed)
# 3:2 stretch — exposure shows 3 decimals plus the side arrows and
# was getting cut; gain (1 decimal) can afford the narrower field.
exposure_gain_layout.addWidget(QLabel("Exposure (s):"))
exposure_gain_layout.addWidget(self.exposure_spinbox)
exposure_gain_layout.addWidget(self.exposure_spinbox, 3)
exposure_gain_layout.addWidget(QLabel("Gain:"))
exposure_gain_layout.addWidget(self.gain_spinbox)
exposure_gain_layout.addWidget(self.gain_spinbox, 2)
# Persist the current gain/exposure as the beam-location preset for the
# current zoom (only meaningful in beam-location mode).
@@ -71,9 +70,6 @@ class SamcamPanel(QWidget):
screenshot_filename_label = QLabel("Filename:")
self.screenshot_filename_edit = QLineEdit()
self.screenshot_filename_edit.setPlaceholderText("optional")
self.screenshot_filename_edit.setStyleSheet(
f"QLineEdit {{ background-color: {INPUT_BG}; }}"
)
screenshot_filename_layout.addWidget(screenshot_filename_label)
screenshot_filename_layout.addWidget(self.screenshot_filename_edit)
@@ -81,7 +77,6 @@ class SamcamPanel(QWidget):
screenshot_message_label = QLabel("Message:")
self.screenshot_message_edit = QLineEdit()
self.screenshot_message_edit.setPlaceholderText("optional")
self.screenshot_message_edit.setStyleSheet(f"QLineEdit {{ background-color: {INPUT_BG}; }}")
screenshot_message_layout.addWidget(screenshot_message_label)
screenshot_message_layout.addWidget(self.screenshot_message_edit)
@@ -111,7 +106,7 @@ class SamcamPanel(QWidget):
)
self.show_overlay_legend_checkbox = QCheckBox("Show overlay legend")
self.show_overlay_legend_checkbox.setChecked(True)
self.show_overlay_legend_checkbox.setChecked(False)
self.show_overlay_legend_checkbox.toggled.connect(self.show_overlay_legend_changed.emit)
self.compact_overlay_legend_checkbox = QCheckBox("Compact legend")
@@ -68,6 +68,9 @@ class SampleQueuePanel(QFrame):
layout.addWidget(TitleLabel("Sample queue", self))
self.table_view = QTableView(self)
# Themed stripes from the QSS, matching the other sample tables — the
# model no longer paints plain rows white.
self.table_view.setAlternatingRowColors(True)
self.table_model = SampleQueueSpreadsheet(show_user=show_user)
self.table_view.setModel(self.table_model)
+9 -27
View File
@@ -20,15 +20,6 @@ from PySide6.QtWidgets import (
from aare.gui.constants import LOGGER_NAME
from aare.gui.models.user_sample_model import COL_STATUS, UserSampleSpreadsheet
from aare.gui.styles import (
CHIP_NEUTRAL_BG,
MUTED_TEXT,
SAMPLE_STATUS_FLAGGED_BG,
SAMPLE_STATUS_MEASURED_BG,
SAMPLE_STATUS_QUEUED_BG,
TAB_FACE_BG,
TEXT,
)
from aare.gui.widgets.title_label import TitleLabel
logger = setup_logger(LOGGER_NAME)
@@ -162,11 +153,11 @@ class TellSamplePanel(QFrame):
chip_row.setSpacing(0)
self.status_chips = QButtonGroup(self)
self.status_chips.setExclusive(True)
for label, key, checked_bg in (
("All", None, CHIP_NEUTRAL_BG),
("Queued", "queued", SAMPLE_STATUS_QUEUED_BG),
("Flagged", "flagged", SAMPLE_STATUS_FLAGGED_BG),
("Measured", "measured", SAMPLE_STATUS_MEASURED_BG),
for label, key in (
("All", None),
("Queued", "queued"),
("Flagged", "flagged"),
("Measured", "measured"),
):
if key == "queued":
chip = QueueDropChip(label, self)
@@ -188,19 +179,10 @@ class TellSamplePanel(QFrame):
chip.setChecked(key is None)
chip.setProperty("status_key", key)
chip.setCursor(Qt.CursorShape.PointingHandCursor)
chip.setStyleSheet(
# Same font, padding and hover hint as the QTabBar tabs above
# (no bold, default size): unchecked tabs sit 3px lower
# (raised-selection effect), the checked one wears its
# row-tint fill, hover underlines just the text.
f"QPushButton {{ background: {TAB_FACE_BG}; color: {MUTED_TEXT};"
f" border: none;"
f" border-top-left-radius: 4px; border-top-right-radius: 4px;"
f" margin-top: 3px; padding: 4px 14px; }}"
f"QPushButton:hover:!checked {{ color: {TEXT}; text-decoration: underline; }}"
f"QPushButton:checked {{ background: {checked_bg}; color: {TEXT};"
f" margin-top: 0px; padding: 6px 14px 5px 14px; }}"
)
# Look lives in the per-theme QPushButton#filterChip rules in
# styles.py (status_key picks the checked row-tint fill there) —
# an inline stylesheet here would pin one theme's colors.
chip.setObjectName("filterChip")
self.status_chips.addButton(chip)
chip_row.addWidget(chip)
chip_row.addStretch()
+923 -278
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -1166,6 +1166,11 @@ class DAQWorker(QObject):
@Slot()
def load_spreadsheet(self):
if self._base_url is None:
# TODO: log spam. This (and load_reference_tools) fires every
# SPREADHSEET_FREQUENCY cycle (~12.5s) while base_url is None,
# logging a GET it never actually sends -> two INFO lines every
# poll. Fix by demoting to logger.debug, or log once on the
# None->set edge rather than on every poll.
logger.info("GET /sample/spreadsheet")
return
+51 -1
View File
@@ -1,5 +1,5 @@
from aarecommon.config.logger import setup_logger
from PySide6.QtCore import Qt, QTimer, Slot
from PySide6.QtCore import QPoint, Qt, QTimer, Slot
from PySide6.QtWidgets import QFrame, QGraphicsDropShadowEffect, QHBoxLayout, QLabel, QSizePolicy
from aare.gui.constants import LOGGER_NAME
@@ -45,6 +45,53 @@ class AlertBanner(QFrame):
self.setVisible(False)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
self._float_host = None
self._float_anchor = None
def float_over(self, host) -> None:
"""Overlay this banner at the top of `host` instead of occupying layout
space — added because showing/hiding the baton banner was shifting the
whole content stack up and down. No event filters on purpose: filters
firing during widget teardown corrupted PySide (tests crashed with
"QPushButton returned NULL"); the host repositions us on resize instead
(see _AlertBannerHost in main_window)."""
self.setParent(host)
self._float_host = host
# Click-through: the banner covers live UI now, so it must not eat
# mouse events meant for the widgets underneath.
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
def anchor_to(self, widget) -> None:
"""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).
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
def showEvent(self, event):
super().showEvent(event)
self.reposition()
def reposition(self) -> None:
host = self._float_host
if host is None or not self.isVisible():
return
anchor = self._float_anchor
if anchor is not None and anchor.isVisible():
top_left = anchor.mapTo(host, QPoint(0, 0))
w = min(self.sizeHint().width(), anchor.width())
h = self.heightForWidth(w) if self.hasHeightForWidth() else self.sizeHint().height()
x = top_left.x() + (anchor.width() - w) // 2
y = min(top_left.y() + anchor.height() + 4, host.height() - h)
self.setGeometry(x, y, w, h)
else:
w = host.width()
h = self.heightForWidth(w) if self.hasHeightForWidth() else self.sizeHint().height()
self.setGeometry(0, 0, w, h)
self.raise_()
def _set_alert_kind(self, kind: str) -> None:
self.setProperty("alertKind", kind)
self.style().unpolish(self)
@@ -79,6 +126,8 @@ class AlertBanner(QFrame):
self._current_is_error = is_error
self._label.setText(decorated)
self.setVisible(True)
# Resize to the new text even when already visible (no showEvent then).
self.reposition()
@Slot(str, int)
def show_waiting(self, msg: str, countdown_seconds: int = 0):
@@ -106,6 +155,7 @@ class AlertBanner(QFrame):
self._countdown_timer.start()
self.setVisible(True)
self.reposition()
def _apply_waiting_style(self):
"""Apply yellow/waiting style."""
+64 -2
View File
@@ -2,7 +2,8 @@ from dataclasses import dataclass
from aarecommon.models.models import SessionsStateEnum
from aarecommon.models.tell import TellStateModel
from PySide6.QtGui import QColor
from PySide6.QtCore import QPoint, QRect, Qt
from PySide6.QtGui import QColor, QFont, QFontMetrics, QPainter, QPen
from aare.gui.styles import (
BUSY_BLUE,
@@ -39,6 +40,66 @@ class BusyOverlayStyle:
overlay_border: QColor
overlay_text: QColor
accent_dot: str
# Hint line under the title. draw_busy_badge renders it whenever set;
# AxisVideoPanel strips it because only the sample-camera badge is a
# click target and the hint invites a click.
subtext: str = ""
def draw_busy_badge(
painter: QPainter,
viewport_width: int,
viewport_height: int,
style: BusyOverlayStyle,
*,
fill: QColor | None = None,
) -> QRect:
"""The one badge renderer for every camera view — sample camera and the
Axis video views draw the same box so the message reads identically
everywhere (each view used to have its own look). Returns the badge rect
so interactive views can use it as a click target. `fill` overrides the
style's fill (hover feedback)."""
font = QFont()
font.setPointSize(24)
font.setBold(True)
font_metrics = QFontMetrics(font)
sub_font = QFont()
sub_font.setPointSize(12)
sub_metrics = QFontMetrics(sub_font)
title_width = font_metrics.horizontalAdvance(style.text)
sub_width = sub_metrics.horizontalAdvance(style.subtext) if style.subtext else 0
padding_x = 20
padding_y = 14
sub_gap = 6
bg_width = max(title_width, sub_width) + 2 * padding_x
bg_height = font_metrics.height() + 2 * padding_y
if style.subtext:
bg_height += sub_gap + sub_metrics.height()
position_x = int((viewport_width - bg_width) / 2)
position_y = int(viewport_height * 0.68 - bg_height / 2)
bg_rect = QRect(position_x, position_y, bg_width, bg_height)
painter.setPen(QPen(style.overlay_border, 2, Qt.PenStyle.SolidLine))
painter.setBrush(fill if fill is not None else QColor(style.overlay_fill))
painter.drawRoundedRect(bg_rect, 10, 10)
painter.setPen(QPen(style.overlay_text, 2, Qt.PenStyle.SolidLine))
painter.setFont(font)
title_x = position_x + (bg_width - title_width) // 2
title_y = position_y + padding_y + font_metrics.ascent()
painter.drawText(QPoint(title_x, title_y), style.text)
if style.subtext:
painter.setFont(sub_font)
sub_x = position_x + (bg_width - sub_width) // 2
sub_y = position_y + padding_y + font_metrics.height() + sub_gap + sub_metrics.ascent()
painter.drawText(QPoint(sub_x, sub_y), style.subtext)
return bg_rect
def build_busy_overlay_style(
@@ -49,13 +110,14 @@ def build_busy_overlay_style(
) -> BusyOverlayStyle | None:
if session_state == SessionsStateEnum.Vacant:
return BusyOverlayStyle(
text="SESSION VACANT",
text="In viewing mode",
badge_bg=BUSY_YELLOW,
badge_fg=WHITE,
overlay_fill=qcolor(BUSY_YELLOW, 195),
overlay_border=qcolor(BUSY_YELLOW_BORDER, 235),
overlay_text=qcolor(WHITE),
accent_dot=BUSY_YELLOW_DOT,
subtext="Click here to grab baton if need to interact with GUI",
)
if session_state in {SessionsStateEnum.OwnedByElse, SessionsStateEnum.PendingYouToElse}:
+201 -63
View File
@@ -1,6 +1,7 @@
import math
import time
from enum import Enum
from typing import ClassVar
from aarecommon.config.logger import setup_logger
from aarecommon.math.coordinate import Coordinate, SmargonCoordinate
@@ -55,12 +56,18 @@ from aare.gui.styles import (
MARKER_GREEN,
PATH_END,
PATH_START,
SHADOW,
TARGET_COLORS,
THEME_SUNSET,
TOOLTIP_TEXT,
WHITE,
qcolor,
)
from aare.gui.widgets.busy_overlay import BusyOverlayStyle, build_busy_overlay_style
from aare.gui.widgets.busy_overlay import (
BusyOverlayStyle,
build_busy_overlay_style,
draw_busy_badge,
)
logger = setup_logger(LOGGER_NAME)
@@ -108,9 +115,14 @@ class SampleCameraImageLabel(QGraphicsView):
self._sam_cam = SampleCameraSettings(exposure=0.1, gain=100.0)
self._is_daq_busy = False
self._camera_available = True
self._camera_error_message: str | None = None
# Baton gate: watching allowed, operating not (main_window drives it).
self._operations_allowed = True
self._session_badge_rect: QRect | None = None # viewport coords
self._session_badge_hovered = False
# Hover polarity for the badge: light themes darken, Sunset brightens.
# Set via set_theme from MainWindow._apply_theme.
self._dark_theme = False
self._last_grid_update_ts = 0.0
self._grid_update_min_interval_s = 1.0 / 25.0
self._tell_state = None
@@ -129,12 +141,12 @@ class SampleCameraImageLabel(QGraphicsView):
self._show_target_point = True
self._show_target_coordinates = True
self._show_overlay_legend = True
self._show_overlay_legend = False
self._compact_overlay_legend = False
# Legend stays collapsed to a "?" badge until clicked — the full box
# covers too much of the camera image to be always-on.
self._legend_expanded = False
self._legend_hit_rect: QRectF | None = None # viewport coords, set on paint
# "?" badge is a mouse-controls cheatsheet, decoupled from the legend —
# legend visibility is already handled by the panel checkboxes.
self._help_expanded = False
self._help_hit_rect: QRectF | None = None # viewport coords, set on paint
self._target_point = None
self._target_shape = None
self._target_color_name = "Cyan"
@@ -219,9 +231,18 @@ class SampleCameraImageLabel(QGraphicsView):
@Slot(bool)
def set_camera_available(self, available: bool):
self._camera_available = available
if available:
self._camera_error_message = None
self._update_camera_interaction_feedback()
self.update()
@Slot(str)
def set_camera_error_message(self, message: str):
# Thread errors arrive as "...unavailable: X" — reword to the
# "...unavailable because X" phrasing the overlay shows.
self._camera_error_message = message.replace(": ", " because ", 1)
self.update()
@Slot(dict)
def update_detections(self, payload: dict):
try:
@@ -277,6 +298,17 @@ class SampleCameraImageLabel(QGraphicsView):
return f"TELL {activity_name}".upper()
def _draw_status_text(
self, painter: QPainter, text: str, color, center_x: int, baseline_y: int, fm: QFontMetrics
):
# Solid colored text with a 1px shadow — survives any camera image
# behind it without a badge box.
x = center_x - fm.horizontalAdvance(text) // 2
painter.setPen(QPen(qcolor(SHADOW, 200)))
painter.drawText(QPoint(x + 1, baseline_y + 1), text)
painter.setPen(QPen(qcolor(color) if isinstance(color, str) else color))
painter.drawText(QPoint(x, baseline_y), text)
def _draw_busy_overlay(self, painter: QPainter):
if self._busy_overlay_style is None:
return
@@ -289,38 +321,55 @@ class SampleCameraImageLabel(QGraphicsView):
font = QFont()
font.setPointSize(24)
font.setBold(True)
painter.setFont(font)
font_metrics = QFontMetrics(font)
text_rect = font_metrics.boundingRect(style.text)
padding_x = 20
padding_y = 14
bg_width = text_rect.width() + 2 * padding_x
bg_height = text_rect.height() + 2 * padding_y
# Robot/busy WARNINGS are not clickable: no badge box, just solid
# text in the state's color. Only the session badges (a real click
# target) keep the button-like pill below.
if self._session_state not in (
SessionsStateEnum.Vacant,
SessionsStateEnum.OwnedByElse,
SessionsStateEnum.PendingYouToElse,
):
self._session_badge_rect = None
painter.setFont(font)
baseline = int(self.viewport().height() * 0.68) + font_metrics.ascent() // 2
self._draw_status_text(
painter,
style.text,
style.badge_bg,
self.viewport().width() // 2,
baseline,
font_metrics,
)
painter.restore()
return
viewport_width = self.viewport().width()
viewport_height = self.viewport().height()
# SESSION VACANT / GUEST MODE badges double as the click target for the
# grab/request menu, same as the _draw_session_overlay badge they hide.
session_badge = self._session_state in (
SessionsStateEnum.Vacant,
SessionsStateEnum.OwnedByElse,
SessionsStateEnum.PendingYouToElse,
)
position_x = int((viewport_width - bg_width) / 2)
position_y = int(viewport_height * 0.68 - bg_height / 2)
fill = QColor(style.overlay_fill)
if session_badge and self._session_badge_hovered:
# Hover: darker in the light themes, brighter in Sunset.
fill = fill.lighter(125) if self._dark_theme else fill.darker(115)
bg_rect = QRect(position_x, position_y, bg_width, bg_height)
painter.setPen(QPen(style.overlay_border, 2, Qt.PenStyle.SolidLine))
painter.setBrush(style.overlay_fill)
painter.drawRoundedRect(bg_rect, 10, 10)
painter.setPen(QPen(style.overlay_text, 2, Qt.PenStyle.SolidLine))
text_pos = QPoint(position_x + padding_x, position_y + padding_y + font_metrics.ascent())
painter.drawText(text_pos, style.text)
bg_rect = draw_busy_badge(
painter, self.viewport().width(), self.viewport().height(), style, fill=fill
)
self._session_badge_rect = bg_rect if session_badge else None
painter.restore()
def _draw_session_overlay(self, painter: QPainter):
self._session_badge_rect = None
if self._busy_overlay_style is not None:
# Busy overlay drew (and owns) the session badge rect — don't clobber.
return
self._session_badge_rect = None
if self._session_state in (
SessionsStateEnum.OwnedByYou,
@@ -362,6 +411,10 @@ class SampleCameraImageLabel(QGraphicsView):
# Clicking the badge opens the session (grab/request) menu.
self._session_badge_rect = bg_rect
if self._session_badge_hovered:
# Same hover polarity as the busy-overlay badge.
bg_color = bg_color.lighter(125) if self._dark_theme else bg_color.darker(115)
painter.setPen(QPen(qcolor(WHITE, 220)))
painter.setBrush(bg_color)
painter.drawRoundedRect(bg_rect, 10, 10)
@@ -382,28 +435,21 @@ class SampleCameraImageLabel(QGraphicsView):
font.setPointSize(22)
font.setBold(True)
painter.setFont(font)
text = "Sample camera feed unavailable"
fm = QFontMetrics(font)
text_rect = fm.boundingRect(text)
padding = 16
position_x = 50
position_y = 120
bg_rect = QRect(
position_x - padding,
position_y - padding,
text_rect.width() + 2 * padding,
text_rect.height() + 2 * padding,
# Bottom-center, no badge box — solid colored text (the pill read
# as a button). The camera thread's reason is appended upstream as
# "... because <reason>" when it is known.
margin = 18
text = fm.elidedText(
self._camera_error_message or "Sample camera feed unavailable",
Qt.TextElideMode.ElideRight,
self.viewport().width() - 2 * margin,
)
baseline = self.viewport().height() - margin - fm.descent()
self._draw_status_text(
painter, text, MARK_BADGE_BG, self.viewport().width() // 2, baseline, fm
)
painter.setPen(QPen(qcolor(WHITE, 220), 2))
painter.setBrush(qcolor(MARK_BADGE_BG, 180))
painter.drawRoundedRect(bg_rect, 10, 10)
painter.setPen(QPen(qcolor(WHITE)))
painter.drawText(QPoint(position_x, position_y + fm.ascent()), text)
painter.restore()
@@ -418,20 +464,21 @@ class SampleCameraImageLabel(QGraphicsView):
self._draw_detections(painter, rect)
self._draw_target_point(painter)
self._draw_overlay_legend(painter)
self._draw_help_overlay(painter)
def resizeEvent(self, event):
super().resizeEvent(event)
self._scaling()
def mousePressEvent(self, event):
# Legend badge first: pure UI affordance, must work even when camera
# Help badge first: pure UI affordance, must work even when camera
# interaction is disabled (session overlay etc.).
if (
event.button() == Qt.MouseButton.LeftButton
and self._legend_hit_rect is not None
and self._legend_hit_rect.contains(QPointF(self.viewport().mapFrom(self, event.pos())))
and self._help_hit_rect is not None
and self._help_hit_rect.contains(QPointF(self.viewport().mapFrom(self, event.pos())))
):
self._legend_expanded = not self._legend_expanded
self._help_expanded = not self._help_expanded
self.update()
event.accept()
return
@@ -490,7 +537,27 @@ class SampleCameraImageLabel(QGraphicsView):
self.switch_raster_grid.emit()
self._raster_mgr.resize_active_grid(self.end_point)
@Slot(str)
def set_theme(self, theme: str):
self._dark_theme = theme == THEME_SUNSET
self.update()
def leaveEvent(self, event):
if self._session_badge_hovered:
self._session_badge_hovered = False
self.update()
super().leaveEvent(event)
def mouseMoveEvent(self, event):
# Badge hover feedback must run BEFORE the interaction gate: the
# badge is visible precisely when interaction is disabled.
hovered = self._session_badge_rect is not None and self._session_badge_rect.contains(
self.viewport().mapFrom(self, event.pos())
)
if hovered != self._session_badge_hovered:
self._session_badge_hovered = hovered
self.update()
if not self._camera_interaction_enabled():
return
@@ -900,7 +967,7 @@ class SampleCameraImageLabel(QGraphicsView):
painter.setBrush(qcolor(LEGEND_BG, 190))
painter.drawRoundedRect(bubble_rect, 8, 8)
painter.setPen(QPen(qcolor(WHITE), 1))
painter.setPen(QPen(qcolor(LEGEND_TEXT), 1))
painter.drawText(
QPointF(bubble_rect.left() + 8, bubble_rect.top() + 7 + fm.ascent()), label_text
)
@@ -971,8 +1038,32 @@ class SampleCameraImageLabel(QGraphicsView):
return lines
def _draw_legend_badge(self, painter: QPainter):
# ponytail: painted circle, not a real QWidget button — the legend it
# (header, [entries]) — kept concise on purpose; the full table lives in
# docs/cheatsheet.md.
_HELP_SECTIONS: ClassVar[list[tuple[str, list[str]]]] = [
(
"Sample camera",
[
"Left click — move sample here",
"Shift + Left click — Z-alignment move",
"Right click — context menu",
"Wheel — rotate omega 90° (Shift: 10°)",
"Ctrl / Alt + Wheel — exposure coarse / fine",
],
),
(
"Raster grid",
[
"Right drag — draw grid (on grid: resize)",
"Left drag — move grid",
"Ctrl + Left click — move sample under grid",
"Shift + move — inspect raster image at cursor",
],
),
]
def _draw_help_badge(self, painter: QPainter):
# ponytail: painted circle, not a real QWidget button — the overlay it
# toggles is painter-drawn too, and a widget would need layout juggling.
diameter = 22
rect = QRectF(18, self.viewport().height() - diameter - 18, diameter, diameter)
@@ -980,7 +1071,7 @@ class SampleCameraImageLabel(QGraphicsView):
painter.save()
painter.resetTransform()
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
painter.setPen(QPen(qcolor(WHITE, 60), 1))
painter.setPen(QPen(qcolor(LEGEND_TEXT, 60), 1))
painter.setBrush(qcolor(LEGEND_BG, 170))
painter.drawEllipse(rect)
@@ -992,15 +1083,62 @@ class SampleCameraImageLabel(QGraphicsView):
painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, "?")
painter.restore()
self._legend_hit_rect = rect
self._help_hit_rect = rect
def _draw_overlay_legend(self, painter: QPainter):
self._legend_hit_rect = None
if not self._legend_should_show():
def _draw_help_overlay(self, painter: QPainter):
self._help_hit_rect = None
if not self._help_expanded:
self._draw_help_badge(painter)
return
if not self._legend_expanded:
self._draw_legend_badge(painter)
painter.save()
painter.resetTransform()
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
font = QFont()
font.setPointSize(9)
header_font = QFont(font)
header_font.setBold(True)
fm = QFontMetrics(font)
header_fm = QFontMetrics(header_font)
line_height = fm.height() + 4
header_height = header_fm.height() + 6
padding = 10
max_width = 0
n_lines = 0
for header, entries in self._HELP_SECTIONS:
max_width = max(max_width, header_fm.horizontalAdvance(header))
n_lines += len(entries)
for entry in entries:
max_width = max(max_width, fm.horizontalAdvance(entry))
width = max_width + padding * 2
height = len(self._HELP_SECTIONS) * header_height + n_lines * line_height + padding * 2
bg_rect = QRectF(18, max(18, self.viewport().height() - height - 18), width, height)
self._help_hit_rect = bg_rect # click anywhere on the box to close
painter.setPen(QPen(qcolor(LEGEND_TEXT, 60), 1))
painter.setBrush(qcolor(LEGEND_BG, 190))
painter.drawRoundedRect(bg_rect, 8, 8)
y = bg_rect.top() + padding
for header, entries in self._HELP_SECTIONS:
painter.setFont(header_font)
painter.setPen(QPen(qcolor(LEGEND_TEXT), 1))
painter.drawText(QPointF(bg_rect.left() + padding, y + header_fm.ascent()), header)
y += header_height
painter.setFont(font)
painter.setPen(QPen(qcolor(LEGEND_TEXT), 1))
for entry in entries:
painter.drawText(QPointF(bg_rect.left() + padding, y + fm.ascent()), entry)
y += line_height
painter.restore()
def _draw_overlay_legend(self, painter: QPainter):
if not self._legend_should_show() or self._help_expanded:
return
painter.save()
@@ -1018,7 +1156,8 @@ class SampleCameraImageLabel(QGraphicsView):
text_padding = 6 if self._compact_overlay_legend else 8
section_padding = 8 if self._compact_overlay_legend else 10
left = 18
top = self.viewport().height() - (len(lines) * line_height + 24)
# Bottom-anchored above the "?" help badge (18px margin + 22px badge + gap).
top = self.viewport().height() - (len(lines) * line_height + 16) - 48
max_text_width = 0
for text, _color in lines:
@@ -1028,8 +1167,7 @@ class SampleCameraImageLabel(QGraphicsView):
height = len(lines) * line_height + 16
bg_rect = QRectF(left, max(18, top), width, height)
self._legend_hit_rect = bg_rect # click anywhere on the box to collapse
painter.setPen(QPen(qcolor(WHITE, 60), 1))
painter.setPen(QPen(qcolor(LEGEND_TEXT, 60), 1))
painter.setBrush(qcolor(LEGEND_BG, 170))
painter.drawRoundedRect(bg_rect, 8, 8)
+2 -2
View File
@@ -7,7 +7,7 @@ from PySide6.QtCore import QByteArray, QUrl, QUrlQuery, Slot
from PySide6.QtNetwork import QNetworkAccessManager, QNetworkReply, QNetworkRequest
from PySide6.QtWidgets import QDialog, QHBoxLayout, QLabel, QLineEdit, QPushButton, QVBoxLayout
from aare.gui.styles import BACKGROUND
from aare.gui.styles import APP_BACKGROUND
class LoginDialog(QDialog):
@@ -16,7 +16,7 @@ class LoginDialog(QDialog):
self.token = ""
self.setWindowTitle("User Authentication")
self.setMinimumWidth(400)
self.setStyleSheet(f"background-color: {BACKGROUND};")
self.setStyleSheet(f"background-color: {APP_BACKGROUND};")
self._base_url = base_url
self._reply = None
self._network_manager = None
+16 -24
View File
@@ -2,10 +2,12 @@ from PySide6.QtCore import Qt, Signal, Slot
from PySide6.QtGui import QDoubleValidator
from PySide6.QtWidgets import QCheckBox, QHBoxLayout, QLineEdit, QWidget
from aare.gui.styles import INPUT_BG, INPUT_DISABLED_BG, INPUT_DISABLED_INVALID_BG, INPUT_INVALID_BG
class NumberLineEdit(QLineEdit):
"""Colors are centralized: the per-theme INPUT rules in styles.py key on
the :read-only pseudo-class and the "invalid" dynamic property set here —
no inline stylesheets, so both themes restyle these fields."""
newValue = Signal(float)
def __init__(
@@ -15,8 +17,6 @@ class NumberLineEdit(QLineEdit):
self._read_only: bool = False
self._is_valid: bool = True
self.setStyleSheet(f"background-color: {INPUT_BG};")
# Use a QDoubleValidator to only allow valid floating point numbers
self.validator = QDoubleValidator()
self.validator.setNotation(QDoubleValidator.Notation.StandardNotation)
@@ -39,14 +39,19 @@ class NumberLineEdit(QLineEdit):
format_string = f"{{:.{self.decimal_count}f}}"
return format_string.format(i)
def _set_invalid(self, invalid: bool) -> None:
if self.property("invalid") == invalid:
return
self.setProperty("invalid", invalid)
# Property selectors are only re-evaluated on repolish.
self.style().unpolish(self)
self.style().polish(self)
@Slot(str)
def on_text_changed(self, text: str):
# when text changes check validation and change the colour of the line edit
self._is_valid = self.validate(text)
if self._is_valid:
self.setStyleSheet(f"background-color: {INPUT_BG};")
else:
self.setStyleSheet(f"background-color: {INPUT_INVALID_BG};")
self._set_invalid(not self._is_valid)
@Slot()
def on_editing_finished(self):
@@ -84,22 +89,10 @@ class NumberLineEdit(QLineEdit):
return self.validator.validate(str(text), 0)[0] == QDoubleValidator.State.Acceptable
def setReadOnly(self, ro: bool):
# change state of read only and change colour of line edit based on read only state and validator
# Colors follow via the QSS :read-only pseudo-class (updates without
# a repolish); validity is already tracked by the invalid property.
super().setReadOnly(ro)
self._read_only = ro
if self._read_only and self._is_valid:
self.setStyleSheet(f"background-color: {INPUT_DISABLED_BG};")
elif not self._read_only and self._is_valid:
self.setStyleSheet(f"background-color: {INPUT_BG};")
elif self._read_only and not self._is_valid:
self.setStyleSheet(f"background-color: {INPUT_DISABLED_INVALID_BG};")
elif not self._read_only and not self._is_valid:
self.setStyleSheet(f"background-color: {INPUT_INVALID_BG};")
else:
print(
f"unknown ro state: {self._read_only} or validity {self._is_valid} default to writeable"
)
self.setStyleSheet(f"background-color: {INPUT_BG};")
def get_default(self) -> float:
return float(self.initial_value)
@@ -167,15 +160,14 @@ class CheckedLineEdit(QWidget):
self.setReadOnly()
self.check_box.blockSignals(True)
# No inline checkbox fills: the theme's :disabled rules grey it.
if self._busy:
self.check_box.setEnabled(False)
self.check_box.setStyleSheet(f"background-color: {INPUT_DISABLED_BG};")
if self._checked:
self.editor.force_update_value(self._internal_value)
else:
self.check_box.setEnabled(True)
self.check_box.setStyleSheet(f"background-color: {INPUT_BG};")
self.check_box.blockSignals(False)
self.blockSignals(False)
+73 -16
View File
@@ -1,23 +1,31 @@
from PySide6.QtCore import QPoint, QRect, QSize, Qt
from PySide6.QtGui import QCursor, QGuiApplication, QIcon, QPainter, QPen, QPixmap
from PySide6.QtCore import QEvent, QPoint, QRect, QSize, Qt
from PySide6.QtGui import QColor, QCursor, QGuiApplication, QIcon, QPainter, QPalette, QPen, QPixmap
from PySide6.QtWidgets import QDockWidget, QHBoxLayout, QLabel, QToolButton, QVBoxLayout, QWidget
from aare.gui.styles import FRAME_L1_COLOR, FRAME_L1_WIDTH, TEXT, qcolor
from aare.gui.styles import FRAME_L1_COLOR, FRAME_L1_WIDTH, qcolor
# Title-bar buttons: icon fills the button, both the same size.
# Title-bar buttons: icon fills the button, both the same size. These are
# minimums — the real size follows the font metrics (see _titlebar_button),
# so the glyphs keep up with the DPI/font settings of the machine (fixed px
# rendered tiny on the RHEL9 consoles).
TITLEBAR_BUTTON_PX = 22
TITLEBAR_ICON_PX = 18
def _titlebar_icon(kind: str, size: int = TITLEBAR_ICON_PX) -> QIcon:
def _titlebar_icon(kind: str, color: QColor, size: int = TITLEBAR_ICON_PX) -> QIcon:
"""Hand-painted borderless glyphs — the style's standard title-bar
pixmaps draw boxed icons, and text glyphs are missing from the
container's fonts."""
pixmap = QPixmap(size, size)
container's fonts. Color comes from the caller's palette so the glyphs
follow the theme (they are pixmaps, QSS color cannot reach them)."""
# Paint at the physical resolution so the glyph stays crisp on HiDPI.
screen = QGuiApplication.primaryScreen()
dpr = screen.devicePixelRatio() if screen is not None else 1.0
pixmap = QPixmap(round(size * dpr), round(size * dpr))
pixmap.setDevicePixelRatio(dpr)
pixmap.fill(Qt.GlobalColor.transparent)
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
pen = QPen(qcolor(TEXT), 2)
pen = QPen(color, 2)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
painter.setPen(pen)
@@ -33,14 +41,24 @@ def _titlebar_icon(kind: str, size: int = TITLEBAR_ICON_PX) -> QIcon:
return QIcon(pixmap)
def _titlebar_button(parent: QWidget, kind: str, tooltip: str) -> QToolButton:
def _titlebar_button(parent: QWidget, tooltip: str) -> QToolButton:
# Icon is set by DockTitleBar._tint_icons (initially and on theme change).
button = QToolButton(parent)
button.setIcon(_titlebar_icon(kind))
button.setIconSize(QSize(TITLEBAR_ICON_PX, TITLEBAR_ICON_PX))
button.setFixedSize(TITLEBAR_BUTTON_PX, TITLEBAR_BUTTON_PX)
# Size from the font, not fixed px: a setup with larger fonts/DPI gets
# proportionally larger glyphs. The constants act as the floor.
icon_px = max(TITLEBAR_ICON_PX, parent.fontMetrics().height())
button_px = icon_px + (TITLEBAR_BUTTON_PX - TITLEBAR_ICON_PX)
button.setIconSize(QSize(icon_px, icon_px))
button.setFixedSize(button_px, button_px)
button.setAutoRaise(True)
# No button chrome — the glyph IS the button.
button.setStyleSheet("QToolButton { border: none; background: transparent; }")
# No button chrome — the glyph IS the button. The padding/height zeroing
# is the opt-out from the app sheets' global QToolButton cap (16px +
# padding): without it the light theme's horizontal padding shrinks the
# content box and Qt scales the glyph down to a speck.
button.setStyleSheet(
"QToolButton { border: none; background: transparent;"
f" padding: 0px; min-height: 0px; max-height: {button_px}px; }}"
)
button.setToolTip(tooltip)
button.setCursor(Qt.CursorShape.PointingHandCursor)
return button
@@ -66,15 +84,30 @@ class DockTitleBar(QWidget):
layout.addStretch(1)
self.popout_button = _titlebar_button(
self, "popout", "Open in a separate window (the panel stays here too)"
self, "Open in a separate window (the panel stays here too)"
)
self.popout_button.clicked.connect(on_popout)
layout.addWidget(self.popout_button)
close_button = _titlebar_button(self, "close", "Close panel (reopen via the View menu)")
close_button = _titlebar_button(self, "Close panel (reopen via the View menu)")
close_button.clicked.connect(dock.close)
layout.addWidget(close_button)
self._icon_buttons = {"popout": self.popout_button, "close": close_button}
self._tint_icons()
def _tint_icons(self) -> None:
color = self.palette().color(QPalette.ColorRole.WindowText)
for kind, button in self._icon_buttons.items():
button.setIcon(_titlebar_icon(kind, color, button.iconSize().width()))
def changeEvent(self, event):
# A theme switch lands here as a palette/style change; the glyphs are
# pixmaps, so they must be repainted in the new text color.
if event.type() in (QEvent.Type.PaletteChange, QEvent.Type.StyleChange):
self._tint_icons()
super().changeEvent(event)
class PopoutWindow(QWidget):
"""Additional top-level window for a panel mirror.
@@ -98,6 +131,10 @@ class PopoutWindow(QWidget):
def __init__(self, title: str, content: QWidget, parent=None):
super().__init__(parent, Qt.WindowType.Window)
# QWidget SUBCLASSES skip QSS background painting unless this is set;
# an unpainted top-level renders black on the container's
# non-composited X11 (the PopoutWindow QSS rule supplies the fill).
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
self.setWindowTitle(title)
self.setMinimumSize(300, 160)
layout = QVBoxLayout(self)
@@ -176,6 +213,8 @@ class PopoutWindow(QWidget):
super().mousePressEvent(event)
def mouseMoveEvent(self, event):
# Both press fields are set together in mousePressEvent; the second
# check exists for the pyright gate, which can't see that pairing.
if self._manual_edges and self._press_global is not None and self._press_geom is not None:
delta = event.globalPosition().toPoint() - self._press_global
geom = QRect(self._press_geom)
@@ -201,3 +240,21 @@ class PopoutWindow(QWidget):
self._press_global = None
self._press_geom = None
super().mouseReleaseEvent(event)
if __name__ == "__main__":
# ponytail: smallest check that fails if the edge maths breaks
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtWidgets import QApplication, QLabel
app = QApplication([])
w = PopoutWindow("t", QLabel("x"))
w.resize(400, 300)
assert w._edges_at(QPoint(5, 150)) == Qt.Edge.LeftEdge
assert w._edges_at(QPoint(398, 298)) == (Qt.Edge.RightEdge | Qt.Edge.BottomEdge)
assert w._edges_at(QPoint(200, 150)) == Qt.Edge(0)
assert w._cursor_for(Qt.Edge.LeftEdge | Qt.Edge.TopEdge) == Qt.CursorShape.SizeFDiagCursor
assert w._cursor_for(Qt.Edge.RightEdge | Qt.Edge.TopEdge) == Qt.CursorShape.SizeBDiagCursor
print("gude")
+5 -3
View File
@@ -1,7 +1,7 @@
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QApplication, QProgressBar, QSplashScreen
from aare.gui.styles import SPLASH_ACCENT, SPLASH_BG, SPLASH_BORDER, WHITE, qcolor
from aare.gui.styles import SPLASH_ACCENT, SPLASH_BG, SPLASH_BORDER, SPLASH_TEXT, qcolor
class LoadingSplashScreen(QSplashScreen):
@@ -17,7 +17,7 @@ class LoadingSplashScreen(QSplashScreen):
border-radius: 5px;
text-align: center;
background-color: {SPLASH_BG};
color: {WHITE};
color: {SPLASH_TEXT};
}}
QProgressBar::chunk {{
background-color: {SPLASH_ACCENT};
@@ -28,6 +28,8 @@ class LoadingSplashScreen(QSplashScreen):
self.progress.setValue(value)
if message:
self.showMessage(
message, Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignCenter, qcolor(WHITE)
message,
Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignCenter,
qcolor(SPLASH_TEXT),
)
QApplication.processEvents()
+54 -32
View File
@@ -8,15 +8,7 @@ from PySide6.QtGui import QFont
from PySide6.QtWidgets import QDialog, QLabel, QMenu, QMessageBox, QSizePolicy, QStatusBar
from aare.gui.constants import LOGGER_NAME
from aare.gui.styles import (
STATE_TELL_TEXT,
STATUS_ALERT,
STATUS_INFO,
STATUS_OK,
STATUS_REQUEST,
STATUS_VACANT,
STATUS_WARN,
)
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.pgroup_dialog import PGroupDialog
@@ -54,6 +46,11 @@ class StatusBar(QStatusBar):
self._is_staff = self._decoded_token.staff
self._allowed_pgroups = self._decoded_token.pgroups
# Per-theme flag colors (MainWindow._apply_theme calls set_theme) —
# painted in code per DAQ tick, QSS cannot reach the rich-text spans.
self._colors = status_colors(THEME_SUNRISE)
self._message_is_error = False
self._message_clear_timer = QTimer(self)
self._message_clear_timer.setSingleShot(True)
self._message_clear_timer.timeout.connect(self.clear_connection_message)
@@ -109,12 +106,24 @@ class StatusBar(QStatusBar):
self.addPermanentWidget(self.busy_label)
self.addPermanentWidget(self.session_label)
def set_theme(self, theme: str) -> None:
"""Adopt the theme's flag colors: recolor the connection message and
re-render the DAQ-driven labels from the last status right away."""
self._colors = status_colors(theme)
self._apply_message_style()
if self._status is not None:
self.update_daq_status(self._status)
def _apply_message_style(self) -> None:
color = self._colors["alert"] if self._message_is_error else self._colors["ok"]
self.message_label.setStyleSheet(f"color: {color}; font-weight: bold;")
@Slot(str, bool)
def show_connection_message(self, msg: str, is_error: bool = True):
color = STATUS_ALERT if is_error else STATUS_OK
self._message_is_error = is_error
self._message_clear_timer.stop()
self.message_label.setText(msg)
self.message_label.setStyleSheet(f"color: {color}; font-weight: bold;")
self._apply_message_style()
self.message_label.setVisible(bool(msg))
if not is_error:
@@ -155,9 +164,13 @@ class StatusBar(QStatusBar):
self.transmission.set_value(f"{status.bl.transmission:.5f}")
if status.bl.ring_current_mA < 5.0:
self.ring_current.set_value(f"{status.bl.ring_current_mA:.2f}", STATUS_ALERT)
self.ring_current.set_value(
f"{status.bl.ring_current_mA:.2f}", self._colors["alert"]
)
elif status.bl.ring_current_mA < 390.0:
self.ring_current.set_value(f"{status.bl.ring_current_mA:.2f}", STATUS_WARN)
self.ring_current.set_value(
f"{status.bl.ring_current_mA:.2f}", self._colors["warn"]
)
else:
self.ring_current.set_value(f"{status.bl.ring_current_mA:.2f}")
@@ -165,28 +178,28 @@ class StatusBar(QStatusBar):
self.wvl.set_value(f"{status.diffraction.wavelength_angstrom:.2f}")
if status.bl.cryojet_K < 110.0:
self.cryo_label.set_value(f"{status.bl.cryojet_K:.1f}", STATUS_INFO)
self.cryo_label.set_value(f"{status.bl.cryojet_K:.1f}", self._colors["info"])
elif status.bl.cryojet_K < 250.0:
self.cryo_label.set_value(f"{status.bl.cryojet_K:.1f}", STATUS_WARN)
self.cryo_label.set_value(f"{status.bl.cryojet_K:.1f}", self._colors["warn"])
else:
self.cryo_label.set_value(f"{status.bl.cryojet_K:.1f}", STATUS_ALERT)
self.cryo_label.set_value(f"{status.bl.cryojet_K:.1f}", self._colors["alert"])
if status.bl.shutter_open:
self.shutter_label.setText(
f"""Fast Shutter: <span style="color: {STATUS_ALERT} ; "> Open ☢️ </span>"""
f"""Fast Shutter: <span style="color: {self._colors["alert"]} ; "> Open ☢️ </span>"""
)
else:
self.shutter_label.setText(
f"""Fast Shutter: <span style="color: {STATUS_OK} ; "> Closed 🚪 </span>"""
f"""Fast Shutter: <span style="color: {self._colors["ok"]} ; "> Closed 🚪 </span>"""
)
if status.bl.exp_shutter_open:
self.exp_shutter_label.setText(
f"""ExpHutch Shutter: <span style="color: {STATUS_ALERT} ; "> Open </span>"""
f"""ExpHutch Shutter: <span style="color: {self._colors["alert"]} ; "> Open </span>"""
)
else:
self.exp_shutter_label.setText(
f"""ExpHutch Shutter: <span style="color: {STATUS_OK} ; "> Closed 🚪 </span>"""
f"""ExpHutch Shutter: <span style="color: {self._colors["ok"]} ; "> Closed 🚪 </span>"""
)
if status.session.current_pgroup is not None:
@@ -197,29 +210,29 @@ class StatusBar(QStatusBar):
self.state_label.setText(f"""State: {status.state.display_name()} """)
tell_text = ""
tell_color = STATE_TELL_TEXT
tell_color = self._colors["tell"]
if status.tell_state is not None:
tell_text = status.tell_state.activity.display_name()
if status.tell_state.activity.value == "error":
tell_color = STATUS_ALERT
tell_color = self._colors["alert"]
elif status.tell_state.activity.value in {
"mounting",
"unmounting",
"drying",
"cooling",
}:
tell_color = STATUS_WARN
tell_color = self._colors["warn"]
else:
tell_color = STATUS_OK
tell_color = self._colors["ok"]
self.tell_state_label.setText(f"Tell: {tell_text} ")
self.tell_state_label.setStyleSheet(f"color: {tell_color};")
if status.busy:
busy_flag = f""" <span style="color: {STATUS_ALERT}; "> Busy 🔒 </span>"""
busy_flag = f""" <span style="color: {self._colors["alert"]}; "> Busy 🔒 </span>"""
else:
busy_flag = f""" <span style="color: {STATUS_OK} ; "> Idle 🔓 </span>"""
busy_flag = f""" <span style="color: {self._colors["ok"]} ; "> Idle 🔓 </span>"""
html_content = f"""Beamline: {busy_flag} """
@@ -227,15 +240,23 @@ class StatusBar(QStatusBar):
session_flag = ""
if status.session.session == SessionsStateEnum.Vacant:
session_flag = f"""<span style="color: {STATUS_VACANT} ; "> Vacant 🔓 </span>"""
session_flag = (
f"""<span style="color: {self._colors["vacant"]} ; "> Vacant 🔓 </span>"""
)
elif status.session.session == SessionsStateEnum.OwnedByYou:
session_flag = f"""<span style="color: {STATUS_OK} ; "> Owned ⬤ </span>"""
session_flag = f"""<span style="color: {self._colors["ok"]} ; "> Owned ⬤ </span>"""
elif status.session.session == SessionsStateEnum.OwnedByElse:
session_flag = f"""<span style="color: {STATUS_ALERT} ; "> Other 🔒 </span>"""
session_flag = (
f"""<span style="color: {self._colors["alert"]} ; "> Other 🔒 </span>"""
)
elif status.session.session == SessionsStateEnum.PendingYouToElse:
session_flag = f"""<span style="color: {STATUS_WARN} ; "> Waiting... ⏳ </span>"""
session_flag = (
f"""<span style="color: {self._colors["warn"]} ; "> Waiting... ⏳ </span>"""
)
elif status.session.session == SessionsStateEnum.PendingElseToYou:
session_flag = f"""<span style="color: {STATUS_REQUEST} ; "> Request! ⚡ </span>"""
session_flag = (
f"""<span style="color: {self._colors["request"]} ; "> Request! ⚡ </span>"""
)
html_content_session = f"""Session: {session_flag}"""
self.session_label.setText(html_content_session)
@@ -603,7 +624,8 @@ class StatusBar(QStatusBar):
def _generate_pgroup_dialogue(self, curr: str | None = None, pgroups: list | None = None):
logger.info(pgroups)
dialog = PGroupDialog(curr_pgroup=curr, pgroups=pgroups)
dialog = PGroupDialog(curr_pgroup=curr, pgroups=pgroups, parent=self.window())
if dialog.exec() == QDialog.DialogCode.Accepted:
entered_text = dialog.get_input()
if pgroups and entered_text not in pgroups:
+14 -24
View File
@@ -1,17 +1,8 @@
from PySide6.QtCore import QSettings, Qt, QTimer
from PySide6.QtGui import QPainter
from PySide6.QtGui import QPainter, QPalette
from PySide6.QtWidgets import QHBoxLayout, QLabel, QLayout, QPushButton, QStyle, QStyleOption
from aare.gui.styles import (
BANNER,
BANNER_TEXT,
BANNER_TEXT_SHADOW,
FONT_BODY,
FONT_HINT,
FONT_TITLE,
MUTED_TEXT,
qcolor,
)
from aare.gui.styles import BANNER_TEXT, BANNER_TEXT_SHADOW, FONT_VALUE, qcolor
# Universal vertical rhythm between stacked panels: each panel contributes
# PANEL_VMARGIN top and bottom, the column adds PANEL_VSPACING between them,
@@ -38,12 +29,11 @@ def tighten_column(layout: QLayout) -> None:
def section_title(text: str, parent=None) -> QLabel:
"""Small in-panel section heading — for controls grouped under one
shared TitleLabel banner (e.g. the Beam Config. panel)."""
shared TitleLabel banner (e.g. the Beam Config. panel). Look lives in
the per-theme QLabel#sectionTitle rules in styles.py."""
label = QLabel(text, parent)
label.setObjectName("sectionTitle")
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
label.setStyleSheet(
f"color: {MUTED_TEXT}; font-size: {FONT_HINT}; font-weight: 700; background: transparent;"
)
return label
@@ -55,13 +45,9 @@ class TitleLabel(QLabel):
# Plain text + QSS font instead of <H3>: rich-text heading margins
# would clip vertically in the halved banner height.
self.setText(text)
# Scoped selector: an unscoped widget stylesheet propagates to child
# widgets and would paint the toggle button solid purple, overriding
# the app QSS.
self.setStyleSheet(
f"TitleLabel {{ background-color: {BANNER}; color: {BANNER_TEXT};"
f" font-size: {FONT_TITLE}; font-weight: 700; }}"
)
# No widget stylesheet: the banner look lives in the per-theme
# TitleLabel rules in styles.py — a stylesheet set here would win
# over the theme and pin the light banner into the dark theme.
self.setAlignment(Qt.AlignmentFlag.AlignCenter)
# Half the original 50px: the full-height banner wasted vertical space.
@@ -79,9 +65,11 @@ class TitleLabel(QLabel):
self.toggle_button = QPushButton("", self)
# Bare glyph, no pill: the shared beamlineStateToggleButton QSS paints
# a translucent white background, which is unwanted on these banners.
# FONT_VALUE (18px), not FONT_BODY: a bare +/- glyph reads smaller than
# the 16px banner title beside it; the big-glyph size evens them out.
self.toggle_button.setStyleSheet(
f"QPushButton {{ background: transparent; border: none;"
f" color: {BANNER_TEXT}; font-size: {FONT_BODY}; font-weight: 700; }}"
f" color: {BANNER_TEXT}; font-size: {FONT_VALUE}; font-weight: 700; }}"
)
self.toggle_button.setToolTip("Minimise panel")
self.toggle_button.setFixedSize(21, 21)
@@ -125,7 +113,9 @@ class TitleLabel(QLabel):
)
painter.setPen(qcolor(BANNER_TEXT_SHADOW, 110))
painter.drawText(rect.translated(0, 1), flags, text)
painter.setPen(qcolor(BANNER_TEXT))
# QSS-resolved 'color' (per-theme TitleLabel rule), not a constant —
# light paints banner white, dark paints dusk gold.
painter.setPen(self.palette().color(QPalette.ColorRole.WindowText))
painter.drawText(rect, flags, text)
def mousePressEvent(self, event):
+7 -43
View File
@@ -1,8 +1,8 @@
from PySide6.QtCore import QRectF, Qt, Slot
from PySide6.QtGui import QColor, QFont, QFontMetrics, QImage, QPainter, QPen, QPixmap
from PySide6.QtGui import QImage, QPainter, QPixmap
from PySide6.QtWidgets import QGraphicsPixmapItem, QGraphicsScene, QGraphicsView
from aare.gui.widgets.busy_overlay import BusyOverlayStyle
from aare.gui.widgets.busy_overlay import BusyOverlayStyle, draw_busy_badge
class VideoGraphicsView(QGraphicsView):
@@ -111,48 +111,12 @@ class VideoGraphicsView(QGraphicsView):
if self._busy_overlay_style is None:
return
style = self._busy_overlay_style
painter.save()
painter.resetTransform()
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
font = QFont()
font.setPointSize(24)
font.setBold(True)
painter.setFont(font)
fm = QFontMetrics(font)
text_rect = fm.boundingRect(style.text)
dot_diameter = 14
gap = 12
padding_x = 22
padding_y = 14
bg_width = text_rect.width() + dot_diameter + gap + padding_x * 2
bg_height = max(text_rect.height(), dot_diameter) + padding_y * 2
viewport_width = self.viewport().width()
viewport_height = self.viewport().height()
pos_x = int((viewport_width - bg_width) / 2)
pos_y = int(viewport_height * 0.68 - bg_height / 2)
bg_rect = QRectF(pos_x, pos_y, bg_width, bg_height)
painter.setPen(QPen(style.overlay_border, 2))
painter.setBrush(style.overlay_fill)
painter.drawRoundedRect(bg_rect, 14, 14)
dot_x = bg_rect.left() + padding_x
dot_y = bg_rect.top() + (bg_rect.height() - dot_diameter) / 2
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QColor(style.accent_dot))
painter.drawEllipse(QRectF(dot_x, dot_y, dot_diameter, dot_diameter))
painter.setPen(QPen(style.overlay_text, 1))
text_x = dot_x + dot_diameter + gap
text_y = bg_rect.top() + padding_y + fm.ascent()
painter.drawText(text_x, text_y, style.text)
# Shared renderer with the sample camera, so every view shows the
# identical badge (this view used to draw its own dot+text variant).
draw_busy_badge(
painter, self.viewport().width(), self.viewport().height(), self._busy_overlay_style
)
painter.restore()
+34
View File
@@ -46,3 +46,37 @@ class WheelValueGuard(QObject):
QApplication.sendEvent(area.viewport(), relayed)
return True
return super().eventFilter(obj, event)
if __name__ == "__main__":
# ponytail: smallest check that fails if the guard logic breaks
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtCore import QPoint, QPointF
app = QApplication([])
guard = WheelValueGuard()
app.installEventFilter(guard)
slider = QSlider(Qt.Orientation.Horizontal)
slider.setRange(0, 100)
slider.setValue(50)
slider.show()
def wheel(buttons):
return QWheelEvent(
QPointF(5, 5),
QPointF(5, 5),
QPoint(0, 0),
QPoint(0, 120),
buttons,
Qt.KeyboardModifier.NoModifier,
Qt.ScrollPhase.NoScrollPhase,
False,
)
QApplication.sendEvent(slider, wheel(Qt.MouseButton.NoButton))
assert slider.value() == 50, "bare wheel must not adjust the slider"
QApplication.sendEvent(slider, wheel(Qt.MouseButton.RightButton))
assert slider.value() != 50, "right-button + wheel must adjust the slider"
print("gude")
+37
View File
@@ -0,0 +1,37 @@
from aarecommon.models.models import SessionsStateEnum
from PySide6.QtWidgets import QVBoxLayout, QWidget
from aare.gui.panels.axis_video_panel import AxisVideoPanel
from aare.gui.widgets.busy_overlay import build_busy_overlay_style
from aare.gui.widgets.video_image import VideoGraphicsView
def _vacant_style():
return build_busy_overlay_style(
is_busy=False, tell_state=None, session_state=SessionsStateEnum.Vacant
)
def test_badge_drawn_once_and_hint_stripped(qtbot):
# Combined-view shape: two video views stacked in one container.
container = QWidget()
layout = QVBoxLayout(container)
first, second = VideoGraphicsView(), VideoGraphicsView()
layout.addWidget(first)
layout.addWidget(second)
panel = AxisVideoPanel("Combined", container)
qtbot.addWidget(panel)
style = _vacant_style()
assert style is not None and style.subtext # sample camera keeps the hint
panel.set_busy_style(style)
applied = first._busy_overlay_style
assert applied is not None
assert applied.text == "In viewing mode"
assert applied.subtext == "" # not clickable here, hint stripped
assert second._busy_overlay_style is None # one badge, not one per view
panel.set_busy_style(None)
assert first._busy_overlay_style is None
+162
View File
@@ -0,0 +1,162 @@
import pytest
from aarecommon.math.coordinate import Coordinate, SmargonCoordinate
from aarecommon.math.diffraction_geometry import DiffractionGeometry
from aarecommon.math.sample_geometry import SampleGeometryModel
from aarecommon.models.models import (
BeamlineStateEnum,
BeamlineStatus,
CrystalSize,
DAQStatusModel,
SampleCameraSettings,
SessionsStateEnum,
SessionStatus,
)
from PySide6.QtCore import QEvent, QPoint, QPointF, Qt
from PySide6.QtGui import QMouseEvent
from aare.gui.scan_logic.raster_grid_manager import RasterGridManager
from aare.gui.styles import THEME_SUNRISE, THEME_SUNSET
from aare.gui.widgets.camera_image import SampleCameraImageLabel
def _geom() -> SampleGeometryModel:
return SampleGeometryModel(
beam_location_pxl=Coordinate(x=1000, y=1000),
pixel_in_mm=0.001,
aerotech=Coordinate(),
aerotech_meas=Coordinate(),
smargon=SmargonCoordinate(sh_mm=Coordinate(), phi_deg=0, chi_deg=0),
omega_deg=0,
beam_size_mm=Coordinate(x=0.01, y=0.01),
)
def _status(*, busy: bool, session: SessionsStateEnum) -> DAQStatusModel:
return DAQStatusModel(
geom=_geom(),
diffraction=DiffractionGeometry(
energy_keV=12.4,
dtz_mm=100.0,
detector_size_pxl=(1553, 1630),
pixel_size_mm=0.150,
beam_center_pxl=(750.0, 750.0),
detector_description="PILATUS 4",
detector_serial_number="1",
poni_rot1_rad=0.0,
poni_rot2_rad=0.0,
),
bl=BeamlineStatus(
name="SIMULATED",
ring_current_mA=400.0,
front_light=50.0,
back_light=50.0,
cryojet_K=100.0,
shutter_open=False,
exp_shutter_open=False,
flux_ph_s=1e12,
sample_camera=SampleCameraSettings(gain=1.0, exposure=0.02),
transmission=1.0,
zoom=1.0,
commissioning_mode=False,
dtz_min=120.0,
dtz_max=1600.0,
),
state=BeamlineStateEnum.SampleAlignment,
busy=busy,
session=SessionStatus(session=session, current_pgroup="p123", staff=True),
crystal_size=CrystalSize(x=0, y=0, z=0),
)
def _mouse_move(widget, pos: QPoint) -> None:
# qtbot.mouseMove drives the real cursor, which the offscreen platform
# ignores — deliver the move event directly instead.
event = QMouseEvent(
QEvent.Type.MouseMove,
QPointF(pos),
QPointF(widget.mapToGlobal(pos)),
Qt.MouseButton.NoButton,
Qt.MouseButton.NoButton,
Qt.KeyboardModifier.NoModifier,
)
widget.mouseMoveEvent(event)
@pytest.fixture
def camera(qtbot):
geom = _geom()
label = SampleCameraImageLabel(geom=geom, raster=RasterGridManager(geom), default_image=None)
qtbot.addWidget(label)
label.resize(800, 600)
return label
def test_help_badge_click_toggles_cheatsheet(camera, qtbot):
camera.grab() # paint records the collapsed "?" badge hit rect
badge = camera._help_hit_rect
assert badge is not None
assert not camera._help_expanded
qtbot.mouseClick(camera.viewport(), Qt.MouseButton.LeftButton, pos=badge.center().toPoint())
assert camera._help_expanded
camera.grab() # expanded overlay: hit rect grows to the whole cheatsheet box
box = camera._help_hit_rect
assert box is not None
assert box.height() > badge.height()
qtbot.mouseClick(camera.viewport(), Qt.MouseButton.LeftButton, pos=box.center().toPoint())
assert not camera._help_expanded
def test_camera_error_message_rewords_and_draws(camera):
camera.set_camera_available(False)
camera.set_camera_error_message("Sample camera feed unavailable: cable unplugged")
assert camera._camera_error_message == (
"Sample camera feed unavailable because cable unplugged"
)
camera.grab() # exercises the bottom-center unavailable overlay text path
camera.set_camera_available(True)
assert camera._camera_error_message is None
def test_busy_warning_is_not_a_click_target(camera):
camera.update_daq_status(_status(busy=True, session=SessionsStateEnum.OwnedByYou))
style = camera._busy_overlay_style
assert style is not None
assert style.text == "BEAMLINE BUSY"
camera.grab()
assert camera._session_badge_rect is None
def test_vacant_badge_hover_click_and_theme(camera, qtbot):
camera.update_daq_status(_status(busy=False, session=SessionsStateEnum.Vacant))
style = camera._busy_overlay_style
assert style is not None
assert style.text == "In viewing mode"
assert style.subtext # the grab-baton hint line
camera.grab() # paint records the badge rect
badge = camera._session_badge_rect
assert badge is not None
_mouse_move(camera, badge.center())
assert camera._session_badge_hovered
camera.grab() # hover fill, light-theme darken branch
camera.set_theme(THEME_SUNSET)
assert camera._dark_theme
camera.grab() # hover fill, sunset brighten branch
camera.set_theme(THEME_SUNRISE)
assert not camera._dark_theme
_mouse_move(camera, QPoint(1, 1))
assert not camera._session_badge_hovered
_mouse_move(camera, badge.center())
camera.leaveEvent(QEvent(QEvent.Type.Leave))
assert not camera._session_badge_hovered
with qtbot.waitSignal(camera.session_badge_clicked, timeout=1000):
qtbot.mouseClick(camera.viewport(), Qt.MouseButton.LeftButton, pos=badge.center())
+25 -25
View File
@@ -1,31 +1,31 @@
"""The console-log pop-out is a second view on the same emitter: history is
copied on open, live lines reach both views, and clear() empties both."""
"""A log mirror view is a second view on the same emitter: history is copied
on creation, live lines reach both views, and clear() empties them all."""
from aare.gui.panels.log_panel import LogDock
from aare.gui.panels.log_panel import LogPanel
def test_log_popout_mirrors_and_clears(qtbot):
dock = LogDock()
qtbot.addWidget(dock)
dock.emitter.message.emit("first line")
assert "first line" in dock.view.toPlainText()
def test_log_mirror_view_and_clear(qtbot):
panel = LogPanel()
qtbot.addWidget(panel)
panel.emitter.message.emit("first line")
assert "first line" in panel.view.toPlainText()
dock._open_popout()
assert dock._popout is not None
assert dock._popout.isVisible()
popout_view = dock._popout_view
assert popout_view is not None
# History copied on open, live lines reach both views.
assert "first line" in popout_view.toPlainText()
dock.emitter.message.emit("second line")
assert "second line" in dock.view.toPlainText()
assert "second line" in popout_view.toPlainText()
mirror = panel.make_mirror_view()
qtbot.addWidget(mirror)
# History copied on creation, live lines reach both views.
assert "first line" in mirror.toPlainText()
panel.emitter.message.emit("second line")
assert "second line" in panel.view.toPlainText()
assert "second line" in mirror.toPlainText()
# Reopening reuses the window instead of stacking mirrors.
popout = dock._popout
dock._open_popout()
assert dock._popout is popout
panel.clear()
assert panel.view.toPlainText() == ""
assert mirror.toPlainText() == ""
dock.clear()
assert dock.view.toPlainText() == ""
assert popout_view.toPlainText() == ""
def test_notification_requests_reveal(qtbot):
panel = LogPanel()
qtbot.addWidget(panel)
with qtbot.waitSignal(panel.reveal_requested, timeout=1000):
panel.show_notification(title="Boom", message="it broke")
assert panel.notification._title.text() == "Boom"
+122
View File
@@ -1,8 +1,11 @@
from unittest.mock import MagicMock, patch
import pytest
from PySide6.QtCore import QSettings
from PySide6.QtWidgets import QDockWidget
from aare.gui.main_window import MainWindow
from aare.gui.styles import THEME_BLUEBIRD, THEME_SUNRISE, THEME_SUNSET
@pytest.fixture
@@ -362,3 +365,122 @@ def test_cleanup_returns_from_compact_automation_view(qtbot, mock_ui_state):
win.cleanup()
assert win.content_stack.currentWidget() is win._standard_main_page
def _make_window(qtbot):
win = MainWindow(
base_url=None,
token="header.payload.signature",
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)
return win
def test_theme_settings_migrate_and_slots_switch(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,
}
win = _make_window(qtbot)
settings = QSettings("PSI", "AareGUI")
saved = settings.value("appearance/theme")
try:
# Pre-rename tokens saved by older builds must map to the new ones.
settings.setValue("appearance/theme", "portrait")
win._restore_theme_settings()
assert win._theme_mode == THEME_SUNSET
settings.setValue("appearance/theme", "original")
win._restore_theme_settings()
assert win._theme_mode == THEME_SUNRISE
settings.setValue("appearance/theme", THEME_BLUEBIRD)
win._restore_theme_settings()
assert win._theme_mode == THEME_BLUEBIRD
finally:
if saved is None:
settings.remove("appearance/theme")
else:
settings.setValue("appearance/theme", saved)
win.use_bluebird_theme()
assert win._theme_mode == THEME_BLUEBIRD
win.use_portrait_theme() # exercises the sunset palette flip
assert win._theme_mode == THEME_SUNSET
win.use_legacy_theme()
assert win._theme_mode == THEME_SUNRISE
def test_restore_window_state_heals_all_hidden_docks(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,
}
win = _make_window(qtbot)
for dock in win.findChildren(QDockWidget):
dock.hide()
assert all(d.isHidden() for d in win.findChildren(QDockWidget))
# state_manager is mocked, so restore_window is a no-op and the
# all-hidden layout survives to the heal check.
win._restore_window_state()
assert not win.tell_samples_dock.isHidden()
def test_close_restores_pre_watch_layout(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,
}
win = _make_window(qtbot)
pre_watch = win.saveState()
for dock in win.findChildren(QDockWidget):
dock.hide()
win._session_operations_enabled = False
win._pre_watch_dock_state = pre_watch
win.close()
# closeEvent put the pre-watch layout back before saving state, so
# the all-hidden fold was not persisted.
assert not win.tell_samples_dock.isHidden()
+14
View File
@@ -0,0 +1,14 @@
from PySide6.QtGui import QPixmap
from aare.gui.widgets.splash_screen import LoadingSplashScreen
def test_splash_progress_and_message(qtbot):
splash = LoadingSplashScreen(QPixmap(200, 100))
qtbot.addWidget(splash)
splash.set_progress(42, "Loading panels")
assert splash.progress.value() == 42
splash.set_progress(43) # message-less update takes the no-showMessage branch
assert splash.progress.value() == 43