From 7b30c959a20e62c58f190f5b06dddbdcafa5d956 Mon Sep 17 00:00:00 2001 From: Dawn Date: Tue, 11 Aug 2026 09:45:55 +0200 Subject: [PATCH] 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 --- src/aare/gui/main_window.py | 8 ++ src/aare/gui/models/sample_queue_model.py | 8 +- src/aare/gui/panels/developer_help_dialog.py | 24 +++++- src/aare/gui/panels/reference_tools_panel.py | 21 ++++- src/aare/gui/panels/sample_queue_panel.py | 3 + src/aare/gui/styles.py | 51 +++++++++++- src/aare/gui/widgets/status_bar.py | 86 ++++++++++++-------- 7 files changed, 157 insertions(+), 44 deletions(-) diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index cb47f0f9..99a437e6 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -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 diff --git a/src/aare/gui/models/sample_queue_model.py b/src/aare/gui/models/sample_queue_model.py index e6d6844e..f6a1c3c7 100644 --- a/src/aare/gui/models/sample_queue_model.py +++ b/src/aare/gui/models/sample_queue_model.py @@ -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): diff --git a/src/aare/gui/panels/developer_help_dialog.py b/src/aare/gui/panels/developer_help_dialog.py index a8c2b47a..734f46e8 100644 --- a/src/aare/gui/panels/developer_help_dialog.py +++ b/src/aare/gui/panels/developer_help_dialog.py @@ -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) diff --git a/src/aare/gui/panels/reference_tools_panel.py b/src/aare/gui/panels/reference_tools_panel.py index 09c409fc..de489191 100644 --- a/src/aare/gui/panels/reference_tools_panel.py +++ b/src/aare/gui/panels/reference_tools_panel.py @@ -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) diff --git a/src/aare/gui/panels/sample_queue_panel.py b/src/aare/gui/panels/sample_queue_panel.py index 13eeedd7..affa50f8 100644 --- a/src/aare/gui/panels/sample_queue_panel.py +++ b/src/aare/gui/panels/sample_queue_panel.py @@ -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) diff --git a/src/aare/gui/styles.py b/src/aare/gui/styles.py index 5dffb881..c6f3021c 100644 --- a/src/aare/gui/styles.py +++ b/src/aare/gui/styles.py @@ -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). */ diff --git a/src/aare/gui/widgets/status_bar.py b/src/aare/gui/widgets/status_bar.py index aca8d4d1..23fb4094 100644 --- a/src/aare/gui/widgets/status_bar.py +++ b/src/aare/gui/widgets/status_bar.py @@ -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: Open ☢️ """ + f"""Fast Shutter: Open ☢️ """ ) else: self.shutter_label.setText( - f"""Fast Shutter: Closed 🚪 """ + f"""Fast Shutter: Closed 🚪 """ ) if status.bl.exp_shutter_open: self.exp_shutter_label.setText( - f"""ExpHutch Shutter: Open """ + f"""ExpHutch Shutter: Open """ ) else: self.exp_shutter_label.setText( - f"""ExpHutch Shutter: Closed 🚪 """ + f"""ExpHutch Shutter: Closed 🚪 """ ) 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""" Busy 🔒 """ + busy_flag = f""" Busy 🔒 """ else: - busy_flag = f""" Idle 🔓 """ + busy_flag = f""" Idle 🔓 """ html_content = f"""Beamline: {busy_flag} """ @@ -227,15 +240,23 @@ class StatusBar(QStatusBar): session_flag = "" if status.session.session == SessionsStateEnum.Vacant: - session_flag = f""" Vacant 🔓 """ + session_flag = ( + f""" Vacant 🔓 """ + ) elif status.session.session == SessionsStateEnum.OwnedByYou: - session_flag = f""" Owned ⬤ """ + session_flag = f""" Owned ⬤ """ elif status.session.session == SessionsStateEnum.OwnedByElse: - session_flag = f""" Other 🔒 """ + session_flag = ( + f""" Other 🔒 """ + ) elif status.session.session == SessionsStateEnum.PendingYouToElse: - session_flag = f""" Waiting... ⏳ """ + session_flag = ( + f""" Waiting... ⏳ """ + ) elif status.session.session == SessionsStateEnum.PendingElseToYou: - session_flag = f""" Request! ⚡ """ + session_flag = ( + f""" Request! ⚡ """ + ) 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: