Theme overhaul: sunrise/sunset/bluebird themes and camera overlay rework #130
@@ -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 |
|
After Width: | Height: | Size: 261 B |
|
After Width: | Height: | Size: 270 B |
|
After Width: | Height: | Size: 103 B |
|
After Width: | Height: | Size: 103 B |
|
After Width: | Height: | Size: 180 B |
|
After Width: | Height: | Size: 176 B |
|
After Width: | Height: | Size: 174 B |
|
After Width: | Height: | Size: 192 B |
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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("")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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}:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||