Collapsible panel banners and adaptive window sizing #129
|
Before Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 261 B |
|
After Width: | Height: | Size: 270 B |
|
After Width: | Height: | Size: 103 B |
|
After Width: | Height: | Size: 103 B |
|
After Width: | Height: | Size: 180 B |
|
After Width: | Height: | Size: 176 B |
|
After Width: | Height: | Size: 174 B |
|
After Width: | Height: | Size: 192 B |
@@ -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())
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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("")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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>")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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> "
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
@@ -0,0 +1,37 @@
|
||||
from aarecommon.models.models import SessionsStateEnum
|
||||
from PySide6.QtWidgets import QVBoxLayout, QWidget
|
||||
|
||||
from aare.gui.panels.axis_video_panel import AxisVideoPanel
|
||||
from aare.gui.widgets.busy_overlay import build_busy_overlay_style
|
||||
from aare.gui.widgets.video_image import VideoGraphicsView
|
||||
|
||||
|
||||
def _vacant_style():
|
||||
return build_busy_overlay_style(
|
||||
is_busy=False, tell_state=None, session_state=SessionsStateEnum.Vacant
|
||||
)
|
||||
|
||||
|
||||
def test_badge_drawn_once_and_hint_stripped(qtbot):
|
||||
# Combined-view shape: two video views stacked in one container.
|
||||
container = QWidget()
|
||||
layout = QVBoxLayout(container)
|
||||
first, second = VideoGraphicsView(), VideoGraphicsView()
|
||||
layout.addWidget(first)
|
||||
layout.addWidget(second)
|
||||
|
||||
panel = AxisVideoPanel("Combined", container)
|
||||
qtbot.addWidget(panel)
|
||||
|
||||
style = _vacant_style()
|
||||
assert style is not None and style.subtext # sample camera keeps the hint
|
||||
|
||||
panel.set_busy_style(style)
|
||||
applied = first._busy_overlay_style
|
||||
assert applied is not None
|
||||
assert applied.text == "In viewing mode"
|
||||
assert applied.subtext == "" # not clickable here, hint stripped
|
||||
assert second._busy_overlay_style is None # one badge, not one per view
|
||||
|
||||
panel.set_busy_style(None)
|
||||
assert first._busy_overlay_style is None
|
||||
@@ -0,0 +1,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
|
||||
@@ -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())
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()))
|
||||
@@ -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
|
||||
@@ -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]]
|
||||
@@ -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")
|
||||
@@ -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
|
||||