fix: dark-mode legibility for the status bar, sample tables, and surfaces

Status-bar flags get status_colors(theme) (Mocha variants on Sunset) via
a new set_theme; sample tables stop hard-filling rows WHITE and pin dark
ink on tinted rows via ForegroundRole; dewar tab and log panel get solid
dark surfaces (transparent renders black on the non-composited X11
container); developer help cards go square for the same reason.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 10:35:55 +02:00
co-authored by Claude Fable 5
parent e77dfb4716
commit 7b30c959a2
7 changed files with 157 additions and 44 deletions
+8
View File
@@ -551,6 +551,7 @@ class MainWindow(QMainWindow):
# hidden, as the queue engine; its buttons are reparented here so all
# their existing wiring keeps working.
dewar_tab = QWidget()
dewar_tab.setObjectName("dewarTab")
dewar_layout = QVBoxLayout(dewar_tab)
dewar_layout.setContentsMargins(0, 0, 0, 0)
dewar_layout.setSpacing(2)
@@ -844,6 +845,8 @@ class MainWindow(QMainWindow):
)
self.status_bar = StatusBar(self._decoded_token, parent=self)
# Created after the first _apply_theme, so hand it the theme directly.
self.status_bar.set_theme(self._theme_mode)
self.setStatusBar(self.status_bar)
self.daq = DAQWorker(base_url=self._base_url, token=self._token)
@@ -1290,6 +1293,7 @@ class MainWindow(QMainWindow):
dewar_panel.set_status_chip(self.tell_samples.table_model.status_filter)
dewar_tab = QWidget()
dewar_tab.setObjectName("dewarTab")
dewar_layout = QVBoxLayout(dewar_tab)
dewar_layout.setContentsMargins(0, 0, 0, 0)
dewar_layout.setSpacing(2)
@@ -1817,6 +1821,10 @@ class MainWindow(QMainWindow):
self.setStyleSheet(build_app_stylesheet(self._theme_mode))
# State colors are painted in code per DAQ tick — QSS can't reach them.
self.beamline_state_panel.set_theme(self._theme_mode)
# Status bar flags likewise; it is created after the first
# _apply_theme call in __init__, hence the guard.
if hasattr(self, "status_bar"):
self.status_bar.set_theme(self._theme_mode)
self.sample_camera.set_theme(self._theme_mode)
if old_look is None:
return
+6 -2
View File
@@ -4,7 +4,7 @@ from PySide6.QtCore import QAbstractTableModel, Qt
from PySide6.QtGui import QBrush
from aare.gui.constants import LOGGER_NAME
from aare.gui.styles import SAMPLE_ROW_ACTIVE_BG, SAMPLE_ROW_QUEUED_BG, WHITE, qcolor
from aare.gui.styles import SAMPLE_ROW_ACTIVE_BG, SAMPLE_ROW_QUEUED_BG, SAMPLE_STATUS_TEXT, qcolor
logger = setup_logger(LOGGER_NAME)
@@ -59,12 +59,16 @@ class SampleQueueSpreadsheet(QAbstractTableModel):
elif role == Qt.ItemDataRole.TextAlignmentRole:
return Qt.AlignmentFlag.AlignCenter
elif role == Qt.ItemDataRole.BackgroundRole:
# Tint only the head-of-queue row; plain rows return None so the
# theme QSS paints them (hardcoded WHITE fills broke dark mode).
if index.row() == 0:
if self._running:
return QBrush(qcolor(SAMPLE_ROW_ACTIVE_BG))
else:
return QBrush(qcolor(SAMPLE_ROW_QUEUED_BG))
return QBrush(qcolor(WHITE))
elif role == Qt.ItemDataRole.ForegroundRole and index.row() == 0:
# Fixed dark ink on the tint so dark-theme white text stays legible.
return QBrush(qcolor(SAMPLE_STATUS_TEXT))
return None
def headerData(self, section, orientation, role=None):
+20 -4
View File
@@ -32,6 +32,7 @@ from PySide6.QtWidgets import (
from aare.gui.constants import LOGGER_NAME
from aare.gui.log import QtLogEmitter, QtLogHandler
from aare.gui.styles import (
FLAT_CARD_RADIUS,
PANEL_BG_FAINT,
PANEL_BG_SOFT,
PANEL_BORDER,
@@ -69,8 +70,16 @@ class DeveloperHelpDialog(QDialog):
self._banner.setVisible(self._is_staff)
self._banner.setWordWrap(True)
self._banner.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
# Square cards throughout this dialog: the rounded corner cut-outs
# render black on the container's non-composited X11.
self._banner.setStyleSheet(
card_style(PANEL_BG_SOFT, PANEL_BORDER, selector="QLabel", extra="padding: 6px 8px;")
card_style(
PANEL_BG_SOFT,
PANEL_BORDER,
selector="QLabel",
radius=FLAT_CARD_RADIUS,
extra="padding: 6px 8px;",
)
)
root.addWidget(self._banner)
@@ -89,7 +98,6 @@ class DeveloperHelpDialog(QDialog):
"QLineEdit {"
f" background: {WHITE};"
f" border: 1px solid {PANEL_BORDER_DARK};"
" border-radius: 6px;"
" padding: 4px 8px;"
"}"
)
@@ -149,7 +157,9 @@ class DeveloperHelpDialog(QDialog):
self._details_frame = QFrame(self)
self._details_frame.setFrameShape(QFrame.Shape.StyledPanel)
self._details_frame.setStyleSheet(card_style(PANEL_BG_FAINT, PANEL_BORDER))
self._details_frame.setStyleSheet(
card_style(PANEL_BG_FAINT, PANEL_BORDER, radius=FLAT_CARD_RADIUS)
)
details_layout = QVBoxLayout(self._details_frame)
details_layout.setContentsMargins(10, 10, 10, 10)
@@ -179,7 +189,13 @@ class DeveloperHelpDialog(QDialog):
self._detail_help.setWordWrap(True)
self._detail_help.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
self._detail_help.setStyleSheet(
card_style(WHITE, PANEL_BORDER_LIGHT, selector="QLabel", extra="padding: 8px;")
card_style(
WHITE,
PANEL_BORDER_LIGHT,
selector="QLabel",
radius=FLAT_CARD_RADIUS,
extra="padding: 8px;",
)
)
details_layout.addWidget(QLabel("Help:", self))
details_layout.addWidget(self._detail_help, 1)
+17 -4
View File
@@ -7,7 +7,7 @@ from PySide6.QtGui import QBrush
from PySide6.QtWidgets import QAbstractItemView, QFrame, QGridLayout, QHeaderView, QMenu, QTableView
from aare.gui.constants import LOGGER_NAME
from aare.gui.styles import SAMPLE_ROW_QUEUED_BG, WHITE, qcolor
from aare.gui.styles import SAMPLE_ROW_QUEUED_BG, SAMPLE_STATUS_TEXT, qcolor
from aare.gui.widgets.title_label import TitleLabel
logger = setup_logger(LOGGER_NAME)
@@ -74,9 +74,19 @@ class ReferenceToolsModel(QAbstractTableModel):
elif role == Qt.ItemDataRole.TextAlignmentRole:
return Qt.AlignmentFlag.AlignCenter
elif role == Qt.ItemDataRole.BackgroundRole:
# Tint only the current-reference row; plain rows return None so
# the theme QSS paints them (a hardcoded WHITE fill here was the
# big white table in dark mode and fought the light theme's
# alternating stripes).
if self._sorted_samples[index.row()].db_id == self.current_reference:
return QBrush(qcolor(SAMPLE_ROW_QUEUED_BG))
return QBrush(qcolor(WHITE))
elif (
role == Qt.ItemDataRole.ForegroundRole
and self._sorted_samples[index.row()].db_id == self.current_reference
):
# Fixed dark ink on the tint — the tint stays pale in BOTH themes,
# so the dark theme's near-white text would vanish on it.
return QBrush(qcolor(SAMPLE_STATUS_TEXT))
return None
@@ -152,7 +162,7 @@ class ReferenceToolsModel(QAbstractTableModel):
self.dataChanged.emit(
self.index(0, 0),
self.index(self.rowCount() - 1, self.columnCount() - 1),
[Qt.ItemDataRole.BackgroundRole],
[Qt.ItemDataRole.BackgroundRole, Qt.ItemDataRole.ForegroundRole],
)
@@ -191,8 +201,11 @@ class ReferenceToolsPanel(QFrame):
layout.addWidget(TitleLabel("Reference tools", parent=self), 0, 0, 1, 4)
self.table_view = QTableView(parent=self)
# Row colors carry the separation — no grid lines.
# Row colors carry the separation — no grid lines. Alternating rows
# come from the theme QSS (alternate-background-color), same as the
# Dewar sample list.
self.table_view.setShowGrid(False)
self.table_view.setAlternatingRowColors(True)
layout.addWidget(self.table_view, 1, 0, 1, 4)
# initialize model with provided samples (or adopt the shared one)
@@ -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)
+49 -2
View File
@@ -352,6 +352,40 @@ STATUS_WARN = "#fe640b" # baton waiting / warming cryo / tell busy (peach)
STATUS_INFO = "#1e66f5" # cold cryo (blue)
STATUS_VACANT = "#df8e1d" # baton vacant (yellow)
STATUS_REQUEST = "#04a5e5" # baton request (sky)
# Dark variants (Catppuccin Mocha) — the latte values above sink into the
# sunset sky. Painted in code per DAQ tick, so status_colors(theme) hands
# them out — same pattern as state_colors below.
DARK_STATUS_OK = "#a6e3a1" # mocha green
DARK_STATUS_ALERT = "#f38ba8" # mocha red
DARK_STATUS_WARN = "#fab387" # mocha peach
DARK_STATUS_INFO = "#89b4fa" # mocha blue
DARK_STATUS_VACANT = "#f9e2af" # mocha yellow
DARK_STATUS_REQUEST = "#89dceb" # mocha sky
DARK_STATE_TELL_TEXT = "#bac2de" # mocha subtext1 — status-bar TELL idle line
def status_colors(theme: str) -> dict[str, str]:
"""Status-bar flag colors for the given theme (painted in code)."""
if theme == THEME_SUNSET:
return {
"ok": DARK_STATUS_OK,
"alert": DARK_STATUS_ALERT,
"warn": DARK_STATUS_WARN,
"info": DARK_STATUS_INFO,
"vacant": DARK_STATUS_VACANT,
"request": DARK_STATUS_REQUEST,
"tell": DARK_STATE_TELL_TEXT,
}
return {
"ok": STATUS_OK,
"alert": STATUS_ALERT,
"warn": STATUS_WARN,
"info": STATUS_INFO,
"vacant": STATUS_VACANT,
"request": STATUS_REQUEST,
"tell": STATE_TELL_TEXT,
}
# -- Beamline state panel ---------------------------------------------------
# The panel paints these in code per DAQ tick (data-driven), so it asks
@@ -398,7 +432,7 @@ SAMPLE_ROW_ALT_BG = "#eef1f5" # staggered row grey (alternates with white)
SAMPLE_STATUS_QUEUED_BG = "#ffe4c4" # pale orange — waiting in the automation queue
SAMPLE_STATUS_FLAGGED_BG = "#ffd9d9" # pale red — automation failed on this sample
SAMPLE_STATUS_MEASURED_BG = "#dcf2e0" # pale green — already has collected data
SAMPLE_STATUS_SELECTED_BG = "#d8e8fd" # pale blue — table selection highlight
SAMPLE_STATUS_SELECTED_BG = "#84abd9" # pale blue — table selection highlight
# Fixed ink on the pastel tints above: the tints stay light in BOTH themes,
# so theme-following text (white in Sunset) would vanish on them. Models
# return this as ForegroundRole wherever they return a tint.
@@ -1641,7 +1675,9 @@ def _sunset_stylesheet() -> str:
}
QLabel#beamlineStateTellLabel {
color: $dark_subtext;
/* Same color as the Current-state neighbor — subtext was unreadable
on the selected-state blue band. */
color: $dark_text;
font-size: $font_body_lg;
font-weight: 700;
padding-left: 4px;
@@ -1671,11 +1707,22 @@ def _sunset_stylesheet() -> str:
background: $dark_surface;
}
/* Dewar tab page: the automation button row sits on this bare QWidget
below the panel's border — left transparent it shows the near-black
gradient bottom, reading as an unpainted hole. */
QWidget#dewarTab {
background-color: $dark_surface;
}
QWidget#logPanel { background-color: $dark_surface; }
/* No L3 border here: the light theme's pale hairline read as a white
frame around dark tables. */
QTableView, QPlainTextEdit {
border: none;
}
QPlainTextEdit {
background: $dark_table_bg; /* solid — transparent renders black on the container's X11 */
}
/* Selection + staggered rows, dark flavor. Solid fills on purpose —
a transparent viewport renders black here (see DARK_TABLE_BG). */
+54 -32
View File
@@ -8,15 +8,7 @@ from PySide6.QtGui import QFont
from PySide6.QtWidgets import QDialog, QLabel, QMenu, QMessageBox, QSizePolicy, QStatusBar
from aare.gui.constants import LOGGER_NAME
from aare.gui.styles import (
STATE_TELL_TEXT,
STATUS_ALERT,
STATUS_INFO,
STATUS_OK,
STATUS_REQUEST,
STATUS_VACANT,
STATUS_WARN,
)
from aare.gui.styles import THEME_SUNRISE, status_colors
from aare.gui.widgets.baton_request_dialog import BatonRequestDialog
from aare.gui.widgets.clickable_label import ClickableLabel
from aare.gui.widgets.pgroup_dialog import PGroupDialog
@@ -54,6 +46,11 @@ class StatusBar(QStatusBar):
self._is_staff = self._decoded_token.staff
self._allowed_pgroups = self._decoded_token.pgroups
# Per-theme flag colors (MainWindow._apply_theme calls set_theme) —
# painted in code per DAQ tick, QSS cannot reach the rich-text spans.
self._colors = status_colors(THEME_SUNRISE)
self._message_is_error = False
self._message_clear_timer = QTimer(self)
self._message_clear_timer.setSingleShot(True)
self._message_clear_timer.timeout.connect(self.clear_connection_message)
@@ -109,12 +106,24 @@ class StatusBar(QStatusBar):
self.addPermanentWidget(self.busy_label)
self.addPermanentWidget(self.session_label)
def set_theme(self, theme: str) -> None:
"""Adopt the theme's flag colors: recolor the connection message and
re-render the DAQ-driven labels from the last status right away."""
self._colors = status_colors(theme)
self._apply_message_style()
if self._status is not None:
self.update_daq_status(self._status)
def _apply_message_style(self) -> None:
color = self._colors["alert"] if self._message_is_error else self._colors["ok"]
self.message_label.setStyleSheet(f"color: {color}; font-weight: bold;")
@Slot(str, bool)
def show_connection_message(self, msg: str, is_error: bool = True):
color = STATUS_ALERT if is_error else STATUS_OK
self._message_is_error = is_error
self._message_clear_timer.stop()
self.message_label.setText(msg)
self.message_label.setStyleSheet(f"color: {color}; font-weight: bold;")
self._apply_message_style()
self.message_label.setVisible(bool(msg))
if not is_error:
@@ -155,9 +164,13 @@ class StatusBar(QStatusBar):
self.transmission.set_value(f"{status.bl.transmission:.5f}")
if status.bl.ring_current_mA < 5.0:
self.ring_current.set_value(f"{status.bl.ring_current_mA:.2f}", STATUS_ALERT)
self.ring_current.set_value(
f"{status.bl.ring_current_mA:.2f}", self._colors["alert"]
)
elif status.bl.ring_current_mA < 390.0:
self.ring_current.set_value(f"{status.bl.ring_current_mA:.2f}", STATUS_WARN)
self.ring_current.set_value(
f"{status.bl.ring_current_mA:.2f}", self._colors["warn"]
)
else:
self.ring_current.set_value(f"{status.bl.ring_current_mA:.2f}")
@@ -165,28 +178,28 @@ class StatusBar(QStatusBar):
self.wvl.set_value(f"{status.diffraction.wavelength_angstrom:.2f}")
if status.bl.cryojet_K < 110.0:
self.cryo_label.set_value(f"{status.bl.cryojet_K:.1f}", STATUS_INFO)
self.cryo_label.set_value(f"{status.bl.cryojet_K:.1f}", self._colors["info"])
elif status.bl.cryojet_K < 250.0:
self.cryo_label.set_value(f"{status.bl.cryojet_K:.1f}", STATUS_WARN)
self.cryo_label.set_value(f"{status.bl.cryojet_K:.1f}", self._colors["warn"])
else:
self.cryo_label.set_value(f"{status.bl.cryojet_K:.1f}", STATUS_ALERT)
self.cryo_label.set_value(f"{status.bl.cryojet_K:.1f}", self._colors["alert"])
if status.bl.shutter_open:
self.shutter_label.setText(
f"""Fast Shutter: <span style="color: {STATUS_ALERT} ; "> Open ☢️ </span>"""
f"""Fast Shutter: <span style="color: {self._colors["alert"]} ; "> Open ☢️ </span>"""
)
else:
self.shutter_label.setText(
f"""Fast Shutter: <span style="color: {STATUS_OK} ; "> Closed 🚪 </span>"""
f"""Fast Shutter: <span style="color: {self._colors["ok"]} ; "> Closed 🚪 </span>"""
)
if status.bl.exp_shutter_open:
self.exp_shutter_label.setText(
f"""ExpHutch Shutter: <span style="color: {STATUS_ALERT} ; "> Open </span>"""
f"""ExpHutch Shutter: <span style="color: {self._colors["alert"]} ; "> Open </span>"""
)
else:
self.exp_shutter_label.setText(
f"""ExpHutch Shutter: <span style="color: {STATUS_OK} ; "> Closed 🚪 </span>"""
f"""ExpHutch Shutter: <span style="color: {self._colors["ok"]} ; "> Closed 🚪 </span>"""
)
if status.session.current_pgroup is not None:
@@ -197,29 +210,29 @@ class StatusBar(QStatusBar):
self.state_label.setText(f"""State: {status.state.display_name()} """)
tell_text = ""
tell_color = STATE_TELL_TEXT
tell_color = self._colors["tell"]
if status.tell_state is not None:
tell_text = status.tell_state.activity.display_name()
if status.tell_state.activity.value == "error":
tell_color = STATUS_ALERT
tell_color = self._colors["alert"]
elif status.tell_state.activity.value in {
"mounting",
"unmounting",
"drying",
"cooling",
}:
tell_color = STATUS_WARN
tell_color = self._colors["warn"]
else:
tell_color = STATUS_OK
tell_color = self._colors["ok"]
self.tell_state_label.setText(f"Tell: {tell_text} ")
self.tell_state_label.setStyleSheet(f"color: {tell_color};")
if status.busy:
busy_flag = f""" <span style="color: {STATUS_ALERT}; "> Busy 🔒 </span>"""
busy_flag = f""" <span style="color: {self._colors["alert"]}; "> Busy 🔒 </span>"""
else:
busy_flag = f""" <span style="color: {STATUS_OK} ; "> Idle 🔓 </span>"""
busy_flag = f""" <span style="color: {self._colors["ok"]} ; "> Idle 🔓 </span>"""
html_content = f"""Beamline: {busy_flag} """
@@ -227,15 +240,23 @@ class StatusBar(QStatusBar):
session_flag = ""
if status.session.session == SessionsStateEnum.Vacant:
session_flag = f"""<span style="color: {STATUS_VACANT} ; "> Vacant 🔓 </span>"""
session_flag = (
f"""<span style="color: {self._colors["vacant"]} ; "> Vacant 🔓 </span>"""
)
elif status.session.session == SessionsStateEnum.OwnedByYou:
session_flag = f"""<span style="color: {STATUS_OK} ; "> Owned ⬤ </span>"""
session_flag = f"""<span style="color: {self._colors["ok"]} ; "> Owned ⬤ </span>"""
elif status.session.session == SessionsStateEnum.OwnedByElse:
session_flag = f"""<span style="color: {STATUS_ALERT} ; "> Other 🔒 </span>"""
session_flag = (
f"""<span style="color: {self._colors["alert"]} ; "> Other 🔒 </span>"""
)
elif status.session.session == SessionsStateEnum.PendingYouToElse:
session_flag = f"""<span style="color: {STATUS_WARN} ; "> Waiting... ⏳ </span>"""
session_flag = (
f"""<span style="color: {self._colors["warn"]} ; "> Waiting... ⏳ </span>"""
)
elif status.session.session == SessionsStateEnum.PendingElseToYou:
session_flag = f"""<span style="color: {STATUS_REQUEST} ; "> Request! ⚡ </span>"""
session_flag = (
f"""<span style="color: {self._colors["request"]} ; "> Request! ⚡ </span>"""
)
html_content_session = f"""Session: {session_flag}"""
self.session_label.setText(html_content_session)
@@ -603,7 +624,8 @@ class StatusBar(QStatusBar):
def _generate_pgroup_dialogue(self, curr: str | None = None, pgroups: list | None = None):
logger.info(pgroups)
dialog = PGroupDialog(curr_pgroup=curr, pgroups=pgroups)
dialog = PGroupDialog(curr_pgroup=curr, pgroups=pgroups, parent=self.window())
if dialog.exec() == QDialog.DialogCode.Accepted:
entered_text = dialog.get_input()
if pgroups and entered_text not in pgroups: