Collapsible panel banners and adaptive window sizing #129

Merged
duan_j merged 57 commits from collapsable-and-adaptive into main 2026-08-11 11:02:31 +02:00
84 changed files with 6572 additions and 2080 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 B

+11 -1
View File
@@ -21,7 +21,9 @@ def main():
try:
basedir = os.path.dirname(__file__)
icon_path = os.path.join(basedir, "graphics/aaregui_logo.svg")
banner_path = os.path.join(basedir, "graphics/aare_banner.png")
# The banner is an SVG now; the old aare_banner.png no longer exists
# and yielded a null splash pixmap.
banner_path = os.path.join(basedir, "graphics/aare_banner.svg")
except Exception:
logger.exception("Failed to load resources for splash screen")
sys.exit(1)
@@ -189,6 +191,14 @@ def main():
splash.set_progress(100, "Ready")
splash.finish(win)
# 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())
win.show()
sys.exit(app.exec())
File diff suppressed because it is too large Load Diff
+3 -7
View File
@@ -3,6 +3,8 @@ from typing import Literal
from aarecommon.math.coordinate import SmargonCoordinate
from PySide6.QtGui import QColor
from aare.gui.styles import BOOKMARK_COLORS, qcolor
class SmargonBookmark:
coord: SmargonCoordinate
@@ -10,13 +12,7 @@ class SmargonBookmark:
def qt_color(self) -> QColor:
"""Convert the color property to a QColor."""
color_map = {
"red": QColor("red"),
"green": QColor("green"),
"blue": QColor("blue"),
"indigo": QColor("indigo"),
"lime": QColor("lime"),
}
color_map = {name: qcolor(hex_str) for name, hex_str in BOOKMARK_COLORS.items()}
return color_map[self.color] # Map the color string to QColor
+9 -4
View File
@@ -1,9 +1,10 @@
from aarecommon.config.logger import setup_logger
from aarecommon.models.models import SampleShortInfo, SampleShortInfoList
from PySide6.QtCore import QAbstractTableModel, Qt
from PySide6.QtGui import QBrush, QColor
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, SAMPLE_STATUS_TEXT, qcolor
logger = setup_logger(LOGGER_NAME)
@@ -58,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(255, 102, 0))
return QBrush(qcolor(SAMPLE_ROW_ACTIVE_BG))
else:
return QBrush(QColor(114, 159, 207))
return QBrush(QColor(255, 255, 255))
return QBrush(qcolor(SAMPLE_ROW_QUEUED_BG))
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):
+141 -28
View File
@@ -3,36 +3,49 @@ import re
from aarecommon.config.logger import setup_logger
from aarecommon.models.models import SampleShortInfo, SampleShortInfoList
from PySide6.QtCore import QAbstractTableModel, QMimeData, Qt
from PySide6.QtGui import QBrush, QColor
from PySide6.QtGui import QBrush
from aare.gui.constants import LOGGER_NAME
from aare.gui.styles import (
SAMPLE_ROW_QUEUED_BG,
SAMPLE_STATUS_FLAGGED_BG,
SAMPLE_STATUS_MEASURED_BG,
SAMPLE_STATUS_QUEUED_BG,
SAMPLE_STATUS_TEXT,
qcolor,
)
logger = setup_logger(LOGGER_NAME)
# Column 0 is display-only: the row position ("#") drawn over the status
# color fill. Data attributes start at column 1.
COL_STATUS = 0
def get_entry(sample: SampleShortInfo, column: int):
if column == 0:
if column == 1:
return sample.sample_name
elif column == 1:
return sample.puck_name
elif column == 2:
return sample.dewar_name
return sample.puck_name
elif column == 3:
return sample.loc_str()
return sample.dewar_name
elif column == 4:
return sample.priority
return sample.loc_str()
elif column == 5:
return sample.user
return sample.priority
elif column == 6:
return sample.mount_count
return sample.user
elif column == 7:
return sample.raster_count
return sample.mount_count
elif column == 8:
return sample.rotation_count
return sample.raster_count
elif column == 9:
return sample.screening_count
return sample.rotation_count
elif column == 10:
return sample.screening_count
elif column == 11:
return sample.comment
return ""
class UserSampleSpreadsheet(QAbstractTableModel):
@@ -48,6 +61,7 @@ class UserSampleSpreadsheet(QAbstractTableModel):
samples = []
self.samples: list[SampleShortInfo] = samples
self.header = [
"#",
"Sample name",
"Puck",
"Dewar",
@@ -62,15 +76,23 @@ class UserSampleSpreadsheet(QAbstractTableModel):
]
self.current_sample = current_sample
self.current_puck = current_puck
self._sort_col = 3
self._sort_col = 4 # Location
self._sort_order = Qt.SortOrder.AscendingOrder
self._filters: dict[int, str] = {}
self._filter_col: int | None = 5
self._filter_col: int | None = 6 # User
self._filter_value: str | None = None
self.current_pgroup: str | None = None
self.show_all_pgroups: bool = False
# Display-only status tints (aaregui2 concept: the dewar table doubles
# as the queue view). Fed from outside; the queue itself stays in the
# SampleQueueSpreadsheet.
self.queued_ids: set[int] = set()
self.flagged_ids: set[int] = set()
# None = All; otherwise "queued" | "flagged" | "measured" (chip row).
self.status_filter: str | None = None
self._sort()
def to_list(self) -> list[dict]:
@@ -89,17 +111,96 @@ class UserSampleSpreadsheet(QAbstractTableModel):
def data(self, index, role=None):
if role == Qt.ItemDataRole.DisplayRole:
if index.column() == COL_STATUS:
return index.row() + 1
return get_entry(self._sorted_samples[index.row()], index.column())
elif role == Qt.ItemDataRole.BackgroundRole:
# Status lives in the "#" column, as a full cell fill under the
# row number — rows themselves alternate grey/white (view-level)
# and selection stays the pale blue tint.
if index.column() == COL_STATUS:
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
elif role == Qt.ItemDataRole.BackgroundRole:
if self._sorted_samples[index.row()].db_id == self.current_sample:
return QBrush(QColor(114, 159, 207)) # darker blue
if self._sorted_samples[index.row()].puck_name == self.current_puck:
return QBrush(QColor(216, 228, 253)) # light blue
return QBrush(QColor(255, 255, 255)) # White
return None # For other roles, return None
def _status_color(self, sample: SampleShortInfo) -> str | None:
# Mounted always wins; below that the dot depends on the active chip:
# inside a filtered view every row carries that status, so its own
# color is redundant — only cross-status marks show (queued view:
# red = also flagged; flagged view: orange = put back in the queue).
# The All view keeps the full priority queued > flagged > measured.
if sample.db_id == self.current_sample:
return SAMPLE_ROW_QUEUED_BG
queued = sample.db_id in self.queued_ids
flagged = sample.db_id in self.flagged_ids
if self.status_filter == "queued":
return SAMPLE_STATUS_FLAGGED_BG if flagged else None
if self.status_filter == "flagged":
return SAMPLE_STATUS_QUEUED_BG if queued else None
if self.status_filter == "measured":
if queued:
return SAMPLE_STATUS_QUEUED_BG
return SAMPLE_STATUS_FLAGGED_BG if flagged else None
if queued:
return SAMPLE_STATUS_QUEUED_BG
if flagged:
return SAMPLE_STATUS_FLAGGED_BG
if self._measured(sample):
return SAMPLE_STATUS_MEASURED_BG
return None
@staticmethod
def _measured(sample: SampleShortInfo) -> bool:
# Automatic status, never relabelled by hand: a sample counts as
# measured once its rotation count exceeds 1.
return isinstance(sample.rotation_count, (int, float)) and sample.rotation_count > 1
def set_queued_ids(self, db_ids) -> None:
self.queued_ids = set(db_ids)
self._status_sets_changed()
def set_flagged(self, db_id: int, flagged: bool) -> None:
if flagged:
self.flagged_ids.add(db_id)
else:
self.flagged_ids.discard(db_id)
self._status_sets_changed()
def set_status_filter(self, status: str | None) -> None:
self.layoutAboutToBeChanged.emit()
self.status_filter = status
self._sort()
self.layoutChanged.emit()
def _status_sets_changed(self) -> None:
# With a status chip active the row SET depends on the sets, not just
# the tint — refilter; otherwise a background repaint is enough.
if self.status_filter:
self.layoutAboutToBeChanged.emit()
self._sort()
self.layoutChanged.emit()
else:
self._emit_tints_changed()
def _emit_tints_changed(self) -> None:
if self.rowCount() > 0:
self.dataChanged.emit(
self.index(0, COL_STATUS),
self.index(self.rowCount() - 1, COL_STATUS),
[Qt.ItemDataRole.BackgroundRole],
)
def headerData(self, section, orientation, role=None):
if role == Qt.ItemDataRole.DisplayRole:
if orientation == Qt.Orientation.Horizontal: # Column header
@@ -113,6 +214,7 @@ class UserSampleSpreadsheet(QAbstractTableModel):
):
self.current_puck = current_puck
self.current_sample = current_sample
self._emit_tints_changed()
def updateData(self, samples: list[SampleShortInfo]):
if samples != self.samples:
@@ -122,6 +224,9 @@ class UserSampleSpreadsheet(QAbstractTableModel):
self.endResetModel()
def sort(self, column, order):
# The "#"/status column is display-only — nothing to sort by.
if column == COL_STATUS:
return
self.layoutAboutToBeChanged.emit()
self._sort_order = order
self._sort_col = column
@@ -130,7 +235,7 @@ class UserSampleSpreadsheet(QAbstractTableModel):
def _sort(self):
filtered = self._apply_filter(self.samples)
if self._sort_col == 3:
if self._sort_col == 4: # Location
self._sorted_samples = sorted(
filtered,
key=lambda row: row.loc_str_sort(),
@@ -149,6 +254,14 @@ class UserSampleSpreadsheet(QAbstractTableModel):
)
def _apply_filter(self, rows: list[SampleShortInfo]) -> list[SampleShortInfo]:
# Status chip filter first (All/Queued/Flagged/Measured row).
if self.status_filter == "queued":
rows = [r for r in rows if r.db_id in self.queued_ids]
elif self.status_filter == "flagged":
rows = [r for r in rows if r.db_id in self.flagged_ids]
elif self.status_filter == "measured":
rows = [r for r in rows if self._measured(r)]
# Default filter by User using current p-group if no explicit filter set
filters: dict[int, str] = {
col: v for col, v in (self._filters or {}).items() if (v or "").strip()
@@ -157,8 +270,8 @@ class UserSampleSpreadsheet(QAbstractTableModel):
if self._filter_col is not None and (self._filter_value or "").strip():
filters[self._filter_col] = self._filter_value
if 5 not in filters and self.current_pgroup and not self.show_all_pgroups:
filters[5] = self.current_pgroup
if 6 not in filters and self.current_pgroup and not self.show_all_pgroups:
filters[6] = self.current_pgroup # User
if not filters:
return rows
@@ -270,7 +383,7 @@ class UserSampleSpreadsheet(QAbstractTableModel):
break
# Sort appropriately
if column == 5: # User/pgroup column
if column == 6: # User/pgroup column
try:
out.sort(
key=lambda x: (
@@ -289,10 +402,10 @@ class UserSampleSpreadsheet(QAbstractTableModel):
def suggested_prefixes_for_sample_name(self, limit: int = 200) -> list[str]:
"""Get sample name prefixes from currently filtered samples (excluding column 0 filter)."""
# Get currently filtered samples, excluding the sample name filter
temp_filter = self._filters.pop(0, None)
temp_filter = self._filters.pop(1, None)
filtered_samples = self._apply_filter(self.samples)
if temp_filter is not None:
self._filters[0] = temp_filter
self._filters[1] = temp_filter
rx = re.compile(r"^([A-Za-z]+)")
counts: dict[str, int] = {}
@@ -313,10 +426,10 @@ class UserSampleSpreadsheet(QAbstractTableModel):
def suggested_prefixes_for_location(self, limit: int = 200) -> tuple[list[str], list[str]]:
"""Get location prefixes from currently filtered samples (excluding column 3 filter)."""
# Get currently filtered samples, excluding the location filter
temp_filter = self._filters.pop(3, None)
temp_filter = self._filters.pop(4, None)
filtered_samples = self._apply_filter(self.samples)
if temp_filter is not None:
self._filters[3] = temp_filter
self._filters[4] = temp_filter
seg_seen: set[str] = set()
segpos_seen: set[str] = set()
+39 -19
View File
@@ -4,6 +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
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
@@ -19,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})
@@ -91,30 +97,44 @@ class AbrTweakWidget(QWidget):
super().__init__(parent)
grid_layout = QGridLayout(self)
grid_layout.setColumnStretch(0, 0)
grid_layout.setColumnStretch(1, 0)
grid_layout.setColumnStretch(2, 1)
grid_layout.addWidget(TitleLabel("ABR meas. pos.", self), 0, 0, 1, 3)
grid_layout.addWidget(
TitleLabel("ABR meas. pos.", self, collapsible=True, default_collapsed=False),
0,
0,
1,
2,
)
self._abr_buttons = AbrTweakButtons(DEFAULT_ABR_STEP_UM / 1000, parent=self)
grid_layout.addWidget(self._abr_buttons, 1, 0, 1, 4)
grid_layout.addWidget(self._abr_buttons, 1, 0)
self._abr_buttons.abr_tweak.connect(self.abr_button_pressed)
grid_layout.addWidget(QLabel("Step", parent=self), 2, 0)
self._step_um = NumberLineEdit(1, 1000, DEFAULT_ABR_STEP_UM, 0, parent=self)
grid_layout.addWidget(self._step_um, 2, 1)
grid_layout.addWidget(QLabel("μm", parent=self), 2, 2)
# Step + actions as a column BESIDE the GM rows instead of below —
# the rows left plenty of dead width.
side = QWidget(self)
side_grid = QGridLayout(side)
side_grid.setContentsMargins(0, 0, 0, 0)
side_grid.addWidget(QLabel("Step", parent=side), 0, 0)
self._step_um = NumberLineEdit(1, 1000, DEFAULT_ABR_STEP_UM, 0, parent=side)
side_grid.addWidget(self._step_um, 0, 1)
side_grid.addWidget(QLabel("μm", parent=side), 0, 2)
self._step_um.newValue.connect(self._abr_buttons.set_step)
save_button = QPushButton("Save ABR pos.")
grid_layout.addWidget(save_button, 3, 0, 1, 3)
side_grid.addWidget(save_button, 1, 0, 1, 3)
save_button.pressed.connect(self.save_button_pressed)
goto_button = QPushButton("Go to meas.")
grid_layout.addWidget(goto_button, 4, 0, 1, 3)
side_grid.addWidget(goto_button, 2, 0, 1, 3)
goto_button.pressed.connect(self.goto_button_pressed)
# ~80% of the width the grid handed it; the freed space goes to the
# GM rows (column 0 takes all stretch).
side.setMaximumWidth(150)
grid_layout.setColumnStretch(0, 1)
grid_layout.addWidget(side, 1, 1)
@Slot()
def goto_button_pressed(self):
self.abr_goto_meas.emit()
@@ -131,17 +151,17 @@ class AbrTweakWidget(QWidget):
def update_daq_status(self, s: DAQStatusModel):
self._abr_buttons.gmx_label.setText(f"{s.geom.aerotech_meas.x:.3f}")
if abs(s.geom.aerotech.x) >= 0.001:
self._abr_buttons.gmx_label.setStyleSheet("color: rgb(255, 0, 0);")
self._abr_buttons.gmx_label.setStyleSheet(f"color: {ALERT_TEXT};")
else:
self._abr_buttons.gmx_label.setStyleSheet("color: rgb(0, 0, 0);")
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("color: rgb(255, 0, 0);")
self._abr_buttons.gmy_label.setStyleSheet(f"color: {ALERT_TEXT};")
else:
self._abr_buttons.gmy_label.setStyleSheet("color: rgb(0, 0, 0);")
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("color: rgb(255, 0, 0);")
self._abr_buttons.gmz_label.setStyleSheet(f"color: {ALERT_TEXT};")
else:
self._abr_buttons.gmz_label.setStyleSheet("color: rgb(0, 0, 0);")
self._abr_buttons.gmz_label.setStyleSheet("")
+85 -45
View File
@@ -1,15 +1,38 @@
from __future__ import annotations
import copy
import time
from datetime import datetime
from aarecommon.config.logger import setup_logger
from aarecommon.models.automation import AutomationProgress, StepStatus, WorkflowStateKind
from PySide6.QtCore import QTimer, Slot
from PySide6.QtWidgets import QLabel, QVBoxLayout, QWidget
from PySide6.QtCore import Qt, QTimer, Slot
from PySide6.QtWidgets import QHBoxLayout, QLabel, QVBoxLayout, QWidget
from aare.gui.constants import LOGGER_NAME
from aare.gui.styles import (
AUTOMATION_HINT_TEXT,
CARD_BORDER,
FAINT_TEXT,
FONT_BODY,
FONT_LABEL,
FONT_TITLE,
MUTED_TEXT,
STEP_ACTIVE_BG,
STEP_ACTIVE_BORDER,
STEP_ACTIVE_TEXT,
STEP_DONE_BG,
STEP_DONE_BORDER,
STEP_DONE_TEXT,
STEP_FAILED_BG,
STEP_FAILED_BORDER,
STEP_FAILED_TEXT,
STEP_IDLE_BG,
STEP_IDLE_BORDER,
STEP_PAUSED_BG,
STEP_PAUSED_BORDER,
STEP_PAUSED_TEXT,
SURFACE,
)
logger = setup_logger(LOGGER_NAME)
@@ -24,7 +47,6 @@ class AutomationProgressWidget(QWidget):
self._progress: AutomationProgress | None = None
self._gui_samples_in_queue: int | None = None
self._labels: dict[WorkflowStateKind, QLabel] = {}
self._title_label: QLabel | None = None
self._stats_label: QLabel | None = None
self._is_paused = False
self._refresh_timer = QTimer(self)
@@ -34,36 +56,45 @@ class AutomationProgressWidget(QWidget):
self.clear()
def _setup_ui(self) -> None:
# No title label: the dock's title bar already says it.
layout = QVBoxLayout(self)
layout.setContentsMargins(10, 10, 10, 10)
layout.setSpacing(10)
self._title_label = QLabel("Automation progress")
self._title_label.setStyleSheet(
"font-size: 16px; font-weight: 700; color: #1F2937; margin-bottom: 2px;"
)
layout.addWidget(self._title_label)
self._stats_label = QLabel()
self._stats_label.setStyleSheet(
"color: #374151; font-size: 13px; "
"background-color: #F8FAFC; border: 1px solid #E2E8F0; "
"border-radius: 8px; padding: 10px;"
f"color: {AUTOMATION_HINT_TEXT}; font-size: {FONT_LABEL}; font-weight: 700; "
f"background-color: {SURFACE}; border: 1px solid {CARD_BORDER}; "
"padding: 10px;"
)
self._stats_label.setWordWrap(True)
layout.addWidget(self._stats_label)
# Stats left, automation run-state card right ("||" paused / "▶"
# running — ASCII bars: fancier pause glyphs are missing from the
# beamline console fonts).
self._state_label = QLabel()
self._state_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
top_row = QHBoxLayout()
top_row.setSpacing(10)
top_row.addWidget(self._stats_label, 2)
top_row.addWidget(self._state_label, 1)
layout.addLayout(top_row)
self._update_state_label()
steps_row = QHBoxLayout()
steps_row.setSpacing(6)
for step in (
WorkflowStateKind.MOUNT,
WorkflowStateKind.LOOP_CENTRE,
WorkflowStateKind.RASTER,
WorkflowStateKind.DATA_COLLECTION,
WorkflowStateKind.FINAL,
):
label = QLabel()
label.setWordWrap(True)
layout.addWidget(label)
steps_row.addWidget(label, 1)
self._labels[step] = label
layout.addLayout(steps_row)
layout.addStretch()
@@ -116,31 +147,37 @@ class AutomationProgressWidget(QWidget):
@staticmethod
def _style_for_status(status: StepStatus) -> str:
base = (
"padding: 10px 12px; border-radius: 10px; "
"font-size: 14px; border: 1px solid transparent;"
)
base = f"padding: 10px 12px; font-size: {FONT_BODY}; border: 1px solid transparent;"
if status == StepStatus.SUCCESS:
return base + " background-color: #ECFDF3; color: #166534; border-color: #A7F3D0;"
return (
base
+ f" background-color: {STEP_DONE_BG}; color: {STEP_DONE_TEXT}; border-color: {STEP_DONE_BORDER};"
)
if status == StepStatus.RUNNING:
return (
base
+ " background-color: #EFF6FF; color: #1D4ED8; font-weight: 700; border-color: #BFDBFE;"
+ f" background-color: {STEP_ACTIVE_BG}; color: {STEP_ACTIVE_TEXT}; font-weight: 700; border-color: {STEP_ACTIVE_BORDER};"
)
if status == StepStatus.FAILED:
return (
base
+ " background-color: #FEF2F2; color: #B91C1C; font-weight: 700; border-color: #FECACA;"
+ f" background-color: {STEP_FAILED_BG}; color: {STEP_FAILED_TEXT}; font-weight: 700; border-color: {STEP_FAILED_BORDER};"
)
if status == StepStatus.PAUSED:
return (
base
+ " background-color: #FFF7ED; color: #C2410C; font-weight: 700; border-color: #FED7AA;"
+ f" background-color: {STEP_PAUSED_BG}; color: {STEP_PAUSED_TEXT}; font-weight: 700; border-color: {STEP_PAUSED_BORDER};"
)
if status == StepStatus.SKIPPED:
return base + " background-color: #F8FAFC; color: #475569; border-color: #E2E8F0;"
return base + " background-color: #F8FAFC; color: #64748B; border-color: #E2E8F0;"
return (
base
+ f" background-color: {STEP_IDLE_BG}; color: {MUTED_TEXT}; border-color: {STEP_IDLE_BORDER};"
)
return (
base
+ f" background-color: {STEP_IDLE_BG}; color: {FAINT_TEXT}; border-color: {STEP_IDLE_BORDER};"
)
@staticmethod
def _format_duration(seconds: float | None) -> str:
@@ -185,32 +222,38 @@ class AutomationProgressWidget(QWidget):
return
self.set_progress(self._progress)
def _update_state_label(self) -> None:
if self._is_paused:
colors = (
f"background-color: {STEP_PAUSED_BG}; color: {STEP_PAUSED_TEXT}; "
f"border: 1px solid {STEP_PAUSED_BORDER};"
)
text = "|| paused"
else:
colors = (
f"background-color: {STEP_ACTIVE_BG}; color: {STEP_ACTIVE_TEXT}; "
f"border: 1px solid {STEP_ACTIVE_BORDER};"
)
text = "▶ running"
self._state_label.setText(text)
self._state_label.setStyleSheet(
f"font-size: {FONT_TITLE}; font-weight: 700; padding: 10px; " + colors
)
@Slot(bool)
def set_running(self, running: bool) -> None:
self._is_paused = not running
self._update_state_label()
if self._progress is None:
return
progress = copy.deepcopy(self._progress)
final_step = next(
(step for step in progress.steps if step.step == WorkflowStateKind.FINAL), None
)
if self._is_paused:
self._refresh_timer.stop()
if final_step is not None:
final_step.status = StepStatus.PAUSED
final_step.message = "Automation paused"
else:
if final_step is not None and final_step.status == StepStatus.PAUSED:
final_step.status = StepStatus.PENDING
final_step.message = ""
if self._has_live_timing(progress):
self._refresh_timer.start()
elif self._has_live_timing(self._progress):
self._refresh_timer.start()
self.set_progress(progress)
self.set_progress(self._progress)
@Slot(int)
def set_samples_in_queue(self, count: int) -> None:
@@ -283,6 +326,3 @@ class AutomationProgressWidget(QWidget):
label.setText(f"{icon} <b>{title}</b>{duration_str}{message}{error_str}")
label.setStyleSheet(self._style_for_status(step_state.status))
if self._title_label is not None:
self._title_label.setText("Automation progress")
+14 -49
View File
@@ -1,4 +1,6 @@
from PySide6.QtCore import Qt, Signal
from dataclasses import replace
from PySide6.QtCore import Signal
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget
from aare.gui.widgets.busy_overlay import BusyOverlayStyle
@@ -8,32 +10,13 @@ from aare.gui.widgets.video_image import VideoGraphicsView
class AxisVideoPanel(QWidget):
refresh_requested = Signal()
def __init__(self, title: str, video_view: VideoGraphicsView | None = None, parent=None):
# video_view may be a bare VideoGraphicsView or any container holding
# them (the combined view passes a QWidget with two stacked views).
def __init__(self, title: str, video_view: QWidget | None = None, parent=None):
super().__init__(parent)
self._title_label = QLabel(title, self)
self._status_container = QWidget(self)
self._status_container.setObjectName("axisVideoStatusContainer")
self._status_container.setProperty("busyState", "idle")
self._status_dot = QLabel(self._status_container)
self._status_dot.setObjectName("axisVideoStatusDot")
self._status_dot.setFixedSize(10, 10)
self._status_label = QLabel("", self._status_container)
self._status_label.setObjectName("axisVideoStatusLabel")
self._status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
status_layout = QHBoxLayout(self._status_container)
status_layout.setContentsMargins(10, 6, 12, 6)
status_layout.setSpacing(8)
status_layout.addWidget(self._status_dot)
status_layout.addWidget(self._status_label)
self._status_container.setMinimumWidth(190)
self._status_container.hide()
self._refresh_button = QPushButton("Refresh Axis Cameras", self)
self._refresh_button.clicked.connect(self.refresh_requested.emit)
@@ -43,7 +26,6 @@ class AxisVideoPanel(QWidget):
controls_layout.setContentsMargins(0, 0, 0, 0)
controls_layout.addWidget(self._title_label)
controls_layout.addStretch()
controls_layout.addWidget(self._status_container)
controls_layout.addWidget(self._refresh_button)
root_layout = QVBoxLayout(self)
@@ -52,12 +34,6 @@ class AxisVideoPanel(QWidget):
root_layout.addLayout(controls_layout)
root_layout.addWidget(self.view)
def _refresh_status_style(self) -> None:
for widget in (self._status_container, self._status_dot, self._status_label):
widget.style().unpolish(widget)
widget.style().polish(widget)
widget.update()
def _all_video_views(self) -> list[VideoGraphicsView]:
views: list[VideoGraphicsView] = []
if isinstance(self.view, VideoGraphicsView):
@@ -70,24 +46,13 @@ class AxisVideoPanel(QWidget):
return unique_views
def set_busy_style(self, style: BusyOverlayStyle | None) -> None:
if style is None:
self._status_label.setText("")
self._status_container.setProperty("busyState", "idle")
self._status_dot.setStyleSheet("background-color: transparent;")
self._status_label.setStyleSheet("")
self._refresh_status_style()
self._status_container.hide()
else:
self._status_label.setText(style.text)
self._status_container.setProperty("busyState", "active")
self._status_dot.setStyleSheet(f"background-color: {style.accent_dot};")
self._status_label.setStyleSheet(f"color: {style.badge_fg};")
self._refresh_status_style()
self._status_container.show()
# The hint line invites a click, but only the sample-camera badge is
# a click target — strip it for these passive views.
if style is not None and style.subtext:
style = replace(style, subtext="")
for view in self._all_video_views():
# Only the first view draws the badge: the combined panel stacks two
# video views and used to show the message once per view.
for index, view in enumerate(self._all_video_views()):
if hasattr(view, "set_busy_overlay_style"):
view.set_busy_overlay_style(style)
def set_status_text(self, text: str) -> None:
self._status_label.setText(text or "")
view.set_busy_overlay_style(style if index == 0 else None)
+2 -2
View File
@@ -3,7 +3,7 @@ from PySide6.QtCore import Signal, Slot
from PySide6.QtWidgets import QGridLayout, QLabel, QWidget
from aare.gui.widgets.number_line_edit import NumberLineEdit
from aare.gui.widgets.title_label import TitleLabel
from aare.gui.widgets.title_label import section_title
class BeamCenterWidget(QWidget):
@@ -14,7 +14,7 @@ class BeamCenterWidget(QWidget):
grid_layout = QGridLayout(self)
grid_layout.addWidget(TitleLabel("Beam center (detector)", self), 0, 0, 1, 5)
grid_layout.addWidget(section_title("Beam center (detector)", self), 0, 0, 1, 5)
self.x = NumberLineEdit(-4000, 4000, 0, parent=self)
self.x.newValue.connect(self.beam_center_edited)
+6 -3
View File
@@ -2,7 +2,7 @@ from aarecommon.models.models import DAQStatusModel
from PySide6.QtCore import Signal, Slot
from PySide6.QtWidgets import QGridLayout, QLabel, QPushButton, QWidget
from aare.gui.widgets.title_label import TitleLabel
from aare.gui.widgets.title_label import section_title
class BeamMarkWidget(QWidget):
@@ -12,8 +12,9 @@ class BeamMarkWidget(QWidget):
super().__init__(parent)
grid_layout = QGridLayout(self)
grid_layout.setVerticalSpacing(2)
grid_layout.addWidget(TitleLabel("Beam mark (image)", self), 0, 0, 1, 5)
grid_layout.addWidget(section_title("Beam mark (image)", self), 0, 0, 1, 6)
self.x = QLabel("0")
self.y = QLabel("0")
@@ -24,8 +25,10 @@ class BeamMarkWidget(QWidget):
grid_layout.addWidget(self.y, 1, 3)
grid_layout.addWidget(QLabel("pxl"), 1, 4)
# Shares the readings row instead of a full-width row below.
clear_button = QPushButton("Clear marks")
grid_layout.addWidget(clear_button, 2, 0, 1, 5)
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)
@Slot()
+2 -2
View File
@@ -3,7 +3,7 @@ from PySide6.QtCore import Signal, Slot
from PySide6.QtWidgets import QGridLayout, QLabel, QWidget
from aare.gui.widgets.number_line_edit import NumberLineEdit
from aare.gui.widgets.title_label import TitleLabel
from aare.gui.widgets.title_label import section_title
class BeamSizeWidget(QWidget):
@@ -14,7 +14,7 @@ class BeamSizeWidget(QWidget):
grid_layout = QGridLayout(self)
grid_layout.addWidget(TitleLabel("Beam size", self), 0, 0, 1, 5)
grid_layout.addWidget(section_title("Beam size", self), 0, 0, 1, 5)
self.x = NumberLineEdit(1, 400.0, 10, parent=self)
self.x.newValue.connect(self.beam_size_edited)
+32 -21
View File
@@ -1,26 +1,52 @@
from PySide6.QtWidgets import QFrame, QVBoxLayout
from PySide6.QtWidgets import QFrame, QVBoxLayout, QWidget
from aare.gui.panels.abr_tweak_panel import AbrTweakWidget
from aare.gui.panels.beam_center_panel import BeamCenterWidget
from aare.gui.panels.beam_mark_panel import BeamMarkWidget
from aare.gui.panels.beam_size_panel import BeamSizeWidget
from aare.gui.panels.illumination_panel import IlluminationPanel
from aare.gui.panels.monochromator_panel import MonochromatorPanel
from aare.gui.panels.omega_panel import OmegaPanel
from aare.gui.panels.samcam_panel import SamcamPanel
from aare.gui.panels.smargon_panel import SmargonPanel
from aare.gui.panels.zoom_panel import ZoomPanel
from aare.gui.widgets.title_label import TitleLabel, tighten_column
class BeamConfigPanel(QWidget):
"""Beam mark / center / size grouped under one collapsible banner; the
sub-widgets keep their own signals and small section titles."""
def __init__(self, parent=None):
super().__init__(parent)
# Default layout margins so the banner aligns with the sibling
# panels' banners in the column.
layout = QVBoxLayout(self)
layout.setSpacing(0)
layout.addWidget(
TitleLabel("Beam configuration", self, collapsible=True, default_collapsed=False)
)
self.beam_mark = BeamMarkWidget(self)
self.beam_center = BeamCenterWidget(self)
self.beam_size = BeamSizeWidget(self)
for sub in (self.beam_mark, self.beam_center, self.beam_size):
sub_layout = sub.layout()
assert sub_layout is not None # each sub-widget builds its grid in __init__
# The outer layout already indents; zero the sub-grids' side
# margins so section content isn't double-inset.
sub_layout.setContentsMargins(0, 4, 0, 4)
layout.addWidget(sub)
class BeamlineControls(QFrame):
set_width = 250
def __init__(self, parent=None, staff: bool = True):
def __init__(self, parent=None):
super().__init__(parent)
self.setObjectName("beamlineControls")
self.setFixedWidth(self.set_width)
self.setFrameShape(QFrame.Shape.StyledPanel)
self.setFrameShadow(QFrame.Shadow.Raised)
# Samcam / monochromator / ABR / beam config moved to the left
# column's "Beamline" group (main_window builds it).
self.v_layout = QVBoxLayout(self)
self.zoom_panel = ZoomPanel(self)
self.v_layout.addWidget(self.zoom_panel)
@@ -34,21 +60,6 @@ class BeamlineControls(QFrame):
self.smargon_panel = SmargonPanel(parent=self)
self.v_layout.addWidget(self.smargon_panel)
self.samcam = SamcamPanel(self)
self.v_layout.addWidget(self.samcam)
if staff:
self.monochromator_panel = MonochromatorPanel(self)
self.abr_tweak = AbrTweakWidget(self)
self.beam_mark = BeamMarkWidget(self)
self.beam_center = BeamCenterWidget(self)
self.beam_size = BeamSizeWidget(self)
self.v_layout.addWidget(self.monochromator_panel)
self.v_layout.addWidget(self.abr_tweak)
self.v_layout.addWidget(self.beam_mark)
self.v_layout.addWidget(self.beam_center)
self.v_layout.addWidget(self.beam_size)
self.v_layout.addStretch()
tighten_column(self.v_layout)
self.setLayout(self.v_layout)
+77 -62
View File
@@ -16,6 +16,21 @@ from PySide6.QtWidgets import (
)
from aare.gui.constants import LOGGER_NAME
from aare.gui.styles import (
BAD_CARD_BORDER,
CHIP_BAD_BG,
CHIP_BAD_TEXT,
CHIP_INFO_TEXT,
CHIP_WARN_BG,
CHIP_WARN_TEXT,
FLAT_CARD_RADIUS,
INFO_CARD_BG,
INFO_CARD_BORDER,
PENDING_CARD_BG,
PENDING_CARD_BORDER,
WARN_CARD_BORDER,
card_style,
)
from aare.gui.threads.daq_worker import DAQWorker
logger = setup_logger(LOGGER_NAME)
@@ -36,14 +51,14 @@ class RecoveryPanel(QWidget):
)
self._warning_primary.setWordWrap(True)
self._warning_primary.setStyleSheet(
"QLabel {"
" background: #fff3cd;"
" color: #7a4b00;"
" border: 1px solid #f0c36d;"
" border-radius: 8px;"
" padding: 10px;"
" font-weight: 600;"
"}"
card_style(
CHIP_WARN_BG,
WARN_CARD_BORDER,
CHIP_WARN_TEXT,
selector="QLabel",
radius=FLAT_CARD_RADIUS,
extra="padding: 10px; font-weight: 600;",
)
)
layout.addWidget(self._warning_primary)
@@ -53,95 +68,95 @@ class RecoveryPanel(QWidget):
)
self._warning_secondary.setWordWrap(True)
self._warning_secondary.setStyleSheet(
"QLabel {"
" background: #fdeaea;"
" color: #8b1e1e;"
" border: 1px solid #e6a8a8;"
" border-radius: 8px;"
" padding: 10px;"
" font-weight: 600;"
"}"
card_style(
CHIP_BAD_BG,
BAD_CARD_BORDER,
CHIP_BAD_TEXT,
selector="QLabel",
radius=FLAT_CARD_RADIUS,
extra="padding: 10px; font-weight: 600;",
)
)
layout.addWidget(self._warning_secondary)
self._last_action = QLabel("Last action: -", self)
self._last_action.setWordWrap(True)
self._last_action.setStyleSheet(
"QLabel {"
" background: #eef6ff;"
" color: #12406a;"
" border: 1px solid #a8c7e6;"
" border-radius: 8px;"
" padding: 10px;"
" font-weight: 600;"
"}"
card_style(
INFO_CARD_BG,
INFO_CARD_BORDER,
CHIP_INFO_TEXT,
selector="QLabel",
radius=FLAT_CARD_RADIUS,
extra="padding: 10px; font-weight: 600;",
)
)
layout.addWidget(self._last_action)
self._take_over_btn = QPushButton("Take over beamline", self)
self._take_over_btn.setStyleSheet(
"QPushButton {"
" background: #fff7db;"
" border: 1px solid #e7cb73;"
" border-radius: 8px;"
" padding: 10px;"
" font-weight: 600;"
"}"
card_style(
PENDING_CARD_BG,
PENDING_CARD_BORDER,
selector="QPushButton",
radius=FLAT_CARD_RADIUS,
extra="padding: 10px; font-weight: 600;",
)
)
self._take_over_btn.clicked.connect(self._take_over_beamline)
layout.addWidget(self._take_over_btn)
self._free_beamline_btn = QPushButton("Free beamline", self)
self._free_beamline_btn.setStyleSheet(
"QPushButton {"
" background: #fff7db;"
" border: 1px solid #e7cb73;"
" border-radius: 8px;"
" padding: 10px;"
" font-weight: 600;"
"}"
card_style(
PENDING_CARD_BG,
PENDING_CARD_BORDER,
selector="QPushButton",
radius=FLAT_CARD_RADIUS,
extra="padding: 10px; font-weight: 600;",
)
)
self._free_beamline_btn.clicked.connect(self._free_beamline)
layout.addWidget(self._free_beamline_btn)
self._recover_beamline_btn = QPushButton("Recover beamline", self)
self._recover_beamline_btn.setStyleSheet(
"QPushButton {"
" background: #fdeaea;"
" color: #8b1e1e;"
" border: 1px solid #e6a8a8;"
" border-radius: 8px;"
" padding: 10px;"
" font-weight: 700;"
"}"
card_style(
CHIP_BAD_BG,
BAD_CARD_BORDER,
CHIP_BAD_TEXT,
selector="QPushButton",
radius=FLAT_CARD_RADIUS,
extra="padding: 10px; font-weight: 700;",
)
)
self._recover_beamline_btn.clicked.connect(self._recover_beamline)
layout.addWidget(self._recover_beamline_btn)
self._recovery_unmount_btn = QPushButton("Unmount sample (recovery)", self)
self._recovery_unmount_btn.setStyleSheet(
"QPushButton {"
" background: #fdeaea;"
" color: #8b1e1e;"
" border: 1px solid #e6a8a8;"
" border-radius: 8px;"
" padding: 10px;"
" font-weight: 700;"
"}"
card_style(
CHIP_BAD_BG,
BAD_CARD_BORDER,
CHIP_BAD_TEXT,
selector="QPushButton",
radius=FLAT_CARD_RADIUS,
extra="padding: 10px; font-weight: 700;",
)
)
self._recovery_unmount_btn.clicked.connect(self._recovery_unmount_sample)
layout.addWidget(self._recovery_unmount_btn)
self._resync_sample_btn = QPushButton("Resync sample from TELL", self)
self._resync_sample_btn.setStyleSheet(
"QPushButton {"
" background: #eef6ff;"
" color: #12406a;"
" border: 1px solid #a8c7e6;"
" border-radius: 8px;"
" padding: 10px;"
" font-weight: 600;"
"}"
card_style(
INFO_CARD_BG,
INFO_CARD_BORDER,
CHIP_INFO_TEXT,
selector="QPushButton",
radius=FLAT_CARD_RADIUS,
extra="padding: 10px; font-weight: 600;",
)
)
self._resync_sample_btn.clicked.connect(self._resync_sample)
layout.addWidget(self._resync_sample_btn)
+310 -546
View File
@@ -1,41 +1,75 @@
from collections import deque
from dataclasses import dataclass
from typing import ClassVar
from aarecommon.models.models import BeamlineStateEnum, DAQStatusModel
from PySide6.QtCore import QPoint, QRect, Qt, Signal, Slot
from PySide6.QtGui import QColor, QPainter, QPen
from PySide6.QtWidgets import QFrame, QLabel, QPushButton
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, 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
# routes between states (_GRAPH), e.g. Manual sample exchange -> Sample
# alignment skipping the robot station, and Maintenance which sits outside
# the route graph. Keep in sync until the server exposes transitions.
_TO_SAMPLE_ALIGNMENT = frozenset({BeamlineStateEnum.SampleAlignment})
MENU_TRANSITIONS: dict[BeamlineStateEnum, frozenset[BeamlineStateEnum]] = {
BeamlineStateEnum.RobotSampleExchange: _TO_SAMPLE_ALIGNMENT,
BeamlineStateEnum.SampleExchange: _TO_SAMPLE_ALIGNMENT,
BeamlineStateEnum.DewarTransfer: _TO_SAMPLE_ALIGNMENT,
BeamlineStateEnum.BeamLocation: _TO_SAMPLE_ALIGNMENT,
BeamlineStateEnum.DataCollection: _TO_SAMPLE_ALIGNMENT,
BeamlineStateEnum.XrayFluorescence: _TO_SAMPLE_ALIGNMENT,
BeamlineStateEnum.XtalSnapshot: _TO_SAMPLE_ALIGNMENT,
BeamlineStateEnum.SampleAlignment: frozenset(
{
BeamlineStateEnum.SampleExchange,
BeamlineStateEnum.DewarTransfer,
BeamlineStateEnum.BeamLocation,
}
),
BeamlineStateEnum.Maintenance: frozenset({BeamlineStateEnum.SampleExchange}),
}
# One-hop routes between states (the former map's segments).
_SEGMENTS = [
(BeamlineStateEnum.DewarTransfer, BeamlineStateEnum.SampleExchange),
(BeamlineStateEnum.SampleExchange, BeamlineStateEnum.RobotSampleExchange),
(BeamlineStateEnum.RobotSampleExchange, BeamlineStateEnum.SampleAlignment),
(BeamlineStateEnum.SampleAlignment, BeamlineStateEnum.BeamLocation),
(BeamlineStateEnum.SampleAlignment, BeamlineStateEnum.BeamstopAlignment),
(BeamlineStateEnum.SampleAlignment, BeamlineStateEnum.FluxMeasurement),
(BeamlineStateEnum.SampleAlignment, BeamlineStateEnum.DataCollection),
(BeamlineStateEnum.BeamLocation, BeamlineStateEnum.BeamstopAlignment),
(BeamlineStateEnum.BeamLocation, BeamlineStateEnum.FluxMeasurement),
(BeamlineStateEnum.BeamstopAlignment, BeamlineStateEnum.FluxMeasurement),
(BeamlineStateEnum.DataCollection, BeamlineStateEnum.XtalSnapshot),
(BeamlineStateEnum.DataCollection, BeamlineStateEnum.XrayFluorescence),
]
@dataclass(frozen=True)
class StationSpec:
state: BeamlineStateEnum
label: str
x: int
y: int
clickable: bool = False
tooltip: str = ""
def _build_graph(
segments: list[tuple[BeamlineStateEnum, BeamlineStateEnum]],
) -> dict[BeamlineStateEnum, set[BeamlineStateEnum]]:
graph: dict[BeamlineStateEnum, set[BeamlineStateEnum]] = {}
for a, b in segments:
graph.setdefault(a, set()).add(b)
graph.setdefault(b, set()).add(a)
return graph
class HoverableLabel(QLabel):
hovered = Signal(object)
unhovered = Signal()
def enterEvent(self, event) -> None:
self.hovered.emit(getattr(self, "_beamline_state", None))
super().enterEvent(event)
def leaveEvent(self, event) -> None:
self.unhovered.emit()
super().leaveEvent(event)
_GRAPH = _build_graph(_SEGMENTS)
class HoverableButton(QPushButton):
hovered = Signal(object)
unhovered = Signal()
# Set by the panel right after construction; carried in hover signals.
_beamline_state: BeamlineStateEnum | None = None
def enterEvent(self, event) -> None:
self.hovered.emit(getattr(self, "_beamline_state", None))
self.hovered.emit(self._beamline_state)
super().enterEvent(event)
def leaveEvent(self, event) -> None:
@@ -44,6 +78,13 @@ class HoverableButton(QPushButton):
class BeamlineStatePanel(QFrame):
"""Slim horizontal state strip shown directly above the status bar.
Replaces the former vertical station map: two-line entries in beamline
order, colored by availability (blue/red = active, orange = reachable in
one transition, grey = not reachable).
"""
sample_exchange = Signal()
sample_alignment = Signal()
dewar_exchange = Signal()
@@ -55,332 +96,192 @@ class BeamlineStatePanel(QFrame):
beamstop_alignment = Signal()
flux_measurement = Signal()
set_width = 400
map_height = 542
title_height = 50
collapsed_height = 50
station_radius = 8
# Physical beamline order. Labels are one line when the bar is wide
# enough and wrap at the last space to two lines when it is not.
_ENTRIES: tuple[tuple[BeamlineStateEnum, str], ...] = (
(BeamlineStateEnum.Maintenance, "Maintenance mode"),
(BeamlineStateEnum.BeamLocation, "Beam location"),
(BeamlineStateEnum.BeamstopAlignment, "Beamstop alignment"),
(BeamlineStateEnum.FluxMeasurement, "Flux measurement"),
(BeamlineStateEnum.SampleAlignment, "Sample alignment"),
(BeamlineStateEnum.SampleExchange, "Manual sample exchange"),
(BeamlineStateEnum.RobotSampleExchange, "Robot sample exchange"),
(BeamlineStateEnum.DewarTransfer, "Dewar transfer"),
(BeamlineStateEnum.DataCollection, "Data collection"),
(BeamlineStateEnum.XtalSnapshot, "Crystal snapshot"),
(BeamlineStateEnum.XrayFluorescence, "X-ray fluorescence"),
)
_TOOLTIPS: ClassVar[dict[BeamlineStateEnum, str]] = {
BeamlineStateEnum.DewarTransfer: "Dewar transfer mode",
BeamlineStateEnum.SampleExchange: "Manual sample exchange mode",
BeamlineStateEnum.RobotSampleExchange: "Robot-assisted sample exchange",
BeamlineStateEnum.SampleAlignment: "Sample centring and alignment mode",
BeamlineStateEnum.BeamLocation: "Beam location mode",
BeamlineStateEnum.BeamstopAlignment: "Beamstop alignment mode",
BeamlineStateEnum.FluxMeasurement: "Flux measurement mode",
BeamlineStateEnum.DataCollection: "Measurement / collection mode",
BeamlineStateEnum.XtalSnapshot: "Crystal snapshot mode",
BeamlineStateEnum.XrayFluorescence: "X-ray fluorescence mode",
}
def __init__(self, parent=None):
super().__init__(parent)
self.setObjectName("beamlineStatePanel")
self.setFrameShape(QFrame.Shape.StyledPanel)
self.setFrameShadow(QFrame.Shadow.Raised)
self.setFixedWidth(self.set_width)
self._is_collapsed = False
self.setMinimumHeight(self.map_height)
self.setMaximumHeight(self.map_height)
self._current_state: BeamlineStateEnum | None = None
self._hovered_state: BeamlineStateEnum | None = None
self._pending_target_state: BeamlineStateEnum | None = None
self._last_stable_state: BeamlineStateEnum | None = None
self._line_color = QColor(111, 129, 160)
self._line_current = QColor(0, 126, 229)
self._line_hover = QColor(244, 196, 48)
# After 3 s of hovering an unavailable state, explain which states
# it can be reached from.
self._hover_hint_timer = QTimer(self)
self._hover_hint_timer.setSingleShot(True)
self._hover_hint_timer.setInterval(3000)
self._hover_hint_timer.timeout.connect(self._show_hover_hint)
self._station_current = QColor(0, 126, 229)
self._station_current_ring = QColor(120, 195, 255)
self._station_hover = QColor(244, 196, 48)
self._label_current_bg = "rgba(0, 126, 229, 0.12)"
self._label_hover_bg = "rgba(244, 196, 48, 0.22)"
# Per-theme colors (MainWindow._apply_theme calls set_theme).
self._colors = state_colors(THEME_SUNRISE)
self._separators: list[QLabel] = []
self._group_colors: dict[BeamlineStateEnum, QColor] = {
BeamlineStateEnum.DewarTransfer: QColor(128, 90, 213),
BeamlineStateEnum.SampleExchange: QColor(237, 137, 54),
BeamlineStateEnum.RobotSampleExchange: QColor(237, 137, 54),
BeamlineStateEnum.SampleAlignment: QColor(72, 187, 120),
BeamlineStateEnum.BeamLocation: QColor(72, 187, 120),
BeamlineStateEnum.BeamstopAlignment: QColor(72, 187, 120),
BeamlineStateEnum.FluxMeasurement: QColor(72, 187, 120),
BeamlineStateEnum.DataCollection: QColor(236, 72, 153),
BeamlineStateEnum.XtalSnapshot: QColor(236, 72, 153),
BeamlineStateEnum.XrayFluorescence: QColor(236, 72, 153),
}
layout = QHBoxLayout(self)
layout.setContentsMargins(10, 2, 10, 2)
layout.setSpacing(2)
layout.addStretch(1)
self._group_label_colors: dict[BeamlineStateEnum, str] = {
state: color.name() for state, color in self._group_colors.items()
}
self._group_label_backgrounds: dict[BeamlineStateEnum, str] = {
BeamlineStateEnum.DewarTransfer: "rgba(128, 90, 213, 0.14)",
BeamlineStateEnum.SampleExchange: "rgba(237, 137, 54, 0.16)",
BeamlineStateEnum.RobotSampleExchange: "rgba(237, 137, 54, 0.16)",
BeamlineStateEnum.SampleAlignment: "rgba(72, 187, 120, 0.16)",
BeamlineStateEnum.BeamLocation: "rgba(72, 187, 120, 0.16)",
BeamlineStateEnum.BeamstopAlignment: "rgba(72, 187, 120, 0.16)",
BeamlineStateEnum.FluxMeasurement: "rgba(72, 187, 120, 0.16)",
BeamlineStateEnum.DataCollection: "rgba(236, 72, 153, 0.14)",
BeamlineStateEnum.XtalSnapshot: "rgba(236, 72, 153, 0.14)",
BeamlineStateEnum.XrayFluorescence: "rgba(236, 72, 153, 0.14)",
}
self._stations = [
StationSpec(
BeamlineStateEnum.DewarTransfer,
"Dewar transfer",
54,
140,
True,
"Dewar transfer mode",
),
StationSpec(
BeamlineStateEnum.SampleExchange,
"Manual sample exchange",
54,
176,
True,
"Manual sample exchange mode",
),
StationSpec(
BeamlineStateEnum.RobotSampleExchange,
"Robot sample exchange",
54,
212,
True,
"Robot-assisted sample exchange",
),
StationSpec(
BeamlineStateEnum.SampleAlignment,
"Sample alignment",
54,
248,
True,
"Sample centring and alignment mode",
),
StationSpec(
BeamlineStateEnum.BeamLocation, "Beam location", 54, 284, True, "Beam location mode"
),
StationSpec(
BeamlineStateEnum.BeamstopAlignment,
"Beamstop alignment",
54,
320,
True,
"Beamstop alignment mode",
),
StationSpec(
BeamlineStateEnum.FluxMeasurement,
"Flux measurement",
54,
356,
True,
"Flux measurement mode",
),
StationSpec(
BeamlineStateEnum.DataCollection,
"Data collection",
54,
392,
True,
"Measurement / collection mode",
),
StationSpec(
BeamlineStateEnum.XtalSnapshot,
"Crystal snapshot",
54,
428,
True,
"Crystal snapshot mode",
),
StationSpec(
BeamlineStateEnum.XrayFluorescence, "XRF", 54, 464, True, "X-ray fluorescence mode"
),
]
self._segments = [
(BeamlineStateEnum.DewarTransfer, BeamlineStateEnum.SampleExchange),
(BeamlineStateEnum.SampleExchange, BeamlineStateEnum.RobotSampleExchange),
(BeamlineStateEnum.RobotSampleExchange, BeamlineStateEnum.SampleAlignment),
(BeamlineStateEnum.SampleAlignment, BeamlineStateEnum.BeamLocation),
(BeamlineStateEnum.SampleAlignment, BeamlineStateEnum.BeamstopAlignment),
(BeamlineStateEnum.SampleAlignment, BeamlineStateEnum.FluxMeasurement),
(BeamlineStateEnum.SampleAlignment, BeamlineStateEnum.DataCollection),
(BeamlineStateEnum.BeamLocation, BeamlineStateEnum.BeamstopAlignment),
(BeamlineStateEnum.BeamLocation, BeamlineStateEnum.FluxMeasurement),
(BeamlineStateEnum.BeamstopAlignment, BeamlineStateEnum.FluxMeasurement),
(BeamlineStateEnum.DataCollection, BeamlineStateEnum.XtalSnapshot),
(BeamlineStateEnum.DataCollection, BeamlineStateEnum.XrayFluorescence),
]
self._graph = self._build_graph(self._segments)
self._station_widgets: dict[BeamlineStateEnum, QLabel | QPushButton] = {}
self.title = QLabel(self)
self.title.setObjectName("beamlineStateTitle")
self.title.setText("<H3>Beamline state</H3>")
self.title.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.title.setFixedHeight(self.title_height)
self.title.setGeometry(0, 0, self.set_width, self.title_height)
self.toggle_button = QPushButton("", self)
self.toggle_button.setObjectName("beamlineStateToggleButton")
self.toggle_button.setToolTip("Minimise beamline state panel")
self.toggle_button.setFixedSize(28, 28)
self.toggle_button.move(self.set_width - 36, 11)
self.toggle_button.clicked.connect(self.toggle_collapsed)
self.current_label = QLabel("Current: —", self)
self.current_label.setObjectName("beamlineStateCurrentLabel")
self.current_label.move(14, 58)
self.current_label.adjustSize()
self.tell_label = QLabel("Tell: —", self)
self.tell_label.setObjectName("beamlineStateTellLabel")
self.tell_label.move(14, 86)
self.tell_label.adjustSize()
self._build_station_widgets()
self._position_station_widgets()
self._update_collapsed_state()
@staticmethod
def _canon_segment(
a: BeamlineStateEnum, b: BeamlineStateEnum
) -> tuple[BeamlineStateEnum, BeamlineStateEnum]:
return tuple(sorted((a, b), key=lambda state: state.value))
def _build_graph(
self, segments: list[tuple[BeamlineStateEnum, BeamlineStateEnum]]
) -> dict[BeamlineStateEnum, set[BeamlineStateEnum]]:
graph: dict[BeamlineStateEnum, set[BeamlineStateEnum]] = {}
for a, b in segments:
graph.setdefault(a, set()).add(b)
graph.setdefault(b, set()).add(a)
return graph
def _station_map(self) -> dict[BeamlineStateEnum, StationSpec]:
return {station.state: station for station in self._stations}
def _path_segments_between(
self, start: BeamlineStateEnum | None, end: BeamlineStateEnum | None
) -> set[tuple[BeamlineStateEnum, BeamlineStateEnum]]:
if start is None or end is None:
return set()
if start == BeamlineStateEnum.Moving or end == BeamlineStateEnum.Moving:
return set()
if start == end:
return set()
queue = deque([start])
previous: dict[BeamlineStateEnum, BeamlineStateEnum | None] = {start: None}
while queue:
node = queue.popleft()
if node == end:
break
for neighbour in self._graph.get(node, set()):
if neighbour in previous:
continue
previous[neighbour] = node
queue.append(neighbour)
if end not in previous:
return set()
path_segments: set[tuple[BeamlineStateEnum, BeamlineStateEnum]] = set()
cursor = end
while previous[cursor] is not None:
parent = previous[cursor]
path_segments.add(self._canon_segment(cursor, parent))
cursor = parent
return path_segments
def _active_hover_route(self) -> set[tuple[BeamlineStateEnum, BeamlineStateEnum]]:
if self._hovered_state is not None:
route_source = (
self._last_stable_state
if self._current_state == BeamlineStateEnum.Moving
else self._current_state
self._buttons: dict[BeamlineStateEnum, HoverableButton] = {}
self._single_line = True
for index, (state, label) in enumerate(self._ENTRIES):
if index:
separator = QLabel("", self)
self._style_separator(separator)
self._separators.append(separator)
layout.addWidget(separator)
button = HoverableButton(label, self)
button.setFlat(True)
# Fill the bar height so the hover region is the whole entry,
# not just the text line.
button.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Expanding)
button._beamline_state = state
# Transitions only via right-click -> "Go to <state>"; a plain
# left click must not move the beamline.
button.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
button.customContextMenuRequested.connect(
lambda pos, s=state, b=button: self._show_state_menu(s, b, pos)
)
return self._path_segments_between(route_source, self._hovered_state)
button.clicked.connect(lambda _checked=False, s=state: self._on_left_click(s))
button.hovered.connect(self._set_hovered_state)
button.unhovered.connect(self._clear_hovered_state)
self._buttons[state] = button
layout.addWidget(button)
if (
self._current_state == BeamlineStateEnum.Moving
and self._pending_target_state is not None
):
return self._path_segments_between(self._last_stable_state, self._pending_target_state)
layout.addStretch(1)
self._apply_highlight()
return set()
def minimumSizeHint(self):
# The 13-button strip would otherwise impose a ~2000px minimum on the
# whole main window, so an un-maximized window could never fit the
# screen. Width 0: the strip adapts (two-line labels) and, below that,
# clips — the window stays freely resizable.
hint = super().minimumSizeHint()
hint.setWidth(0)
return hint
def _build_station_widgets(self) -> None:
for station in self._stations:
if station.clickable:
widget: QLabel | QPushButton = HoverableButton(station.label, self)
widget.setFlat(True)
widget.setCursor(Qt.CursorShape.PointingHandCursor)
widget.clicked.connect(
lambda _checked=False, state=station.state: self._emit_for_state(state)
)
else:
widget = HoverableLabel(station.label, self)
def resizeEvent(self, event) -> None:
super().resizeEvent(event)
self._update_label_mode()
widget._beamline_state = station.state
widget.hovered.connect(self._set_hovered_state)
widget.unhovered.connect(self._clear_hovered_state)
widget.setToolTip(station.tooltip or station.label)
self._station_widgets[station.state] = widget
self._apply_station_highlight()
def _position_station_widgets(self) -> None:
station_map = self._station_map()
for state, widget in self._station_widgets.items():
station = station_map[state]
widget.setText(station.label)
widget.adjustSize()
label_x = station.x + 20
label_y = station.y - 12
widget.move(label_x, label_y)
widget.show()
self._apply_station_highlight()
def toggle_collapsed(self) -> None:
self._is_collapsed = not self._is_collapsed
self._update_collapsed_state()
def set_collapsed(self, collapsed: bool) -> None:
if self._is_collapsed == collapsed:
def _update_label_mode(self) -> None:
# One line while it fits, wrapped at the last space otherwise.
# Measured with bold so the mode does not flap when the active
# state (the only bold entry) changes.
font = QFont(self.font())
font.setPixelSize(18)
font.setWeight(QFont.Weight.Bold)
fm = QFontMetrics(font)
needed = 20 # layout margins
for index, (_state, label) in enumerate(self._ENTRIES):
if index:
needed += fm.horizontalAdvance("") + 4
needed += fm.horizontalAdvance(label) + 20 # padding + frame
single_line = needed <= self.width()
if single_line == self._single_line:
return
self._is_collapsed = collapsed
self._update_collapsed_state()
self._single_line = single_line
for state, label in self._ENTRIES:
text = label if single_line else "\n".join(label.rsplit(" ", 1))
self._buttons[state].setText(text)
def _update_collapsed_state(self) -> None:
show_content = not self._is_collapsed
def _show_state_menu(self, state: BeamlineStateEnum, button: QPushButton, pos) -> None:
if state not in self._available_targets():
return
menu = QMenu(button)
go_action = menu.addAction(f"Go to {state.display_name()}")
go_action.triggered.connect(lambda: self._emit_for_state(state))
menu.exec(button.mapToGlobal(pos))
self.current_label.setVisible(show_content)
self.tell_label.setVisible(show_content)
for widget in self._station_widgets.values():
widget.setVisible(show_content)
if self._is_collapsed:
self.setMinimumHeight(self.collapsed_height)
self.setMaximumHeight(self.collapsed_height)
self.toggle_button.setText("+")
self.toggle_button.setToolTip("Restore beamline state panel")
def _on_left_click(self, state: BeamlineStateEnum) -> None:
# Left click never moves the beamline: remind about right-click for
# available states, explain unreachability for the rest.
if state == self._current_state:
return
if state in self._available_targets():
button = self._buttons[state]
QToolTip.showText(
QCursor.pos(),
f"Right-click to go to {state.display_name()}.",
button,
button.rect(),
)
else:
self.setMinimumHeight(self.map_height)
self.setMaximumHeight(self.map_height)
self.toggle_button.setText("")
self.toggle_button.setToolTip("Minimise beamline state panel")
self._show_unavailable_hint(state)
self.updateGeometry()
self.update()
def _available_targets(self) -> frozenset[BeamlineStateEnum]:
current = self._current_state
if current is None or current == BeamlineStateEnum.Moving:
return frozenset()
# Reachable in one step: the route graph plus the status-bar
# shortcut transitions.
return frozenset(_GRAPH.get(current, set())) | MENU_TRANSITIONS.get(current, frozenset())
def _set_hovered_state(self, state: BeamlineStateEnum | None) -> None:
self._hovered_state = state
self._hover_hint_timer.start()
@Slot()
def _clear_hovered_state(self) -> None:
self._hovered_state = None
self._hover_hint_timer.stop()
QToolTip.hideText()
def _show_hover_hint(self) -> None:
state = self._hovered_state
if state is None or state == self._current_state or state in self._available_targets():
return
self._show_unavailable_hint(state)
def _show_unavailable_hint(self, state: BeamlineStateEnum) -> None:
sources = set(_GRAPH.get(state, set()))
sources |= {s for s, targets in MENU_TRANSITIONS.items() if state in targets}
sources.discard(state)
if not sources:
return
reachable = ", ".join(sorted(s.display_name() for s in sources))
button = self._buttons[state]
# Anchoring to the button rect makes Qt drop the tip as soon as the
# mouse leaves the entry, instead of letting it linger.
QToolTip.showText(
QCursor.pos(),
f"You can only go to {state.display_name()} by being in: {reachable}.",
button,
button.rect(),
)
def _emit_for_state(self, state: BeamlineStateEnum) -> None:
# Unavailable transitions are not clickable (grey + forbidden cursor).
if state not in self._available_targets():
return
self._pending_target_state = state
self._hovered_state = None
self.update()
if state == BeamlineStateEnum.SampleExchange:
self.sample_exchange.emit()
@@ -403,236 +304,99 @@ class BeamlineStatePanel(QFrame):
elif state == BeamlineStateEnum.XrayFluorescence:
self.xray_fluorescence.emit()
@Slot(object)
def _set_hovered_state(self, state: BeamlineStateEnum | None) -> None:
self._hovered_state = state
self._apply_station_highlight()
def _style_separator(self, separator: QLabel) -> None:
separator.setStyleSheet(
f"color: {self._colors['unavailable']};"
f" background: transparent; border: none; font-size: {FONT_VALUE};"
)
@Slot()
def _clear_hovered_state(self) -> None:
self._hovered_state = None
self._apply_station_highlight()
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_station_highlight(self) -> None:
for station in self._stations:
widget = self._station_widgets[station.state]
is_current = station.state == self._current_state
is_hovered = station.state == self._hovered_state
def _apply_highlight(self) -> None:
available = self._available_targets()
for state, button in self._buttons.items():
is_current = state == self._current_state
is_pending = (
station.state == self._pending_target_state
state == self._pending_target_state
and self._current_state == BeamlineStateEnum.Moving
)
is_available = state in available
label_color = self._group_label_colors.get(station.state, "rgb(55, 67, 87)")
label_bg = self._group_label_backgrounds.get(station.state, "transparent")
if isinstance(widget, QPushButton):
if is_current:
widget.setStyleSheet(f"""
QPushButton {{
border: none;
border-radius: 10px;
background: {self._label_current_bg};
color: rgb(0, 92, 170);
font-size: 14px;
font-weight: 700;
text-align: left;
padding: 2px 6px 2px 8px;
}}
QPushButton:hover {{
color: rgb(0, 92, 170);
}}
""")
elif is_hovered or is_pending:
widget.setStyleSheet(f"""
QPushButton {{
border: none;
border-radius: 10px;
background: {self._label_hover_bg};
color: rgb(115, 88, 0);
font-size: 14px;
font-weight: 700;
text-align: left;
padding: 2px 6px 2px 8px;
}}
QPushButton:hover {{
color: rgb(115, 88, 0);
}}
""")
else:
widget.setStyleSheet(f"""
QPushButton {{
border: none;
border-radius: 10px;
background: {label_bg};
color: {label_color};
font-size: 14px;
font-weight: 600;
text-align: left;
padding: 2px 6px 2px 8px;
}}
QPushButton:hover {{
color: {label_color};
}}
""")
else:
if is_current:
widget.setStyleSheet(f"""
QLabel {{
border-radius: 10px;
background: {self._label_current_bg};
color: rgb(0, 92, 170);
font-size: 14px;
font-weight: 700;
padding: 2px 6px 2px 8px;
}}
""")
elif is_hovered or is_pending:
widget.setStyleSheet(f"""
QLabel {{
border-radius: 10px;
background: {self._label_hover_bg};
color: rgb(115, 88, 0);
font-size: 14px;
font-weight: 700;
padding: 2px 6px 2px 8px;
}}
""")
else:
widget.setStyleSheet(f"""
QLabel {{
border-radius: 10px;
background: {label_bg};
color: {label_color};
font-size: 14px;
font-weight: 600;
padding: 2px 6px 2px 8px;
}}
""")
widget.adjustSize()
self.update()
def _station_center(self, state: BeamlineStateEnum) -> QPoint:
station = self._station_map()[state]
return QPoint(station.x, station.y)
def _segment_color(self, a: BeamlineStateEnum, b: BeamlineStateEnum) -> QColor:
segment = self._canon_segment(a, b)
active_hover_route = self._active_hover_route()
if segment in active_hover_route:
return self._line_hover
current_path = self._path_segments_between(
BeamlineStateEnum.DewarTransfer, self._last_stable_state or self._current_state
)
if segment in current_path:
return self._line_current
return self._line_color
def _draw_segment(self, painter: QPainter, start: QPoint, end: QPoint, color: QColor) -> None:
pen = QPen(color, 3)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
painter.setPen(pen)
painter.drawLine(start, end)
def _station_base_color(self, state: BeamlineStateEnum) -> QColor:
return self._group_colors.get(state, QColor(180, 190, 210))
def _draw_station(self, painter: QPainter, station: StationSpec) -> None:
center = self._station_center(station.state)
rect = QRect(
center.x() - self.station_radius,
center.y() - self.station_radius,
self.station_radius * 2,
self.station_radius * 2,
)
if station.state == self._hovered_state or (
self._current_state == BeamlineStateEnum.Moving
and station.state == self._pending_target_state
):
painter.setPen(QPen(self._station_hover, 3))
painter.setBrush(self._station_hover)
elif station.state == self._current_state:
painter.setPen(QPen(self._station_current_ring, 3))
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawEllipse(
QRect(
center.x() - self.station_radius - 3,
center.y() - self.station_radius - 3,
(self.station_radius + 3) * 2,
(self.station_radius + 3) * 2,
# Availability drives the look: active = bold (red for
# Maintenance, blue otherwise), reachable = orange, rest = grey.
# No backgrounds, no rounded corners.
if is_current or is_pending:
color = (
self._colors["error"]
if state == BeamlineStateEnum.Maintenance
else self._colors["info"]
)
)
painter.setPen(QPen(self._station_current, 2))
painter.setBrush(self._station_current)
else:
base_color = self._station_base_color(station.state)
painter.setPen(QPen(base_color.darker(125), 2))
painter.setBrush(base_color)
painter.drawEllipse(rect)
def paintEvent(self, event) -> None:
super().paintEvent(event)
if self._is_collapsed:
return
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
for start_state, end_state in self._segments:
start = self._station_center(start_state)
end = self._station_center(end_state)
color = self._segment_color(start_state, end_state)
self._draw_segment(painter, start, end, color)
for station in self._stations:
self._draw_station(painter, station)
@Slot(DAQStatusModel)
def update_daq_status(self, status: DAQStatusModel) -> None:
self.set_current_state(status.state)
tell_text = "Tell: —"
tell_color = "rgb(55, 67, 87)"
if status.tell_state is not None:
tell_state = status.tell_state
tell_text = f"Tell: {tell_state.activity.display_name()}"
tell_phase = tell_state.phase.display_name() if tell_state.phase is not None else ""
tell_message = (tell_state.message or "").strip()
if tell_phase:
tell_text = f"{tell_text} ({tell_phase})"
elif tell_message:
tell_text = f"{tell_text} ({tell_message})"
if tell_state.activity.value == "error":
tell_color = "red"
elif tell_state.activity.value in {"mounting", "unmounting", "drying", "cooling"}:
tell_color = "orange"
bold = True
elif is_available:
color = self._colors["available"]
bold = False
else:
tell_color = "green"
color = self._colors["unavailable"]
bold = False
self.tell_label.setText(tell_text)
self.tell_label.setStyleSheet(f"color: {tell_color};")
self.tell_label.adjustSize()
# Font set in code (not QSS) so _update_label_mode can measure
# the real metrics when deciding one- vs two-line labels.
font = QFont(self.font())
font.setPixelSize(18)
font.setWeight(QFont.Weight.Bold if bold else QFont.Weight.Normal)
if button.font() != font:
button.setFont(font)
# 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};{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.
# 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:
cursor = Qt.CursorShape.ForbiddenCursor
tooltip = ""
if button.cursor().shape() != cursor:
button.setCursor(cursor)
if button.toolTip() != tooltip:
button.setToolTip(tooltip)
def set_current_state(self, state: BeamlineStateEnum | None) -> None:
self._current_state = state
if (
state is not None
and state != BeamlineStateEnum.Moving
and self._pending_target_state == state
):
self._pending_target_state = None
self._apply_highlight()
if state is not None and state != BeamlineStateEnum.Moving:
self._last_stable_state = state
if self._pending_target_state == state:
self._pending_target_state = None
label = state.display_name() if state is not None else ""
self.current_label.setText(f"Current: {label}")
self.current_label.adjustSize()
self._apply_station_highlight()
def update_daq_status(self, status: DAQStatusModel) -> None:
self.set_current_state(status.state)
+96 -18
View File
@@ -2,14 +2,26 @@ from aarecommon.math.diffraction_geometry import DiffractionGeometry
from aarecommon.math.sample_geometry import SampleGeometryModel
from aarecommon.models.models import DAQStatusModel
from PySide6.QtCore import Signal, Slot
from PySide6.QtWidgets import QFrame, QPushButton, QTabWidget, QVBoxLayout
from PySide6.QtWidgets import (
QFrame,
QHBoxLayout,
QPushButton,
QSizePolicy,
QStackedWidget,
QTabBar,
QVBoxLayout,
QWidget,
)
from aare.gui.panels.file_path_panel import FilePathPanel
from aare.gui.panels.fluorescence_data_collection import FluorescenceDataCollectionPanel
from aare.gui.panels.manual_sample_panel import ManualSamplePanel
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
class DataCollectionSettings(QFrame):
@@ -25,6 +37,7 @@ class DataCollectionSettings(QFrame):
parent=None,
):
super().__init__(parent)
self.setObjectName("dataCollectionSettings")
self.setFixedWidth(self.set_width)
self.setFrameShape(QFrame.Shape.StyledPanel)
self.setFrameShadow(QFrame.Shadow.Raised)
@@ -33,29 +46,80 @@ class DataCollectionSettings(QFrame):
self.file_path_panel = FilePathPanel(self)
v_layout.addWidget(self.file_path_panel)
self._tab_widget = QTabWidget()
# Between Dataset path and Exp. Config., collapsible like both;
# main_window aliases this instead of the former bottom dock.
self.manual_sample_panel = ManualSamplePanel(self)
v_layout.addWidget(self.manual_sample_panel)
# QTabBar + QStackedWidget instead of QTabWidget: the loop-centering
# button row must sit BETWEEN the tab bar and the pages, which a
# QTabWidget cannot host.
self._tab_bar = QTabBar(self)
self._stack = QStackedWidget(self)
self.raster = RasterDataCollectionPanel(
parent=self, raster_mgr=raster_mgr, diffraction=diffraction
)
self._tab_widget.addTab(self.raster, "Raster scan")
self.screening = RotationDataCollectionPanel(parent=self, diffraction=diffraction)
self._tab_widget.addTab(self.screening, "Rotation")
self.simple = SimpleRotationSettingsPanel(parent=self)
self._tab_widget.addTab(self.simple, "Simple")
self.fluo = FluorescenceDataCollectionPanel(parent=self)
self._tab_widget.addTab(self.fluo, "XRF")
for panel, label in (
(self.raster, "Raster scan"),
(self.screening, "Rotation"),
(self.simple, "Simple"),
(self.fluo, "XRF"),
):
self._stack.addWidget(panel)
self._tab_bar.addTab(label)
v_layout.addWidget(self._tab_widget)
# Ex-"Loop centering" panel buttons; always visible, whatever the tab.
self.find_tip = QPushButton("ML Loop Centring", parent=self)
self.bounding_box = QPushButton("Make Raster Grid", parent=self)
centering_row = QWidget(self)
centering_layout = QHBoxLayout(centering_row)
centering_layout.setContentsMargins(0, 0, 0, 0)
centering_layout.addWidget(self.find_tip)
centering_layout.addWidget(self.bounding_box)
# Pane frame carries the border QTabWidget::pane used to draw
# (#expConfigPane rule in styles.py).
pane = QFrame(self)
pane.setObjectName("expConfigPane")
pane_layout = QVBoxLayout(pane)
# No bottom padding: the pages' own bottom margins breathe inside the
# border, and the Abort button should hug the pane.
pane_layout.setContentsMargins(6, 6, 6, 0)
pane_layout.addWidget(centering_row)
pane_layout.addWidget(self._stack)
# Own container: TitleLabel collapse hides its siblings, so without it
# "Exp. Config." would also swallow the dataset path and abort button.
exp_config = QWidget(self)
exp_config_layout = QVBoxLayout(exp_config)
exp_config_layout.setContentsMargins(0, 0, 0, 0)
exp_config_layout.setSpacing(0) # tab bar flush on the pane, like QTabWidget
exp_config_layout.addWidget(
TitleLabel(
"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)
# Abort lives inside each tab now, under that tab's action buttons;
# all four feed the same cancel signal.
for panel in (self.raster, self.screening, self.simple, self.fluo):
panel.abort_button.clicked.connect(self.cancel_button_clicked)
v_layout.addStretch()
abort_button = QPushButton("Abort measurement", parent=self)
abort_button.setStyleSheet("color: rgb(164, 0, 0);")
abort_button.clicked.connect(self.cancel_button_clicked)
v_layout.addWidget(abort_button)
tighten_column(v_layout)
# Abort hugs the pane: undo the uniform bottom margin tighten_column
# just gave the Exp. Config. group.
m = exp_config_layout.contentsMargins()
exp_config_layout.setContentsMargins(m.left(), m.top(), m.right(), 0)
raster_mgr.update_filename(self.file_path_panel.filename)
self.screening.update_filename(self.file_path_panel.filename)
@@ -67,11 +131,25 @@ class DataCollectionSettings(QFrame):
self.file_path_panel.path_updated.connect(self.simple.update_filename)
self._sample_id = None
self._tab_widget.currentChanged.connect(self._on_tab_changed)
self._tab_bar.currentChanged.connect(self._stack.setCurrentIndex)
self._tab_bar.currentChanged.connect(self._on_tab_changed)
self._tab_bar.currentChanged.connect(self._sync_stack_height)
self._sync_stack_height(self._tab_bar.currentIndex())
@Slot(int)
def _sync_stack_height(self, idx: int):
# QStackedWidget's sizeHint is its TALLEST page, which left a dead gap
# above the Abort button on shorter tabs. Ignored vertical policy on
# hidden pages makes the stack track only the current page's height.
for i in range(self._stack.count()):
page = self._stack.widget(i)
vertical = QSizePolicy.Policy.Preferred if i == idx else QSizePolicy.Policy.Ignored
page.setSizePolicy(QSizePolicy.Policy.Preferred, vertical)
self._stack.adjustSize()
@Slot()
def switch_to_raster(self):
self._tab_widget.setCurrentIndex(0)
self._tab_bar.setCurrentIndex(0)
@Slot()
def cancel_button_clicked(self):
@@ -84,7 +162,7 @@ class DataCollectionSettings(QFrame):
self.simple.update_daq_status(s)
if s.sample is not None and s.sample.db_id != self._sample_id:
self._sample_id = s.sample.db_id
self._tab_widget.setCurrentIndex(0)
self._tab_bar.setCurrentIndex(0)
@Slot(int)
def _on_tab_changed(self, idx: int):
+29 -16
View File
@@ -31,6 +31,16 @@ 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,
PANEL_BORDER_DARK,
PANEL_BORDER_LIGHT,
WHITE,
card_style,
)
from aare.gui.threads.daq_worker import DAQWorker
logger = setup_logger(LOGGER_NAME)
@@ -60,13 +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(
"QLabel {"
" background: #f6f6f6;"
" border: 1px solid #d0d0d0;"
" border-radius: 6px;"
" padding: 6px 8px;"
"}"
card_style(
PANEL_BG_SOFT,
PANEL_BORDER,
selector="QLabel",
radius=FLAT_CARD_RADIUS,
extra="padding: 6px 8px;",
)
)
root.addWidget(self._banner)
@@ -83,9 +96,8 @@ class DeveloperHelpDialog(QDialog):
self._filter.setMinimumHeight(28)
self._filter.setStyleSheet(
"QLineEdit {"
" background: white;"
" border: 1px solid #bdbdbd;"
" border-radius: 6px;"
f" background: {WHITE};"
f" border: 1px solid {PANEL_BORDER_DARK};"
" padding: 4px 8px;"
"}"
)
@@ -146,7 +158,7 @@ class DeveloperHelpDialog(QDialog):
self._details_frame = QFrame(self)
self._details_frame.setFrameShape(QFrame.Shape.StyledPanel)
self._details_frame.setStyleSheet(
"QFrame { background: #fafafa; border: 1px solid #d0d0d0; border-radius: 6px;}"
card_style(PANEL_BG_FAINT, PANEL_BORDER, radius=FLAT_CARD_RADIUS)
)
details_layout = QVBoxLayout(self._details_frame)
@@ -177,12 +189,13 @@ class DeveloperHelpDialog(QDialog):
self._detail_help.setWordWrap(True)
self._detail_help.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
self._detail_help.setStyleSheet(
"QLabel {"
" background: white;"
" border: 1px solid #e0e0e0;"
" border-radius: 6px;"
" 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)
+2 -1
View File
@@ -6,6 +6,7 @@ from PySide6.QtCore import Signal
from PySide6.QtWidgets import QGridLayout, QLabel, QPushButton, QVBoxLayout, QWidget
from aare.gui.constants import LOGGER_NAME
from aare.gui.styles import GO_TEXT
from aare.gui.widgets.number_line_edit import NumberLineEdit
from aare.gui.widgets.title_label import TitleLabel
@@ -51,7 +52,7 @@ class FaceDetectionPanel(QWidget):
self.steps_enter.newValue.connect(_set_steps)
self.face_detection_button = QPushButton("Face Detection")
self.face_detection_button.setStyleSheet("color: rgb(78, 154, 6);")
self.face_detection_button.setStyleSheet(f"color: {GO_TEXT};")
self.face_detection_button.clicked.connect(self.run_and_refresh)
self._top_layout.addWidget(self.face_detection_button, 3, 0, 1, 3)
+11 -7
View File
@@ -7,6 +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 PATH_WARN_TEXT
from aare.gui.widgets.title_label import TitleLabel
## Logic for filenames:
@@ -23,6 +24,10 @@ class FilePathPanel(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
grid_layout = QGridLayout(self)
# No horizontal inset: aligns the banner and fields edge-to-edge with
# the zero-margin Exp. Config. section below.
_m = grid_layout.contentsMargins()
grid_layout.setContentsMargins(0, _m.top(), 0, _m.bottom())
self._sample_name = "sample"
self._sample_id = -1
self._dewar_pos = "None"
@@ -36,11 +41,12 @@ class FilePathPanel(QWidget):
self._formatted_date = datetime.now().strftime("%Y%m%d")
grid_layout.addWidget(TitleLabel("Dataset path", self), 0, 0, 1, 2)
grid_layout.addWidget(
TitleLabel("Dataset path", self, collapsible=True, default_collapsed=False), 0, 0, 1, 2
)
grid_layout.addWidget(QLabel("Directory", parent=self), 1, 0)
self.directory_edit = QLineEdit("{date}/{puck}/{pos}", parent=self)
self.directory_edit.setStyleSheet("background-color: rgb(255, 255, 255);")
self.directory_edit.setToolTip(
"Provide subdirectory for your files. The following macros are allowed: <br/>"
"<b>{date}</b> - date in format yyyymmdd <br/>"
@@ -54,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("background-color: rgb(255, 255, 255);")
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/>"
@@ -68,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("background-color: rgb(255, 255, 255);")
self.run_number_edit.setValue(1)
self.run_number_edit.setRange(1, 999)
self.run_number_edit.setAlignment(Qt.AlignmentFlag.AlignRight)
@@ -156,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(
"color: rgb(200, 0, 0);" if exists else "color: rgb(0, 0, 0);"
)
# 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()
@@ -2,6 +2,7 @@ from aarecommon.models.models import FluorescenceSpectrumParameterModel
from PySide6.QtCore import Signal, Slot
from PySide6.QtWidgets import QCheckBox, QGridLayout, QLabel, QPushButton, QWidget
from aare.gui.styles import ABORT_TEXT, GO_TEXT
from aare.gui.widgets.number_line_edit import NumberLineEdit
@@ -34,9 +35,14 @@ class FluorescenceDataCollectionPanel(QWidget):
# Run button
self.run_btn = QPushButton("Run fluorescence", self)
self.run_btn.setStyleSheet("color: rgb(78, 154, 6);")
self.run_btn.setStyleSheet(f"color: {GO_TEXT};")
lay.addWidget(self.run_btn, 4, 0, 1, 3)
# Per-tab Abort (DataCollectionSettings wires it to the DAQ cancel).
self.abort_button = QPushButton("Abort measurement", self)
self.abort_button.setStyleSheet(f"color: {ABORT_TEXT};")
lay.addWidget(self.abort_button, 5, 0, 1, 3)
self.run_btn.clicked.connect(self._emit_params)
@Slot()
+3 -2
View File
@@ -3,10 +3,11 @@ from aarecommon.config.logger import setup_logger
from aarecommon.models.models import DAQStatusModel, FluorescenceSpectrumOutputModel
from PySide6.QtCharts import QChart, QChartView, QLineSeries, QValueAxis
from PySide6.QtCore import QEvent, QPointF, Qt, Slot
from PySide6.QtGui import QColor, QPainter, QPen
from PySide6.QtGui import QPainter, QPen
from PySide6.QtWidgets import QGraphicsSimpleTextItem, QGridLayout, QLabel, QWidget
from aare.gui.constants import LOGGER_NAME
from aare.gui.styles import SPECTRUM_LINE, qcolor
logger = setup_logger(LOGGER_NAME)
@@ -65,7 +66,7 @@ class FluorescencePanel(QWidget):
# Vertical marker line (two-point series)
self._vline = QLineSeries()
pen = QPen(QColor("#cc0000"))
pen = QPen(qcolor(SPECTRUM_LINE))
pen.setWidth(2)
self._vline.setPen(pen)
self.chart.addSeries(self._vline)
+3 -1
View File
@@ -13,7 +13,9 @@ class IlluminationPanel(QWidget):
super().__init__(parent)
grid_layout = QGridLayout(self)
grid_layout.addWidget(TitleLabel("Light", self), 0, 0, 1, 2)
grid_layout.addWidget(
TitleLabel("Light", self, collapsible=True, default_collapsed=False), 0, 0, 1, 2
)
front_label = QLabel("Front light", parent=self)
front_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+49 -28
View File
@@ -20,6 +20,7 @@ from PySide6.QtWidgets import (
QLabel,
QMessageBox,
QPushButton,
QScrollArea,
QTabWidget,
QTextEdit,
QVBoxLayout,
@@ -28,10 +29,19 @@ from PySide6.QtWidgets import (
from aare.gui.constants import LOGGER_NAME
from aare.gui.panels.beamline_recovery_panel import RecoveryPanel
from aare.gui.styles import (
BAD_CARD_BORDER,
BORDER,
CARD_BORDER,
CHIP_BAD_BG,
CHIP_BAD_TEXT,
HEADING_TEXT,
SURFACE,
card_style,
)
from aare.gui.threads.daq_worker import DAQWorker
from aare.gui.widgets.local_contact_status_widget import LocalContactStatusWidget
from aare.gui.widgets.text_list_dialog import TextListDialog
from aare.gui.widgets.title_label import TitleLabel
logger = setup_logger(LOGGER_NAME)
@@ -65,30 +75,36 @@ class LocalContactPanel(QFrame):
self._bec_macros_dialog: TextListDialog | None = None
self._bec_devices_dialog: TextListDialog | None = None
self._local_contact_config_payload: dict = {}
self._mount_to_center_sleep_s = QDoubleSpinBox(self)
self._line_scan_loop_face_y_padding_fraction_each_side = QDoubleSpinBox(self)
self._line_scan_loop_all_y_padding_fraction_each_side = QDoubleSpinBox(self)
# Recreated with proper parents in _build_config_tab; parentless here
# so no orphan widget floats over the panel (the old parent=self
# copies painted themselves over the top-left corner).
self._mount_to_center_sleep_s = QDoubleSpinBox()
self._line_scan_loop_face_y_padding_fraction_each_side = QDoubleSpinBox()
self._line_scan_loop_all_y_padding_fraction_each_side = QDoubleSpinBox()
self.setFrameShape(QFrame.Shape.StyledPanel)
self.setFrameShadow(QFrame.Shadow.Raised)
self.setObjectName("localContactPanel")
self.setStyleSheet(
"""
QGroupBox {
background-color: white;
border: 1px solid #c7d4e5;
border-radius: 5px;
f"""
QFrame#localContactPanel {{
border: 1px solid {BORDER};
}}
QGroupBox {{
background-color: {SURFACE};
border: 1px solid {CARD_BORDER};
margin-top: 15px;
padding-top: 15px;
font-weight: 700;
color: #1e293b;
}
QGroupBox::title {
color: {HEADING_TEXT};
}}
QGroupBox::title {{
subcontrol-origin: margin;
subcontrol-position: top left;
left: 10px;
padding: 0 6px 0 6px;
font:bold;
}
}}
"""
)
@@ -96,8 +112,7 @@ class LocalContactPanel(QFrame):
layout.setContentsMargins(8, 8, 8, 8)
layout.setSpacing(8)
layout.addWidget(TitleLabel("Local Contact", parent=self))
# No TitleLabel banner: the dialog window title already says it.
self._info_label = QLabel(
"Staff tools for beamline recovery and local-contact operations.", self
)
@@ -107,12 +122,7 @@ class LocalContactPanel(QFrame):
self._transfer_error_frame = QFrame(self)
self._transfer_error_frame.setVisible(False)
self._transfer_error_frame.setStyleSheet(
"QFrame {"
" background: #fdeaea;"
" color: #8b1e1e;"
" border: 1px solid #e6a8a8;"
" border-radius: 8px;"
"}"
card_style(CHIP_BAD_BG, BAD_CARD_BORDER, CHIP_BAD_TEXT)
)
transfer_error_layout = QVBoxLayout(self._transfer_error_frame)
transfer_error_layout.setContentsMargins(10, 10, 10, 10)
@@ -133,13 +143,16 @@ class LocalContactPanel(QFrame):
self._tabs = QTabWidget(self)
layout.addWidget(self._tabs, 1)
self._tabs.addTab(self._build_status_tab(), self.TAB_STATUS)
self._tabs.addTab(self._build_recovery_tab(), self.TAB_RECOVERY)
self._tabs.addTab(self._build_tell_tab(), self.TAB_TELL)
self._tabs.addTab(self._build_bec_tab(), self.TAB_BEC)
self._tabs.addTab(self._build_hardware_tab(), self.TAB_HARDWARE)
self._tabs.addTab(self._build_detector_tab(), self.TAB_DETECTOR)
self._tabs.addTab(self._build_config_tab(), self.TAB_CONFIG)
# Every tab scrolls: the dialog's explicit minimum size is smaller
# than some tabs' content, and without a scroll area Qt squeezes the
# rows below text height (clipped labels on the Hardware tab).
self._tabs.addTab(self._scrolled(self._build_status_tab()), self.TAB_STATUS)
self._tabs.addTab(self._scrolled(self._build_recovery_tab()), self.TAB_RECOVERY)
self._tabs.addTab(self._scrolled(self._build_tell_tab()), self.TAB_TELL)
self._tabs.addTab(self._scrolled(self._build_bec_tab()), self.TAB_BEC)
self._tabs.addTab(self._scrolled(self._build_hardware_tab()), self.TAB_HARDWARE)
self._tabs.addTab(self._scrolled(self._build_detector_tab()), self.TAB_DETECTOR)
self._tabs.addTab(self._scrolled(self._build_config_tab()), self.TAB_CONFIG)
self._daq.local_contact_simulation_state_loaded.connect(self._apply_simulation_state)
self._daq.local_contact_device_state_loaded.connect(self._apply_device_state)
@@ -179,6 +192,14 @@ class LocalContactPanel(QFrame):
)
return self._register_status_widget(widget)
@staticmethod
def _scrolled(widget: QWidget) -> QScrollArea:
area = QScrollArea()
area.setWidgetResizable(True)
area.setFrameShape(QFrame.Shape.NoFrame)
area.setWidget(widget)
return area
def _build_status_tab(self) -> QWidget:
tab = QWidget(self)
layout = QVBoxLayout(tab)
+90 -50
View File
@@ -1,7 +1,6 @@
from aarecommon.config.logger import attach_to_logger, find_existing_formatter
from PySide6.QtCore import Qt, QTimer, Signal, Slot
from PySide6.QtCore import QTimer, Signal, Slot
from PySide6.QtWidgets import (
QDockWidget,
QFrame,
QHBoxLayout,
QLabel,
@@ -13,6 +12,21 @@ from PySide6.QtWidgets import (
)
from aare.gui.log import QtLogEmitter, QtLogHandler
from aare.gui.styles import (
FLAT_CARD_RADIUS,
LOG_BORDER,
LOG_ERROR_BG,
LOG_ERROR_BORDER,
LOG_INFO_BG,
LOG_INFO_BORDER,
LOG_PANEL_BG,
LOG_SUCCESS_BG,
LOG_SUCCESS_BORDER,
LOG_WARN_BG,
LOG_WARN_BORDER,
TEXT,
card_style,
)
class RuntimeNotificationWidget(QFrame):
@@ -45,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()
@@ -60,6 +74,7 @@ class RuntimeNotificationWidget(QFrame):
button_layout.addWidget(self._clear_button)
self._body = QWidget(self)
self._body.setObjectName("runtimeNotificationBody")
body_layout = QVBoxLayout(self._body)
body_layout.setContentsMargins(0, 0, 0, 0)
body_layout.setSpacing(8)
@@ -78,32 +93,44 @@ class RuntimeNotificationWidget(QFrame):
self._sticky = True
self.setStyleSheet(
"""
QFrame#runtimeNotification {
border: 1px solid #8a8a8a;
border-radius: 8px;
background-color: #fff4f4;
}
QFrame#runtimeNotification[noticeLevel="error"] {
background-color: #fff1f1;
border: 1px solid #d66;
}
QFrame#runtimeNotification[noticeLevel="warning"] {
background-color: #fff8e8;
border: 1px solid #d7aa42;
}
QFrame#runtimeNotification[noticeLevel="success"] {
background-color: #eefaf0;
border: 1px solid #6cb37a;
}
QFrame#runtimeNotification[noticeLevel="info"] {
background-color: #eef5ff;
border: 1px solid #6b9bd6;
}
QLabel#runtimeNotificationTitle {
font-weight: bold;
}
"""
card_style(
LOG_PANEL_BG,
LOG_BORDER,
selector="QFrame#runtimeNotification",
radius=FLAT_CARD_RADIUS,
)
+ card_style(
LOG_ERROR_BG,
LOG_ERROR_BORDER,
selector='QFrame#runtimeNotification[noticeLevel="error"]',
radius=FLAT_CARD_RADIUS,
)
+ card_style(
LOG_WARN_BG,
LOG_WARN_BORDER,
selector='QFrame#runtimeNotification[noticeLevel="warning"]',
radius=FLAT_CARD_RADIUS,
)
+ card_style(
LOG_SUCCESS_BG,
LOG_SUCCESS_BORDER,
selector='QFrame#runtimeNotification[noticeLevel="success"]',
radius=FLAT_CARD_RADIUS,
)
+ card_style(
LOG_INFO_BG,
LOG_INFO_BORDER,
selector='QFrame#runtimeNotification[noticeLevel="info"]',
radius=FLAT_CARD_RADIUS,
)
# Transparent children: the app-wide QWidget background rule would
# 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; }"
)
def _set_level(self, level: str) -> None:
@@ -165,30 +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."""
self.container = QWidget(self)
reveal_requested = Signal()
self.notification = RuntimeNotificationWidget(self.container)
self.notification.show_log_requested.connect(self._raise_and_focus_log)
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.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)
@@ -203,10 +230,23 @@ class LogDock(QDockWidget):
def _append_line(self, text: str):
self.view.appendPlainText(text)
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(
@@ -218,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:
@@ -234,3 +272,5 @@ class LogDock(QDockWidget):
def clear(self):
self.view.clear()
for mirror in self._mirror_views:
mirror.clear()
+1 -1
View File
@@ -8,7 +8,7 @@ class LoopCenteringPanel(QWidget):
super().__init__(parent)
grid_layout = QGridLayout(self)
grid_layout.addWidget(TitleLabel("Loop centering", self), 0, 0, 1, 2)
grid_layout.addWidget(TitleLabel("Loop centering", self, collapsible=True), 0, 0, 1, 2)
grid_layout.setColumnStretch(0, 1)
grid_layout.setColumnStretch(1, 1)
+9 -2
View File
@@ -3,6 +3,7 @@ from aareDB import DataCollectionParameters
from PySide6.QtCore import Signal, Slot
from PySide6.QtWidgets import QCheckBox, QGridLayout, QLabel, QLineEdit, QPushButton, QWidget
from aare.gui.styles import SURFACE
from aare.gui.widgets.number_line_edit import NumberLineEdit
from aare.gui.widgets.title_label import TitleLabel
@@ -18,14 +19,20 @@ class ManualSamplePanel(QWidget):
self._pgroup = "p16371"
grid_layout = QGridLayout(self)
# No horizontal inset: aligns the banner edge-to-edge with the Dataset
# path / Exp. Config. sections around it in the left column.
_m = grid_layout.contentsMargins()
grid_layout.setContentsMargins(0, _m.top(), 0, _m.bottom())
grid_layout.addWidget(TitleLabel("Manual sample", self), 0, 0, 1, 3)
# Kept as attribute: the Ctrl+M shortcut expands the panel via title.
self.title = TitleLabel("Manual sample", self, collapsible=True)
grid_layout.addWidget(self.title, 0, 0, 1, 3)
grid_layout.addWidget(QLabel("Sample"), 1, 0)
self._text_name = QLineEdit(self._sample_name)
grid_layout.addWidget(self._text_name, 1, 1)
self._text_name.textChanged.connect(self._name_changed)
self._text_name.setStyleSheet("background-color: rgb(255, 255, 255);")
self._text_name.setStyleSheet(f"background-color: {SURFACE};")
# Unit cell parameters
self._unit_cell = QCheckBox("Provide unit cell")
+15 -10
View File
@@ -13,29 +13,34 @@ class MonochromatorPanel(QWidget):
super().__init__(parent)
grid_layout = QGridLayout(self)
grid_layout.addWidget(TitleLabel("Monochromator", self), 0, 0, 1, 2)
grid_layout.addWidget(
TitleLabel("Monochromator", self, collapsible=True, default_collapsed=False), 0, 0, 1, 3
)
self.mono_pitch_scan_button = QPushButton("Mono Pitch Scan", parent=self)
self.mono_pitch_scan_button.clicked.connect(self.mono_pitch_scan.emit)
grid_layout.addWidget(self.mono_pitch_scan_button, 1, 0, 1, 2)
grid_layout.addWidget(self.mono_pitch_scan_button, 1, 0, 1, 3)
grid_layout.addWidget(QLabel("Energy", parent=self), 2, 0)
# One row (label | value | button) instead of three — vertical space.
# Display in keV; the DAQ API stays in eV (converted on emit).
# 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(1000.0, 30000.0)
self.energy_spin.setSingleStep(100.0)
self.energy_spin.setSuffix(" eV")
self.energy_spin.setValue(12000.0)
grid_layout.addWidget(self.energy_spin, 3, 0)
self.energy_spin.setRange(1.0, 30.0)
self.energy_spin.setSingleStep(0.1)
self.energy_spin.setValue(12.0)
grid_layout.addWidget(self.energy_spin, 2, 1)
self.change_energy_button = QPushButton("Change Energy", parent=self)
self.change_energy_button.clicked.connect(self._emit_change_energy)
grid_layout.addWidget(self.change_energy_button, 3, 1)
grid_layout.addWidget(self.change_energy_button, 2, 2)
@Slot()
def _emit_change_energy(self):
self.change_energy.emit(float(self.energy_spin.value()))
self.change_energy.emit(float(self.energy_spin.value()) * 1000.0)
@Slot(DAQStatusModel)
def update_daq_status(self, _status: DAQStatusModel):
+3 -1
View File
@@ -29,7 +29,9 @@ class OmegaPanel(QWidget):
grid_layout = QGridLayout(self)
grid_layout.addWidget(TitleLabel("Omega", self), 0, 0, 1, 2)
grid_layout.addWidget(
TitleLabel("Omega", self, collapsible=True, default_collapsed=False), 0, 0, 1, 2
)
grid_layout.setColumnStretch(0, 1)
grid_layout.setColumnStretch(1, 1)
omega_settings = [
+62 -44
View File
@@ -20,22 +20,33 @@ from PySide6.QtWidgets import (
)
from aare.gui.constants import LOGGER_NAME
from aare.gui.styles import DARK_ACCENT as ACCENT
from aare.gui.styles import DARK_BG as BG
from aare.gui.styles import DARK_BORDER as ACCENT_DIM
from aare.gui.styles import DARK_BORDER as LED_OFF
from aare.gui.styles import DARK_ELEVATED as BUTTON_BG
from aare.gui.styles import (
DARK_ERROR_BG,
DARK_ERROR_BORDER,
DARK_ERROR_TEXT,
DARK_SUCCESS_BG,
DARK_SUCCESS_BORDER,
DARK_SUCCESS_TEXT,
FONT_FINE,
FONT_HERO,
FONT_HINT,
FONT_LABEL,
FONT_VALUE,
WHITE,
qcolor,
)
from aare.gui.styles import DARK_MUTED as SUBTEXT
from aare.gui.styles import DARK_SURFACE as CARD_BG
from aare.gui.styles import DARK_TEXT as TEXT
from aare.gui.styles import WHITE as ACTIVE_STEP
logger = setup_logger(LOGGER_NAME)
# ---------------------------------------------------------------------------
# Colour palette (kept identical to gui_designer.py)
# ---------------------------------------------------------------------------
BG = "#071018"
CARD_BG = "#0E1A26"
ACCENT = "#62D8C8"
ACCENT_DIM = "#1A3A36"
TEXT = "#F5F7FA"
SUBTEXT = "#8A9BB0"
BUTTON_BG = "#132131"
LED_OFF = "#1C2E3E"
ACTIVE_STEP = "#FFFFFF"
# ---------------------------------------------------------------------------
# LED step indicator
@@ -112,7 +123,8 @@ class LEDStages(QWidget):
p.drawLine(QPointF(cx - 4, cy), QPointF(cx - 1, cy + 3))
p.drawLine(QPointF(cx - 1, cy + 3), QPointF(cx + 4, cy - 3))
elif i == self._active:
glow_pen = QPen(QColor(ACCENT + "55"), 4)
# was QColor(ACCENT + "55"), which mis-parsed as #AARRGGBB
glow_pen = QPen(qcolor(ACCENT, 0x55), 4)
p.setPen(glow_pen)
p.setBrush(Qt.NoBrush)
p.drawEllipse(QPointF(cx, cy), led_r + 4, led_r + 4)
@@ -166,7 +178,7 @@ class PlayPauseButton(QPushButton):
cx, cy = rect.width() / 2, rect.height() / 2
r = min(rect.width(), rect.height()) / 2 - 2
bg_color = QColor("#FFFFFF") if self._hovered else QColor(ACCENT)
bg_color = qcolor(WHITE) if self._hovered else QColor(ACCENT)
p.setBrush(bg_color)
p.setPen(Qt.NoPen)
p.drawEllipse(QPointF(cx, cy), r, r)
@@ -189,7 +201,7 @@ class QueueItemCard(QFrame):
self.setFixedHeight(72)
self.setStyleSheet(f"""
QFrame {{
background: {"#112030" if is_next else "#0C1720"};
background: {BUTTON_BG if is_next else CARD_BG};
border-radius: 14px;
border: {"1px solid " + ACCENT_DIM if is_next else "none"};
}}
@@ -206,13 +218,13 @@ class QueueItemCard(QFrame):
badge.setText("")
badge.setStyleSheet(f"""
color: {ACCENT}; background: {ACCENT_DIM};
border-radius: 16px; font-size: 12px; font-weight: bold;
border-radius: 16px; font-size: {FONT_HINT}; font-weight: bold;
""")
else:
badge.setText(str(index))
badge.setStyleSheet(f"""
color: {SUBTEXT}; background: {BUTTON_BG};
border-radius: 16px; font-size: 12px;
border-radius: 16px; font-size: {FONT_HINT};
""")
layout.addWidget(badge)
@@ -222,11 +234,11 @@ class QueueItemCard(QFrame):
title_lbl = QLabel(title)
title_lbl.setStyleSheet(
f"color: {TEXT}; font-size: 13px; font-weight: 600; background: transparent;"
f"color: {TEXT}; font-size: {FONT_LABEL}; font-weight: 700; background: transparent;"
)
title_lbl.setWordWrap(False)
sub_lbl = QLabel(subtitle)
sub_lbl.setStyleSheet(f"color: {SUBTEXT}; font-size: 11px; background: transparent;")
sub_lbl.setStyleSheet(f"color: {SUBTEXT}; font-size: {FONT_FINE}; background: transparent;")
text_col.addWidget(title_lbl)
text_col.addWidget(sub_lbl)
layout.addLayout(text_col, stretch=1)
@@ -245,6 +257,12 @@ class PortraitModePanel(QWidget):
self.setMaximumWidth(self.PORTRAIT_WIDTH)
self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding)
# Created here rather than in the build helper so the attributes are
# initialized in __init__ (basedpyright gate); the helper styles and
# mounts them.
self._name_lbl = QLabel("")
self._sub_lbl = QLabel("No sample queued")
self._job_list_panel = None
self._tell_samples = None
self._is_running = False
@@ -285,26 +303,26 @@ class PortraitModePanel(QWidget):
title = QLabel("S A M C A M E R A")
title.setAlignment(Qt.AlignCenter)
title.setStyleSheet(
f"color: {ACCENT}; font-size: 13px; letter-spacing: 3px; font-weight: 600;"
f"color: {ACCENT}; font-size: {FONT_LABEL}; letter-spacing: 3px; font-weight: 700;"
)
layout.addWidget(title)
# ── Portrait alert toast (hidden by default) ───────────────────────
self._alert_toast = QFrame()
self._alert_toast.setVisible(False)
self._alert_toast.setStyleSheet("""
QFrame {
background: #1A0E0E;
border: 1px solid #8f1d2c;
self._alert_toast.setStyleSheet(f"""
QFrame {{
background: {DARK_ERROR_BG};
border: 1px solid {DARK_ERROR_BORDER};
border-radius: 10px;
}
}}
""")
toast_layout = QHBoxLayout(self._alert_toast)
toast_layout.setContentsMargins(12, 8, 12, 8)
self._alert_toast_label = QLabel("")
self._alert_toast_label.setWordWrap(True)
self._alert_toast_label.setStyleSheet(
"color: #ffb3bc; font-size: 11px; font-weight: 600; background: transparent;"
f"color: {DARK_ERROR_TEXT}; font-size: {FONT_FINE}; font-weight: 600; background: transparent;"
)
toast_layout.addWidget(self._alert_toast_label)
# Dismiss button
@@ -315,7 +333,7 @@ class PortraitModePanel(QWidget):
color: {SUBTEXT};
background: transparent;
border: none;
font-size: 11px;
font-size: {FONT_FINE};
}}
QPushButton:hover {{ color: {TEXT}; }}
""")
@@ -338,11 +356,9 @@ class PortraitModePanel(QWidget):
cam_card_layout.addWidget(cam_widget)
layout.addWidget(cam_card)
# Sample name labels
self._name_lbl = QLabel("")
self._name_lbl.setStyleSheet(f"color: {TEXT}; font-size: 18px; font-weight: 700;")
self._sub_lbl = QLabel("No sample queued")
self._sub_lbl.setStyleSheet(f"color: {SUBTEXT}; font-size: 12px;")
# Sample name labels (created in __init__)
self._name_lbl.setStyleSheet(f"color: {TEXT}; font-size: {FONT_VALUE}; font-weight: 700;")
self._sub_lbl.setStyleSheet(f"color: {SUBTEXT}; font-size: {FONT_HINT};")
layout.addWidget(self._name_lbl)
layout.addWidget(self._sub_lbl)
@@ -374,7 +390,7 @@ class PortraitModePanel(QWidget):
border-radius: 26px;
background: {BUTTON_BG};
color: {ACCENT};
font-size: 28px;
font-size: {FONT_HERO};
font-weight: bold;
}}
QPushButton:checked {{
@@ -401,11 +417,11 @@ class PortraitModePanel(QWidget):
up_next_row = QHBoxLayout()
up_next_lbl = QLabel("UP NEXT")
up_next_lbl.setStyleSheet(
f"color: {ACCENT}; font-size: 11px; letter-spacing: 2px; font-weight: 700;"
f"color: {ACCENT}; font-size: {FONT_FINE}; letter-spacing: 2px; font-weight: 700;"
)
self._samples_count_lbl = QLabel("0 SAMPLES")
self._samples_count_lbl.setStyleSheet(
f"color: {SUBTEXT}; font-size: 11px; letter-spacing: 1px;"
f"color: {SUBTEXT}; font-size: {FONT_FINE}; letter-spacing: 1px;"
)
up_next_row.addWidget(up_next_lbl)
up_next_row.addStretch()
@@ -453,7 +469,7 @@ class PortraitModePanel(QWidget):
title = QLabel("SAMPLE QUEUE")
title.setAlignment(Qt.AlignCenter)
title.setStyleSheet(
f"color: {ACCENT}; font-size: 13px; letter-spacing: 3px; font-weight: 700;"
f"color: {ACCENT}; font-size: {FONT_LABEL}; letter-spacing: 3px; font-weight: 700;"
)
layout.addWidget(title)
@@ -591,8 +607,10 @@ class PortraitModePanel(QWidget):
if not samples:
placeholder = QLabel("No samples in queue")
placeholder.setStyleSheet(f"color: {SUBTEXT}; font-size: 13px;")
placeholder.setAlignment(Qt.AlignCenter)
placeholder.setStyleSheet(
f"color: {SUBTEXT}; font-size: {FONT_LABEL}; font-weight: 700;"
)
placeholder.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._queue_inner_layout.addWidget(placeholder)
return
@@ -627,7 +645,7 @@ class PortraitModePanel(QWidget):
border: 1.5px solid {ACCENT};
border-radius: 14px;
color: {ACCENT};
font-size: 12px;
font-size: {FONT_HINT};
font-weight: 700;
letter-spacing: 1.5px;
}}
@@ -658,9 +676,9 @@ class PortraitModePanel(QWidget):
icon = "🛑" if is_error else ""
self._alert_toast_label.setText(f"{icon} {msg}")
border_color = "#8f1d2c" if is_error else "#2a7a44"
text_color = "#ffb3bc" if is_error else "#a8f0c0"
bg_color = "#1A0E0E" if is_error else "#0E1A12"
border_color = DARK_ERROR_BORDER if is_error else DARK_SUCCESS_BORDER
text_color = DARK_ERROR_TEXT if is_error else DARK_SUCCESS_TEXT
bg_color = DARK_ERROR_BG if is_error else DARK_SUCCESS_BG
self._alert_toast.setStyleSheet(f"""
QFrame {{
@@ -670,7 +688,7 @@ class PortraitModePanel(QWidget):
}}
""")
self._alert_toast_label.setStyleSheet(
f"color: {text_color}; font-size: 11px; font-weight: 600; background: transparent;"
f"color: {text_color}; font-size: {FONT_FINE}; font-weight: 600; background: transparent;"
)
self._alert_toast.setVisible(True)
+25 -24
View File
@@ -42,6 +42,18 @@ from PySide6.QtWidgets import (
)
from aare.gui.constants import LOGGER_NAME
from aare.gui.styles import (
CHART_BLUE,
CHART_GREEN,
CHART_MUTED,
CHART_ORANGE,
CHART_RED,
CONFIDENCE_BIN_COLORS,
FONT_BODY,
FONT_TITLE,
qcolor,
)
from aare.gui.styles import CHART_CLASS_COLORS as CLASS_COLORS
logger = setup_logger(LOGGER_NAME)
@@ -79,17 +91,6 @@ class GroundTruthComparison:
# ─────────────────────────────────────────────────────────────────────────────
# Colors matching the bounding box colors in camera_image.py
CLASS_COLORS = {
"Crystal": "#0000ff", # blue
"Loop_face": "#ffff00", # yellow
"Loop_all": "#00ff00", # green
"Pin": "#ff0000", # red
"Ice": "#00ffff", # cyan
"Needle": "#ff00ff", # magenta
}
def get_class_name(cls_id: int) -> str:
"""Convert class ID to human-readable name."""
try:
@@ -100,7 +101,7 @@ def get_class_name(cls_id: int) -> str:
def get_class_color(class_name: str) -> str:
"""Get color for a class name (matches bounding box colors)."""
return CLASS_COLORS.get(class_name, "#888888")
return CLASS_COLORS.get(class_name, CHART_MUTED)
# ─────────────────────────────────────────────────────────────────────────────
@@ -112,7 +113,7 @@ class ConfidenceHistogramWidget(QWidget):
"""Real-time histogram of prediction confidence scores."""
BINS: ClassVar[list[float]] = [0.0, 0.2, 0.4, 0.6, 0.8, 1.0]
BIN_COLORS: ClassVar[list[str]] = ["#d62728", "#ff7f0e", "#ffbb78", "#98df8a", "#2ca02c"]
BIN_COLORS: ClassVar[list[str]] = CONFIDENCE_BIN_COLORS
def __init__(self, parent=None):
super().__init__(parent)
@@ -209,7 +210,7 @@ class ObjectCountWidget(QWidget):
# Create colored indicator square
indicator = QLabel("")
indicator.setStyleSheet(f"color: {color}; font-size: 16px;")
indicator.setStyleSheet(f"color: {color}; font-size: {FONT_TITLE};")
indicator.setFixedWidth(20)
# Class name label
@@ -218,7 +219,7 @@ class ObjectCountWidget(QWidget):
# Count label with matching color
count_label = QLabel("0")
count_label.setStyleSheet(f"color: {color}; font-size: 14px; font-weight: bold;")
count_label.setStyleSheet(f"color: {color}; font-size: {FONT_BODY}; font-weight: bold;")
count_label.setAlignment(Qt.AlignmentFlag.AlignRight)
row = i // 2
@@ -335,19 +336,19 @@ class ErrorTrackingWidget(QWidget):
# Empty frames (no detections)
grid.addWidget(QLabel("Empty frames:"), 0, 0)
self.empty_frames_label = QLabel("0")
self.empty_frames_label.setStyleSheet("color: #d62728; font-weight: bold;")
self.empty_frames_label.setStyleSheet(f"color: {CHART_RED}; font-weight: bold;")
grid.addWidget(self.empty_frames_label, 0, 1)
# Low confidence detections
grid.addWidget(QLabel("Low conf (<0.5):"), 0, 2)
self.low_conf_label = QLabel("0")
self.low_conf_label.setStyleSheet("color: #ff7f0e; font-weight: bold;")
self.low_conf_label.setStyleSheet(f"color: {CHART_ORANGE}; font-weight: bold;")
grid.addWidget(self.low_conf_label, 0, 3)
# Detection rate
grid.addWidget(QLabel("Detection rate:"), 1, 0)
self.detection_rate_label = QLabel("-")
self.detection_rate_label.setStyleSheet("color: #2ca02c; font-weight: bold;")
self.detection_rate_label.setStyleSheet(f"color: {CHART_GREEN}; font-weight: bold;")
grid.addWidget(self.detection_rate_label, 1, 1)
# Reset button
@@ -411,12 +412,12 @@ class RollingStatsChart(QWidget):
# Detection count series (left Y axis)
self.count_series = QLineSeries()
self.count_series.setName("Detections")
self.count_series.setPen(QPen(QColor("#1f77b4"), 2))
self.count_series.setPen(QPen(qcolor(CHART_BLUE), 2))
# Mean confidence series (right Y axis)
self.conf_series = QLineSeries()
self.conf_series.setName("Mean Confidence")
self.conf_series.setPen(QPen(QColor("#2ca02c"), 2))
self.conf_series.setPen(QPen(qcolor(CHART_GREEN), 2))
self.chart = QChart()
self.chart.addSeries(self.count_series)
@@ -554,7 +555,7 @@ class PredictionMetricsPanel(QWidget):
controls.addStretch()
self.status_label = QLabel("Waiting for predictions...")
self.status_label.setStyleSheet("color: #888;")
self.status_label.setStyleSheet(f"color: {CHART_MUTED};")
controls.addWidget(self.status_label)
layout.addLayout(controls)
@@ -633,14 +634,14 @@ class PredictionMetricsPanel(QWidget):
"""Update status label."""
if self._paused:
self.status_label.setText("Paused")
self.status_label.setStyleSheet("color: #ff7f0e;")
self.status_label.setStyleSheet(f"color: {CHART_ORANGE};")
elif self._history:
count = len(self._history)
self.status_label.setText(f"Live: {count} frames recorded")
self.status_label.setStyleSheet("color: #2ca02c;")
self.status_label.setStyleSheet(f"color: {CHART_GREEN};")
else:
self.status_label.setText("Waiting for predictions...")
self.status_label.setStyleSheet("color: #888;")
self.status_label.setStyleSheet(f"color: {CHART_MUTED};")
def showEvent(self, event) -> None:
super().showEvent(event)
+9 -16
View File
@@ -2,19 +2,12 @@ from aarecommon.config.logger import setup_logger
from aarecommon.math.diffraction_geometry import DiffractionGeometry
from aarecommon.models.models import BeamlineStateEnum, DAQStatusModel
from PySide6.QtCore import Qt, Signal, Slot
from PySide6.QtWidgets import (
QComboBox,
QLabel,
QMessageBox,
QPushButton,
QSizePolicy,
QSlider,
QSpacerItem,
)
from PySide6.QtWidgets import QComboBox, QLabel, QMessageBox, QPushButton, QSlider
from aare.gui.constants import LOGGER_NAME
from aare.gui.panels.scan_settings_panel import ScanSettingsPanel
from aare.gui.scan_logic.raster_grid_manager import RasterGridManager, RasterGridMetric
from aare.gui.styles import ABORT_TEXT, GO_TEXT
from aare.gui.widgets.number_line_edit import DbOverrideLineEdit
from aare.gui.widgets.raster_grid_table import RasterGridTable
@@ -130,11 +123,6 @@ class RasterDataCollectionPanel(ScanSettingsPanel):
self._table = RasterGridTable(raster_mgr)
self._layout.addWidget(self._table, 9, 0, 1, 5)
horizontal_spacer = QSpacerItem(
40, 20, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding
)
self._layout.addItem(horizontal_spacer, 10, 0, 1, 5)
self._layout.addWidget(QLabel("Measurement time", parent=self), 11, 0)
self.total_time = QLabel(f"{self._total_time} min 0 s")
self.total_time.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
@@ -143,14 +131,19 @@ class RasterDataCollectionPanel(ScanSettingsPanel):
self.calculate_total_time()
self.start_button = QPushButton("Evaluate grid")
self.start_button.setStyleSheet("color: rgb(78, 154, 6);")
self.start_button.setStyleSheet(f"color: {GO_TEXT};")
self.start_button.clicked.connect(self._on_evaluate_clicked)
self._layout.addWidget(self.start_button, 12, 0, 1, 5)
self.auto_button = QPushButton("X-ray Centering")
self.auto_button.setStyleSheet("color: rgb(78, 154, 6);")
self.auto_button.setStyleSheet(f"color: {GO_TEXT};")
self.auto_button.clicked.connect(self._on_evaluate_auto_clicked)
self._layout.addWidget(self.auto_button, 13, 0, 1, 5)
# Per-tab Abort (DataCollectionSettings wires it to the DAQ cancel).
self.abort_button = QPushButton("Abort measurement")
self.abort_button.setStyleSheet(f"color: {ABORT_TEXT};")
self._layout.addWidget(self.abort_button, 14, 0, 1, 5)
self._reset_to_defaults()
self.update_grid_scan_size()
+64 -52
View File
@@ -3,19 +3,11 @@
from aarecommon.config.logger import setup_logger
from aarecommon.models.models import DAQStatusModel, SampleShortInfo, SampleShortInfoList
from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt, Signal, Slot
from PySide6.QtGui import QBrush, QColor
from PySide6.QtWidgets import (
QAbstractItemView,
QFrame,
QGridLayout,
QHeaderView,
QLabel,
QMenu,
QPushButton,
QTableView,
)
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, SAMPLE_STATUS_TEXT, qcolor
from aare.gui.widgets.title_label import TitleLabel
logger = setup_logger(LOGGER_NAME)
@@ -48,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",
@@ -56,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:
@@ -78,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(114, 159, 207)) # darker blue
return QBrush(QColor(255, 255, 255)) # white
return QBrush(qcolor(SAMPLE_ROW_QUEUED_BG))
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
@@ -107,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
@@ -119,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")
@@ -139,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),
)
@@ -160,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],
)
@@ -173,11 +184,13 @@ class ReferenceToolsPanel(QFrame):
samples: SampleShortInfoList | None = None,
parent=None,
refresh_interval_ms: int = 5000,
model: ReferenceToolsModel | None = None,
):
"""
:param samples: optional initial SampleShortInfoList to populate the table
:param parent: Qt parent
:param refresh_interval_ms: how often to call request_refresh (panel doesn't implement the request itself)
:param model: share an existing model instead of owning one (pop-out window)
"""
super().__init__(parent)
@@ -189,26 +202,24 @@ class ReferenceToolsPanel(QFrame):
layout = QGridLayout(self)
self.setLayout(layout)
# Flush layout, matching TellSamplePanel: full-width banner, no
# padding ring, no gap to the dock's tab row.
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
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. 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)
self.curr_sample_label = QLabel("No sample mounted", parent=self)
layout.addWidget(self.curr_sample_label, 2, 0)
self.unmount_button = QPushButton("Unmount", parent=self)
layout.addWidget(self.unmount_button, 2, 1)
self.unmount_button.clicked.connect(self._on_unmount_clicked)
layout.setColumnStretch(0, 1)
layout.setColumnStretch(1, 0)
# initialize model with provided samples
self.table_model = ReferenceToolsModel(rows=samples.s)
# initialize model with provided samples (or adopt the shared one)
self.table_model = model if model is not None else ReferenceToolsModel(rows=samples.s)
self.table_view.setModel(self.table_model)
self.table_view.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
self.table_view.setEditTriggers(QTableView.EditTrigger.NoEditTriggers)
logger.debug("Setting up table header")
@@ -217,8 +228,15 @@ class ReferenceToolsPanel(QFrame):
header.setSectionResizeMode(QHeaderView.ResizeMode.Interactive)
logger.debug("Setting up table header")
header.setStretchLastSection(True)
self.table_view.verticalHeader().setVisible(True)
# No bold column titles when cells are selected.
header.setHighlightSections(False)
# 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.
header.setSortIndicator(self.table_model._sort_col, self.table_model._sort_order)
self.table_view.setSortingEnabled(True)
logger.debug("Setting up table view context menu")
self.table_view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
@@ -226,15 +244,18 @@ class ReferenceToolsPanel(QFrame):
self.table_view.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.table_view.setSelectionMode(QTableView.SelectionMode.SingleSelection)
# Columns at full content width, sized once (see TellSamplePanel).
self._columns_autosized = False
if self.table_model.rowCount() > 0:
self.table_view.resizeColumnsToContents()
self._columns_autosized = True
def _selected_item(self) -> SampleShortInfo | None:
idx = self.table_view.currentIndex()
if not idx.isValid():
return None
return self.table_model.get_item(idx.row())
def _on_unmount_clicked(self):
self.unmount.emit()
def _context_menu(self, position):
idx = self.table_view.indexAt(position)
@@ -262,22 +283,13 @@ class ReferenceToolsPanel(QFrame):
def new_list(self, samples: SampleShortInfoList):
# signal from DAQWorker will call this with the model
self.table_model.update_rows(rows=samples.s)
if not self._columns_autosized and self.table_model.rowCount() > 0:
self.table_view.resizeColumnsToContents()
self._columns_autosized = True
@Slot(DAQStatusModel)
def update_daq_status(self, status: DAQStatusModel):
# The "No sample mounted" label and Unmount button were dropped —
# mounted state now shows as the existing row highlight instead.
sample = status.sample
if sample is None:
self.curr_sample_label.setText("No sample mounted")
else:
try:
if sample.location is None:
self.curr_sample_label.setText(
f"Current sample: <b>{sample.sample_name} (Manual mount)</b>"
)
else:
self.curr_sample_label.setText(
f"Current sample: <b>{sample.sample_name} ({sample.location.segment}{sample.location.pos}-{sample.pin})</b>"
)
except Exception as e:
logger.debug("Could not update the current sample label", exc_info=True)
self.curr_sample_label.setText(f"Confusing information :/ {e}")
self.table_model.update_current_reference(sample.db_id if sample is not None else None)
@@ -10,6 +10,7 @@ from PySide6.QtWidgets import QComboBox, QLabel, QMessageBox, QPushButton
from aare.gui.constants import LOGGER_NAME
from aare.gui.panels.scan_settings_panel import ScanSettingsPanel
from aare.gui.styles import ABORT_TEXT, GO_TEXT
from aare.gui.widgets.number_line_edit import DbOverrideLineEdit, NumberLineEdit
logger = setup_logger(LOGGER_NAME)
@@ -96,7 +97,7 @@ class RotationDataCollectionPanel(ScanSettingsPanel):
self._layout.addWidget(self.screening_type, 7, 0, 1, 6)
self.screening_button = QPushButton("Run screening")
self.screening_button.setStyleSheet("color: rgb(78, 154, 6);")
self.screening_button.setStyleSheet(f"color: {GO_TEXT};")
self.screening_button.clicked.connect(self.run_screening)
self._layout.addWidget(self.screening_button, 8, 0, 1, 6)
@@ -160,9 +161,14 @@ class RotationDataCollectionPanel(ScanSettingsPanel):
self.reload_params_button.setVisible(True)
self.measurement_button = QPushButton("Run rotation")
self.measurement_button.setStyleSheet("color: rgb(78, 154, 6);")
self.measurement_button.setStyleSheet(f"color: {GO_TEXT};")
self.measurement_button.clicked.connect(self.run_measurement)
self._layout.addWidget(self.measurement_button, 16, 0, 1, 6)
# Per-tab Abort (DataCollectionSettings wires it to the DAQ cancel).
self.abort_button = QPushButton("Abort measurement")
self.abort_button.setStyleSheet(f"color: {ABORT_TEXT};")
self._layout.addWidget(self.abort_button, 17, 0, 1, 6)
self._reset_to_defaults()
@Slot()
+36 -46
View File
@@ -4,6 +4,7 @@ from PySide6.QtWidgets import (
QCheckBox,
QComboBox,
QDoubleSpinBox,
QGridLayout,
QHBoxLayout,
QLabel,
QLineEdit,
@@ -35,32 +36,30 @@ class SamcamPanel(QWidget):
# Create layout
layout = QVBoxLayout()
layout.addWidget(TitleLabel("Sample camera", self))
layout.addWidget(
TitleLabel("Sample camera", self, collapsible=True, default_collapsed=False)
)
# Exposure control
exposure_layout = QHBoxLayout()
exposure_label = QLabel("Exposure (s):")
# Exposure + gain share one row to save vertical space.
exposure_gain_layout = QHBoxLayout()
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("QDoubleSpinBox { background-color: white; }")
self.exposure_spinbox.setDecimals(3)
self.exposure_spinbox.valueChanged.connect(self._changed)
exposure_layout.addWidget(exposure_label)
exposure_layout.addWidget(self.exposure_spinbox)
# Gain control
gain_layout = QHBoxLayout()
gain_label = QLabel("Gain:")
self.gain_spinbox = QDoubleSpinBox()
self.gain_spinbox.setRange(0, 1000) # Adjust range as needed
self.gain_spinbox.setSingleStep(1)
self.gain_spinbox.setDecimals(1)
self.gain_spinbox.setStyleSheet("QDoubleSpinBox { background-color: white; }")
self.gain_spinbox.valueChanged.connect(self._changed)
gain_layout.addWidget(gain_label)
gain_layout.addWidget(self.gain_spinbox)
# 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, 3)
exposure_gain_layout.addWidget(QLabel("Gain:"))
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,7 +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("QLineEdit { background-color: white; }")
screenshot_filename_layout.addWidget(screenshot_filename_label)
screenshot_filename_layout.addWidget(self.screenshot_filename_edit)
@@ -79,60 +77,56 @@ class SamcamPanel(QWidget):
screenshot_message_label = QLabel("Message:")
self.screenshot_message_edit = QLineEdit()
self.screenshot_message_edit.setPlaceholderText("optional")
self.screenshot_message_edit.setStyleSheet("QLineEdit { background-color: white; }")
screenshot_message_layout.addWidget(screenshot_message_label)
screenshot_message_layout.addWidget(self.screenshot_message_edit)
self.screenshot_button = QPushButton("Take screenshot")
self.screenshot_button = QPushButton("Save samcam image")
self.screenshot_button.clicked.connect(self._request_screenshot)
# Show detections checkbox
detections_layout = QHBoxLayout()
# Overlay checkboxes, two columns to save vertical space; related
# toggles share a row.
self.show_detections_checkbox = QCheckBox("Show ML detections")
self.show_detections_checkbox.setChecked(True) # Default to checked
self.show_detections_checkbox.toggled.connect(self.show_detections_changed.emit)
detections_layout.addWidget(self.show_detections_checkbox)
# Show detection polygons checkbox
detection_polygons_layout = QHBoxLayout()
self.show_detection_polygons_checkbox = QCheckBox("Show ML polygons")
self.show_detection_polygons_checkbox.setChecked(True)
self.show_detection_polygons_checkbox.toggled.connect(
self.show_detection_polygons_changed.emit
)
detection_polygons_layout.addWidget(self.show_detection_polygons_checkbox)
# Show target point checkbox
target_point_layout = QHBoxLayout()
self.show_target_point_checkbox = QCheckBox("Show target point")
self.show_target_point_checkbox.setChecked(True)
self.show_target_point_checkbox.toggled.connect(self.show_target_point_changed.emit)
target_point_layout.addWidget(self.show_target_point_checkbox)
# Show target coordinates checkbox
target_coords_layout = QHBoxLayout()
self.show_target_coordinates_checkbox = QCheckBox("Show target coordinates")
self.show_target_coordinates_checkbox.setChecked(True)
self.show_target_coordinates_checkbox.toggled.connect(
self.show_target_coordinates_changed.emit
)
target_coords_layout.addWidget(self.show_target_coordinates_checkbox)
# Show legend checkbox
legend_layout = QHBoxLayout()
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)
legend_layout.addWidget(self.show_overlay_legend_checkbox)
# Compact legend checkbox
compact_legend_layout = QHBoxLayout()
self.compact_overlay_legend_checkbox = QCheckBox("Compact legend")
self.compact_overlay_legend_checkbox.setChecked(False)
self.compact_overlay_legend_checkbox.toggled.connect(
self.compact_overlay_legend_changed.emit
)
compact_legend_layout.addWidget(self.compact_overlay_legend_checkbox)
checkbox_grid = QGridLayout()
for i, checkbox in enumerate(
(
self.show_detections_checkbox,
self.show_detection_polygons_checkbox,
self.show_target_point_checkbox,
self.show_target_coordinates_checkbox,
self.show_overlay_legend_checkbox,
self.compact_overlay_legend_checkbox,
)
):
checkbox_grid.addWidget(checkbox, i // 2, i % 2)
# Target color
target_color_layout = QHBoxLayout()
@@ -145,18 +139,14 @@ class SamcamPanel(QWidget):
target_color_layout.addWidget(self.target_color_combo)
# Add controls to main layout
layout.addLayout(exposure_layout)
layout.addLayout(gain_layout)
layout.addWidget(self.save_beam_location_button)
layout.addLayout(exposure_gain_layout)
samcam_buttons_layout = QHBoxLayout()
samcam_buttons_layout.addWidget(self.save_beam_location_button)
samcam_buttons_layout.addWidget(self.screenshot_button)
layout.addLayout(samcam_buttons_layout)
layout.addLayout(screenshot_filename_layout)
layout.addLayout(screenshot_message_layout)
layout.addWidget(self.screenshot_button)
layout.addLayout(detections_layout)
layout.addLayout(detection_polygons_layout)
layout.addLayout(target_point_layout)
layout.addLayout(target_coords_layout)
layout.addLayout(legend_layout)
layout.addLayout(compact_legend_layout)
layout.addLayout(checkbox_grid)
layout.addLayout(target_color_layout)
self.setLayout(layout)
+10
View File
@@ -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)
@@ -194,6 +197,13 @@ class SampleQueuePanel(QFrame):
self._emit_samples_in_queue_changed()
def remove_samples(self, db_ids) -> None:
"""Remove the given samples from the queue. Public entry point for the
combined dewar view, whose selection lives outside this panel."""
for db_id in db_ids:
self.table_model.remove_sample(db_id)
self._emit_samples_in_queue_changed()
def remove_selected_samples(self):
selected_indexes = self.table_view.selectionModel().selectedRows()
if not selected_indexes:
@@ -63,10 +63,15 @@ class ScanSettingsPanel(QWidget):
# before, so they are unaffected by the wrapping.
outer = QVBoxLayout(self)
outer.setContentsMargins(0, 0, 0, 0)
outer.setSpacing(0)
outer.addWidget(self._build_source_toggle())
grid_host = QWidget(self)
self._layout = QGridLayout(grid_host)
# Toggle-to-grid gap = one grid row gap (top). Bottom 3 + the column's
# 3px spacing = one row gap between the last button and Abort too.
m = self._layout.contentsMargins()
self._layout.setContentsMargins(m.left(), 6, m.right(), 3)
outer.addWidget(grid_host)
self._layout.addWidget(QLabel("High resolution", parent=self), 0, 0)
+3 -1
View File
@@ -58,7 +58,9 @@ class SmargonPanel(QWidget):
grid_layout = QGridLayout(self)
grid_layout.addWidget(TitleLabel("Smargon", self), 0, 0, 1, 6)
grid_layout.addWidget(
TitleLabel("Smargon", self, collapsible=True, default_collapsed=False), 0, 0, 1, 6
)
grid_layout.addWidget(QLabel("Chi", parent=self), 1, 0)
self.chi_enter = NumberLineEdit(-0.2, 40, decimals=1, parent=self)
+74 -62
View File
@@ -8,6 +8,7 @@ from PySide6.QtWidgets import QGridLayout, QLabel, QPushButton, QSizePolicy, QSp
from aare.gui.constants import LOGGER_NAME
from aare.gui.panels.rotation_data_collection import add_data_to_path
from aare.gui.styles import ABORT_TEXT, GO_TEXT, STATUS_ALERT
from aare.gui.widgets.number_line_edit import NumberLineEdit
logger = setup_logger(LOGGER_NAME)
@@ -43,6 +44,10 @@ class SimpleRotationSettingsPanel(QWidget):
self._prev_params = SimpleScanParameters()
self._layout = QGridLayout(self)
# Top margin 0 like the Raster/Rotation pages (their toggle row sits
# at margin 0), so the gap under the ML-centring row matches.
m = self._layout.contentsMargins()
self._layout.setContentsMargins(m.left(), 0, m.right(), 3)
# Visible resolution (entry)
self._layout.addWidget(QLabel("Visible resolution", parent=self), 0, 0)
@@ -82,133 +87,138 @@ class SimpleRotationSettingsPanel(QWidget):
self._layout.addWidget(QLabel("K", parent=self), 4, 4)
self.temp_enter.newValue.connect(self.set_temperature)
# Run + Abort directly under the last configurable row; the read-only
# block below is reference info, not something to scroll past to act.
self.run_rotation_button = QPushButton("Run rotation", parent=self)
self.run_rotation_button.setStyleSheet(f"color: {GO_TEXT};")
self.run_rotation_button.clicked.connect(self.run_measurement)
self._layout.addWidget(self.run_rotation_button, 5, 0, 1, 6)
self.abort_button = QPushButton("Abort measurement", parent=self)
self.abort_button.setStyleSheet(f"color: {ABORT_TEXT};")
self._layout.addWidget(self.abort_button, 6, 0, 1, 6)
# Calculated labels
self._layout.addWidget(QLabel("Target resolution", parent=self), 5, 0)
self._layout.addWidget(QLabel("Target resolution", parent=self), 7, 0)
self.target_res_label = QLabel("--", parent=self)
self.target_res_label.setAlignment(
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
)
self._layout.addWidget(self.target_res_label, 5, 1, 1, 3)
self._layout.addWidget(QLabel("Å", parent=self), 5, 4)
self._layout.addWidget(self.target_res_label, 7, 1, 1, 3)
self._layout.addWidget(QLabel("Å", parent=self), 7, 4)
self._layout.addWidget(QLabel("Image time", parent=self), 6, 0)
self._layout.addWidget(QLabel("Image time", parent=self), 8, 0)
self.image_time_label = QLabel("--", parent=self)
self.image_time_label.setAlignment(
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
)
self._layout.addWidget(self.image_time_label, 6, 1, 1, 3)
self._layout.addWidget(QLabel("s", parent=self), 6, 4)
self._layout.addWidget(self.image_time_label, 8, 1, 1, 3)
self._layout.addWidget(QLabel("s", parent=self), 8, 4)
self._layout.addWidget(QLabel("Transmission", parent=self), 7, 0)
self._layout.addWidget(QLabel("Transmission", parent=self), 9, 0)
self.transmission_label = QLabel("--", parent=self)
self.transmission_label.setAlignment(
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
)
self._layout.addWidget(self.transmission_label, 7, 1, 1, 3)
self._layout.addWidget(QLabel("%", parent=self), 7, 4)
self._layout.addWidget(self.transmission_label, 9, 1, 1, 3)
self._layout.addWidget(QLabel("%", parent=self), 9, 4)
self._layout.addWidget(QLabel("Detector distance", parent=self), 8, 0)
self._layout.addWidget(QLabel("Detector distance", parent=self), 10, 0)
self.dtz_label = QLabel("--", parent=self)
self.dtz_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
self._layout.addWidget(self.dtz_label, 8, 1, 1, 3)
self._layout.addWidget(QLabel("mm", parent=self), 8, 4)
self._layout.addWidget(self.dtz_label, 10, 1, 1, 3)
self._layout.addWidget(QLabel("mm", parent=self), 10, 4)
self._layout.addWidget(QLabel("Target Dose", parent=self), 9, 0)
self._layout.addWidget(QLabel("Target Dose", parent=self), 11, 0)
self.target_dose_label = QLabel("--", parent=self)
self.target_dose_label.setAlignment(
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
)
self._layout.addWidget(self.target_dose_label, 9, 1, 1, 3)
self._layout.addWidget(QLabel("MGy", parent=self), 9, 4)
self._layout.addWidget(self.target_dose_label, 11, 1, 1, 3)
self._layout.addWidget(QLabel("MGy", parent=self), 11, 4)
self._layout.addWidget(QLabel("Calculated Dose Rate", parent=self), 10, 0)
self._layout.addWidget(QLabel("Calculated Dose Rate", parent=self), 12, 0)
self.calculated_dose_rate_label = QLabel("--", parent=self)
self.calculated_dose_rate_label.setAlignment(
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
)
self._layout.addWidget(self.calculated_dose_rate_label, 10, 1, 1, 3)
self._layout.addWidget(QLabel("MGy s<sup>-1</sup>", parent=self), 10, 4)
self._layout.addWidget(self.calculated_dose_rate_label, 12, 1, 1, 3)
self._layout.addWidget(QLabel("MGy s<sup>-1</sup>", parent=self), 12, 4)
self._layout.addWidget(QLabel("Wilson B Factor", parent=self), 11, 0)
self._layout.addWidget(QLabel("Wilson B Factor", parent=self), 13, 0)
self.wilson_b_label = QLabel("--", parent=self)
self.wilson_b_label.setAlignment(
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
)
self._layout.addWidget(self.wilson_b_label, 11, 1, 1, 3)
self._layout.addWidget(QLabel("Å<sup>2</sup>", parent=self), 11, 4)
self._layout.addWidget(self.wilson_b_label, 13, 1, 1, 3)
self._layout.addWidget(QLabel("Å<sup>2</sup>", parent=self), 13, 4)
self._layout.addWidget(QLabel("Crystal Size x", parent=self), 12, 0)
self._layout.addWidget(QLabel("Crystal Size x", parent=self), 14, 0)
self.xtal_x_label = QLabel("--", parent=self)
self.xtal_x_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
self._layout.addWidget(self.xtal_x_label, 12, 1, 1, 3)
self._layout.addWidget(QLabel("um", parent=self), 12, 4)
self._layout.addWidget(QLabel("Crystal Size y", parent=self), 13, 0)
self.xtal_y_label = QLabel("--", parent=self)
self.xtal_y_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
self._layout.addWidget(self.xtal_y_label, 13, 1, 1, 3)
self._layout.addWidget(QLabel("um", parent=self), 13, 4)
self._layout.addWidget(QLabel("Crystal Size z", parent=self), 14, 0)
self.xtal_z_label = QLabel("--", parent=self)
self.xtal_z_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
self._layout.addWidget(self.xtal_z_label, 14, 1, 1, 3)
self._layout.addWidget(self.xtal_x_label, 14, 1, 1, 3)
self._layout.addWidget(QLabel("um", parent=self), 14, 4)
self._layout.addWidget(QLabel("Calculated Dose (xtal size)", parent=self), 15, 0)
self._layout.addWidget(QLabel("Crystal Size y", parent=self), 15, 0)
self.xtal_y_label = QLabel("--", parent=self)
self.xtal_y_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
self._layout.addWidget(self.xtal_y_label, 15, 1, 1, 3)
self._layout.addWidget(QLabel("um", parent=self), 15, 4)
self._layout.addWidget(QLabel("Crystal Size z", parent=self), 16, 0)
self.xtal_z_label = QLabel("--", parent=self)
self.xtal_z_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
self._layout.addWidget(self.xtal_z_label, 16, 1, 1, 3)
self._layout.addWidget(QLabel("um", parent=self), 16, 4)
self._layout.addWidget(QLabel("Calculated Dose (xtal size)", parent=self), 17, 0)
self.xtal_size_dose_label = QLabel("--", parent=self)
self.xtal_size_dose_label.setAlignment(
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
)
self._layout.addWidget(self.xtal_size_dose_label, 15, 1, 1, 3)
self._layout.addWidget(QLabel("MGy", parent=self), 15, 4)
self._layout.addWidget(self.xtal_size_dose_label, 17, 1, 1, 3)
self._layout.addWidget(QLabel("MGy", parent=self), 17, 4)
self._layout.addWidget(QLabel("X-ray Wavelength", parent=self), 16, 0)
self._layout.addWidget(QLabel("X-ray Wavelength", parent=self), 18, 0)
self.wavelength_label = QLabel("--", parent=self)
self.wavelength_label.setAlignment(
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
)
self._layout.addWidget(self.wavelength_label, 16, 1, 1, 3)
self._layout.addWidget(QLabel("Å", parent=self), 16, 4)
self._layout.addWidget(self.wavelength_label, 18, 1, 1, 3)
self._layout.addWidget(QLabel("Å", parent=self), 18, 4)
self._layout.addWidget(QLabel("Flux", parent=self), 17, 0)
self._layout.addWidget(QLabel("Flux", parent=self), 19, 0)
self.flux_label = QLabel("--", parent=self)
self.flux_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
self._layout.addWidget(self.flux_label, 17, 1, 1, 3)
self._layout.addWidget(QLabel("x 10<sup>9</sup> ph s<sup>-1</sup>", parent=self), 17, 4)
self._layout.addWidget(self.flux_label, 19, 1, 1, 3)
self._layout.addWidget(QLabel("x 10<sup>9</sup> ph s<sup>-1</sup>", parent=self), 19, 4)
self._layout.addWidget(QLabel("Beam Size", parent=self), 18, 0)
self._layout.addWidget(QLabel("Beam Size", parent=self), 20, 0)
self.beam_size_label = QLabel("--", parent=self)
self.beam_size_label.setAlignment(
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
)
self._layout.addWidget(self.beam_size_label, 18, 1, 1, 3)
self._layout.addWidget(QLabel("um<sup>2</sup>", parent=self), 18, 4)
self._layout.addWidget(self.beam_size_label, 20, 1, 1, 3)
self._layout.addWidget(QLabel("um<sup>2</sup>", parent=self), 20, 4)
self._layout.addWidget(QLabel("Calculated Dose", parent=self), 19, 0)
self._layout.addWidget(QLabel("Calculated Dose", parent=self), 21, 0)
self.calculated_dose_label = QLabel("--", parent=self)
self.calculated_dose_label.setAlignment(
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
)
self._layout.addWidget(self.calculated_dose_label, 19, 1, 1, 3)
self._layout.addWidget(QLabel("MGy", parent=self), 19, 4)
self._layout.addWidget(self.calculated_dose_label, 21, 1, 1, 3)
self._layout.addWidget(QLabel("MGy", parent=self), 21, 4)
self._layout.addWidget(QLabel("Total measurement time", parent=self), 20, 0)
self._layout.addWidget(QLabel("Total measurement time", parent=self), 22, 0)
self.total_time = QLabel(f"{self.total_time_s} min 0 s")
self.total_time.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
self._layout.addWidget(self.total_time, 20, 1, 1, 3)
self._layout.addWidget(self.total_time, 22, 1, 1, 3)
# add vertical stretch between detector distance and the run button
# Vertical stretch below everything (surplus space sink).
self._layout.addItem(
QSpacerItem(0, 0, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding), 21, 0, 1, 6
QSpacerItem(0, 0, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding), 23, 0, 1, 6
)
# Run rotation button
self.run_rotation_button = QPushButton("Run rotation", parent=self)
self.run_rotation_button.setStyleSheet("color: rgb(78, 154, 6);")
self.run_rotation_button.clicked.connect(self.run_measurement)
self._layout.addWidget(self.run_rotation_button, 22, 0, 1, 6)
@Slot(DAQStatusModel)
def update_daq_status(self, s: DAQStatusModel):
self._d = s
@@ -355,9 +365,11 @@ class SimpleRotationSettingsPanel(QWidget):
self.image_time_label.setText(f"{self.image_time_s:.4f}")
if self.dtz <= 0.0:
self.dtz_label.setText("""<span style="color: red ; ">-</span>""")
self.dtz_label.setText(f"""<span style="color: {STATUS_ALERT} ; ">-</span>""")
elif self.dtz < self._d.bl.dtz_min:
self.dtz_label.setText(f"""<span style="color: red ; ">{self.dtz:.2f}</span>""")
self.dtz_label.setText(
f"""<span style="color: {STATUS_ALERT} ; ">{self.dtz:.2f}</span>"""
)
self.dtz = self._d.bl.dtz_min
else:
self.dtz_label.setText(f"{self.dtz:.2f}")
+2 -1
View File
@@ -4,6 +4,7 @@ from PySide6.QtCore import Qt, Slot
from PySide6.QtGui import QPixmap
from PySide6.QtWidgets import QFrame, QGridLayout, QLabel, QSizePolicy, QSpacerItem
from aare.gui.styles import STATUS_ALERT
from aare.gui.widgets.status_label import StatusLabel
from aare.gui.widgets.title_label import TitleLabel
@@ -65,7 +66,7 @@ class StatusPanel(QFrame):
if s.bl.ring_current_mA < 390.0:
self.ring_current.setText(
f'<span style="color: red ; "><b>{s.bl.ring_current_mA:.1f}</b></span>'
f'<span style="color: {STATUS_ALERT} ; "><b>{s.bl.ring_current_mA:.1f}</b></span>'
)
else:
self.ring_current.setText(f"<b>{s.bl.ring_current_mA:.1f}</b>")
+22 -10
View File
@@ -23,6 +23,18 @@ from PySide6.QtWidgets import (
)
from aare.gui.constants import LOGGER_NAME
from aare.gui.styles import (
CHART_BLUE,
CHART_BLUE_LIGHT,
CHART_BLUE_PALE,
CHART_CYAN,
CHART_GREEN,
CHART_ORANGE,
CHART_PURPLE,
CHART_RED,
CHART_RED_DARK,
CHART_RED_LIGHT,
)
logger = setup_logger(LOGGER_NAME)
@@ -97,19 +109,19 @@ class InteractiveChartView(QChartView):
class TargetStabilityPanel(QWidget):
SIGMA_COLOR = "#1f77b4"
SIGMA_X_COLOR = "#6baed6"
SIGMA_Y_COLOR = "#9ecae1"
SIGMA_COLOR = CHART_BLUE
SIGMA_X_COLOR = CHART_BLUE_LIGHT
SIGMA_Y_COLOR = CHART_BLUE_PALE
DISTANCE_COLOR = "#d62728"
DX_COLOR = "#ff9896"
DY_COLOR = "#c43c39"
DISTANCE_COLOR = CHART_RED
DX_COLOR = CHART_RED_LIGHT
DY_COLOR = CHART_RED_DARK
SCORE_COLOR = "#ff7f0e"
STEP_COLOR = "#17becf"
SCORE_COLOR = CHART_ORANGE
STEP_COLOR = CHART_CYAN
TARGET_COLOR = "#2ca02c"
BEAM_COLOR = "#9467bd"
TARGET_COLOR = CHART_GREEN
BEAM_COLOR = CHART_PURPLE
SCORE_FROM_STEP_XY = "Step XY"
SCORE_FROM_SIGMA_XY = "Sigma XY"
+242 -63
View File
@@ -8,29 +8,124 @@ from aarecommon.models.models import (
from PySide6.QtCore import Qt, Signal, Slot
from PySide6.QtWidgets import (
QAbstractItemView,
QButtonGroup,
QFrame,
QGridLayout,
QHBoxLayout,
QHeaderView,
QLabel,
QMenu,
QPushButton,
QTableView,
)
from aare.gui.constants import LOGGER_NAME
from aare.gui.models.user_sample_model import UserSampleSpreadsheet
from aare.gui.models.user_sample_model import COL_STATUS, UserSampleSpreadsheet
from aare.gui.widgets.title_label import TitleLabel
logger = setup_logger(LOGGER_NAME)
class FrozenColumnTableView(QTableView):
"""QTableView with the "#"/status column frozen (Qt frozen-column
pattern): an overlay view shares the model and selection, sits on top of
column 0, and stays put while the rest scrolls horizontally."""
FROZEN_WIDTH = 36
def __init__(self, parent=None):
super().__init__(parent)
self.frozen = QTableView(self)
self.frozen.setFocusPolicy(Qt.FocusPolicy.NoFocus)
self.frozen.verticalHeader().hide()
self.frozen.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.frozen.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.frozen.setShowGrid(False)
self.frozen.setAlternatingRowColors(True)
self.frozen.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.frozen.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
self.frozen.setEditTriggers(QTableView.EditTrigger.NoEditTriggers)
self.frozen.setDragEnabled(True)
self.frozen.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Fixed)
self.frozen.horizontalHeader().setHighlightSections(False)
# The overlay must not carry the L3 frame — it sits INSIDE the view.
self.frozen.setStyleSheet("QTableView { border: none; }")
self.viewport().stackUnder(self.frozen)
self.frozen.verticalScrollBar().valueChanged.connect(self.verticalScrollBar().setValue)
self.verticalScrollBar().valueChanged.connect(self.frozen.verticalScrollBar().setValue)
def setModel(self, model):
super().setModel(model)
self.frozen.setModel(model)
# Share the selection so clicking either view highlights both.
self.frozen.setSelectionModel(self.selectionModel())
for col in range(1, model.columnCount()):
self.frozen.setColumnHidden(col, True)
self.set_frozen_width(self.FROZEN_WIDTH)
self.frozen.show()
def set_frozen_width(self, width: int) -> None:
self.setColumnWidth(0, width)
self.frozen.setColumnWidth(0, width)
self._update_frozen_geometry()
def _update_frozen_geometry(self) -> None:
self.frozen.setGeometry(
self.frameWidth(),
self.frameWidth(),
self.columnWidth(0),
self.viewport().height() + self.horizontalHeader().height(),
)
def resizeEvent(self, event):
super().resizeEvent(event)
self._update_frozen_geometry()
class QueueDropChip(QPushButton):
"""Filter chip that doubles as a drop target: dragging table rows onto it
relabels them (Queued adds to the automation queue, Flagged marks them
flagged — same mime the old queue dock took). Measured is deliberately
NOT one of these: it is automatic, from the rotation count."""
samples_dropped = Signal(SampleShortInfoList)
def __init__(self, label: str, parent=None):
super().__init__(label, parent)
self.setAcceptDrops(True)
def dragEnterEvent(self, event):
if event.mimeData().hasText():
event.acceptProposedAction()
def dropEvent(self, event):
try:
samples = SampleShortInfoList.model_validate_json(event.mimeData().text())
except Exception:
logger.debug("Ignoring drop that is not a sample list", exc_info=True)
return
self.samples_dropped.emit(samples)
event.acceptProposedAction()
class TellSamplePanel(QFrame):
mount = Signal(SampleShortInfo)
unmount = Signal()
state = Signal(BeamlineStateEnum)
# Queue membership is edited from this table now (aaregui2 concept: the
# dewar list doubles as the queue view); the queue itself lives in the
# SampleQueuePanel these signals are wired to.
add_to_queue = Signal(SampleShortInfoList)
remove_from_queue = Signal(SampleShortInfoList)
def __init__(self, samples: SampleShortInfoList | None = None, parent=None):
def __init__(
self,
samples: SampleShortInfoList | None = None,
parent=None,
model: UserSampleSpreadsheet | None = None,
):
"""`model`: share an existing spreadsheet model instead of owning one —
used by the pop-out window so both panels operate on the same data,
tints and filters with no syncing."""
super().__init__(parent)
if samples is None:
@@ -40,28 +135,77 @@ class TellSamplePanel(QFrame):
grid_layout = QGridLayout(self)
self.setLayout(grid_layout)
# Flush layout: the banner spans the full panel width and sits
# directly under the dock's tab row — no padding ring.
grid_layout.setContentsMargins(0, 0, 0, 0)
grid_layout.setSpacing(0)
grid_layout.addWidget(TitleLabel("TELL sample changer", self), 0, 0, 1, 4)
self.table_view = QTableView()
grid_layout.addWidget(self.table_view, 1, 0, 1, 4)
# Status filter row (aaregui2 concept): filters the table by queue/
# collection status, and each checked button wears its row-tint color,
# doubling as the legend. Styled like the Dewar/Auxiliary tab row
# above (square tabs, touching), not pills. Colors live in styles.py.
chip_row = QHBoxLayout()
# Left margin 0: "All" shares the table's left edge; bottom 0: the
# row sits directly on the table.
chip_row.setContentsMargins(0, 2, 6, 0)
chip_row.setSpacing(0)
self.status_chips = QButtonGroup(self)
self.status_chips.setExclusive(True)
for label, key in (
("All", None),
("Queued", "queued"),
("Flagged", "flagged"),
("Measured", "measured"),
):
if key == "queued":
chip = QueueDropChip(label, self)
chip.samples_dropped.connect(self.add_to_queue)
# Deselect after the drop: the selection tint would otherwise
# sit on top of the fresh status color and hide it.
chip.samples_dropped.connect(lambda _: self.table_view.clearSelection())
chip.setToolTip("Filter queued samples — or drop table rows here to queue them")
elif key == "flagged":
chip = QueueDropChip(label, self)
chip.samples_dropped.connect(self._flag_dropped_samples)
chip.samples_dropped.connect(lambda _: self.table_view.clearSelection())
chip.setToolTip("Filter flagged samples — or drop table rows here to flag them")
else:
chip = QPushButton(label, self)
if key == "measured":
chip.setToolTip("Filter measured samples (automatic: rotation count > 1)")
chip.setCheckable(True)
chip.setChecked(key is None)
chip.setProperty("status_key", key)
chip.setCursor(Qt.CursorShape.PointingHandCursor)
# 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()
grid_layout.addLayout(chip_row, 1, 0, 1, 4)
self.status_chips.buttonClicked.connect(
lambda chip: self.table_model.set_status_filter(chip.property("status_key"))
)
self.curr_sample_label = QLabel("No sample mounted", parent=self)
self.curr_sample_label.setTextFormat(Qt.TextFormat.RichText)
self.curr_sample_label.setWordWrap(True)
grid_layout.addWidget(self.curr_sample_label, 2, 0)
# Staggered grey/white rows tell rows apart — no grid lines, no row
# tints; status fills the frozen "#" column and selection stays blue.
self.table_view = FrozenColumnTableView()
self.table_view.setShowGrid(False)
self.table_view.setAlternatingRowColors(True)
grid_layout.addWidget(self.table_view, 2, 0, 1, 4)
self.unmount_button = QPushButton("Unmount", parent=self)
grid_layout.addWidget(self.unmount_button, 2, 1)
self.unmount_button.clicked.connect(self.unmount_button_clicked)
grid_layout.setColumnStretch(0, 1)
grid_layout.setColumnStretch(1, 0)
self.table_model = UserSampleSpreadsheet(samples=samples.s)
self.table_model = model if model is not None else UserSampleSpreadsheet(samples=samples.s)
self.table_view.setModel(self.table_model)
self.table_view.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
# Adopt the model's current order before enabling sorting: a second
# panel on a shared model must not re-sort it to column 0 on open.
self.table_view.horizontalHeader().setSortIndicator(
self.table_model._sort_col, self.table_model._sort_order
)
self.table_view.setSortingEnabled(True)
self.table_view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.table_view.customContextMenuRequested.connect(self.context_menu)
@@ -73,17 +217,44 @@ class TellSamplePanel(QFrame):
header = self.table_view.horizontalHeader()
header.setSectionResizeMode(QHeaderView.ResizeMode.Interactive)
header.setStretchLastSection(True)
self.table_view.verticalHeader().setVisible(True)
# No bold column titles when cells are selected.
header.setHighlightSections(False)
# Row numbers + status color live in the frozen "#" column: fixed
# width, not resizable, stays visible on horizontal scroll.
self.table_view.verticalHeader().setVisible(False)
header.setSectionResizeMode(COL_STATUS, QHeaderView.ResizeMode.Fixed)
self.table_view.set_frozen_width(FrozenColumnTableView.FROZEN_WIDTH)
# Right-click on the frozen column behaves like the main table (the
# handler only uses the row, which both views share).
self.table_view.frozen.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.table_view.frozen.customContextMenuRequested.connect(self.context_menu)
header.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
header.customContextMenuRequested.connect(self.header_context_menu)
def unmount_button_clicked(self):
self.unmount.emit()
# Columns at full content width (horizontal scroll instead of
# squishing); done ONCE so later data refreshes don't fight manual
# column adjustments.
self._columns_autosized = False
if self.table_model.rowCount() > 0:
self._autosize_columns()
def _autosize_columns(self) -> None:
self.table_view.resizeColumnsToContents()
header = self.table_view.horizontalHeader()
# Cap: one long comment must not eat the whole view.
for col in range(1, self.table_model.columnCount()):
if header.sectionSize(col) > 300:
self.table_view.setColumnWidth(col, 300)
# Re-pin the frozen display column after the autosize pass.
self.table_view.set_frozen_width(FrozenColumnTableView.FROZEN_WIDTH)
self._columns_autosized = True
@Slot(SampleShortInfoList)
def new_sample_list(self, samples: SampleShortInfoList):
self.table_model.updateData(samples=samples.s)
if not self._columns_autosized and self.table_model.rowCount() > 0:
self._autosize_columns()
def annotate_sample_comment(self, db_id: int, comment: str) -> None:
samples = list(self.table_model.samples)
@@ -97,6 +268,29 @@ class TellSamplePanel(QFrame):
self.table_model.updateData(samples=updated_samples)
@Slot(SampleShortInfoList)
def _flag_dropped_samples(self, samples: SampleShortInfoList) -> None:
# Flagged is display state owned by the (shared) model, so relabeling
# here reaches the docked panel and the pop-out alike.
for sample in samples.s:
self.table_model.set_flagged(sample.db_id, True)
def set_status_chip(self, key: str | None) -> None:
"""Check the chip for `key` without firing its filter — keeps the
main and pop-out chip rows in sync (both drive one shared model)."""
for chip in self.status_chips.buttons():
if chip.property("status_key") == key:
chip.setChecked(True)
return
def _selected_samples(self, clicked_row: int) -> list[SampleShortInfo]:
"""Selected rows if the clicked row is part of the selection, else
just the clicked row — so right-click on an unselected row acts on it."""
rows = sorted({i.row() for i in self.table_view.selectionModel().selectedRows()})
if clicked_row not in rows:
rows = [clicked_row]
return [self.table_model.get_id(r) for r in rows]
def context_menu(self, position):
index = self.table_view.indexAt(position)
if not index.isValid():
@@ -108,18 +302,35 @@ class TellSamplePanel(QFrame):
if sample.location is None:
return
selected = [s for s in self._selected_samples(row) if s.location is not None]
menu = QMenu()
count = f" ({len(selected)})" if len(selected) > 1 else ""
add_queue_action = menu.addAction(f"Add to queue{count}")
remove_queue_action = menu.addAction(f"Remove from queue{count}")
menu.addSeparator()
mount_action = menu.addAction("Mount")
# Unmount moved here from the removed bottom-row button — same signal.
unmount_action = menu.addAction("Unmount")
action = menu.exec_(self.table_view.viewport().mapToGlobal(position))
if action == mount_action:
self.mount.emit(sample)
elif action == unmount_action:
self.unmount.emit()
elif action == add_queue_action:
self.add_to_queue.emit(SampleShortInfoList(s=selected))
self.table_view.clearSelection()
elif action == remove_queue_action:
self.remove_from_queue.emit(SampleShortInfoList(s=selected))
self.table_view.clearSelection()
def header_context_menu(self, pos):
header = self.table_view.horizontalHeader()
logical_index = header.logicalIndexAt(pos)
if logical_index < 0:
# The "#"/status column is display-only — no filter menus there.
if logical_index < 1:
return
col_name = self.table_model.header[logical_index]
@@ -127,7 +338,7 @@ class TellSamplePanel(QFrame):
menu = QMenu(self)
# Column-specific preset submenus (existing logic) ...
if logical_index == 0:
if logical_index == 1: # Sample name
presets = self.table_model.suggested_prefixes_for_sample_name()
if presets:
prefix_menu = menu.addMenu("Filter by name prefix")
@@ -138,7 +349,7 @@ class TellSamplePanel(QFrame):
logical_index, vv
)
)
elif logical_index == 3:
elif logical_index == 4: # Location
segs, segpos = self.table_model.suggested_prefixes_for_location()
if segs:
seg_menu = menu.addMenu("Filter by segment (A..F,X,R)")
@@ -175,7 +386,7 @@ class TellSamplePanel(QFrame):
clear_filter_action = menu.addAction(f"Clear filter: {col_name}")
clear_all_action = menu.addAction("Clear all filters")
if logical_index == 5:
if logical_index == 6: # User
menu.addSeparator()
toggle_all = menu.addAction("Show all pgroups (ignore current p-group)")
toggle_all.setCheckable(True)
@@ -203,48 +414,16 @@ class TellSamplePanel(QFrame):
@Slot(DAQStatusModel)
def update_daq_status(self, status: DAQStatusModel):
# Mounted state shows as the row highlight only — the "No sample
# mounted" label and Unmount button were dropped; TELL activity text
# lives in the beamline state panel already.
sample = status.sample
tell_state = status.tell_state
tell_details = ""
if tell_state is not None:
activity = tell_state.activity.display_name()
phase = tell_state.phase.display_name() if tell_state.phase is not None else ""
message = (tell_state.message or "").strip()
tell_parts = [activity]
if phase:
tell_parts.append(phase)
tell_details = " / ".join(tell_parts)
if message:
tell_details = f"{tell_details}{message}"
if sample is None:
base_text = "No sample mounted"
self.table_model.updateCurrentSample(current_puck=None, current_sample=None)
else:
try:
if sample.location is None:
base_text = f"Current sample: <b>{sample.sample_name} (Manual mount)</b>"
else:
base_text = (
f"Current sample: <b>{sample.sample_name} "
f"({sample.location.segment}{sample.location.pos}-{sample.pin})</b>"
)
self.table_model.updateCurrentSample(
current_puck=sample.puck_name, current_sample=sample.db_id
)
except Exception as e:
logger.debug("Could not build the TELL sample panel text", exc_info=True)
base_text = f"Confusing information :/ {e}"
if tell_details:
self.curr_sample_label.setText(
f"{base_text}<br/><span style='color:#555;'>TELL: {tell_details}</span>"
self.table_model.updateCurrentSample(
current_puck=sample.puck_name, current_sample=sample.db_id
)
else:
self.curr_sample_label.setText(base_text)
if status.session.current_pgroup is not None:
self._current_pgroup = status.session.current_pgroup
+3 -1
View File
@@ -23,7 +23,9 @@ class ZoomPanel(QWidget):
{"name": "7.5x", "value": 800},
{"name": "12.5x", "value": 1000},
]
grid_layout.addWidget(TitleLabel("Zoom", self), 0, 0, 1, 2)
grid_layout.addWidget(
TitleLabel("Zoom", self, collapsible=True, default_collapsed=False), 0, 0, 1, 2
)
i = 2
self._buttons = []
+10 -5
View File
@@ -17,6 +17,7 @@ from PySide6.QtCore import QLineF, QObject, QPointF, QRect, QRectF, Qt, Signal,
from PySide6.QtGui import QBrush, QColor, QImage, QPainter, QPen
from aare.gui.constants import LOGGER_NAME
from aare.gui.styles import RASTER_GRID_LINE, qcolor
logger = setup_logger(LOGGER_NAME)
@@ -643,7 +644,9 @@ class RasterGridManager(QObject):
painter.drawImage(bounds, image)
painter.setOpacity(1.0)
painter.setPen(QPen(QColor(114, 159, 207, min(255, alpha + 40)), 1, Qt.PenStyle.SolidLine))
painter.setPen(
QPen(qcolor(RASTER_GRID_LINE, min(255, alpha + 40)), 1, Qt.PenStyle.SolidLine)
)
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawRect(bounds)
painter.restore()
@@ -697,7 +700,7 @@ class RasterGridManager(QObject):
max_value = max((x for x in values if x is not None and not math.isnan(x)), default=1)
diff = 1 if min_value == max_value else (max_value - min_value)
else:
painter.setPen(QPen(QColor(114, 159, 207), 1, Qt.PenStyle.SolidLine))
painter.setPen(QPen(qcolor(RASTER_GRID_LINE), 1, Qt.PenStyle.SolidLine))
min_value = 0
diff = 1
@@ -732,7 +735,7 @@ class RasterGridManager(QObject):
painter.setBrush(Qt.BrushStyle.NoBrush)
else:
painter.setPen(
QPen(QColor(114, 159, 207, min(255, alpha + 40)), 1, Qt.PenStyle.SolidLine)
QPen(qcolor(RASTER_GRID_LINE, min(255, alpha + 40)), 1, Qt.PenStyle.SolidLine)
)
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawRect(bounds)
@@ -760,7 +763,7 @@ class RasterGridManager(QObject):
painter.save()
painter.setRenderHint(QPainter.RenderHint.Antialiasing, False)
painter.setPen(QPen(QColor(114, 159, 207), 1, Qt.PenStyle.SolidLine))
painter.setPen(QPen(qcolor(RASTER_GRID_LINE), 1, Qt.PenStyle.SolidLine))
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawRect(bounds)
@@ -854,7 +857,9 @@ class RasterGridManager(QObject):
brush = float_to_viridis_brush((value - min_value) / diff, alpha=alpha)
painter.fillRect(QRectF(px, py, draw_w, draw_h), brush)
painter.setPen(QPen(QColor(114, 159, 207, min(255, alpha + 40)), 1, Qt.PenStyle.SolidLine))
painter.setPen(
QPen(qcolor(RASTER_GRID_LINE, min(255, alpha + 40)), 1, Qt.PenStyle.SolidLine)
)
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawRect(bounds)
+1567 -188
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -1166,6 +1166,11 @@ class DAQWorker(QObject):
@Slot()
def load_spreadsheet(self):
if self._base_url is None:
# TODO: log spam. This (and load_reference_tools) fires every
# SPREADHSEET_FREQUENCY cycle (~12.5s) while base_url is None,
# logging a GET it never actually sends -> two INFO lines every
# poll. Fix by demoting to logger.debug, or log once on the
# None->set edge rather than on every poll.
logger.info("GET /sample/spreadsheet")
return
+25 -14
View File
@@ -15,10 +15,21 @@ from PySide6.QtCore import (
QTimer,
Signal,
)
from PySide6.QtGui import QColor, QPainter, QPen
from PySide6.QtGui import QPainter, QPen
from PySide6.QtWidgets import QLabel, QPushButton, QWidget
from aare.gui.constants import LOGGER_NAME
from aare.gui.styles import (
DEFAULT_TEXT,
FONT_BODY_LG,
FONT_TITLE,
NOTE_TEXT,
SHADOW,
TUTORIAL_BORDER,
TUTORIAL_HIGHLIGHT,
WHITE,
qcolor,
)
from aare.gui.tutorials.tutorial_models import (
StepFlow,
StepStatus,
@@ -95,23 +106,23 @@ class TutorialOverlay(QWidget):
self.anim.valueChanged.connect(self.update)
self.callout = QLabel(self)
self.callout.setStyleSheet("""
background: white;
color: black;
self.callout.setStyleSheet(f"""
background: {WHITE};
color: {DEFAULT_TEXT};
padding: 16px;
border: 2px solid #555;
border: 2px solid {TUTORIAL_BORDER};
border-radius: 10px;
font-size: 16px;
font-size: {FONT_TITLE};
""")
self.callout.setWordWrap(True)
self.callout.hide()
button_style = """
QPushButton {
font-size: 15px;
font-weight: 600;
button_style = f"""
QPushButton {{
font-size: {FONT_BODY_LG};
font-weight: 700;
padding: 10px 16px;
}
}}
"""
self.back_button = QPushButton("Back", self)
@@ -185,10 +196,10 @@ class TutorialOverlay(QWidget):
def paintEvent(self, event) -> None:
painter = QPainter(self)
painter.fillRect(self.rect(), QColor(0, 0, 0, int(150 * self.opacity)))
painter.fillRect(self.rect(), qcolor(SHADOW, int(150 * self.opacity)))
if self.current_rect.isValid():
pen = QPen(Qt.yellow, 4)
pen = QPen(qcolor(TUTORIAL_HIGHLIGHT), 4)
painter.setPen(pen)
painter.setBrush(Qt.NoBrush)
painter.drawRoundedRect(self.current_rect, 8, 8)
@@ -219,7 +230,7 @@ class TutorialOverlay(QWidget):
if view.body.strip():
text_parts.append(view.body)
if view.hint:
text_parts.append(f"<span style='color:#555;'><i>{view.hint}</i></span>")
text_parts.append(f"<span style='color:{NOTE_TEXT};'><i>{view.hint}</i></span>")
self.callout.setText("<br><br>".join(text_parts))
self.callout.setMaximumWidth(460)
+53 -3
View File
@@ -1,9 +1,9 @@
from aarecommon.config.logger import setup_logger
from PySide6.QtCore import Qt, QTimer, Slot
from PySide6.QtGui import QColor
from PySide6.QtCore import QPoint, Qt, QTimer, Slot
from PySide6.QtWidgets import QFrame, QGraphicsDropShadowEffect, QHBoxLayout, QLabel, QSizePolicy
from aare.gui.constants import LOGGER_NAME
from aare.gui.styles import SHADOW, qcolor
logger = setup_logger(LOGGER_NAME)
@@ -39,12 +39,59 @@ class AlertBanner(QFrame):
shadow = QGraphicsDropShadowEffect(self)
shadow.setBlurRadius(18)
shadow.setOffset(0, 3)
shadow.setColor(QColor(0, 0, 0, 55))
shadow.setColor(qcolor(SHADOW, 55))
self.setGraphicsEffect(shadow)
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."""
+23 -13
View File
@@ -7,6 +7,16 @@ from aarecommon.models.automation import AutomationProgress, StepStatus, Workflo
from PySide6.QtCore import QTimer, Slot
from PySide6.QtWidgets import QFrame, QHBoxLayout, QLabel, QVBoxLayout, QWidget
from aare.gui.styles import (
FAINT_TEXT,
FONT_HINT,
FONT_VALUE,
STEP_FAILED_TEXT,
STEP_PAUSED_TEXT,
STEP_RUNNING_TEXT,
STEP_SUCCESS_TEXT,
)
class CompactAutomationProgressStrip(QFrame):
DEFAULT_SAMPLE_ESTIMATE_S = 150.0
@@ -89,13 +99,13 @@ class CompactAutomationProgressStrip(QFrame):
@staticmethod
def _step_color(status: StepStatus) -> str:
return {
StepStatus.PENDING: "#64748b",
StepStatus.RUNNING: "#2563eb",
StepStatus.SUCCESS: "#15803d",
StepStatus.FAILED: "#b91c1c",
StepStatus.SKIPPED: "#64748b",
StepStatus.PAUSED: "#c2410c",
}.get(status, "#64748b")
StepStatus.PENDING: FAINT_TEXT,
StepStatus.RUNNING: STEP_RUNNING_TEXT,
StepStatus.SUCCESS: STEP_SUCCESS_TEXT,
StepStatus.FAILED: STEP_FAILED_TEXT,
StepStatus.SKIPPED: FAINT_TEXT,
StepStatus.PAUSED: STEP_PAUSED_TEXT,
}.get(status, FAINT_TEXT)
def _format_step_html(self, step: WorkflowStateKind, status: StepStatus) -> str:
color = self._step_color(status)
@@ -103,8 +113,8 @@ class CompactAutomationProgressStrip(QFrame):
title = self._step_title(step)
return (
f"<div style='text-align:center; background:transparent;'>"
f"<div style='font-size:18px; color:{color}; font-weight:700; background:transparent;'>{icon}</div>"
f"<div style='font-size:12px; color:{color}; font-weight:700; background:transparent;'>{title}</div>"
f"<div style='font-size:{FONT_VALUE}; color:{color}; font-weight:700; background:transparent;'>{icon}</div>"
f"<div style='font-size:{FONT_HINT}; color:{color}; font-weight:700; background:transparent;'>{title}</div>"
f"</div>"
)
@@ -163,16 +173,16 @@ class CompactAutomationProgressStrip(QFrame):
eta = time.time() + queue_remaining if queue_remaining > 0 else None
state_text = "Paused"
state_color = "#c2410c"
state_color = STEP_PAUSED_TEXT
if progress.finished and progress.success is True:
state_text = "Completed"
state_color = "#15803d"
state_color = STEP_SUCCESS_TEXT
elif progress.finished and progress.success is False:
state_text = "Failed"
state_color = "#b91c1c"
state_color = STEP_FAILED_TEXT
elif self._running:
state_text = "Running"
state_color = "#2563eb"
state_color = STEP_RUNNING_TEXT
self._summary_label.setText(
f"<span style='background:transparent;'><b>Status:</b> "
+90 -70
View File
@@ -10,6 +10,24 @@ from PySide6.QtWidgets import (
QVBoxLayout,
)
from aare.gui.styles import (
BATON_DANGER_BG,
BATON_DANGER_HOVER,
BATON_DANGER_PRESSED,
BATON_INFO,
BATON_OK_BG,
BATON_OK_HOVER,
BATON_OK_PRESSED,
BATON_WARN,
DIM_TEXT,
FONT_BODY,
FONT_LABEL,
HINT_TEXT,
LIGHT_BORDER,
PROGRESS_TRACK_BG,
WHITE,
)
class BatonRequestDialog(QDialog):
"""
@@ -73,22 +91,22 @@ class BatonRequestDialog(QDialog):
self.progress.setValue(self._timeout)
self.progress.setTextVisible(False)
self.progress.setFixedHeight(8)
self.progress.setStyleSheet("""
QProgressBar {
border: 1px solid #ccc;
self.progress.setStyleSheet(f"""
QProgressBar {{
border: 1px solid {LIGHT_BORDER};
border-radius: 4px;
background-color: #f0f0f0;
}
QProgressBar::chunk {
background-color: #4CAF50;
background-color: {PROGRESS_TRACK_BG};
}}
QProgressBar::chunk {{
background-color: {BATON_OK_BG};
border-radius: 3px;
}
}}
""")
progress_layout.addWidget(self.progress)
self.time_label = QLabel(f"{self._timeout} seconds remaining")
self.time_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.time_label.setStyleSheet("color: #666;")
self.time_label.setStyleSheet(f"color: {DIM_TEXT};")
progress_layout.addWidget(self.time_label)
layout.addLayout(progress_layout)
@@ -97,7 +115,7 @@ class BatonRequestDialog(QDialog):
self.warning_label = QLabel("⚠️ If you don't respond, control will transfer automatically.")
self.warning_label.setWordWrap(True)
self.warning_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.warning_label.setStyleSheet("color: #ff9800; font-style: italic;")
self.warning_label.setStyleSheet(f"color: {BATON_WARN}; font-style: italic;")
layout.addWidget(self.warning_label)
# Buttons
@@ -106,42 +124,42 @@ class BatonRequestDialog(QDialog):
self.accept_btn = QPushButton("✓ Accept")
self.accept_btn.setMinimumHeight(40)
self.accept_btn.setStyleSheet("""
QPushButton {
background-color: #4CAF50;
color: white;
self.accept_btn.setStyleSheet(f"""
QPushButton {{
background-color: {BATON_OK_BG};
color: {WHITE};
border: none;
border-radius: 5px;
font-weight: bold;
font-size: 13px;
}
QPushButton:hover {
background-color: #45a049;
}
QPushButton:pressed {
background-color: #3d8b40;
}
font-size: {FONT_LABEL};
}}
QPushButton:hover {{
background-color: {BATON_OK_HOVER};
}}
QPushButton:pressed {{
background-color: {BATON_OK_PRESSED};
}}
""")
self.accept_btn.clicked.connect(self._on_accept)
button_layout.addWidget(self.accept_btn)
self.refuse_btn = QPushButton("✗ Refuse")
self.refuse_btn.setMinimumHeight(40)
self.refuse_btn.setStyleSheet("""
QPushButton {
background-color: #f44336;
color: white;
self.refuse_btn.setStyleSheet(f"""
QPushButton {{
background-color: {BATON_DANGER_BG};
color: {WHITE};
border: none;
border-radius: 5px;
font-weight: bold;
font-size: 13px;
}
QPushButton:hover {
background-color: #da190b;
}
QPushButton:pressed {
background-color: #c41000;
}
font-size: {FONT_LABEL};
}}
QPushButton:hover {{
background-color: {BATON_DANGER_HOVER};
}}
QPushButton:pressed {{
background-color: {BATON_DANGER_PRESSED};
}}
""")
self.refuse_btn.clicked.connect(self._on_refuse)
button_layout.addWidget(self.refuse_btn)
@@ -155,7 +173,7 @@ class BatonRequestDialog(QDialog):
)
info_label.setWordWrap(True)
info_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
info_label.setStyleSheet("color: #999;")
info_label.setStyleSheet(f"color: {HINT_TEXT};")
layout.addWidget(info_label)
def _start_timer(self):
@@ -171,31 +189,31 @@ class BatonRequestDialog(QDialog):
# Change progress bar color as time runs out
if self._remaining <= 10:
self.progress.setStyleSheet("""
QProgressBar {
border: 1px solid #ccc;
self.progress.setStyleSheet(f"""
QProgressBar {{
border: 1px solid {LIGHT_BORDER};
border-radius: 4px;
background-color: #f0f0f0;
}
QProgressBar::chunk {
background-color: #ff9800;
background-color: {PROGRESS_TRACK_BG};
}}
QProgressBar::chunk {{
background-color: {BATON_WARN};
border-radius: 3px;
}
}}
""")
if self._remaining <= 5:
self.progress.setStyleSheet("""
QProgressBar {
border: 1px solid #ccc;
self.progress.setStyleSheet(f"""
QProgressBar {{
border: 1px solid {LIGHT_BORDER};
border-radius: 4px;
background-color: #f0f0f0;
}
QProgressBar::chunk {
background-color: #f44336;
background-color: {PROGRESS_TRACK_BG};
}}
QProgressBar::chunk {{
background-color: {BATON_DANGER_BG};
border-radius: 3px;
}
}}
""")
self.time_label.setStyleSheet("color: #f44336; font-weight: bold;")
self.time_label.setStyleSheet(f"color: {BATON_DANGER_BG}; font-weight: bold;")
if self._remaining <= 0:
self._timer.stop()
@@ -270,22 +288,22 @@ class BatonPendingDialog(QDialog):
self.progress.setValue(self._timeout)
self.progress.setTextVisible(False)
self.progress.setFixedHeight(8)
self.progress.setStyleSheet("""
QProgressBar {
border: 1px solid #ccc;
self.progress.setStyleSheet(f"""
QProgressBar {{
border: 1px solid {LIGHT_BORDER};
border-radius: 4px;
background-color: #f0f0f0;
}
QProgressBar::chunk {
background-color: #2196F3;
background-color: {PROGRESS_TRACK_BG};
}}
QProgressBar::chunk {{
background-color: {BATON_INFO};
border-radius: 3px;
}
}}
""")
self.progress_layout.addWidget(self.progress)
self.time_label = QLabel(f"{self._timeout} seconds remaining")
self.time_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.time_label.setStyleSheet("color: #666;")
self.time_label.setStyleSheet(f"color: {DIM_TEXT};")
self.progress_layout.addWidget(self.time_label)
layout.addLayout(self.progress_layout)
@@ -293,17 +311,17 @@ class BatonPendingDialog(QDialog):
button_layout = QHBoxLayout()
self.cancel_btn = QPushButton("✗ Cancel Request")
self.cancel_btn.setMinimumHeight(40)
self.cancel_btn.setStyleSheet("""
QPushButton {
background-color: #f44336;
color: white;
self.cancel_btn.setStyleSheet(f"""
QPushButton {{
background-color: {BATON_DANGER_BG};
color: {WHITE};
border: none;
border-radius: 5px;
font-weight: bold;
font-size: 13px;
}
QPushButton:hover { background-color: #da190b; }
QPushButton:pressed { background-color: #c41000; }
font-size: {FONT_LABEL};
}}
QPushButton:hover {{ background-color: {BATON_DANGER_HOVER}; }}
QPushButton:pressed {{ background-color: {BATON_DANGER_PRESSED}; }}
""")
self.cancel_btn.clicked.connect(self._on_cancel)
button_layout.addWidget(self.cancel_btn)
@@ -332,7 +350,9 @@ class BatonPendingDialog(QDialog):
def set_queued_state(self):
self._timer.stop()
self.header.setText("⏳ Transfer Queued")
self.header.setStyleSheet("color: #FF9800; font-weight: bold; font-size: 14px;")
self.header.setStyleSheet(
f"color: {BATON_WARN}; font-weight: bold; font-size: {FONT_BODY};"
)
self.message_label.setText(
"The beamline is currently <b>busy</b>. Your request was accepted and "
"the baton will be transferred as soon as the current operation completes."
+131 -44
View File
@@ -2,7 +2,33 @@ 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,
BUSY_BLUE_BORDER,
BUSY_BLUE_DOT,
BUSY_ORANGE,
BUSY_ORANGE_BORDER,
BUSY_ORANGE_DOT,
BUSY_PSI_RED,
BUSY_PSI_RED_BORDER,
BUSY_PSI_RED_DOT,
BUSY_PURPLE,
BUSY_PURPLE_BORDER,
BUSY_PURPLE_DOT,
BUSY_RED_BADGE,
BUSY_RED_BORDER,
BUSY_RED_DOT,
BUSY_RED_FILL,
BUSY_YELLOW,
BUSY_YELLOW_BORDER,
BUSY_YELLOW_DOT,
BUSY_YELLOW_TEXT_DARK,
WHITE,
qcolor,
)
@dataclass(frozen=True)
@@ -14,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(
@@ -24,24 +110,25 @@ def build_busy_overlay_style(
) -> BusyOverlayStyle | None:
if session_state == SessionsStateEnum.Vacant:
return BusyOverlayStyle(
text="SESSION VACANT",
badge_bg="#f1c40f",
badge_fg="#ffffff",
overlay_fill=QColor(241, 196, 15, 195),
overlay_border=QColor(255, 248, 210, 235),
overlay_text=QColor(255, 255, 255),
accent_dot="#fff6bf",
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}:
return BusyOverlayStyle(
text="GUEST MODE",
badge_bg="#8e44ad",
badge_fg="#ffffff",
overlay_fill=QColor(142, 68, 173, 190),
overlay_border=QColor(235, 220, 245, 230),
overlay_text=QColor(255, 255, 255),
accent_dot="#f0dfff",
badge_bg=BUSY_PURPLE,
badge_fg=WHITE,
overlay_fill=qcolor(BUSY_PURPLE, 190),
overlay_border=qcolor(BUSY_PURPLE_BORDER, 230),
overlay_text=qcolor(WHITE),
accent_dot=BUSY_PURPLE_DOT,
)
if not is_busy:
@@ -52,53 +139,53 @@ def build_busy_overlay_style(
if activity_value == "mounting":
return BusyOverlayStyle(
text="ROBOT MOUNTING",
badge_bg="#d64545",
badge_fg="#ffffff",
overlay_fill=QColor(190, 40, 40, 185),
overlay_border=QColor(255, 220, 220, 230),
overlay_text=QColor(255, 255, 255),
accent_dot="#ffdddd",
badge_bg=BUSY_RED_BADGE,
badge_fg=WHITE,
overlay_fill=qcolor(BUSY_RED_FILL, 185),
overlay_border=qcolor(BUSY_RED_BORDER, 230),
overlay_text=qcolor(WHITE),
accent_dot=BUSY_RED_DOT,
)
if activity_value == "unmounting":
return BusyOverlayStyle(
text="ROBOT UNMOUNTING",
badge_bg="#e67e22",
badge_fg="#ffffff",
overlay_fill=QColor(230, 126, 34, 190),
overlay_border=QColor(255, 234, 214, 230),
overlay_text=QColor(255, 255, 255),
accent_dot="#fff0db",
badge_bg=BUSY_ORANGE,
badge_fg=WHITE,
overlay_fill=qcolor(BUSY_ORANGE, 190),
overlay_border=qcolor(BUSY_ORANGE_BORDER, 230),
overlay_text=qcolor(WHITE),
accent_dot=BUSY_ORANGE_DOT,
)
if activity_value == "drying":
return BusyOverlayStyle(
text="ROBOT DRYING",
badge_bg="#f1c40f",
badge_fg="#3b2f00",
overlay_fill=QColor(241, 196, 15, 195),
overlay_border=QColor(255, 248, 210, 235),
overlay_text=QColor(59, 47, 0),
accent_dot="#fff6bf",
badge_bg=BUSY_YELLOW,
badge_fg=BUSY_YELLOW_TEXT_DARK,
overlay_fill=qcolor(BUSY_YELLOW, 195),
overlay_border=qcolor(BUSY_YELLOW_BORDER, 235),
overlay_text=qcolor(BUSY_YELLOW_TEXT_DARK),
accent_dot=BUSY_YELLOW_DOT,
)
if activity_value == "cooling":
return BusyOverlayStyle(
text="ROBOT COOLING",
badge_bg="#3498db",
badge_fg="#ffffff",
overlay_fill=QColor(52, 152, 219, 190),
overlay_border=QColor(220, 240, 255, 235),
overlay_text=QColor(255, 255, 255),
accent_dot="#dff2ff",
badge_bg=BUSY_BLUE,
badge_fg=WHITE,
overlay_fill=qcolor(BUSY_BLUE, 190),
overlay_border=qcolor(BUSY_BLUE_BORDER, 235),
overlay_text=qcolor(WHITE),
accent_dot=BUSY_BLUE_DOT,
)
return BusyOverlayStyle(
text="BEAMLINE BUSY",
badge_bg="#e04f39",
badge_fg="#ffffff",
overlay_fill=QColor(224, 79, 57, 195),
overlay_border=QColor(255, 225, 220, 235),
overlay_text=QColor(255, 255, 255),
accent_dot="#ffd8d1",
badge_bg=BUSY_PSI_RED,
badge_fg=WHITE,
overlay_fill=qcolor(BUSY_PSI_RED, 195),
overlay_border=qcolor(BUSY_PSI_RED_BORDER, 235),
overlay_text=qcolor(WHITE),
accent_dot=BUSY_PSI_RED_DOT,
)
+317 -100
View File
@@ -1,6 +1,7 @@
import math
import time
from enum import Enum
from typing import ClassVar
from aarecommon.config.logger import setup_logger
from aarecommon.math.coordinate import Coordinate, SmargonCoordinate
@@ -40,7 +41,33 @@ from PySide6.QtWidgets import (
from aare.gui.constants import LOGGER_NAME
from aare.gui.models.bookmark import SmargonBookmarkList
from aare.gui.scan_logic.raster_grid_manager import RasterGridManager
from aare.gui.widgets.busy_overlay import BusyOverlayStyle, build_busy_overlay_style
from aare.gui.styles import (
BEAM_BUSY,
BEAM_IDLE,
BEAM_MARKING,
BEAM_OPEN,
CLASS_COLORS,
LEGEND_BG,
LEGEND_TEXT,
MARK_BADGE_BG,
MARK_TOOLTIP_GOLD,
MARK_TOOLTIP_ORANGE,
MARK_TOOLTIP_RED,
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,
draw_busy_badge,
)
logger = setup_logger(LOGGER_NAME)
@@ -55,6 +82,7 @@ class SampleCameraImageState(Enum):
class SampleCameraImageLabel(QGraphicsView):
smargon = Signal(SmargonCoordinate)
session_badge_clicked = Signal()
evaluate_grid = Signal()
clear_grid = Signal()
@@ -87,6 +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
@@ -105,8 +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
# "?" 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"
@@ -179,14 +219,30 @@ class SampleCameraImageLabel(QGraphicsView):
)
def _camera_interaction_enabled(self) -> bool:
return self._camera_available
# Watching is free; clicking (targets, raster, smargon moves) needs
# the camera AND the session baton.
return self._camera_available and self._operations_allowed
@Slot(bool)
def set_operations_enabled(self, enabled: bool):
self._operations_allowed = enabled
self.update()
@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:
@@ -242,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
@@ -254,37 +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):
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,
@@ -301,13 +386,13 @@ class SampleCameraImageLabel(QGraphicsView):
painter.setFont(font)
if self._session_state == SessionsStateEnum.Vacant:
bg_color = QColor(255, 215, 0, 180)
bg_color = qcolor(MARK_TOOLTIP_GOLD, 180)
text = "Session Vacant"
elif self._session_state == SessionsStateEnum.PendingYouToElse:
bg_color = QColor(255, 165, 0, 150)
bg_color = qcolor(MARK_TOOLTIP_ORANGE, 150)
text = "Baton Requested..."
else:
bg_color = QColor(255, 0, 0, 150)
bg_color = qcolor(MARK_TOOLTIP_RED, 150)
text = "Guest Mode"
fm = QFontMetrics(font)
@@ -323,12 +408,18 @@ class SampleCameraImageLabel(QGraphicsView):
position_y = int((vh - bg_h) / 2)
bg_rect = QRect(position_x, position_y, bg_w, bg_h)
# Clicking the badge opens the session (grab/request) menu.
self._session_badge_rect = bg_rect
painter.setPen(QPen(QColor(255, 255, 255, 220)))
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)
painter.setPen(QPen(QColor(255, 255, 255)))
painter.setPen(QPen(qcolor(WHITE)))
painter.drawText(QPoint(position_x + padding, position_y + padding + fm.ascent()), text)
painter.restore()
@@ -344,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(255, 255, 255, 220), 2))
painter.setBrush(QColor(180, 60, 0, 180))
painter.drawRoundedRect(bg_rect, 10, 10)
painter.setPen(QPen(QColor(255, 255, 255)))
painter.drawText(QPoint(position_x, position_y + fm.ascent()), text)
painter.restore()
@@ -380,12 +464,36 @@ 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):
# 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._help_hit_rect is not None
and self._help_hit_rect.contains(QPointF(self.viewport().mapFrom(self, event.pos())))
):
self._help_expanded = not self._help_expanded
self.update()
event.accept()
return
# Session badge: the way IN when everything else is gated — must fire
# before the interaction-enabled check below.
if (
event.button() == Qt.MouseButton.LeftButton
and self._session_badge_rect is not None
and self._session_badge_rect.contains(self.viewport().mapFrom(self, event.pos()))
):
self.session_badge_clicked.emit()
event.accept()
return
if not self._camera_interaction_enabled():
if event.button() in (Qt.MouseButton.LeftButton, Qt.MouseButton.RightButton):
self._show_camera_unavailable_tooltip(event)
@@ -429,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
@@ -716,14 +844,7 @@ class SampleCameraImageLabel(QGraphicsView):
sx = disp_w / float(img_w)
sy = disp_h / float(img_h)
color_map = {
"pin": QColor("red"),
"loop_all": QColor("green"),
"loop_face": QColor("yellow"),
"crystal": QColor("blue"),
"needle": QColor("magenta"),
"ice": QColor("cyan"),
}
color_map = {label: qcolor(hex_str) for label, hex_str in CLASS_COLORS.items()}
for det in self._detections:
try:
@@ -738,7 +859,7 @@ class SampleCameraImageLabel(QGraphicsView):
logger.debug(f"Error in draw detection {det}: {e}", exc_info=True)
continue
color = color_map.get(label, QColor("magenta"))
color = color_map.get(label, qcolor(CLASS_COLORS["needle"]))
pen = QPen(color, 3)
painter.setPen(pen)
@@ -753,20 +874,16 @@ class SampleCameraImageLabel(QGraphicsView):
)
painter.drawPolygon(polygon)
painter.setPen(QPen(QColor(255, 255, 255), 1))
painter.setPen(QPen(qcolor(WHITE), 1))
painter.setBrush(color)
text_bg_rect = QRect(int(x1), int(y1 - 16), int(8 + 7 * len(label)), 16)
painter.drawRect(text_bg_rect)
painter.setPen(QPen(QColor(255, 255, 255)))
painter.setPen(QPen(qcolor(WHITE)))
painter.drawText(QPoint(int(x1) + 2, int(y1 - 4)), f"{label} {conf:.2f}")
def _target_color(self) -> QColor:
color_map = {
"Cyan": QColor(0, 255, 255),
"Dark Blue": QColor(0, 70, 160),
"Dark Red": QColor(140, 25, 25),
}
return color_map.get(self._target_color_name, QColor(0, 255, 255))
color_map = {name: qcolor(hex_str) for name, hex_str in TARGET_COLORS.items()}
return color_map.get(self._target_color_name, qcolor(TARGET_COLORS["Cyan"]))
def _coerce_target_point(self, raw) -> tuple[float, float] | None:
try:
@@ -824,7 +941,7 @@ class SampleCameraImageLabel(QGraphicsView):
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.setPen(QPen(color, 3, Qt.PenStyle.SolidLine))
painter.drawEllipse(QPointF(px, py), 12, 12)
painter.setPen(QPen(QColor(255, 255, 255), 1, Qt.PenStyle.SolidLine))
painter.setPen(QPen(qcolor(WHITE), 1, Qt.PenStyle.SolidLine))
painter.drawEllipse(QPointF(px, py), 5, 5)
painter.setPen(QPen(color, 2, Qt.PenStyle.SolidLine))
@@ -847,10 +964,10 @@ class SampleCameraImageLabel(QGraphicsView):
bubble_rect = QRectF(px + 16, py - 28, text_rect.width() + 16, text_rect.height() + 10)
painter.setPen(QPen(color, 2))
painter.setBrush(QColor(20, 20, 20, 190))
painter.setBrush(qcolor(LEGEND_BG, 190))
painter.drawRoundedRect(bubble_rect, 8, 8)
painter.setPen(QPen(QColor(255, 255, 255), 1))
painter.setPen(QPen(qcolor(LEGEND_TEXT), 1))
painter.drawText(
QPointF(bubble_rect.left() + 8, bubble_rect.top() + 7 + fm.ascent()), label_text
)
@@ -878,19 +995,19 @@ class SampleCameraImageLabel(QGraphicsView):
if self._show_detections:
lines.extend(
[
("Pin", QColor("red")),
("Loop", QColor("green")),
("Face", QColor("yellow")),
("Crystal", QColor("blue")),
("Pin", qcolor(CLASS_COLORS["pin"])),
("Loop", qcolor(CLASS_COLORS["loop_all"])),
("Face", qcolor(CLASS_COLORS["loop_face"])),
("Crystal", qcolor(CLASS_COLORS["crystal"])),
]
)
if self._show_coords:
lines.append(("Coords tooltip", QColor(230, 230, 230)))
lines.append(("Coords tooltip", qcolor(TOOLTIP_TEXT)))
lines.append(("Beam marker: shutter open", QColor(0, 255, 0)))
lines.append(("Beam marker: idle", QColor(245, 121, 0)))
lines.append(("Beam marker: busy", QColor(255, 0, 0)))
lines.append(("Beam marker: shutter open", qcolor(BEAM_OPEN)))
lines.append(("Beam marker: idle", qcolor(BEAM_IDLE)))
lines.append(("Beam marker: busy", qcolor(BEAM_BUSY)))
return lines
if self._show_target_point:
@@ -902,27 +1019,126 @@ class SampleCameraImageLabel(QGraphicsView):
if self._show_detections:
lines.extend(
[
("Prediction: Pin", QColor("red")),
("Prediction: Loop_all", QColor("green")),
("Prediction: Loop_face", QColor("yellow")),
("Prediction: Crystal", QColor("blue")),
("Prediction: Needle", QColor("magenta")),
("Prediction: Ice", QColor("cyan")),
("Prediction: Pin", qcolor(CLASS_COLORS["pin"])),
("Prediction: Loop_all", qcolor(CLASS_COLORS["loop_all"])),
("Prediction: Loop_face", qcolor(CLASS_COLORS["loop_face"])),
("Prediction: Crystal", qcolor(CLASS_COLORS["crystal"])),
("Prediction: Needle", qcolor(CLASS_COLORS["needle"])),
("Prediction: Ice", qcolor(CLASS_COLORS["ice"])),
]
)
if self._show_coords:
lines.append(("Cursor tooltip: pixel coordinates", QColor(230, 230, 230)))
lines.append(("Cursor tooltip: pixel coordinates", qcolor(TOOLTIP_TEXT)))
lines.append(("Beam marker: shutter open", QColor(0, 255, 0)))
lines.append(("Beam marker: idle", QColor(245, 121, 0)))
lines.append(("Beam marker: busy", QColor(255, 0, 0)))
lines.append(("Beam marker: marking mode", QColor(102, 51, 153)))
lines.append(("Beam marker: shutter open", qcolor(BEAM_OPEN)))
lines.append(("Beam marker: idle", qcolor(BEAM_IDLE)))
lines.append(("Beam marker: busy", qcolor(BEAM_BUSY)))
lines.append(("Beam marker: marking mode", qcolor(BEAM_MARKING)))
return lines
# (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)
painter.save()
painter.resetTransform()
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
painter.setPen(QPen(qcolor(LEGEND_TEXT, 60), 1))
painter.setBrush(qcolor(LEGEND_BG, 170))
painter.drawEllipse(rect)
font = QFont()
font.setPointSize(10)
font.setBold(True)
painter.setFont(font)
painter.setPen(QPen(qcolor(LEGEND_TEXT), 1))
painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, "?")
painter.restore()
self._help_hit_rect = rect
def _draw_help_overlay(self, painter: QPainter):
self._help_hit_rect = None
if not self._help_expanded:
self._draw_help_badge(painter)
return
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():
if not self._legend_should_show() or self._help_expanded:
return
painter.save()
@@ -940,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:
@@ -950,8 +1167,8 @@ class SampleCameraImageLabel(QGraphicsView):
height = len(lines) * line_height + 16
bg_rect = QRectF(left, max(18, top), width, height)
painter.setPen(QPen(QColor(255, 255, 255, 60), 1))
painter.setBrush(QColor(20, 20, 20, 170))
painter.setPen(QPen(qcolor(LEGEND_TEXT, 60), 1))
painter.setBrush(qcolor(LEGEND_BG, 170))
painter.drawRoundedRect(bg_rect, 8, 8)
y = bg_rect.top() + 12
@@ -966,7 +1183,7 @@ class SampleCameraImageLabel(QGraphicsView):
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.setPen(QPen(QColor(240, 240, 240), 1))
painter.setPen(QPen(qcolor(LEGEND_TEXT), 1))
painter.drawText(
QPointF(
bg_rect.left() + section_padding + swatch_size + text_padding,
@@ -1028,7 +1245,7 @@ class SampleCameraImageLabel(QGraphicsView):
if self._bounding_box is None:
return
painter.setPen(QPen(QColor(50, 205, 50), 3, Qt.PenStyle.SolidLine))
painter.setPen(QPen(qcolor(MARKER_GREEN), 3, Qt.PenStyle.SolidLine))
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawRect(
@@ -1044,16 +1261,16 @@ class SampleCameraImageLabel(QGraphicsView):
beam_size_pxl = self._geom.beam_size_pxl
if self._state == SampleCameraImageState.BEAM_MARKING:
painter.setPen(QPen(QColor(102, 51, 153), 3, Qt.PenStyle.SolidLine))
painter.setPen(QPen(qcolor(BEAM_MARKING), 3, Qt.PenStyle.SolidLine))
elif self._shutter:
painter.setPen(QPen(QColor(0, 255, 0), 3, Qt.PenStyle.SolidLine))
painter.setPen(QPen(qcolor(BEAM_OPEN), 3, Qt.PenStyle.SolidLine))
elif self._is_daq_busy is True:
# Use red color to indicate busy state
painter.setPen(QPen(QColor(255, 0, 0), 3, Qt.PenStyle.SolidLine))
painter.setPen(QPen(qcolor(BEAM_BUSY), 3, Qt.PenStyle.SolidLine))
else:
painter.setPen(QPen(QColor(245, 121, 0), 3, Qt.PenStyle.SolidLine))
painter.setPen(QPen(qcolor(BEAM_IDLE), 3, Qt.PenStyle.SolidLine))
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawRect(
@@ -1077,8 +1294,8 @@ class SampleCameraImageLabel(QGraphicsView):
gradient.setStart(QPointF(start.x, start.y)) # Start of the gradient (green)
gradient.setFinalStop(QPointF(end.x, end.y)) # End of the gradient (red)
gradient.setColorAt(0.0, QColor("green")) # Start color
gradient.setColorAt(1.0, QColor("red")) # End color
gradient.setColorAt(0.0, qcolor(PATH_START)) # Start color
gradient.setColorAt(1.0, qcolor(PATH_END)) # End color
pen = QPen()
pen.setBrush(gradient) # Use gradient as the brush for the pen
@@ -1091,13 +1308,13 @@ class SampleCameraImageLabel(QGraphicsView):
def _draw_helical(self, painter: QPainter):
if self._helical_start.sh_mm is not None:
start_pxl = self._geom.smargon_to_picture(self._helical_start.sh_mm)
self._draw_circle(painter, start_pxl, QColor("green"))
self._draw_circle(painter, start_pxl, qcolor(PATH_START))
else:
start_pxl = None
if self._helical_end.sh_mm is not None:
end_pxl = self._geom.smargon_to_picture(self._helical_end.sh_mm)
self._draw_circle(painter, end_pxl, QColor("red"))
self._draw_circle(painter, end_pxl, qcolor(PATH_END))
else:
end_pxl = None
@@ -9,6 +9,22 @@ from PySide6.QtCore import Qt, Slot
from PySide6.QtWidgets import QFrame, QGridLayout, QLabel, QSizePolicy, QVBoxLayout
from aare.gui.constants import LOGGER_NAME
from aare.gui.styles import (
CARD_BORDER,
CHIP_BAD_BG,
CHIP_BAD_TEXT,
CHIP_INFO_BG,
CHIP_INFO_TEXT,
CHIP_NEUTRAL_BG,
CHIP_WARN_BG,
CHIP_WARN_TEXT,
HEADING_TEXT,
MUTED_TEXT,
SUBTLE_TEXT,
SUCCESS_BG,
SUCCESS_TEXT,
SURFACE,
)
from aare.gui.widgets.title_label import TitleLabel
logger = setup_logger(LOGGER_NAME)
@@ -79,12 +95,11 @@ class LocalContactStatusWidget(QFrame):
self.setFrameShadow(QFrame.Shadow.Raised)
self.setObjectName("localContactStatusCard")
self.setStyleSheet(
"""
QFrame#localContactStatusCard {
background: #f8fbff;
border: 1px solid #c7d4e5;
border-radius: 10px;
}
f"""
QFrame#localContactStatusCard {{
background: {SURFACE};
border: 1px solid {CARD_BORDER};
}}
"""
)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Maximum)
@@ -97,13 +112,14 @@ class LocalContactStatusWidget(QFrame):
self._summary = QLabel(summary, self)
self._summary.setWordWrap(True)
self._summary.setStyleSheet("border: none; background: transparent; color: #334155;")
self._summary.setStyleSheet(f"border: none; background: transparent; color: {SUBTLE_TEXT};")
layout.addWidget(self._summary)
self._grid = QGridLayout()
self._grid.setContentsMargins(0, 0, 0, 0)
self._grid.setHorizontalSpacing(14)
self._grid.setVerticalSpacing(5)
# Tight rows; the badge keeps 2px vertical padding so text never clips.
self._grid.setVerticalSpacing(2)
layout.addLayout(self._grid)
self._rebuild_rows()
@@ -130,28 +146,32 @@ class LocalContactStatusWidget(QFrame):
for row, key in enumerate(self._visible_fields):
title = QLabel(self.FIELD_TITLES.get(key, key.replace("_", " ").title()), self)
title.setStyleSheet(
"font-weight: 700; border: none; background: transparent; color: #1e293b;"
f"font-weight: 700; border: none; background: transparent; color: {HEADING_TEXT};"
)
value = QLabel(self._badge("WAITING", tone="neutral"), self)
value.setWordWrap(True)
value.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
value.setStyleSheet("border: none; background: transparent; color: #334155;")
value.setStyleSheet(f"border: none; background: transparent; color: {SUBTLE_TEXT};")
self._row_widgets[key] = (title, value)
self._grid.addWidget(title, row, 0, alignment=Qt.AlignmentFlag.AlignTop)
self._grid.addWidget(value, row, 1)
# Word-wrapped value labels report a near-zero minimum height, so
# cramped tabs (e.g. Hardware) could compress rows until the text
# clipped; guarantee one full text line + badge padding per row.
self._grid.setRowMinimumHeight(row, self.fontMetrics().height() + 6)
def _badge(self, text: str, *, tone: str = "neutral") -> str:
palette = {
"good": ("#e7f6ea", "#1f6a3a"),
"warn": ("#fff3cd", "#7a4b00"),
"bad": ("#fdeaea", "#8b1e1e"),
"neutral": ("#e9eef5", "#475569"),
"info": ("#e8f1ff", "#12406a"),
"good": (SUCCESS_BG, SUCCESS_TEXT),
"warn": (CHIP_WARN_BG, CHIP_WARN_TEXT),
"bad": (CHIP_BAD_BG, CHIP_BAD_TEXT),
"neutral": (CHIP_NEUTRAL_BG, MUTED_TEXT),
"info": (CHIP_INFO_BG, CHIP_INFO_TEXT),
}
background, foreground = palette.get(tone, palette["neutral"])
return (
f"<span style='background:{background};color:{foreground};"
f"padding:2px 6px;border-radius:8px;'><b>{text}</b></span>"
f"padding:2px 6px;'><b>{text}</b></span>"
)
def _format_bool(
@@ -176,7 +196,7 @@ class LocalContactStatusWidget(QFrame):
return (
f"{self._badge('ERROR', tone='bad')} "
f"{self._badge(mode.upper(), tone='warn')} "
f"<span style='color:#8b1e1e;'>{error}</span>"
f"<span style='color:{CHIP_BAD_TEXT};'>{error}</span>"
)
if mode == "simulated":
return self._badge("SIMULATED", tone="warn")
+3 -1
View File
@@ -7,6 +7,8 @@ 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 APP_BACKGROUND
class LoginDialog(QDialog):
def __init__(self, base_url: str | None):
@@ -14,7 +16,7 @@ class LoginDialog(QDialog):
self.token = ""
self.setWindowTitle("User Authentication")
self.setMinimumWidth(400)
self.setStyleSheet("background-color: rgb(216, 228, 253);")
self.setStyleSheet(f"background-color: {APP_BACKGROUND};")
self._base_url = base_url
self._reply = None
self._network_manager = None
+5 -4
View File
@@ -2,9 +2,10 @@ from PySide6.QtWidgets import QScrollArea
class NoWheelScrollArea(QScrollArea):
"""Historic name: it used to swallow the wheel entirely so scrolling the
column could not nudge a value widget. WheelValueGuard now protects the
value widgets themselves (right button + wheel to adjust), so the wheel
scrolls the column content normally again and only ever scrolls."""
def __init__(self, parent=None):
super().__init__(parent)
def wheelEvent(self, event):
# Override the wheelEvent and do nothing
pass
+16 -22
View File
@@ -4,6 +4,10 @@ from PySide6.QtWidgets import QCheckBox, QHBoxLayout, QLineEdit, QWidget
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__(
@@ -13,8 +17,6 @@ class NumberLineEdit(QLineEdit):
self._read_only: bool = False
self._is_valid: bool = True
self.setStyleSheet("background-color: rgb(255, 255, 255);")
# Use a QDoubleValidator to only allow valid floating point numbers
self.validator = QDoubleValidator()
self.validator.setNotation(QDoubleValidator.Notation.StandardNotation)
@@ -37,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("background-color: rgb(255, 255, 255);")
else:
self.setStyleSheet("background-color: rgb(255, 213, 213);")
self._set_invalid(not self._is_valid)
@Slot()
def on_editing_finished(self):
@@ -82,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("background-color: rgb(240, 240, 240);")
elif not self._read_only and self._is_valid:
self.setStyleSheet("background-color: rgb(255, 255, 255);")
elif self._read_only and not self._is_valid:
self.setStyleSheet("background-color: rgb(240, 225, 225);")
elif not self._read_only and not self._is_valid:
self.setStyleSheet("background-color: rgb(255, 213, 213);")
else:
print(
f"unknown ro state: {self._read_only} or validity {self._is_valid} default to writeable"
)
self.setStyleSheet("background-color: rgb(255, 255, 255);")
def get_default(self) -> float:
return float(self.initial_value)
@@ -165,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("background-color: rgb(240, 240, 240);")
if self._checked:
self.editor.force_update_value(self._internal_value)
else:
self.check_box.setEnabled(True)
self.check_box.setStyleSheet("background-color: rgb(255, 255, 255);")
self.check_box.blockSignals(False)
self.blockSignals(False)
+6 -2
View File
@@ -9,6 +9,8 @@ from PySide6.QtWidgets import (
QVBoxLayout,
)
from aare.gui.styles import DANGER_ACCENT
class PGroupDialog(QDialog):
def __init__(
@@ -78,9 +80,11 @@ class PGroupDialog(QDialog):
def _set_error_state(self, is_error: bool, message: str | None = None) -> None:
if is_error:
self.combo.setStyleSheet("border: 2px solid #d9534f;")
self.combo.setStyleSheet(f"border: 2px solid {DANGER_ACCENT};")
if message:
self.label.setText(f"Set p-group: <span style='color:#d9534f;'>{message}</span>")
self.label.setText(
f"Set p-group: <span style='color:{DANGER_ACCENT};'>{message}</span>"
)
else:
self.label.setText("Set p-group:")
else:
+260
View File
@@ -0,0 +1,260 @@
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, qcolor
# 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, 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. 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(color, 2)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
painter.setPen(pen)
if kind == "close":
painter.drawLine(4, 4, size - 4, size - 4)
painter.drawLine(size - 4, 4, 4, size - 4)
else: # "popout": window in the lower left, arrow escaping top-right
painter.drawRect(3, size // 2 - 1, size // 2 - 1, size // 2 - 1)
painter.drawLine(size // 2 + 1, size // 2 - 1, size - 3, 3)
painter.drawLine(size - 8, 3, size - 3, 3)
painter.drawLine(size - 3, 3, size - 3, 8)
painter.end()
return QIcon(pixmap)
def _titlebar_button(parent: QWidget, tooltip: str) -> QToolButton:
# Icon is set by DockTitleBar._tint_icons (initially and on theme change).
button = QToolButton(parent)
# 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. 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
class DockTitleBar(QWidget):
"""Dock title bar with a ⤢ pop-out button right next to ✕.
Qt's native dock title bar cannot host extra buttons, so this replaces
it: [title ]. Trade-off: the dock can no longer be dragged by its
title acceptable here, these docks are pinned to the bottom row.
"""
def __init__(self, dock: QDockWidget, on_popout):
super().__init__(dock)
layout = QHBoxLayout(self)
layout.setContentsMargins(8, 2, 4, 2)
layout.setSpacing(2)
title = QLabel(dock.windowTitle(), self)
title.setStyleSheet("background: transparent;")
layout.addWidget(title)
layout.addStretch(1)
self.popout_button = _titlebar_button(
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 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.
Unlike a floated QDockWidget it never removes anything from the main
window closing it just hides it (geometry kept for reopening) and the
main window is untouched. The layout leaves RESIZE_MARGIN px of the
window exposed around the content as a fat, easy-to-hit resize band;
frameless floats only give a few px. Resize uses startSystemResize with
a manual fallback for window managers that lack it.
"""
# 6px: enough to grab without pixel-hunting, small enough that the area
# right around the content doesn't hijack table interactions.
RESIZE_MARGIN = 6
# Clicks can never land outside a window, so a from-the-outside grab zone
# has to be window area that only LOOKS external: the visible border is
# drawn OUTER_GRIP px inside the real edge, and the halo beyond it
# resizes too.
OUTER_GRIP = 4
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)
m = self.RESIZE_MARGIN + self.OUTER_GRIP
layout.setContentsMargins(m, m, m, m)
layout.addWidget(content)
self.setMouseTracking(True)
self._manual_edges = Qt.Edge(0)
self._press_global: QPoint | None = None
self._press_geom: QRect | None = None
self._placed = False
def showEvent(self, event):
# First show opens near the click (the ⤢ button = the cursor), not at
# the WM's default top-left; reopening keeps the last geometry.
if not self._placed:
self._placed = True
cursor = QCursor.pos()
pos = cursor - QPoint(60, 20)
screen = QGuiApplication.screenAt(cursor) or QGuiApplication.primaryScreen()
if screen is not None:
geo = screen.availableGeometry()
pos.setX(max(geo.left(), min(pos.x(), geo.right() - self.width())))
pos.setY(max(geo.top(), min(pos.y(), geo.bottom() - self.height())))
self.move(pos)
super().showEvent(event)
def paintEvent(self, event):
super().paintEvent(event)
# Optional perceived window border, inset by OUTER_GRIP (see class
# note). Level-1 frame — weight/color are knobs in styles.py; the
# default width 0 paints nothing (resize still works via the cursor
# hint over the grab band).
width = int(FRAME_L1_WIDTH.rstrip("px"))
if width <= 0:
return
painter = QPainter(self)
painter.setPen(QPen(qcolor(FRAME_L1_COLOR), width))
g = self.OUTER_GRIP
painter.drawRect(self.rect().adjusted(g, g, -g - 1, -g - 1))
def _edges_at(self, pos: QPoint) -> Qt.Edge:
m = self.RESIZE_MARGIN + self.OUTER_GRIP
edges = Qt.Edge(0)
if pos.x() <= m:
edges |= Qt.Edge.LeftEdge
if pos.x() >= self.width() - m:
edges |= Qt.Edge.RightEdge
if pos.y() <= m:
edges |= Qt.Edge.TopEdge
if pos.y() >= self.height() - m:
edges |= Qt.Edge.BottomEdge
return edges
def _cursor_for(self, edges: Qt.Edge):
horizontal = edges & (Qt.Edge.LeftEdge | Qt.Edge.RightEdge)
vertical = edges & (Qt.Edge.TopEdge | Qt.Edge.BottomEdge)
if horizontal and vertical:
same_diag = bool(edges & Qt.Edge.LeftEdge) == bool(edges & Qt.Edge.TopEdge)
return Qt.CursorShape.SizeFDiagCursor if same_diag else Qt.CursorShape.SizeBDiagCursor
if horizontal:
return Qt.CursorShape.SizeHorCursor
if vertical:
return Qt.CursorShape.SizeVerCursor
return None
def mousePressEvent(self, event):
edges = self._edges_at(event.position().toPoint())
if event.button() == Qt.MouseButton.LeftButton and edges:
handle = self.windowHandle()
if handle is None or not handle.startSystemResize(edges):
self._manual_edges = edges
self._press_global = event.globalPosition().toPoint()
self._press_geom = QRect(self.geometry())
return
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)
if self._manual_edges & Qt.Edge.LeftEdge:
geom.setLeft(min(geom.left() + delta.x(), geom.right() - self.minimumWidth()))
if self._manual_edges & Qt.Edge.RightEdge:
geom.setRight(max(geom.right() + delta.x(), geom.left() + self.minimumWidth()))
if self._manual_edges & Qt.Edge.TopEdge:
geom.setTop(min(geom.top() + delta.y(), geom.bottom() - self.minimumHeight()))
if self._manual_edges & Qt.Edge.BottomEdge:
geom.setBottom(max(geom.bottom() + delta.y(), geom.top() + self.minimumHeight()))
self.setGeometry(geom)
return
cursor = self._cursor_for(self._edges_at(event.position().toPoint()))
if cursor is None:
self.unsetCursor()
else:
self.setCursor(cursor)
super().mouseMoveEvent(event)
def mouseReleaseEvent(self, event):
self._manual_edges = Qt.Edge(0)
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")
+12 -9
View File
@@ -9,6 +9,7 @@ from PySide6.QtWidgets import (
)
from aare.gui.scan_logic.raster_grid_manager import RasterGridManager
from aare.gui.styles import FONT_BODY, TABLE_SHADE_BG
class RasterGridTable(QTableWidget):
@@ -32,8 +33,10 @@ class RasterGridTable(QTableWidget):
header.setSectionResizeMode(i, QHeaderView.ResizeMode.ResizeToContents)
header.setSectionResizeMode(4, QHeaderView.ResizeMode.Stretch)
# Set minimum height
self.setMinimumHeight(100)
# Exactly 5 rows of space; more grids scroll inside the table.
header_height = self.horizontalHeader().sizeHint().height()
row_height = self.verticalHeader().defaultSectionSize()
self.setFixedHeight(header_height + 3 * row_height + 1 * self.frameWidth())
# Connect to raster manager signals
self._raster_mgr.completed_grid_updated.connect(self.refresh_table)
@@ -65,16 +68,16 @@ class RasterGridTable(QTableWidget):
actions_layout.setContentsMargins(4, 4, 4, 4)
actions_layout.setSpacing(4)
button_style = """
QPushButton {
button_style = f"""
QPushButton {{
border: none;
background: transparent;
font-size: 14px;
}
QPushButton:hover {
background-color: #e0e0e0;
font-size: {FONT_BODY};
}}
QPushButton:hover {{
background-color: {TABLE_SHADE_BG};
border-radius: 3px;
}
}}
"""
# Copy button
+16 -10
View File
@@ -1,6 +1,8 @@
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QApplication, QProgressBar, QSplashScreen
from aare.gui.styles import SPLASH_ACCENT, SPLASH_BG, SPLASH_BORDER, SPLASH_TEXT, qcolor
class LoadingSplashScreen(QSplashScreen):
def __init__(self, pixmap):
@@ -9,21 +11,25 @@ class LoadingSplashScreen(QSplashScreen):
self.progress = QProgressBar(self)
# Position the progress bar at the bottom of the splash screen
self.progress.setGeometry(10, self.size().height() - 30, self.size().width() - 20, 20)
self.progress.setStyleSheet("""
QProgressBar {
border: 1px solid #444;
self.progress.setStyleSheet(f"""
QProgressBar {{
border: 1px solid {SPLASH_BORDER};
border-radius: 5px;
text-align: center;
background-color: #222;
color: white;
}
QProgressBar::chunk {
background-color: #0078d7;
}
background-color: {SPLASH_BG};
color: {SPLASH_TEXT};
}}
QProgressBar::chunk {{
background-color: {SPLASH_ACCENT};
}}
""")
def set_progress(self, value, message=None):
self.progress.setValue(value)
if message:
self.showMessage(message, Qt.AlignBottom | Qt.AlignCenter, Qt.white)
self.showMessage(
message,
Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignCenter,
qcolor(SPLASH_TEXT),
)
QApplication.processEvents()
+65 -28
View File
@@ -8,6 +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 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
@@ -45,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)
@@ -100,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 = "red" if is_error else "green"
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:
@@ -146,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}", "red")
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}", "orange")
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}")
@@ -156,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}", "blue")
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}", "orange")
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}", "red")
self.cryo_label.set_value(f"{status.bl.cryojet_K:.1f}", self._colors["alert"])
if status.bl.shutter_open:
self.shutter_label.setText(
"""Fast Shutter: <span style="color: red ; "> Open ☢️ </span>"""
f"""Fast Shutter: <span style="color: {self._colors["alert"]} ; "> Open ☢️ </span>"""
)
else:
self.shutter_label.setText(
"""Fast Shutter: <span style="color: green ; "> Closed 🚪 </span>"""
f"""Fast Shutter: <span style="color: {self._colors["ok"]} ; "> Closed 🚪 </span>"""
)
if status.bl.exp_shutter_open:
self.exp_shutter_label.setText(
"""ExpHutch Shutter: <span style="color: red ; "> Open </span>"""
f"""ExpHutch Shutter: <span style="color: {self._colors["alert"]} ; "> Open </span>"""
)
else:
self.exp_shutter_label.setText(
"""ExpHutch Shutter: <span style="color: green ; "> Closed 🚪 </span>"""
f"""ExpHutch Shutter: <span style="color: {self._colors["ok"]} ; "> Closed 🚪 </span>"""
)
if status.session.current_pgroup is not None:
@@ -188,29 +210,29 @@ class StatusBar(QStatusBar):
self.state_label.setText(f"""State: {status.state.display_name()} """)
tell_text = ""
tell_color = "rgb(55, 67, 87)"
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 = "red"
tell_color = self._colors["alert"]
elif status.tell_state.activity.value in {
"mounting",
"unmounting",
"drying",
"cooling",
}:
tell_color = "orange"
tell_color = self._colors["warn"]
else:
tell_color = "green"
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 = """ <span style="color: red; "> Busy 🔒 </span>"""
busy_flag = f""" <span style="color: {self._colors["alert"]}; "> Busy 🔒 </span>"""
else:
busy_flag = """ <span style="color: green ; "> Idle 🔓 </span>"""
busy_flag = f""" <span style="color: {self._colors["ok"]} ; "> Idle 🔓 </span>"""
html_content = f"""Beamline: {busy_flag} """
@@ -218,15 +240,23 @@ class StatusBar(QStatusBar):
session_flag = ""
if status.session.session == SessionsStateEnum.Vacant:
session_flag = """<span style="color: yellow ; "> Vacant 🔓 </span>"""
session_flag = (
f"""<span style="color: {self._colors["vacant"]} ; "> Vacant 🔓 </span>"""
)
elif status.session.session == SessionsStateEnum.OwnedByYou:
session_flag = """<span style="color: green ; "> Owned ⬤ </span>"""
session_flag = f"""<span style="color: {self._colors["ok"]} ; "> Owned ⬤ </span>"""
elif status.session.session == SessionsStateEnum.OwnedByElse:
session_flag = """<span style="color: red ; "> Other 🔒 </span>"""
session_flag = (
f"""<span style="color: {self._colors["alert"]} ; "> Other 🔒 </span>"""
)
elif status.session.session == SessionsStateEnum.PendingYouToElse:
session_flag = """<span style="color: orange ; "> Waiting... ⏳ </span>"""
session_flag = (
f"""<span style="color: {self._colors["warn"]} ; "> Waiting... ⏳ </span>"""
)
elif status.session.session == SessionsStateEnum.PendingElseToYou:
session_flag = """<span style="color: cyan ; "> 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)
@@ -325,7 +355,7 @@ class StatusBar(QStatusBar):
self.session_label.setText(text)
def show_session_menu(self):
def show_session_menu(self, global_pos: QPoint | None = None):
menu = QMenu(self)
is_busy = self._status and self._status.busy
session_state = self._status.session.session if self._status else SessionsStateEnum.Vacant
@@ -404,10 +434,16 @@ class StatusBar(QStatusBar):
action_force = menu.addAction("⚠️ Force Take Over")
action_force.triggered.connect(self._on_force_session_clicked)
label_geometry = self.session_label.geometry()
menu_width = max(label_geometry.width(), menu.sizeHint().width())
menu.move(self.mapToGlobal(label_geometry.topLeft()) - QPoint(0, menu.sizeHint().height()))
menu.setFixedWidth(menu_width)
if global_pos is not None:
# Invoked from the camera's session badge — open at the click.
menu.move(global_pos)
else:
label_geometry = self.session_label.geometry()
menu_width = max(label_geometry.width(), menu.sizeHint().width())
menu.move(
self.mapToGlobal(label_geometry.topLeft()) - QPoint(0, menu.sizeHint().height())
)
menu.setFixedWidth(menu_width)
menu.exec()
def show_pgroup_menu(self):
@@ -588,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:
+173 -6
View File
@@ -1,12 +1,179 @@
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QLabel
from PySide6.QtCore import QSettings, Qt, QTimer
from PySide6.QtGui import QPainter, QPalette
from PySide6.QtWidgets import QHBoxLayout, QLabel, QLayout, QPushButton, QStyle, QStyleOption
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,
# so every banner-to-banner gap is 4 + 3 + 4 = 11px in every column.
PANEL_VSPACING = 3
PANEL_VMARGIN = 4
def tighten_column(layout: QLayout) -> None:
"""Apply the universal panel gap to a column layout and its child panels.
Qt's defaults (9px margins + 6px spacing) and ad-hoc per-panel margins
made the gaps uneven between the left and right columns.
"""
layout.setSpacing(PANEL_VSPACING)
for i in range(layout.count()):
item = layout.itemAt(i)
widget = item.widget() if item is not None else None
child_layout = widget.layout() if widget is not None else None
if child_layout is not None:
m = child_layout.contentsMargins()
child_layout.setContentsMargins(m.left(), PANEL_VMARGIN, m.right(), PANEL_VMARGIN)
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). Look lives in
the per-theme QLabel#sectionTitle rules in styles.py."""
label = QLabel(text, parent)
label.setObjectName("sectionTitle")
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
return label
class TitleLabel(QLabel):
def __init__(self, text: str, parent=None):
def __init__(
self, text: str, parent=None, collapsible: bool = False, default_collapsed: bool = True
):
super().__init__(parent)
self.setText(f"<H3>{text}</H3>")
self.setStyleSheet("background-color: #4B0082; color: #ffffff;")
# Plain text + QSS font instead of <H3>: rich-text heading margins
# would clip vertically in the halved banner height.
self.setText(text)
# 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)
self.setFixedHeight(50)
# Half the original 50px: the full-height banner wasted vertical space.
self.setFixedHeight(25)
self._collapsible = collapsible
if not collapsible:
return
self._collapsed = False
# ponytail: settings key is the title text — unique across panels;
# renaming a title just resets that panel to expanded once.
self._settings_key = f"panel_collapsed/{text}"
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_VALUE}; font-weight: 700; }}"
)
self.toggle_button.setToolTip("Minimise panel")
self.toggle_button.setFixedSize(21, 21)
self.toggle_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.toggle_button.clicked.connect(self.toggle_collapsed)
button_layout = QHBoxLayout(self)
button_layout.setContentsMargins(0, 0, 8, 0)
button_layout.addStretch()
button_layout.addWidget(self.toggle_button)
self.setCursor(Qt.CursorShape.PointingHandCursor)
settings = QSettings("PSI", "AareGUI")
# Per-panel default (collapsed unless the caller opts out); once the
# user toggles a banner, their choice is persisted per title and wins.
if settings.value(self._settings_key, default_collapsed, type=bool):
self._collapsed = True
# Deferred: the panel adds its other widgets after constructing
# the TitleLabel, so siblings don't exist yet.
QTimer.singleShot(0, self._apply_collapsed)
def paintEvent(self, event):
# QSS has no text-shadow, so paint by hand: the QSS background box
# first, then the title twice — an offset dark pass under the normal
# one — for the subtle emboss the OS titlebar text has.
painter = QPainter(self)
opt = QStyleOption()
opt.initFrom(self)
self.style().drawPrimitive(QStyle.PrimitiveElement.PE_Widget, opt, painter, self)
flags = int(self.alignment())
# Reserve the toggle-button zone (21px + 8px margin) on BOTH sides in
# the text rect only — widget margins would move the button itself.
reserve = 29 if self._collapsible else 0
rect = self.rect().adjusted(reserve, 0, -reserve, 0)
painter.setFont(self.font())
# Elide instead of overflowing when the panel column is narrow.
text = painter.fontMetrics().elidedText(
self.text(), Qt.TextElideMode.ElideRight, rect.width()
)
painter.setPen(qcolor(BANNER_TEXT_SHADOW, 110))
painter.drawText(rect.translated(0, 1), flags, 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):
if self._collapsible:
self.toggle_collapsed()
super().mousePressEvent(event)
def expand(self) -> None:
if self._collapsible and self._collapsed:
self.toggle_collapsed()
def is_collapsed(self) -> bool:
return bool(self._collapsible and self._collapsed)
def set_collapsed(self, collapsed: bool, persist: bool = True) -> None:
# persist=False: transient programmatic fold (e.g. the session-vacant
# gate) that must not overwrite the user's saved per-panel choice.
if not self._collapsible or collapsed == self._collapsed:
return
self._collapsed = collapsed
self._apply_collapsed()
if persist:
QSettings("PSI", "AareGUI").setValue(self._settings_key, self._collapsed)
def toggle_collapsed(self) -> None:
self._collapsed = not self._collapsed
self._apply_collapsed()
QSettings("PSI", "AareGUI").setValue(self._settings_key, self._collapsed)
if not self._collapsed:
# Expanding a group banner (Beamline / Experiment) opens every
# nested panel banner too — a group opening onto a wall of still-
# collapsed banners reads as broken. No-op for leaf panels, which
# have no nested TitleLabels.
parent = self.parentWidget()
if parent is not None:
for child in parent.findChildren(TitleLabel):
if child is not self:
child.expand()
def _apply_collapsed(self) -> None:
parent = self.parentWidget()
parent_layout = parent.layout() if parent is not None else None
if parent_layout is None:
return
self._set_visible(parent_layout, not self._collapsed)
self.toggle_button.setText("+" if self._collapsed else "")
self.toggle_button.setToolTip("Restore panel" if self._collapsed else "Minimise panel")
def _set_visible(self, layout: QLayout, visible: bool) -> None:
# Recursive: panels like SamcamPanel nest sub-layouts via addLayout.
for i in range(layout.count()):
item = layout.itemAt(i)
if item is None:
continue
widget = item.widget()
child_layout = item.layout()
if widget is not None:
if widget is not self:
widget.setVisible(visible)
elif child_layout is not None:
self._set_visible(child_layout, visible)
+7 -43
View File
@@ -1,8 +1,8 @@
from PySide6.QtCore import QRectF, Qt, Slot
from PySide6.QtGui import QColor, QFont, QFontMetrics, QImage, QPainter, QPen, QPixmap
from PySide6.QtGui import QImage, QPainter, QPixmap
from PySide6.QtWidgets import QGraphicsPixmapItem, QGraphicsScene, QGraphicsView
from aare.gui.widgets.busy_overlay import BusyOverlayStyle
from aare.gui.widgets.busy_overlay import BusyOverlayStyle, draw_busy_badge
class VideoGraphicsView(QGraphicsView):
@@ -111,48 +111,12 @@ class VideoGraphicsView(QGraphicsView):
if self._busy_overlay_style is None:
return
style = self._busy_overlay_style
painter.save()
painter.resetTransform()
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
font = QFont()
font.setPointSize(24)
font.setBold(True)
painter.setFont(font)
fm = QFontMetrics(font)
text_rect = fm.boundingRect(style.text)
dot_diameter = 14
gap = 12
padding_x = 22
padding_y = 14
bg_width = text_rect.width() + dot_diameter + gap + padding_x * 2
bg_height = max(text_rect.height(), dot_diameter) + padding_y * 2
viewport_width = self.viewport().width()
viewport_height = self.viewport().height()
pos_x = int((viewport_width - bg_width) / 2)
pos_y = int(viewport_height * 0.68 - bg_height / 2)
bg_rect = QRectF(pos_x, pos_y, bg_width, bg_height)
painter.setPen(QPen(style.overlay_border, 2))
painter.setBrush(style.overlay_fill)
painter.drawRoundedRect(bg_rect, 14, 14)
dot_x = bg_rect.left() + padding_x
dot_y = bg_rect.top() + (bg_rect.height() - dot_diameter) / 2
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QColor(style.accent_dot))
painter.drawEllipse(QRectF(dot_x, dot_y, dot_diameter, dot_diameter))
painter.setPen(QPen(style.overlay_text, 1))
text_x = dot_x + dot_diameter + gap
text_y = bg_rect.top() + padding_y + fm.ascent()
painter.drawText(text_x, text_y, style.text)
# Shared renderer with the sample camera, so every view shows the
# identical badge (this view used to draw its own dot+text variant).
draw_busy_badge(
painter, self.viewport().width(), self.viewport().height(), self._busy_overlay_style
)
painter.restore()
+82
View File
@@ -0,0 +1,82 @@
from PySide6.QtCore import QEvent, QObject, Qt
from PySide6.QtGui import QWheelEvent
from PySide6.QtWidgets import (
QAbstractScrollArea,
QAbstractSpinBox,
QApplication,
QComboBox,
QDial,
QSlider,
QTabBar,
)
class WheelValueGuard(QObject):
"""App-level wheel safety for value widgets.
The wheel only ADJUSTS a slider / spin box / dial / combo while the
RIGHT mouse button is held down a deliberate two-hand gesture. A bare
wheel over any of them is re-aimed at the enclosing scroll area, so
scrolling a page can never nudge a value and therefore never moves a
motor. Install once with QApplication.installEventFilter.
"""
# QTabBar: wheel switches tabs on Linux by default — same accidental-input
# hazard as a value nudge, so guard it too.
GUARDED = (QAbstractSpinBox, QSlider, QDial, QComboBox, QTabBar)
def eventFilter(self, obj, event):
if event.type() == QEvent.Type.Wheel and isinstance(obj, self.GUARDED):
if event.buttons() & Qt.MouseButton.RightButton:
return False # right button held: deliberate value adjustment
area = obj.parentWidget()
while area is not None and not isinstance(area, QAbstractScrollArea):
area = area.parentWidget()
if area is not None:
relayed = QWheelEvent(
area.viewport().mapFromGlobal(event.globalPosition()),
event.globalPosition(),
event.pixelDelta(),
event.angleDelta(),
event.buttons(),
event.modifiers(),
event.phase(),
event.inverted(),
)
QApplication.sendEvent(area.viewport(), relayed)
return True
return super().eventFilter(obj, event)
if __name__ == "__main__":
# ponytail: smallest check that fails if the guard logic breaks
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtCore import QPoint, QPointF
app = QApplication([])
guard = WheelValueGuard()
app.installEventFilter(guard)
slider = QSlider(Qt.Orientation.Horizontal)
slider.setRange(0, 100)
slider.setValue(50)
slider.show()
def wheel(buttons):
return QWheelEvent(
QPointF(5, 5),
QPointF(5, 5),
QPoint(0, 0),
QPoint(0, 120),
buttons,
Qt.KeyboardModifier.NoModifier,
Qt.ScrollPhase.NoScrollPhase,
False,
)
QApplication.sendEvent(slider, wheel(Qt.MouseButton.NoButton))
assert slider.value() == 50, "bare wheel must not adjust the slider"
QApplication.sendEvent(slider, wheel(Qt.MouseButton.RightButton))
assert slider.value() != 50, "right-button + wheel must adjust the slider"
print("gude")
+37
View File
@@ -0,0 +1,37 @@
from aarecommon.models.models import SessionsStateEnum
from PySide6.QtWidgets import QVBoxLayout, QWidget
from aare.gui.panels.axis_video_panel import AxisVideoPanel
from aare.gui.widgets.busy_overlay import build_busy_overlay_style
from aare.gui.widgets.video_image import VideoGraphicsView
def _vacant_style():
return build_busy_overlay_style(
is_busy=False, tell_state=None, session_state=SessionsStateEnum.Vacant
)
def test_badge_drawn_once_and_hint_stripped(qtbot):
# Combined-view shape: two video views stacked in one container.
container = QWidget()
layout = QVBoxLayout(container)
first, second = VideoGraphicsView(), VideoGraphicsView()
layout.addWidget(first)
layout.addWidget(second)
panel = AxisVideoPanel("Combined", container)
qtbot.addWidget(panel)
style = _vacant_style()
assert style is not None and style.subtext # sample camera keeps the hint
panel.set_busy_style(style)
applied = first._busy_overlay_style
assert applied is not None
assert applied.text == "In viewing mode"
assert applied.subtext == "" # not clickable here, hint stripped
assert second._busy_overlay_style is None # one badge, not one per view
panel.set_busy_style(None)
assert first._busy_overlay_style is None
+123
View File
@@ -0,0 +1,123 @@
from aarecommon.models.models import BeamlineStateEnum
from PySide6.QtCore import Qt
from aare.gui.panels.beamline_state_panel import BeamlineStatePanel
from aare.gui.styles import STATE_AVAILABLE, STATE_MSG_ERROR, STATE_MSG_INFO, STATE_UNAVAILABLE
def _panel(qtbot):
panel = BeamlineStatePanel()
qtbot.addWidget(panel)
return panel
def test_availability_from_maintenance(qtbot):
panel = _panel(qtbot)
panel.set_current_state(BeamlineStateEnum.Maintenance)
assert panel._available_targets() == frozenset({BeamlineStateEnum.SampleExchange})
def test_availability_is_union_of_routes_and_menu_shortcuts(qtbot):
panel = _panel(qtbot)
panel.set_current_state(BeamlineStateEnum.SampleAlignment)
targets = panel._available_targets()
assert BeamlineStateEnum.FluxMeasurement in targets # one-hop route
assert BeamlineStateEnum.SampleExchange in targets # status-bar shortcut
assert BeamlineStateEnum.XtalSnapshot not in targets # two hops away
def test_no_targets_while_moving_or_unknown(qtbot):
panel = _panel(qtbot)
panel.set_current_state(None)
assert panel._available_targets() == frozenset()
panel.set_current_state(BeamlineStateEnum.Moving)
assert panel._available_targets() == frozenset()
def test_emit_gated_by_availability(qtbot):
panel = _panel(qtbot)
panel.set_current_state(BeamlineStateEnum.Maintenance)
with qtbot.waitSignal(panel.sample_exchange, timeout=1000):
panel._emit_for_state(BeamlineStateEnum.SampleExchange)
with qtbot.assertNotEmitted(panel.flux_measurement):
panel._emit_for_state(BeamlineStateEnum.FluxMeasurement)
def test_active_maintenance_is_red_and_bold(qtbot):
panel = _panel(qtbot)
panel.set_current_state(BeamlineStateEnum.Maintenance)
maintenance = panel._buttons[BeamlineStateEnum.Maintenance]
assert maintenance.font().bold()
assert STATE_MSG_ERROR in maintenance.styleSheet()
def test_availability_palette_and_cursors(qtbot):
panel = _panel(qtbot)
panel.set_current_state(BeamlineStateEnum.Maintenance)
available = panel._buttons[BeamlineStateEnum.SampleExchange]
grey = panel._buttons[BeamlineStateEnum.FluxMeasurement]
assert STATE_AVAILABLE in available.styleSheet()
assert available.cursor().shape() == Qt.CursorShape.PointingHandCursor
assert not available.font().bold()
assert STATE_UNAVAILABLE in grey.styleSheet()
assert grey.cursor().shape() == Qt.CursorShape.ForbiddenCursor
assert grey.toolTip() == ""
assert available.toolTip() != ""
def test_active_non_maintenance_is_blue(qtbot):
panel = _panel(qtbot)
panel.set_current_state(BeamlineStateEnum.SampleAlignment)
active = panel._buttons[BeamlineStateEnum.SampleAlignment]
assert active.font().bold()
assert STATE_MSG_INFO in active.styleSheet()
def test_labels_wrap_when_narrow_and_unwrap_when_wide(qtbot):
panel = _panel(qtbot)
sample_exchange = panel._buttons[BeamlineStateEnum.SampleExchange]
panel.resize(600, 60)
panel._update_label_mode()
assert not panel._single_line
assert sample_exchange.text() == "Manual sample\nexchange"
panel.resize(4000, 60)
panel._update_label_mode()
assert panel._single_line
assert sample_exchange.text() == "Manual sample exchange"
def test_left_click_never_transitions_but_hints(qtbot):
panel = _panel(qtbot)
panel.set_current_state(BeamlineStateEnum.Maintenance)
with qtbot.assertNotEmitted(panel.sample_exchange):
panel._on_left_click(BeamlineStateEnum.SampleExchange) # reminder tip
panel._on_left_click(BeamlineStateEnum.FluxMeasurement) # reachability hint
panel._on_left_click(BeamlineStateEnum.Maintenance) # current: no-op
def test_hover_hint_timer_lifecycle(qtbot):
panel = _panel(qtbot)
panel.set_current_state(BeamlineStateEnum.Maintenance)
panel._set_hovered_state(BeamlineStateEnum.FluxMeasurement)
assert panel._hover_hint_timer.isActive()
panel._show_hover_hint() # runs the unavailable-hint path
panel._clear_hovered_state()
assert not panel._hover_hint_timer.isActive()
assert panel._hovered_state is None
def test_update_daq_status_sets_state(qtbot, daq_status_factory):
panel = _panel(qtbot)
panel.update_daq_status(daq_status_factory(state=BeamlineStateEnum.SampleAlignment))
assert panel._current_state == BeamlineStateEnum.SampleAlignment
def test_pending_target_cleared_on_arrival(qtbot):
panel = _panel(qtbot)
panel.set_current_state(BeamlineStateEnum.Maintenance)
panel._emit_for_state(BeamlineStateEnum.SampleExchange)
assert panel._pending_target_state == BeamlineStateEnum.SampleExchange
panel.set_current_state(BeamlineStateEnum.SampleExchange)
assert panel._pending_target_state is None
+162
View File
@@ -0,0 +1,162 @@
import pytest
from aarecommon.math.coordinate import Coordinate, SmargonCoordinate
from aarecommon.math.diffraction_geometry import DiffractionGeometry
from aarecommon.math.sample_geometry import SampleGeometryModel
from aarecommon.models.models import (
BeamlineStateEnum,
BeamlineStatus,
CrystalSize,
DAQStatusModel,
SampleCameraSettings,
SessionsStateEnum,
SessionStatus,
)
from PySide6.QtCore import QEvent, QPoint, QPointF, Qt
from PySide6.QtGui import QMouseEvent
from aare.gui.scan_logic.raster_grid_manager import RasterGridManager
from aare.gui.styles import THEME_SUNRISE, THEME_SUNSET
from aare.gui.widgets.camera_image import SampleCameraImageLabel
def _geom() -> SampleGeometryModel:
return SampleGeometryModel(
beam_location_pxl=Coordinate(x=1000, y=1000),
pixel_in_mm=0.001,
aerotech=Coordinate(),
aerotech_meas=Coordinate(),
smargon=SmargonCoordinate(sh_mm=Coordinate(), phi_deg=0, chi_deg=0),
omega_deg=0,
beam_size_mm=Coordinate(x=0.01, y=0.01),
)
def _status(*, busy: bool, session: SessionsStateEnum) -> DAQStatusModel:
return DAQStatusModel(
geom=_geom(),
diffraction=DiffractionGeometry(
energy_keV=12.4,
dtz_mm=100.0,
detector_size_pxl=(1553, 1630),
pixel_size_mm=0.150,
beam_center_pxl=(750.0, 750.0),
detector_description="PILATUS 4",
detector_serial_number="1",
poni_rot1_rad=0.0,
poni_rot2_rad=0.0,
),
bl=BeamlineStatus(
name="SIMULATED",
ring_current_mA=400.0,
front_light=50.0,
back_light=50.0,
cryojet_K=100.0,
shutter_open=False,
exp_shutter_open=False,
flux_ph_s=1e12,
sample_camera=SampleCameraSettings(gain=1.0, exposure=0.02),
transmission=1.0,
zoom=1.0,
commissioning_mode=False,
dtz_min=120.0,
dtz_max=1600.0,
),
state=BeamlineStateEnum.SampleAlignment,
busy=busy,
session=SessionStatus(session=session, current_pgroup="p123", staff=True),
crystal_size=CrystalSize(x=0, y=0, z=0),
)
def _mouse_move(widget, pos: QPoint) -> None:
# qtbot.mouseMove drives the real cursor, which the offscreen platform
# ignores — deliver the move event directly instead.
event = QMouseEvent(
QEvent.Type.MouseMove,
QPointF(pos),
QPointF(widget.mapToGlobal(pos)),
Qt.MouseButton.NoButton,
Qt.MouseButton.NoButton,
Qt.KeyboardModifier.NoModifier,
)
widget.mouseMoveEvent(event)
@pytest.fixture
def camera(qtbot):
geom = _geom()
label = SampleCameraImageLabel(geom=geom, raster=RasterGridManager(geom), default_image=None)
qtbot.addWidget(label)
label.resize(800, 600)
return label
def test_help_badge_click_toggles_cheatsheet(camera, qtbot):
camera.grab() # paint records the collapsed "?" badge hit rect
badge = camera._help_hit_rect
assert badge is not None
assert not camera._help_expanded
qtbot.mouseClick(camera.viewport(), Qt.MouseButton.LeftButton, pos=badge.center().toPoint())
assert camera._help_expanded
camera.grab() # expanded overlay: hit rect grows to the whole cheatsheet box
box = camera._help_hit_rect
assert box is not None
assert box.height() > badge.height()
qtbot.mouseClick(camera.viewport(), Qt.MouseButton.LeftButton, pos=box.center().toPoint())
assert not camera._help_expanded
def test_camera_error_message_rewords_and_draws(camera):
camera.set_camera_available(False)
camera.set_camera_error_message("Sample camera feed unavailable: cable unplugged")
assert camera._camera_error_message == (
"Sample camera feed unavailable because cable unplugged"
)
camera.grab() # exercises the bottom-center unavailable overlay text path
camera.set_camera_available(True)
assert camera._camera_error_message is None
def test_busy_warning_is_not_a_click_target(camera):
camera.update_daq_status(_status(busy=True, session=SessionsStateEnum.OwnedByYou))
style = camera._busy_overlay_style
assert style is not None
assert style.text == "BEAMLINE BUSY"
camera.grab()
assert camera._session_badge_rect is None
def test_vacant_badge_hover_click_and_theme(camera, qtbot):
camera.update_daq_status(_status(busy=False, session=SessionsStateEnum.Vacant))
style = camera._busy_overlay_style
assert style is not None
assert style.text == "In viewing mode"
assert style.subtext # the grab-baton hint line
camera.grab() # paint records the badge rect
badge = camera._session_badge_rect
assert badge is not None
_mouse_move(camera, badge.center())
assert camera._session_badge_hovered
camera.grab() # hover fill, light-theme darken branch
camera.set_theme(THEME_SUNSET)
assert camera._dark_theme
camera.grab() # hover fill, sunset brighten branch
camera.set_theme(THEME_SUNRISE)
assert not camera._dark_theme
_mouse_move(camera, QPoint(1, 1))
assert not camera._session_badge_hovered
_mouse_move(camera, badge.center())
camera.leaveEvent(QEvent(QEvent.Type.Leave))
assert not camera._session_badge_hovered
with qtbot.waitSignal(camera.session_badge_clicked, timeout=1000):
qtbot.mouseClick(camera.viewport(), Qt.MouseButton.LeftButton, pos=badge.center())
+31
View File
@@ -0,0 +1,31 @@
"""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 LogPanel
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()
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()
panel.clear()
assert panel.view.toPlainText() == ""
assert mirror.toPlainText() == ""
def test_notification_requests_reveal(qtbot):
panel = LogPanel()
qtbot.addWidget(panel)
with qtbot.waitSignal(panel.reveal_requested, timeout=1000):
panel.show_notification(title="Boom", message="it broke")
assert panel.notification._title.text() == "Boom"
+122
View File
@@ -1,8 +1,11 @@
from unittest.mock import MagicMock, patch
import pytest
from PySide6.QtCore import QSettings
from PySide6.QtWidgets import QDockWidget
from aare.gui.main_window import MainWindow
from aare.gui.styles import THEME_BLUEBIRD, THEME_SUNRISE, THEME_SUNSET
@pytest.fixture
@@ -362,3 +365,122 @@ def test_cleanup_returns_from_compact_automation_view(qtbot, mock_ui_state):
win.cleanup()
assert win.content_stack.currentWidget() is win._standard_main_page
def _make_window(qtbot):
win = MainWindow(
base_url=None,
token="header.payload.signature",
default_image=None,
zmq_addr=None,
pred_zmq_addr=None,
beamline_cam_addr=None,
gonio_cam_addr=None,
gonio_cam_id=None,
)
qtbot.addWidget(win)
return win
def test_theme_settings_migrate_and_slots_switch(qtbot, mock_ui_state):
with (
patch("requests.get"),
patch("aare.gui.main_window.DAQWorker"),
patch("aare.gui.main_window.PredictionSubscriber"),
patch("aare.gui.main_window.VideoThread"),
patch("aare.gui.main_window.JFJochDBusClient"),
patch("aare.gui.main_window.jwt.decode") as mock_jwt,
):
mock_jwt.return_value = {
"sub": "testuser",
"staff": True,
"pgroups": ["p123"],
"session": 15,
}
win = _make_window(qtbot)
settings = QSettings("PSI", "AareGUI")
saved = settings.value("appearance/theme")
try:
# Pre-rename tokens saved by older builds must map to the new ones.
settings.setValue("appearance/theme", "portrait")
win._restore_theme_settings()
assert win._theme_mode == THEME_SUNSET
settings.setValue("appearance/theme", "original")
win._restore_theme_settings()
assert win._theme_mode == THEME_SUNRISE
settings.setValue("appearance/theme", THEME_BLUEBIRD)
win._restore_theme_settings()
assert win._theme_mode == THEME_BLUEBIRD
finally:
if saved is None:
settings.remove("appearance/theme")
else:
settings.setValue("appearance/theme", saved)
win.use_bluebird_theme()
assert win._theme_mode == THEME_BLUEBIRD
win.use_portrait_theme() # exercises the sunset palette flip
assert win._theme_mode == THEME_SUNSET
win.use_legacy_theme()
assert win._theme_mode == THEME_SUNRISE
def test_restore_window_state_heals_all_hidden_docks(qtbot, mock_ui_state):
with (
patch("requests.get"),
patch("aare.gui.main_window.DAQWorker"),
patch("aare.gui.main_window.PredictionSubscriber"),
patch("aare.gui.main_window.VideoThread"),
patch("aare.gui.main_window.JFJochDBusClient"),
patch("aare.gui.main_window.jwt.decode") as mock_jwt,
):
mock_jwt.return_value = {
"sub": "testuser",
"staff": True,
"pgroups": ["p123"],
"session": 15,
}
win = _make_window(qtbot)
for dock in win.findChildren(QDockWidget):
dock.hide()
assert all(d.isHidden() for d in win.findChildren(QDockWidget))
# state_manager is mocked, so restore_window is a no-op and the
# all-hidden layout survives to the heal check.
win._restore_window_state()
assert not win.tell_samples_dock.isHidden()
def test_close_restores_pre_watch_layout(qtbot, mock_ui_state):
with (
patch("requests.get"),
patch("aare.gui.main_window.DAQWorker"),
patch("aare.gui.main_window.PredictionSubscriber"),
patch("aare.gui.main_window.VideoThread"),
patch("aare.gui.main_window.JFJochDBusClient"),
patch("aare.gui.main_window.jwt.decode") as mock_jwt,
):
mock_jwt.return_value = {
"sub": "testuser",
"staff": True,
"pgroups": ["p123"],
"session": 15,
}
win = _make_window(qtbot)
pre_watch = win.saveState()
for dock in win.findChildren(QDockWidget):
dock.hide()
win._session_operations_enabled = False
win._pre_watch_dock_state = pre_watch
win.close()
# closeEvent put the pre-watch layout back before saving state, so
# the all-hidden fold was not persisted.
assert not win.tell_samples_dock.isHidden()
+128 -4
View File
@@ -57,8 +57,8 @@ def test_user_sample_model_init(sample_list):
def test_user_sample_model_column_filter(sample_list):
model = UserSampleSpreadsheet(samples=sample_list)
model.set_show_all_pgroups(True)
# Column 5 is user
model.set_column_filter(5, "U1")
# Column 6 is User (column 0 is the frozen #+status cell)
model.set_column_filter(6, "U1")
assert model.rowCount() == 2
model.clear_all_column_filters()
assert model.rowCount() == 3
@@ -67,8 +67,8 @@ def test_user_sample_model_column_filter(sample_list):
def test_user_sample_model_unique_values(sample_list):
model = UserSampleSpreadsheet(samples=sample_list)
model.set_show_all_pgroups(True)
# Column 5 is User
users = model.unique_values_for_column(5)
# Column 6 is User (column 0 is the frozen #+status cell)
users = model.unique_values_for_column(6)
assert "U1" in users
assert "U2" in users
assert len(users) == 2
@@ -121,3 +121,127 @@ def test_sample_queue_model_flags(sample_list):
model = SampleQueueSpreadsheet(samples=sample_list[:2])
flags = model.flags(model.index(0, 0))
assert flags & Qt.ItemFlag.ItemIsDropEnabled
# --- Status logic of the combined dewar/queue view ---------------------------
# The dewar table doubles as the queue view: the frozen "#" column carries a
# status fill (mounted > queued > flagged > measured) and the chip row filters
# by status. This is the logic a local contact trusts at a glance, so it gets
# its own tests.
def _status(model, row):
brush = model.data(model.index(row, 0), Qt.ItemDataRole.BackgroundRole)
return None if brush is None else brush.color().name().lower()
def _row_of(model, db_id):
return next(r for r in range(model.rowCount()) if model.get_id(r).db_id == db_id)
@pytest.fixture
def status_model(sample_list):
from aarecommon.models.models import DewarAddress, SampleShortInfo
# A measured sample: rotation_count > 1 (exactly 1 must NOT count).
sample_list.append(
SampleShortInfo(
db_id=4,
puck_name="P3",
dewar_name="D3",
sample_name="S4",
run_number=4,
user="U1",
pin=4,
rotation_count=2,
location=DewarAddress(segment="B", pos=1),
)
)
model = UserSampleSpreadsheet(samples=sample_list)
model.set_show_all_pgroups(True)
return model
def test_status_color_priority(status_model):
from aare.gui.styles import (
SAMPLE_ROW_QUEUED_BG,
SAMPLE_STATUS_FLAGGED_BG,
SAMPLE_STATUS_MEASURED_BG,
SAMPLE_STATUS_QUEUED_BG,
)
model = status_model
assert _status(model, _row_of(model, 1)) is None
model.set_queued_ids({1})
model.set_flagged(1, True)
# Queued beats flagged in the All view.
assert _status(model, _row_of(model, 1)) == SAMPLE_STATUS_QUEUED_BG.lower()
model.set_queued_ids(set())
assert _status(model, _row_of(model, 1)) == SAMPLE_STATUS_FLAGGED_BG.lower()
# Measured is automatic: rotation_count 2 counts, the fixture's 1-3 don't.
assert _status(model, _row_of(model, 4)) == SAMPLE_STATUS_MEASURED_BG.lower()
assert _status(model, _row_of(model, 2)) is None
# Mounted always wins.
model.updateCurrentSample(current_puck="P1", current_sample=1)
assert _status(model, _row_of(model, 1)) == SAMPLE_ROW_QUEUED_BG.lower()
def test_status_filter_selects_rows(status_model):
model = status_model
model.set_queued_ids({1, 2})
model.set_flagged(3, True)
model.set_status_filter("queued")
assert {model.get_id(r).db_id for r in range(model.rowCount())} == {1, 2}
model.set_status_filter("flagged")
assert {model.get_id(r).db_id for r in range(model.rowCount())} == {3}
model.set_status_filter("measured")
assert {model.get_id(r).db_id for r in range(model.rowCount())} == {4}
model.set_status_filter(None)
assert model.rowCount() == 4
def test_status_tints_are_context_dependent(status_model):
from aare.gui.styles import SAMPLE_STATUS_FLAGGED_BG, SAMPLE_STATUS_QUEUED_BG
model = status_model
model.set_queued_ids({1, 2})
model.set_flagged(1, True)
# Queued view: own tint suppressed, only the also-flagged mark shows.
model.set_status_filter("queued")
assert _status(model, _row_of(model, 1)) == SAMPLE_STATUS_FLAGGED_BG.lower()
assert _status(model, _row_of(model, 2)) is None
# Flagged view: a re-queued sample wears the queued mark.
model.set_status_filter("flagged")
assert _status(model, _row_of(model, 1)) == SAMPLE_STATUS_QUEUED_BG.lower()
def test_status_sets_refilter_while_chip_active(status_model):
model = status_model
model.set_status_filter("queued")
assert model.rowCount() == 0
model.set_queued_ids({2})
assert {model.get_id(r).db_id for r in range(model.rowCount())} == {2}
def test_status_column_is_display_only(status_model):
model = status_model
assert model.data(model.index(0, 0), Qt.ItemDataRole.DisplayRole) == 1
before = [model.get_id(r).db_id for r in range(model.rowCount())]
model.sort(0, Qt.SortOrder.DescendingOrder) # no-op on the "#" column
assert [model.get_id(r).db_id for r in range(model.rowCount())] == before
def test_mime_data_round_trips_for_chip_drops(status_model):
from aarecommon.models.models import SampleShortInfoList
model = status_model
payload = model.mimeData([model.index(0, 1), model.index(1, 1)])
samples = SampleShortInfoList.model_validate_json(payload.text())
assert len(samples.s) == 2
assert samples.s[0].db_id == model.get_id(0).db_id
+2 -1
View File
@@ -11,6 +11,7 @@ from aarecommon.models.models import (
)
from aare.gui.panels.status_panel import StatusPanel
from aare.gui.styles import STATUS_ALERT
@pytest.fixture
@@ -84,5 +85,5 @@ def test_status_panel_low_current(qtbot, mock_daq_status):
mock_daq_status.bl.ring_current_mA = 300.0
panel.update_daq_status(mock_daq_status)
assert "color: red" in panel.ring_current.text()
assert f"color: {STATUS_ALERT}" in panel.ring_current.text()
assert "300.0" in panel.ring_current.text()
+171
View File
@@ -0,0 +1,171 @@
"""PopoutWindow replaces dock floating: an additional top-level window whose
edge band resizes, whose close only hides, and whose first show lands near
the cursor. DockTitleBar puts the pop-out button next to the close box."""
from PySide6.QtCore import QEvent, QPoint, QPointF, Qt
from PySide6.QtGui import QMouseEvent
from PySide6.QtWidgets import QDockWidget, QLabel
from aare.gui.widgets.popout_window import DockTitleBar, PopoutWindow
def _window(qtbot):
w = PopoutWindow("test", QLabel("content"))
qtbot.addWidget(w)
w.resize(400, 300)
return w
def test_edges_at(qtbot):
w = _window(qtbot)
band = PopoutWindow.RESIZE_MARGIN + PopoutWindow.OUTER_GRIP
assert w._edges_at(QPoint(band - 1, 150)) == Qt.Edge.LeftEdge
assert w._edges_at(QPoint(200, band - 1)) == Qt.Edge.TopEdge
assert w._edges_at(QPoint(399, 299)) == (Qt.Edge.RightEdge | Qt.Edge.BottomEdge)
assert w._edges_at(QPoint(200, 150)) == Qt.Edge(0)
def test_cursor_for(qtbot):
w = _window(qtbot)
assert w._cursor_for(Qt.Edge.LeftEdge) == Qt.CursorShape.SizeHorCursor
assert w._cursor_for(Qt.Edge.BottomEdge) == Qt.CursorShape.SizeVerCursor
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
assert w._cursor_for(Qt.Edge(0)) is None
def test_close_hides_and_keeps_geometry(qtbot):
w = _window(qtbot)
w.show()
qtbot.waitExposed(w)
w.move(120, 130)
geometry = w.geometry()
w.close()
assert not w.isVisible()
# Reopen: the first-show placement must not run again (geometry kept).
w.show()
assert w._placed
assert w.geometry() == geometry
def test_manual_resize_fallback(qtbot):
w = _window(qtbot)
w.show()
qtbot.waitExposed(w)
w.resize(400, 300)
start = w.geometry()
grab = QPointF(399.0, 299.0) # bottom-right band
press = QMouseEvent(
QEvent.Type.MouseButtonPress,
grab,
w.mapToGlobal(grab.toPoint()).toPointF(),
Qt.MouseButton.LeftButton,
Qt.MouseButton.LeftButton,
Qt.KeyboardModifier.NoModifier,
)
w.mousePressEvent(press)
if not w._manual_edges:
# The platform accepted startSystemResize; the manual path is not
# reachable here, and the press must not have moved the window.
assert w.geometry() == start
return
move_to = grab + QPointF(40.0, 25.0)
move = QMouseEvent(
QEvent.Type.MouseMove,
move_to,
w.mapToGlobal(move_to.toPoint()).toPointF(),
Qt.MouseButton.NoButton,
Qt.MouseButton.LeftButton,
Qt.KeyboardModifier.NoModifier,
)
w.mouseMoveEvent(move)
assert w.width() == start.width() + 40
assert w.height() == start.height() + 25
release = QMouseEvent(
QEvent.Type.MouseButtonRelease,
move_to,
w.mapToGlobal(move_to.toPoint()).toPointF(),
Qt.MouseButton.LeftButton,
Qt.MouseButton.NoButton,
Qt.KeyboardModifier.NoModifier,
)
w.mouseReleaseEvent(release)
assert not w._manual_edges
def test_hover_sets_resize_cursor(qtbot):
w = _window(qtbot)
w.show()
qtbot.waitExposed(w)
def hover(pos):
w.mouseMoveEvent(
QMouseEvent(
QEvent.Type.MouseMove,
pos,
w.mapToGlobal(pos.toPoint()).toPointF(),
Qt.MouseButton.NoButton,
Qt.MouseButton.NoButton,
Qt.KeyboardModifier.NoModifier,
)
)
hover(QPointF(2.0, 150.0))
assert w.cursor().shape() == Qt.CursorShape.SizeHorCursor
hover(QPointF(200.0, 150.0))
assert w.cursor().shape() == Qt.CursorShape.ArrowCursor
def test_dock_title_bar_buttons(qtbot):
dock = QDockWidget("Console Log")
qtbot.addWidget(dock)
opened = []
bar = DockTitleBar(dock, on_popout=lambda: opened.append(True))
dock.setTitleBarWidget(bar)
dock.show()
qtbot.waitExposed(dock)
qtbot.mouseClick(bar.popout_button, Qt.MouseButton.LeftButton)
assert opened == [True]
assert dock.isVisible()
def test_manual_resize_from_top_left(qtbot):
w = _window(qtbot)
w.show()
qtbot.waitExposed(w)
w.resize(400, 300)
# Drive the manual path directly; whether startSystemResize is available
# is platform luck and the top/left arithmetic deserves coverage either way.
w._manual_edges = Qt.Edge.LeftEdge | Qt.Edge.TopEdge
w._press_global = w.mapToGlobal(QPoint(2, 2))
w._press_geom = w.geometry()
move_to = QPointF(2.0 + 30.0, 2.0 + 20.0)
w.mouseMoveEvent(
QMouseEvent(
QEvent.Type.MouseMove,
move_to,
w.mapToGlobal(move_to.toPoint()).toPointF(),
Qt.MouseButton.NoButton,
Qt.MouseButton.LeftButton,
Qt.KeyboardModifier.NoModifier,
)
)
assert w.width() == 400 - 30
assert w.height() == 300 - 20
def test_paint_border_knob(qtbot, monkeypatch):
from PySide6.QtGui import QPixmap
from aare.gui.widgets import popout_window as mod
w = _window(qtbot)
# Default FRAME_L1_WIDTH "0px": render must take the paint-nothing path.
w.render(QPixmap(w.size()))
# With a visible level-1 border the painter path runs.
monkeypatch.setattr(mod, "FRAME_L1_WIDTH", "2px")
w.render(QPixmap(w.size()))
+14
View File
@@ -0,0 +1,14 @@
from PySide6.QtGui import QPixmap
from aare.gui.widgets.splash_screen import LoadingSplashScreen
def test_splash_progress_and_message(qtbot):
splash = LoadingSplashScreen(QPixmap(200, 100))
qtbot.addWidget(splash)
splash.set_progress(42, "Loading panels")
assert splash.progress.value() == 42
splash.set_progress(43) # message-less update takes the no-showMessage branch
assert splash.progress.value() == 43
+134
View File
@@ -0,0 +1,134 @@
"""The combined sample dock: chip row filters the table by status, chips
double as drop targets for queue/flag relabeling, and a pop-out panel shares
the docked panel's model so both stay in sync without wiring."""
import pytest
from aarecommon.models.models import DewarAddress, SampleShortInfo, SampleShortInfoList
from aare.gui.panels.tell_sample_panel import TellSamplePanel
@pytest.fixture
def samples():
return SampleShortInfoList(
s=[
SampleShortInfo(
db_id=i,
puck_name=f"P{i}",
dewar_name="D1",
sample_name=f"S{i}",
run_number=i,
user="U1",
pin=i,
location=DewarAddress(segment="A", pos=i),
)
for i in (1, 2, 3)
]
)
@pytest.fixture
def panel(qtbot, samples):
panel = TellSamplePanel(samples=samples)
qtbot.addWidget(panel)
panel.table_model.set_show_all_pgroups(True)
return panel
def _chip(panel, key):
return next(c for c in panel.status_chips.buttons() if c.property("status_key") == key)
def test_chip_click_drives_the_status_filter(panel):
panel.table_model.set_queued_ids({2})
_chip(panel, "queued").click()
assert panel.table_model.status_filter == "queued"
assert panel.table_model.rowCount() == 1
_chip(panel, None).click()
assert panel.table_model.status_filter is None
assert panel.table_model.rowCount() == 3
def test_set_status_chip_syncs_without_filtering(panel):
panel.set_status_chip("flagged")
assert _chip(panel, "flagged").isChecked()
# Sync only checks the chip; it must not fire the filter.
assert panel.table_model.status_filter is None
def test_queued_chip_drop_relays_to_the_queue(panel, qtbot, samples):
chip = _chip(panel, "queued")
with qtbot.waitSignal(panel.add_to_queue) as blocker:
chip.samples_dropped.emit(samples)
assert [s.db_id for s in blocker.args[0].s] == [1, 2, 3]
def test_flagged_chip_drop_flags_in_the_model(panel, samples):
panel._flag_dropped_samples(SampleShortInfoList(s=samples.s[:2]))
assert panel.table_model.flagged_ids == {1, 2}
def test_popout_panel_shares_the_model(panel, qtbot):
popout = TellSamplePanel(model=panel.table_model)
qtbot.addWidget(popout)
assert popout.table_model is panel.table_model
panel.table_model.set_flagged(3, True)
assert 3 in popout.table_model.flagged_ids
def test_new_sample_list_updates_rows(panel, samples):
extra = samples.s + [
SampleShortInfo(
db_id=9,
puck_name="P9",
dewar_name="D2",
sample_name="S9",
run_number=9,
user="U2",
pin=9,
location=DewarAddress(segment="B", pos=1),
)
]
panel.new_sample_list(SampleShortInfoList(s=extra))
assert panel.table_model.rowCount() == 4
def test_queue_drop_chip_accepts_sample_payloads(panel, qtbot, samples):
from PySide6.QtCore import QMimeData, QPointF, Qt
from PySide6.QtGui import QDropEvent
from aare.gui.panels.tell_sample_panel import QueueDropChip
chip = next(c for c in panel.status_chips.buttons() if isinstance(c, QueueDropChip))
# The event only borrows the QMimeData (C++ pointer), so the mime must
# outlive the dropEvent call — hence created in the test's scope.
def drop(mime):
return QDropEvent(
QPointF(1, 1),
Qt.DropAction.CopyAction,
mime,
Qt.MouseButton.NoButton,
Qt.KeyboardModifier.NoModifier,
)
good = QMimeData()
good.setText(samples.model_dump_json())
with qtbot.waitSignal(chip.samples_dropped):
chip.dropEvent(drop(good))
# A non-sample payload is ignored, not crashed on.
bad = QMimeData()
bad.setText("not json")
with qtbot.assertNotEmitted(chip.samples_dropped):
chip.dropEvent(drop(bad))
def test_selected_samples_follow_the_click(panel):
view = panel.table_view
view.selectRow(0)
row_ids = [panel.table_model.get_id(r).db_id for r in range(3)]
# Click inside the selection: the selection is acted on.
assert [s.db_id for s in panel._selected_samples(0)] == [row_ids[0]]
# Click outside the selection: only the clicked row is acted on.
assert [s.db_id for s in panel._selected_samples(2)] == [row_ids[2]]
+75
View File
@@ -0,0 +1,75 @@
from PySide6.QtCore import QSettings
from PySide6.QtWidgets import QGridLayout, QHBoxLayout, QPushButton, QWidget
from aare.gui.widgets.title_label import TitleLabel
# Unique title so the test never clashes with real panel settings.
TITLE = "TitleLabelTestPanel"
KEY = f"panel_collapsed/{TITLE}"
def _remove_key():
QSettings("PSI", "AareGUI").remove(KEY)
def _build_panel(qtbot):
panel = QWidget()
qtbot.addWidget(panel)
grid = QGridLayout(panel)
title = TitleLabel(TITLE, panel, collapsible=True)
grid.addWidget(title, 0, 0, 1, 2)
direct_child = QPushButton("direct", panel)
grid.addWidget(direct_child, 1, 0)
nested = QHBoxLayout()
nested_child = QPushButton("nested", panel)
nested.addWidget(nested_child)
grid.addLayout(nested, 1, 1)
return panel, title, direct_child, nested_child
def test_starts_collapsed_by_default_and_toggle_persists(qtbot):
_remove_key()
try:
_panel, title, direct_child, nested_child = _build_panel(qtbot)
# Default is collapsed; applied deferred with a 0 ms timer (siblings
# don't exist yet at TitleLabel construction).
qtbot.waitUntil(lambda: direct_child.isHidden(), timeout=1000)
assert nested_child.isHidden()
assert not title.isHidden()
assert title.toggle_button.text() == "+"
title.toggle_collapsed()
assert not direct_child.isHidden()
assert not nested_child.isHidden()
assert title.toggle_button.text() == ""
assert QSettings("PSI", "AareGUI").value(KEY, True, type=bool) is False
title.toggle_collapsed()
assert direct_child.isHidden()
assert title.toggle_button.text() == "+"
assert QSettings("PSI", "AareGUI").value(KEY, False, type=bool) is True
finally:
_remove_key()
def test_saved_expanded_state_restored_on_construction(qtbot):
QSettings("PSI", "AareGUI").setValue(KEY, False)
try:
_panel, title, direct_child, nested_child = _build_panel(qtbot)
# A saved expanded state must override the collapsed default; give the
# (absent) deferred collapse a chance to run before asserting.
qtbot.wait(100)
assert not direct_child.isHidden()
assert not nested_child.isHidden()
assert title.toggle_button.text() == ""
finally:
_remove_key()
def test_not_collapsible_by_default(qtbot):
panel = QWidget()
qtbot.addWidget(panel)
grid = QGridLayout(panel)
title = TitleLabel("Plain", panel)
grid.addWidget(title, 0, 0)
assert not hasattr(title, "toggle_button")
+74
View File
@@ -0,0 +1,74 @@
"""The wheel guard is motor protection: a bare wheel over a value widget must
never change the value (it scrolls the page instead); adjusting requires the
deliberate right-button + wheel gesture."""
import pytest
from PySide6.QtCore import QPoint, QPointF, Qt
from PySide6.QtGui import QWheelEvent
from PySide6.QtWidgets import QApplication, QScrollArea, QSlider, QSpinBox, QVBoxLayout, QWidget
from aare.gui.widgets.wheel_value_guard import WheelValueGuard
@pytest.fixture
def guard(qapp):
guard = WheelValueGuard()
qapp.installEventFilter(guard)
yield guard
qapp.removeEventFilter(guard)
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,
)
def test_bare_wheel_does_not_adjust(guard, qtbot):
slider = QSlider(Qt.Orientation.Horizontal)
qtbot.addWidget(slider)
slider.setRange(0, 100)
slider.setValue(50)
QApplication.sendEvent(slider, _wheel(Qt.MouseButton.NoButton))
assert slider.value() == 50
def test_right_button_wheel_adjusts(guard, qtbot):
slider = QSlider(Qt.Orientation.Horizontal)
qtbot.addWidget(slider)
slider.setRange(0, 100)
slider.setValue(50)
QApplication.sendEvent(slider, _wheel(Qt.MouseButton.RightButton))
assert slider.value() != 50
def test_bare_wheel_scrolls_the_enclosing_area(guard, qtbot):
area = QScrollArea()
qtbot.addWidget(area)
content = QWidget()
layout = QVBoxLayout(content)
spin = QSpinBox()
spin.setRange(0, 100)
spin.setValue(50)
layout.addWidget(spin)
# Tall filler so the area has something to scroll.
filler = QWidget()
filler.setFixedHeight(2000)
layout.addWidget(filler)
area.setWidget(content)
area.resize(200, 200)
area.show()
bar = area.verticalScrollBar()
bar.setValue(bar.maximum() // 2)
before = bar.value()
QApplication.sendEvent(spin, _wheel(Qt.MouseButton.NoButton))
assert spin.value() == 50
assert bar.value() != before