From ebd60d4c5d3fcdb9137e4ef33baf4c4338dcac82 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Thu, 25 Jun 2026 13:54:11 +0200 Subject: [PATCH] new_gui: Catppuccin theming (Latte/Mocha) + readability + beam-path pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - retarget theme.py to Catppuccin Latte (light) + Mocha (dark) - route on-fill text through accent_text (Mocha's light pastels need dark text); fixes unreadable buttons - transport buttons: labelled + sized (Play/Pause/Skip/End were cramped glyphs) - top-bar ☾/☀ theme toggle: live rebuild of views, backend kept, persisted to QSettings (wiring split into persistent vs view-facing for safe re-wire) - beam-path pipeline signature: continuous rail, green completed segment, mauve focal bloom on the active station - palette-ize stray colours (slider grooves, alert tints, state LED hues, legend) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/aare/gui/new_gui/README.md | 7 + src/aare/gui/new_gui/automation_view.py | 4 +- src/aare/gui/new_gui/main_window.py | 218 +++++++++++------- src/aare/gui/new_gui/status_bar.py | 17 +- src/aare/gui/new_gui/theme.py | 102 ++++---- src/aare/gui/new_gui/top_bar.py | 17 +- src/aare/gui/new_gui/widgets/alert_banner.py | 2 +- src/aare/gui/new_gui/widgets/baton_dialogs.py | 2 +- src/aare/gui/new_gui/widgets/bookmarks.py | 2 +- src/aare/gui/new_gui/widgets/camera.py | 6 +- src/aare/gui/new_gui/widgets/motors_panel.py | 4 +- src/aare/gui/new_gui/widgets/pipeline.py | 59 ++++- .../gui/new_gui/widgets/pipeline_panel.py | 25 +- .../gui/new_gui/widgets/sample_changer.py | 4 +- src/aare/gui/new_gui/widgets/spectrum_view.py | 2 +- 15 files changed, 294 insertions(+), 177 deletions(-) diff --git a/src/aare/gui/new_gui/README.md b/src/aare/gui/new_gui/README.md index cbbeda50..1d4e302e 100644 --- a/src/aare/gui/new_gui/README.md +++ b/src/aare/gui/new_gui/README.md @@ -16,6 +16,13 @@ uv run python -m aare.gui.new_gui.app -u https://host -c /path/to.crt -p tcp://h `BEAMLINE` (X06DA / X10SA / X06SA / unset→SIMULATED) drives all config via `cfg_get`, exactly like the existing GUI. +**Theme:** Catppuccin — **Latte** (light) and **Mocha** (dark), toggled with the +☾/☀ button in the top bar (persisted to QSettings; rebuilds the views live while +keeping the backend connection). `theme.py` is the single source of truth; text +on coloured fills routes through `accent_text` so Mocha's light pastels stay +legible. The Mount→Center→Raster→XRF→Collect tracker is a **beam-path rail** — +the completed length glows green and the active station is a mauve focal bloom. + **Offline dev mode:** with no server (`base_url is None`, e.g. `BEAMLINE=SIMULATED` and no `-u`), `dev_seed.py` injects a few fake samples + a synthetic live status + a fake camera frame so the full Manual flow (mount → pipeline, motors, bookmarks) diff --git a/src/aare/gui/new_gui/automation_view.py b/src/aare/gui/new_gui/automation_view.py index 3909ed97..84414a86 100644 --- a/src/aare/gui/new_gui/automation_view.py +++ b/src/aare/gui/new_gui/automation_view.py @@ -95,7 +95,7 @@ class LibraryRow(QWidget): if added: css = f"background:{palette.success_tint}; color:{palette.success}; font-size:13px;" else: - css = f"background:{palette.accent}; color:#fff; font-size:17px;" + css = f"background:{palette.accent}; color:{palette.accent_text}; font-size:17px;" self._add.setStyleSheet( f"QPushButton {{ {css} border:none; border-radius:8px; font-weight:600; }}" ) @@ -455,7 +455,7 @@ class AutomationView(QWidget): self._start = QPushButton("▶ Start automation") self._start.setCursor(Qt.PointingHandCursor) self._start.setStyleSheet( - f"QPushButton {{ background:{p.accent}; color:#fff; border:none;" + f"QPushButton {{ background:{p.accent}; color:{p.accent_text}; border:none;" f" border-radius:10px; padding:12px 26px; font-size:14px; font-weight:700; }}" ) self._start.clicked.connect(self.start_requested) diff --git a/src/aare/gui/new_gui/main_window.py b/src/aare/gui/new_gui/main_window.py index b4a66aa3..3f8054de 100644 --- a/src/aare/gui/new_gui/main_window.py +++ b/src/aare/gui/new_gui/main_window.py @@ -23,7 +23,7 @@ from aare.gui.new_gui.automation_view import AutomationView from aare.gui.new_gui.manual_view import ManualView from aare.gui.new_gui.state import AppState from aare.gui.new_gui.status_bar import StatusBar -from aare.gui.new_gui.theme import LIGHT, build_qss +from aare.gui.new_gui.theme import DARK, LIGHT, build_qss from aare.gui.new_gui.top_bar import TopBar from aare.gui.new_gui.widgets.alert_banner import AlertBanner from aare.gui.new_gui.widgets import preconditions @@ -33,6 +33,12 @@ logger = setup_logger("aareGUI.new") _BEAMLINE_SUBTITLE = {"X06DA": "PXIII", "X10SA": "PXII", "X06SA": "PXI"} +def _saved_palette(): + """Initial theme from QSettings (defaults to Catppuccin Latte / light).""" + from PySide6.QtCore import QSettings + return DARK if QSettings("PSI", "AareGUI-new").value("theme") == "dark" else LIGHT + + def _collect_defaults() -> dict: """Scan defaults from the beamline YAML (with safe fall-backs).""" rot = "data_collection_settings.default_rotation_settings" @@ -61,7 +67,7 @@ class MainWindow(QWidget): self._gonio_cam_addr = gonio_cam_addr self._beamline_cam_addr = beamline_cam_addr self._gonio_cam_id = gonio_cam_id - self._palette = LIGHT + self._palette = _saved_palette() self._defaults = _collect_defaults() # automation run bookkeeping @@ -78,6 +84,11 @@ class MainWindow(QWidget): self._baton_poll.setInterval(1000) self._baton_poll.timeout.connect(self._poll_baton_timeout) + # last payloads cached so a live theme rebuild can re-feed the new views + self._last_status = None + self._last_spreadsheet = None + self._last_reference_tools = None + self.setObjectName("AppRoot") self.setStyleSheet(build_qss(self._palette)) self.setWindowTitle("AareGUI") @@ -86,34 +97,19 @@ class MainWindow(QWidget): beamline = str(mx_beamline().name if hasattr(mx_beamline(), "name") else mx_beamline()) subtitle = _BEAMLINE_SUBTITLE.get(beamline, "") - bl_label = f"{beamline} · {subtitle}" if subtitle else beamline + self._bl_label = f"{beamline} · {subtitle}" if subtitle else beamline self.state = AppState(self) - # chrome + body - self.top_bar = TopBar(self._palette, bl_label) - self.alert = AlertBanner(self._palette) - self.status_bar = StatusBar(self._palette) - self.body = QStackedWidget() - self.manual = ManualView(self.state, self._palette, self._defaults) - self.automation = AutomationView(self.state, self._palette) - from aare.gui.new_gui.staff_view import StaffView - self.staff = StaffView(self._palette) - self.body.addWidget(self.manual) - self.body.addWidget(self.automation) - self.body.addWidget(self.staff) - - layout = QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(0) - layout.addWidget(self.top_bar) - layout.addWidget(self.alert) - layout.addWidget(self.body, 1) - layout.addWidget(self.status_bar) + self._root_layout = QVBoxLayout(self) + self._root_layout.setContentsMargins(0, 0, 0, 0) + self._root_layout.setSpacing(0) + self._build_chrome_and_body() # backend self._build_backend(pred_zmq_addr) - self._wire() + self._wire_persistent() + self._wire_views() self._build_shortcuts() self._restore_state() @@ -127,6 +123,57 @@ class MainWindow(QWidget): except Exception as exc: logger.warning("Dev seed failed: %s", exc) + def _build_chrome_and_body(self) -> None: + """Create the top bar / alert / body / status bar. Repeatable so a theme + switch can rebuild every view with the new palette (backend untouched).""" + from aare.gui.new_gui.staff_view import StaffView + self.top_bar = TopBar(self._palette, self._bl_label) + self.alert = AlertBanner(self._palette) + self.status_bar = StatusBar(self._palette) + self.body = QStackedWidget() + self.manual = ManualView(self.state, self._palette, self._defaults) + self.automation = AutomationView(self.state, self._palette) + self.staff = StaffView(self._palette) + self.body.addWidget(self.manual) + self.body.addWidget(self.automation) + self.body.addWidget(self.staff) + self._root_layout.addWidget(self.top_bar) + self._root_layout.addWidget(self.alert) + self._root_layout.addWidget(self.body, 1) + self._root_layout.addWidget(self.status_bar) + + # ----------------------------------------------------------- theme switch + def _on_theme_toggle(self) -> None: + from PySide6.QtCore import QSettings + new = DARK if self._palette is LIGHT else LIGHT + QSettings("PSI", "AareGUI-new").setValue( + "theme", "dark" if new is DARK else "light") + self.apply_theme(new) + + def apply_theme(self, palette) -> None: + self._palette = palette + mode = self.state.mode + for w in (self.top_bar, self.alert, self.body, self.status_bar): + self._root_layout.removeWidget(w) + w.setParent(None) + w.deleteLater() + self._build_chrome_and_body() + self.setStyleSheet(build_qss(palette)) + self._wire_views() + # restore staff gating, mode, and last-seen data into the fresh views + self.top_bar.set_staff(self._staff) + self._on_mode_changed(mode) + if self._baton is not None: + self.top_bar.update_baton_status(self._baton) + if self._last_spreadsheet is not None: + self._on_spreadsheet(self._last_spreadsheet) + if self._last_reference_tools is not None: + self._on_reference_tools(self._last_reference_tools) + if self._last_status is not None: + self.status_bar.update_daq_status(self._last_status) + self.manual.update_daq_status(self._last_status) + self.staff.update_daq_status(self._last_status) + # ------------------------------------------------------------- backend def _build_backend(self, pred_zmq_addr) -> None: from aare.gui.threads.daq_worker import DAQWorker @@ -169,43 +216,86 @@ class MainWindow(QWidget): return None # -------------------------------------------------------------- wiring - def _wire(self) -> None: + # Wiring is split so a theme switch can rebuild the views (see apply_theme): + # _wire_persistent — backend<->self/state edges, connected ONCE. + # _wire_views — every edge touching a rebuildable view widget; safe to + # re-run because the old widgets are deleted (their + # connections drop) and each new connection is unique. + def _wire_persistent(self) -> None: s = self.state - - # mode toggle <-> state <-> body - self.top_bar.mode_changed.connect(s.set_mode) s.mode_changed.connect(self._on_mode_changed) + s.mount_requested.connect(self._on_mount_requested) + s.unmount_requested.connect(self.daq.unmount) - # live status fan-out - self.daq.update.connect(self.status_bar.update_daq_status) - self.daq.update.connect(self.manual.update_daq_status) self.daq.update.connect(self._on_status) - self.daq.baton_status_changed.connect(self.top_bar.update_baton_status) self.daq.baton_status_changed.connect(self._on_baton_status) - - # sample list -> changer + library self.daq.spreadsheet.connect(self._on_spreadsheet) self.daq.reference_tools.connect(self._on_reference_tools) - - # baton controls - self.top_bar.request_baton.connect(self.daq.request_baton) - self.top_bar.release_baton.connect(self.daq.release_baton) - self.top_bar.cancel_baton_request.connect(self._on_cancel_baton) - self.top_bar.tools_clicked.connect(self._open_staff_tools) self.daq.baton_request_result.connect(self._on_baton_request_result) self.daq.baton_response_result.connect(self._on_baton_response_result) if hasattr(self.daq, "baton_incoming_request"): self.daq.baton_incoming_request.connect(self._on_baton_incoming) if hasattr(self.daq, "baton_timeout_checked"): self.daq.baton_timeout_checked.connect(self._on_baton_timeout) + if hasattr(self.daq, "fluorimeter_spectrum_update"): + self.daq.fluorimeter_spectrum_update.connect(self._on_spectrum) + self.daq.automated_scan_done.connect(self._on_auto_done) + + # errors / alerts -> banner (handlers read self.alert dynamically) + if hasattr(self.daq, "http_error"): + self.daq.http_error.connect(lambda msg: self._note(msg, error=True)) + if hasattr(self.daq, "auth_error"): + self.daq.auth_error.connect(self._on_auth_error) + if hasattr(self.daq, "sample_missing"): + self.daq.sample_missing.connect(self._on_sample_missing) + if hasattr(self.daq, "polled_devices_status"): + self.daq.polled_devices_status.connect(self._on_device_status) + if hasattr(self.daq, "detector_error"): + self.daq.detector_error.connect(self._on_device_status) + if hasattr(self.daq, "automation_critical_failure"): + self.daq.automation_critical_failure.connect( + lambda msg: self._note(msg, error=True)) + if hasattr(self.daq, "manual_collection_critical_failure"): + self.daq.manual_collection_critical_failure.connect( + lambda msg: self._note(msg, error=True)) + if hasattr(self.daq, "pss_alarm_changed"): + self.daq.pss_alarm_changed.connect( + lambda *a: self.alert.show_message( + "PSS alarm — check hutch door / interlocks.", "warning")) + if hasattr(self.daq, "standard_scan_completed"): + self.daq.standard_scan_completed.connect(self._on_rotation_completed) + if hasattr(self.daq, "raster_scan_completed"): + self.daq.raster_scan_completed.connect(self._on_raster_banner) + + if self.camera_thread is not None and hasattr( + self.camera_thread, "update_daq_status"): + self.daq.update.connect(self.camera_thread.update_daq_status) + + def _wire_views(self) -> None: + s = self.state + + # mode toggle + theme switch + self.top_bar.mode_changed.connect(s.set_mode) + self.top_bar.theme_toggle_requested.connect(self._on_theme_toggle) + + # live status fan-out -> view widgets + self.daq.update.connect(self.status_bar.update_daq_status) + self.daq.update.connect(self.manual.update_daq_status) + self.daq.update.connect(self.staff.update_daq_status) + self.daq.baton_status_changed.connect(self.top_bar.update_baton_status) + + # baton controls + self.top_bar.request_baton.connect(self.daq.request_baton) + self.top_bar.release_baton.connect(self.daq.release_baton) + self.top_bar.cancel_baton_request.connect(self._on_cancel_baton) + self.top_bar.tools_clicked.connect(self._open_staff_tools) # beamline state switching (clickable LED) self.status_bar.state_change_requested.connect(self._on_state_change) - # manual intents -> DAQ + # manual intents -> DAQ (scans precondition-gated at the boundary) self.manual.center_loop_requested.connect(self.daq.center_loop) self.manual.center_at_point_requested.connect(self._on_center_at_point) - # Scans are precondition-gated (ring/shutter/hutch) at the boundary. self.manual.raster_scan_requested.connect(self._do_raster) self.manual.rotation_scan_requested.connect(self._do_rotation) self.manual.smart_params_requested.connect(self._on_smart_params) @@ -237,20 +327,13 @@ class MainWindow(QWidget): spec.start_clicked.connect(self.daq.fluorimeter_start) spec.stop_clicked.connect(self.daq.fluorimeter_stop) spec.snapshot_clicked.connect(self.daq.fluorimeter_request_snapshot) - if hasattr(self.daq, "fluorimeter_spectrum_update"): - self.daq.fluorimeter_spectrum_update.connect(self._on_spectrum) if hasattr(self.daq, "fluorimeter_update"): self.daq.fluorimeter_update.connect(spec.update_live) - # state mount intents -> DAQ - s.mount_requested.connect(self._on_mount_requested) - s.unmount_requested.connect(self.daq.unmount) - - # motors -> DAQ + # motors self._wire_motors(self.manual.motors) # staff alignment view - self.daq.update.connect(self.staff.update_daq_status) self.staff.state_requested.connect(self._on_state_change) if hasattr(self.daq, "beam_center"): self.staff.beam_center_changed.connect(self.daq.beam_center) @@ -273,40 +356,12 @@ class MainWindow(QWidget): self.automation.save_template_requested.connect( lambda: self._note("Save as template is not wired yet.") ) - self.daq.automated_scan_done.connect(self._on_auto_done) if hasattr(self.daq, "automation_progress"): self.daq.automation_progress.connect( self.automation.progress.update_progress ) - # errors / alerts -> banner - if hasattr(self.daq, "http_error"): - self.daq.http_error.connect(lambda msg: self._note(msg, error=True)) - if hasattr(self.daq, "auth_error"): - self.daq.auth_error.connect(self._on_auth_error) - if hasattr(self.daq, "sample_missing"): - self.daq.sample_missing.connect(self._on_sample_missing) - if hasattr(self.daq, "polled_devices_status"): - self.daq.polled_devices_status.connect(self._on_device_status) - if hasattr(self.daq, "detector_error"): - self.daq.detector_error.connect(self._on_device_status) - if hasattr(self.daq, "automation_critical_failure"): - self.daq.automation_critical_failure.connect( - lambda msg: self._note(msg, error=True)) - if hasattr(self.daq, "manual_collection_critical_failure"): - self.daq.manual_collection_critical_failure.connect( - lambda msg: self._note(msg, error=True)) - if hasattr(self.daq, "pss_alarm_changed"): - self.daq.pss_alarm_changed.connect( - lambda *a: self.alert.show_message( - "PSS alarm — check hutch door / interlocks.", "warning")) - # completion notices - if hasattr(self.daq, "standard_scan_completed"): - self.daq.standard_scan_completed.connect(self._on_rotation_completed) - if hasattr(self.daq, "raster_scan_completed"): - self.daq.raster_scan_completed.connect(self._on_raster_banner) - - # camera stream + # camera stream -> camera widgets if self.camera_thread is not None: cam = self.manual.camera self.camera_thread.image.connect(cam.update_pixmap) @@ -316,8 +371,6 @@ class MainWindow(QWidget): self.camera_thread.target_point.connect(cam.update_target_point) if hasattr(self.camera_thread, "prediction"): self.camera_thread.prediction.connect(cam.update_predictions) - if hasattr(self.camera_thread, "update_daq_status"): - self.daq.update.connect(self.camera_thread.update_daq_status) # staff-view camera mirrors the same stream scam = self.staff.camera self.camera_thread.image.connect(scam.update_pixmap) @@ -350,6 +403,7 @@ class MainWindow(QWidget): @Slot(object) def _on_spreadsheet(self, payload) -> None: + self._last_spreadsheet = payload samples = getattr(payload, "s", payload) try: samples = list(samples) @@ -449,6 +503,7 @@ class MainWindow(QWidget): @Slot(object) def _on_reference_tools(self, payload) -> None: + self._last_reference_tools = payload tools = getattr(payload, "s", payload) try: tools = list(tools) @@ -459,6 +514,7 @@ class MainWindow(QWidget): # ---- staff + control (guest) state ---- @Slot(object) def _on_status(self, s) -> None: + self._last_status = s session = getattr(s, "session", None) staff = bool(getattr(session, "staff", False)) if session else False if staff != self._staff: diff --git a/src/aare/gui/new_gui/status_bar.py b/src/aare/gui/new_gui/status_bar.py index e3f2c59a..0a370e26 100644 --- a/src/aare/gui/new_gui/status_bar.py +++ b/src/aare/gui/new_gui/status_bar.py @@ -23,14 +23,15 @@ STATE_OPTIONS: tuple[tuple[str, str], ...] = ( ("X-ray fluorescence", "xray_fluorescence"), ) -# LED colour by BeamlineStateEnum name (group colours from the old GUI). +# LED colour by BeamlineStateEnum name (Catppuccin group hues, legible on +# both Latte and Mocha). _STATE_COLORS: dict[str, str] = { - "DewarTransfer": "#805ad5", - "SampleExchange": "#ed8936", "RobotSampleExchange": "#ed8936", - "SampleAlignment": "#48bb78", "BeamLocation": "#48bb78", - "BeamstopAlignment": "#48bb78", "FluxMeasurement": "#48bb78", - "DataCollection": "#ec4899", "XtalSnapshot": "#ec4899", - "XrayFluorescence": "#ec4899", + "DewarTransfer": "#7287fd", # lavender + "SampleExchange": "#fe640b", "RobotSampleExchange": "#fe640b", # peach + "SampleAlignment": "#40a02b", "BeamLocation": "#40a02b", # green + "BeamstopAlignment": "#40a02b", "FluxMeasurement": "#40a02b", + "DataCollection": "#ea76cb", "XtalSnapshot": "#ea76cb", # pink + "XrayFluorescence": "#ea76cb", } @@ -65,7 +66,7 @@ class StatusBar(QWidget): # clickable State LED self._state_dot = QLabel() self._state_dot.setFixedSize(8, 8) - self._set_state_dot("#a49d8f") + self._set_state_dot(self._p.text_faint) self._state = ClickableLabel() self._state.setObjectName("StatusItem") self._state.setTextFormat(Qt.RichText) diff --git a/src/aare/gui/new_gui/theme.py b/src/aare/gui/new_gui/theme.py index fb60ec91..d1505e95 100644 --- a/src/aare/gui/new_gui/theme.py +++ b/src/aare/gui/new_gui/theme.py @@ -48,60 +48,64 @@ class Palette: cam_stop2: str +# --- Catppuccin Latte (light) --------------------------------------------- +# https://catppuccin.com — accents are dark enough that white reads on them. LIGHT = Palette( - app_bg="#f3f1ea", - panel_bg="#fbfaf6", - header_bg="#f7f5ef", - surface="#ffffff", - border_panel="#e5e1d7", - border_control="#ddd8cc", - pending_track="#e1dccf", - pending_node="#c9c2b2", - chip_off_bg="#efece3", - text_primary="#221f19", - text_secondary="#4d473d", - text_muted="#7a756a", - text_faint="#a49d8f", - accent="#6d4bd1", + app_bg="#e6e9ef", # mantle + panel_bg="#eff1f5", # base + header_bg="#e6e9ef", # mantle + surface="#ffffff", # cards/inputs (lift off base) + border_panel="#ccd0da", # surface0 + border_control="#bcc0cc", # surface1 + pending_track="#ccd0da", + pending_node="#acb0be", # surface2 + chip_off_bg="#dce0e8", # crust + text_primary="#4c4f69", # text + text_secondary="#5c5f77", # subtext1 + text_muted="#6c6f85", # subtext0 + text_faint="#8c8fa1", # overlay1 + accent="#8839ef", # mauve accent_text="#ffffff", - accent_tint_bg="#f0e9fb", - accent_tint_border="#cabaf2", - accent_ring="rgba(109,75,209,.28)", - success="#1a7f37", - success_tint="#e3f2e8", - breakpoint="#c2641f", - danger="#cf222e", - cam_stop0="#2b2622", - cam_stop1="#141312", - cam_stop2="#0a0a0b", + accent_tint_bg="#f3e9fd", + accent_tint_border="#d2b3f7", + accent_ring="rgba(136,57,239,.28)", + success="#40a02b", # green + success_tint="#e4f1de", + breakpoint="#fe640b", # peach + danger="#d20f39", # red + cam_stop0="#313244", # camera is always dark (mocha surfaces) + cam_stop1="#1e1e2e", + cam_stop2="#11111b", ) +# --- Catppuccin Mocha (dark) ---------------------------------------------- +# Accents are LIGHT pastels -> foreground on a fill must be the dark base. DARK = Palette( - app_bg="#15121c", - panel_bg="#1e1a28", - header_bg="#1e1a28", - surface="#241f30", - border_panel="#332c44", - border_control="#332c44", - pending_track="#332c44", - pending_node="#4a4159", - chip_off_bg="#2a2438", - text_primary="#ece8f4", - text_secondary="#cfc8db", - text_muted="#9c95ad", - text_faint="#807890", - accent="#9b7bff", - accent_text="#190f2e", - accent_tint_bg="#2c2440", - accent_tint_border="#473a63", - accent_ring="rgba(155,123,255,.32)", - success="#3fb950", - success_tint="#16331f", - breakpoint="#e0975a", - danger="#f0837d", - cam_stop0="#2b2622", - cam_stop1="#141312", - cam_stop2="#0a0a0b", + app_bg="#1e1e2e", # base + panel_bg="#181825", # mantle + header_bg="#181825", # mantle + surface="#313244", # surface0 + border_panel="#313244", # surface0 + border_control="#45475a", # surface1 + pending_track="#45475a", + pending_node="#6c7086", # overlay0 + chip_off_bg="#313244", # surface0 + text_primary="#cdd6f4", # text + text_secondary="#bac2de", # subtext1 + text_muted="#a6adc8", # subtext0 + text_faint="#7f849c", # overlay1 + accent="#cba6f7", # mauve + accent_text="#1e1e2e", # base — dark text on light mauve + accent_tint_bg="#302d41", + accent_tint_border="#45437a", + accent_ring="rgba(203,166,247,.40)", + success="#a6e3a1", # green + success_tint="#293a2c", + breakpoint="#fab387", # peach + danger="#f38ba8", # red + cam_stop0="#313244", + cam_stop1="#1e1e2e", + cam_stop2="#11111b", ) diff --git a/src/aare/gui/new_gui/top_bar.py b/src/aare/gui/new_gui/top_bar.py index 85d72271..d00da2c8 100644 --- a/src/aare/gui/new_gui/top_bar.py +++ b/src/aare/gui/new_gui/top_bar.py @@ -11,7 +11,7 @@ from PySide6.QtWidgets import ( QWidget, ) -from aare.gui.new_gui.theme import TOPBAR_H, Palette +from aare.gui.new_gui.theme import LIGHT, TOPBAR_H, Palette class ModeToggle(QWidget): @@ -57,6 +57,7 @@ class TopBar(QWidget): release_baton = Signal() cancel_baton_request = Signal() tools_clicked = Signal() + theme_toggle_requested = Signal() def __init__(self, palette: Palette, beamline_label: str, parent=None): super().__init__(parent) @@ -128,6 +129,18 @@ class TopBar(QWidget): self._tools_btn.setVisible(False) lay.addWidget(self._tools_btn) + # theme toggle (Latte <-> Mocha) + self._theme_btn = QPushButton("☾" if p is LIGHT else "☀") + self._theme_btn.setCursor(Qt.PointingHandCursor) + self._theme_btn.setToolTip("Switch theme (light / dark)") + self._theme_btn.setFixedSize(34, 34) + self._theme_btn.setStyleSheet( + f"QPushButton {{ background:{p.surface}; border:1px solid {p.border_control};" + f" border-radius:8px; font-size:15px; color:{p.text_secondary}; }}" + ) + self._theme_btn.clicked.connect(self.theme_toggle_requested) + lay.addWidget(self._theme_btn) + # avatar self._avatar = QLabel("--") self._avatar.setObjectName("Avatar") @@ -168,7 +181,7 @@ class TopBar(QWidget): return primary = self._baton_action in ("grab", "request") if primary: - css = (f"background:{p.accent}; color:#fff; border:none;") + css = (f"background:{p.accent}; color:{p.accent_text}; border:none;") else: css = (f"background:{p.surface}; border:1px solid {p.border_control};" f" color:{p.text_secondary};") diff --git a/src/aare/gui/new_gui/widgets/alert_banner.py b/src/aare/gui/new_gui/widgets/alert_banner.py index ac2ea4f7..4c897143 100644 --- a/src/aare/gui/new_gui/widgets/alert_banner.py +++ b/src/aare/gui/new_gui/widgets/alert_banner.py @@ -47,7 +47,7 @@ class AlertBanner(QWidget): "info": (p.accent_tint_bg, p.accent_tint_border, p.text_primary, "ℹ"), "success": (p.success_tint, p.success, p.text_primary, "✓"), "warning": (p.chip_off_bg, p.breakpoint, p.text_primary, "⚠"), - "error": ("#fbe9e7", p.danger, p.text_primary, "⛔"), + "error": (p.chip_off_bg, p.danger, p.text_primary, "⛔"), } bg, border, fg, icon = styles.get(level, styles["info"]) self.setStyleSheet( diff --git a/src/aare/gui/new_gui/widgets/baton_dialogs.py b/src/aare/gui/new_gui/widgets/baton_dialogs.py index b70e5068..ca50a996 100644 --- a/src/aare/gui/new_gui/widgets/baton_dialogs.py +++ b/src/aare/gui/new_gui/widgets/baton_dialogs.py @@ -60,7 +60,7 @@ class BatonIncomingDialog(QDialog): ) accept = QPushButton("✓ Accept") accept.setStyleSheet( - f"QPushButton {{ background:{palette.success}; color:#fff; border:none;" + f"QPushButton {{ background:{palette.success}; color:{palette.accent_text}; border:none;" f" border-radius:8px; padding:8px 16px; font-weight:600; }}" ) refuse.clicked.connect(self._on_refuse) diff --git a/src/aare/gui/new_gui/widgets/bookmarks.py b/src/aare/gui/new_gui/widgets/bookmarks.py index 90cfe123..f4f416bc 100644 --- a/src/aare/gui/new_gui/widgets/bookmarks.py +++ b/src/aare/gui/new_gui/widgets/bookmarks.py @@ -95,7 +95,7 @@ class BookmarksBar(QWidget): b = QPushButton(text) b.setCursor(Qt.PointingHandCursor) if accent: - css = f"background:{p.accent}; color:#fff; border:none;" + css = f"background:{p.accent}; color:{p.accent_text}; border:none;" elif danger: css = f"background:transparent; color:{p.danger}; border:none;" else: diff --git a/src/aare/gui/new_gui/widgets/camera.py b/src/aare/gui/new_gui/widgets/camera.py index f26234b9..8d8ee926 100644 --- a/src/aare/gui/new_gui/widgets/camera.py +++ b/src/aare/gui/new_gui/widgets/camera.py @@ -105,7 +105,7 @@ class CameraViewport(QWidget): tile.setAlignment(Qt.AlignCenter) tile.setFixedSize(56, 56) tile.setStyleSheet( - f"background:{p.accent}; color:#fff; border-radius:14px; font-size:27px;" + f"background:{p.accent}; color:{p.accent_text}; border-radius:14px; font-size:27px;" ) title = QLabel("No sample mounted") title.setAlignment(Qt.AlignCenter) @@ -121,7 +121,7 @@ class CameraViewport(QWidget): btn = QPushButton("⊕ Mount sample") btn.setCursor(Qt.PointingHandCursor) btn.setStyleSheet( - f"QPushButton {{ background:{p.accent}; color:#fff; border:none;" + f"QPushButton {{ background:{p.accent}; color:{p.accent_text}; border:none;" f" border-radius:10px; padding:11px 22px; font-size:14px; font-weight:600; }}" ) btn.clicked.connect(self.mount_clicked) @@ -513,7 +513,7 @@ class CameraViewport(QWidget): self._paint_legend(painter, r) def _paint_legend(self, painter: QPainter, r: QRect) -> None: - items = [("#9b7bff", "Target + coords"), + items = [("#cba6f7", "Target + coords"), ("#d29922", "Loop / face"), ("#3fb950", "Crystal")] pad, line_h, sw = 11, 18, 9 diff --git a/src/aare/gui/new_gui/widgets/motors_panel.py b/src/aare/gui/new_gui/widgets/motors_panel.py index f5c6609e..28af055d 100644 --- a/src/aare/gui/new_gui/widgets/motors_panel.py +++ b/src/aare/gui/new_gui/widgets/motors_panel.py @@ -264,7 +264,7 @@ class MotorsPanel(QWidget): @staticmethod def _slider_qss(p: Palette) -> str: return ( - f"QSlider::groove:horizontal {{ height:4px; background:#e9e5db;" + f"QSlider::groove:horizontal {{ height:4px; background:{p.pending_track};" f" border-radius:2px; }}" f" QSlider::sub-page:horizontal {{ background:{p.accent};" f" border-radius:2px; }}" @@ -276,7 +276,7 @@ class MotorsPanel(QWidget): p = self._p if b.isChecked(): b.setStyleSheet( - f"QPushButton {{ background:{p.accent}; color:#fff; border:none;" + f"QPushButton {{ background:{p.accent}; color:{p.accent_text}; border:none;" f" border-radius:6px; font-size:11px; font-weight:600; padding:5px 10px; }}") else: b.setStyleSheet( diff --git a/src/aare/gui/new_gui/widgets/pipeline.py b/src/aare/gui/new_gui/widgets/pipeline.py index 3d093c43..307b8762 100644 --- a/src/aare/gui/new_gui/widgets/pipeline.py +++ b/src/aare/gui/new_gui/widgets/pipeline.py @@ -21,10 +21,10 @@ from PySide6.QtWidgets import QHBoxLayout, QPushButton, QWidget from aare.gui.new_gui.state import STAGES, Stage from aare.gui.new_gui.theme import Palette -NODE_W = 70 -NODE_H = 62 +NODE_W = 72 +NODE_H = 66 CIRCLE_R = 12 -CIRCLE_CY = 18 +CIRCLE_CY = 20 class _Connector(QWidget): @@ -44,9 +44,15 @@ class _Connector(QWidget): def paintEvent(self, event): # noqa: N802 painter = QPainter(self) - col = self._p.success if self._done else self._p.pending_track y = CIRCLE_CY - painter.fillRect(QRect(0, y - 1, self.width(), 2), QColor(col)) + if self._done: + glow = QColor(self._p.success) + glow.setAlpha(64) + painter.fillRect(QRect(0, y - 3, self.width(), 6), glow) + painter.fillRect(QRect(0, y - 1, self.width(), 2), QColor(self._p.success)) + else: + painter.fillRect(QRect(0, y - 1, self.width(), 2), + QColor(self._p.pending_track)) class StageNode(QWidget): @@ -128,7 +134,7 @@ class StageNode(QWidget): return p = self._p if self._has_break: - css = (f"background:{p.breakpoint}; color:#fff;" + css = (f"background:{p.breakpoint}; color:{p.accent_text};" f" border:1px solid {p.breakpoint};") else: css = (f"background:{p.surface}; color:{p.text_faint};" @@ -152,9 +158,10 @@ class StageNode(QWidget): painter.drawRoundedRect(self.rect().adjusted(0, 0, -1, -1), 10, 10) cx, cy, r = NODE_W // 2, CIRCLE_CY, CIRCLE_R + self._paint_beam(painter, cx, cy) circle = QRect(cx - r, cy - r, 2 * r, 2 * r) glyph = "" - glyph_color = "#fff" + glyph_color = p.accent_text if self._start_here: painter.setBrush(Qt.NoBrush) @@ -223,12 +230,40 @@ class StageNode(QWidget): painter.drawText(QRect(0, cy + r + 16, NODE_W, 12), Qt.AlignHCenter | Qt.AlignTop, caption) + def _paint_beam(self, painter: QPainter, cx: int, cy: int) -> None: + """The beam-path rail running through the station, left→right. + + Completed length glows green (the beam has passed); at the active + station the green front meets the upcoming dim track. + """ + p = self._p + w = NODE_W + + def seg(x0: int, x1: int, color: str, glow: bool = False) -> None: + if x1 <= x0: + return + if glow: + g = QColor(color) + g.setAlpha(64) + painter.fillRect(QRect(x0, cy - 3, x1 - x0, 6), g) + painter.fillRect(QRect(x0, cy - 1, x1 - x0, 2), QColor(color)) + + if self._done: + seg(0, w, p.success, glow=True) + elif self._selected and not self._start_here: + seg(0, cx, p.success, glow=True) # beam front reaches here + seg(cx, w, p.pending_track) + else: + seg(0, w, p.pending_track) + def _draw_ring(self, painter: QPainter, circle: QRect) -> None: - ring = QColor(self._p.accent) - ring.setAlpha(72) - painter.setBrush(Qt.NoBrush) - painter.setPen(QPen(ring, 3)) - painter.drawEllipse(circle.adjusted(-3, -3, 3, 3)) + # Soft focal bloom — the focused beam spot at the active station. + for grow, alpha in ((3, 90), (7, 40), (11, 18)): + ring = QColor(self._p.accent) + ring.setAlpha(alpha) + painter.setBrush(Qt.NoBrush) + painter.setPen(QPen(ring, 2)) + painter.drawEllipse(circle.adjusted(-grow, -grow, grow, grow)) class PipelineTracker(QWidget): diff --git a/src/aare/gui/new_gui/widgets/pipeline_panel.py b/src/aare/gui/new_gui/widgets/pipeline_panel.py index ca57e4f4..44669aa6 100644 --- a/src/aare/gui/new_gui/widgets/pipeline_panel.py +++ b/src/aare/gui/new_gui/widgets/pipeline_panel.py @@ -41,7 +41,7 @@ def _primary_button(text: str, palette: Palette) -> QPushButton: b.setObjectName("Primary") b.setCursor(Qt.PointingHandCursor) b.setStyleSheet( - f"QPushButton {{ background:{palette.accent}; color:#fff; border:none;" + f"QPushButton {{ background:{palette.accent}; color:{palette.accent_text}; border:none;" f" border-radius:8px; padding:8px 16px; font-size:12.5px; font-weight:600; }}" ) return b @@ -441,7 +441,7 @@ class ContextualSettings(QStackedWidget): def _style_automate_chip(self, on: bool) -> None: p = self._p if on: - css = f"background:{p.accent}; color:#fff; border:none;" + css = f"background:{p.accent}; color:{p.accent_text}; border:none;" else: css = f"background:{p.chip_off_bg}; color:{p.text_faint}; border:none;" self._automate_chip.setStyleSheet( @@ -467,7 +467,7 @@ class ContextualSettings(QStackedWidget): p = self._p if on: self._draw_btn.setText("✏ Drawing… (drag on camera)") - css = f"background:{p.accent}; color:#fff; border:none;" + css = f"background:{p.accent}; color:{p.accent_text}; border:none;" else: self._draw_btn.setText("✏ Draw grid") css = f"background:{p.surface}; border:1px solid {p.border_control}; color:{p.text_secondary};" @@ -525,10 +525,10 @@ class TransportRow(QWidget): btns = QHBoxLayout() btns.setSpacing(6) - self._play = self._tbtn("▶") - self._pause = self._tbtn("❚❚") - self._skip = self._tbtn("⏭") - self._end = self._tbtn("⏹", danger=True) + self._play = self._tbtn("▶", "Play") + self._pause = self._tbtn("⏸", "Pause") + self._skip = self._tbtn("⏭", "Skip") + self._end = self._tbtn("⏹", "End", danger=True) self._play.clicked.connect(self.play) self._pause.clicked.connect(self.pause) self._skip.clicked.connect(self.skip) @@ -562,9 +562,9 @@ class TransportRow(QWidget): self._unmount.clicked.connect(self.unmount) lay.addWidget(self._unmount) - def _tbtn(self, glyph: str, danger: bool = False) -> QPushButton: - b = QPushButton(glyph) - b.setFixedSize(34, 34) + def _tbtn(self, glyph: str, label: str, danger: bool = False) -> QPushButton: + b = QPushButton(f"{glyph} {label}") + b.setMinimumHeight(32) b.setCursor(Qt.PointingHandCursor) b._danger = danger # type: ignore[attr-defined] self._style_tbtn(b, active=False) @@ -573,12 +573,13 @@ class TransportRow(QWidget): def _style_tbtn(self, b: QPushButton, active: bool) -> None: p = self._p if active: - css = f"background:{p.accent}; color:#fff; border:none;" + css = f"background:{p.accent}; color:{p.accent_text}; border:none;" else: color = p.danger if getattr(b, "_danger", False) else p.text_secondary css = f"background:{p.surface}; border:1px solid {p.border_control}; color:{color};" b.setStyleSheet( - f"QPushButton {{ {css} border-radius:8px; font-size:11px; }}" + f"QPushButton {{ {css} border-radius:8px; font-size:12px;" + f" font-weight:600; padding:6px 14px; }}" ) def update_from_state(self, state) -> None: diff --git a/src/aare/gui/new_gui/widgets/sample_changer.py b/src/aare/gui/new_gui/widgets/sample_changer.py index 63112a97..a9fce1d9 100644 --- a/src/aare/gui/new_gui/widgets/sample_changer.py +++ b/src/aare/gui/new_gui/widgets/sample_changer.py @@ -86,7 +86,7 @@ class SampleRow(QWidget): if mounted: badge = QLabel("MOUNTED") badge.setStyleSheet( - f"font-size:10px; background:{palette.accent}; color:#fff;" + f"font-size:10px; background:{palette.accent}; color:{palette.accent_text};" " padding:2px 7px; border-radius:9px; font-weight:600;" ) lay.addWidget(badge) @@ -232,7 +232,7 @@ class SampleChangerPanel(QWidget): def _style_mount_btn(self) -> None: p = self._p if self._mount_btn.isEnabled(): - css = f"background:{p.accent}; color:#fff; border:none;" + css = f"background:{p.accent}; color:{p.accent_text}; border:none;" else: css = (f"background:{p.chip_off_bg}; color:{p.text_faint};" f" border:none;") diff --git a/src/aare/gui/new_gui/widgets/spectrum_view.py b/src/aare/gui/new_gui/widgets/spectrum_view.py index 43cc398c..4118a333 100644 --- a/src/aare/gui/new_gui/widgets/spectrum_view.py +++ b/src/aare/gui/new_gui/widgets/spectrum_view.py @@ -89,7 +89,7 @@ class SpectrumView(QWidget): b.setCursor(Qt.PointingHandCursor) p = self._p if primary: - css = f"background:{p.accent}; color:#fff; border:none;" + css = f"background:{p.accent}; color:{p.accent_text}; border:none;" else: css = (f"background:{p.surface}; border:1px solid {p.border_control};" f" color:{p.text_secondary};")