diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md
index d7d0654..8a01cbf 100644
--- a/.claude/CLAUDE.md
+++ b/.claude/CLAUDE.md
@@ -66,9 +66,7 @@ pipeline orchestration, and GUI stay in their own packages.
`uv run ty check`; CI fails on either. Ruff runs with `select = ALL`
and a curated ignore list in `pyproject.toml` — add to that list only with a
one-line reason, as the existing entries do.
-- There is no test suite yet (`tests/` is empty, pytest is configured). When
- adding tests, put them in `tests/`, add `pytest` to the `dev` group, and keep
- them free of Palace, torch training, and network access.
+- Tests live in `tests/` and run with `uv run pytest` (`pytest` is in the `dev`
## Simulation and Training
diff --git a/.claude/GUI_DESIGN.md b/.claude/GUI_DESIGN.md
index 5e4b9a2..620e975 100644
--- a/.claude/GUI_DESIGN.md
+++ b/.claude/GUI_DESIGN.md
@@ -277,17 +277,16 @@ never the only signal · sentence case, units, tooltip · docs touched.
## 9. Known deviations in the current GUI
-To be resolved in one GUI update step, not incrementally by unrelated changes:
-
-- `theme.py` is light-only with Material-blue literals (the file COBRA's theme
- started from); it has no tokens, no dark theme, no `ThemeManager`.
-- The stylesheet is applied to the main window (`apply_theme(window)` in
- `app.py`) rather than the `QApplication`, so dialogs and message boxes miss it.
-- `pipeline_window.py` styles the run button with a per-widget `setStyleSheet`
- and uses `
` HTML in labels for headings; both become QSS properties
- (`primaryAction`, `role="heading"`).
-- No `actionState` on the run button, no `progressState` on the progress bar.
-- Pipeline success is announced with a modal `QMessageBox`; it should be the
- status label plus the `finished` progress state.
-- Labels use trailing colons and Title Case ("Select Geometry:", "Geometry Name:").
-- No icons, no theme toggle, no persisted appearance setting, no `help_texts.py`.
+The GUI update of September 2026 resolved the original list (tokens, both
+themes, `ThemeManager`, application-level stylesheet, QSS properties instead of
+inline styles, no success modal, sentence-case labels, icons, theme toggle,
+`help_texts.py`). What remains, by design rather than by omission:
+
+- No plots, so `theme.py` has no `style_plot` and ORCA does not depend on
+ pyqtgraph. Add both together with the first plot.
+- No pause/stop: `ORCA.run` cannot be interrupted, so the run button only uses
+ the `start` action state and is disabled while a run is in progress.
+- The stage forms are derived from the constructor signatures, so their labels
+ are the parameter names in sentence case without units; a stage option that
+ wants a unit or a friendlier label gets it in its `Args:` docstring, which
+ is what the tooltip shows.
diff --git a/README.md b/README.md
index 1d10e8c..4a3a98d 100644
--- a/README.md
+++ b/README.md
@@ -155,9 +155,10 @@ orca
The GUI lets you:
- select a geometry preset or load a custom geometry class,
-- configure pipeline stages and parameters,
-- monitor simulation and training progress in real time,
-- inspect and test the trained model.
+- configure pipeline stages and parameters (each field has a tooltip),
+- monitor simulation and training progress and the log in real time,
+- test the trained model,
+- switch between a light and a dark theme, or follow the system setting.
### 2. Python script mode
diff --git a/docs/running_orca.md b/docs/running_orca.md
index 8129ec2..8a1e2fd 100644
--- a/docs/running_orca.md
+++ b/docs/running_orca.md
@@ -29,10 +29,12 @@ orca
In GUI mode:
-1. Select a geometry class.
-2. Configure pipeline stages and parameters.
-3. Set Palace executable path.
-4. Start the pipeline.
+1. Select a geometry preset, or load a custom `.py` file that defines a `BaseGeometry` subclass. The name field sets the output folder (`output//`).
+2. Tick the pipeline stages to run and set their parameters. Every field has a tooltip taken from the stage's documentation; leave an optional field empty (or `None`) to use the default.
+3. Set the Palace executable path in the `PalaceSimulator` stage.
+4. Click **Run pipeline**. Progress, the current stage, and the outcome show next to the progress bar (green when finished, red text on an error); the log panel mirrors what ORCA prints on the console. Validation problems, such as no geometry selected, appear inline instead of in a dialog. If the output directory already exists you are asked once before it is overwritten.
+
+The button in the top-right corner switches the appearance between *system* (follows the OS colour scheme), *light* (Sandbank) and *dark* (Deepwater). The choice is remembered across sessions.
## 3. Run Script Mode
diff --git a/pyproject.toml b/pyproject.toml
index d809d47..961049a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -63,6 +63,7 @@ dependencies = [
"tqdm",
"pandas",
"PySide6",
+ "qtawesome",
"colorlog",
"matplotlib",
"pebble>=5.2.2",
@@ -89,10 +90,11 @@ cu126 = ["torch"]
cu130 = ["torch"]
[dependency-groups]
-# Installed by `uv sync` (not by `pip install`): the linters CI runs, pinned by uv.lock.
+# Installed by `uv sync` (not by `pip install`): the linters and test runner CI uses, pinned by uv.lock.
dev = [
"ruff",
"ty",
+ "pytest",
]
[tool.uv]
diff --git a/src/orca/gui/README.md b/src/orca/gui/README.md
index 97dc1b2..3b9eac8 100644
--- a/src/orca/gui/README.md
+++ b/src/orca/gui/README.md
@@ -1,2 +1,2 @@
## GUI
-This file contains the GUI of ORCA. It allows the user to use the trained models for inference on new data.
\ No newline at end of file
+PySide6 pipeline window of ORCA: pick a geometry, enable and configure the stages, run them, and follow the log.
diff --git a/src/orca/gui/app.py b/src/orca/gui/app.py
index c4750b9..6e6bed3 100644
--- a/src/orca/gui/app.py
+++ b/src/orca/gui/app.py
@@ -2,15 +2,16 @@
from PySide6.QtWidgets import QApplication
+from orca.gui import theme
from orca.gui.pipeline_window import PipelineWindow
-from orca.gui.theme import apply_theme
def run_gui():
app = QApplication(sys.argv)
-
+ app.setOrganizationName("ORCA")
+ app.setApplicationName("ORCA")
+ theme.manager().apply()
window = PipelineWindow()
- apply_theme(window)
window.show()
sys.exit(app.exec())
diff --git a/src/orca/gui/help_texts.py b/src/orca/gui/help_texts.py
new file mode 100644
index 0000000..f84a3d3
--- /dev/null
+++ b/src/orca/gui/help_texts.py
@@ -0,0 +1,64 @@
+"""Tooltips of the ORCA GUI, in one place so wording stays consistent.
+
+Static controls look their text up by key with :func:`tooltip`; the stage
+parameter forms, which are derived from the stage constructors, take theirs
+from the ``Args:`` section of the constructor docstring via
+:func:`parameter_tooltips`, so a stage documents its options once.
+"""
+
+from __future__ import annotations
+
+import inspect
+import re
+
+TOOLTIPS = {
+ "theme_btn": "Appearance: {mode} ({theme}). Click to switch between system, light and dark.",
+ "run_btn": "Run the enabled stages in order on the selected geometry.",
+ "geometry_combo": "Built-in geometry presets. Loading a custom file replaces the selection.",
+ "geometry_file_btn": "Browse for a Python file that defines a BaseGeometry subclass.",
+ "geometry_name_edit": (
+ "Name of the run. Output goes to output//; leave the preset name to keep "
+ "its default."
+ ),
+ "stage_group": (
+ "Tick to include this stage in the run. A stage whose inputs are missing "
+ "reports which earlier stage was skipped."
+ ),
+ "log_output": "Messages from the pipeline; the same text ORCA prints on the console.",
+}
+
+#: ``name (type): text`` or ``name: text`` at the start of an Args entry.
+_ARG_LINE = re.compile(r"^(?P\w+)(?:\s*\([^)]*\))?:\s*(?P.*)$")
+
+
+def tooltip(key: str) -> str:
+ return TOOLTIPS.get(key, "")
+
+
+def parameter_tooltips(cls: type) -> dict[str, str]:
+ """Per-parameter descriptions from the ``Args:`` section of ``cls.__init__``'s docstring.
+
+ Continuation lines are joined with spaces; parameters without an entry are
+ absent from the result.
+ """
+ doc = inspect.getdoc(cls.__init__)
+ if not doc:
+ return {}
+ tips: dict[str, str] = {}
+ current: str | None = None
+ in_args = False
+ for raw in doc.splitlines():
+ line = raw.strip()
+ if not in_args:
+ in_args = line == "Args:"
+ continue
+ if not line or (raw and not raw[0].isspace()):
+ # A blank line or a new section heading ends the Args block.
+ break
+ match = _ARG_LINE.match(line)
+ if match and not raw.startswith(" "):
+ current = match.group("name")
+ tips[current] = match.group("text")
+ elif current is not None:
+ tips[current] = f"{tips[current]} {line}".strip()
+ return tips
diff --git a/src/orca/gui/pipeline_window.py b/src/orca/gui/pipeline_window.py
index 0888c90..a4ae17b 100644
--- a/src/orca/gui/pipeline_window.py
+++ b/src/orca/gui/pipeline_window.py
@@ -1,8 +1,8 @@
import logging
from PySide6.QtCore import QThread, Signal
-from PySide6.QtGui import QFontDatabase
from PySide6.QtWidgets import (
+ QGroupBox,
QHBoxLayout,
QLabel,
QMainWindow,
@@ -16,6 +16,9 @@
)
from orca import ORCA
+from orca.gui.help_texts import tooltip
+from orca.gui.theme import ThemeTokens, refresh_style
+from orca.gui.theme import manager as theme_manager
from orca.gui.utils import get_available_stages
from orca.gui.widgets.geometry_selector import GeometrySelector
from orca.gui.widgets.stage_widget import StageConfigWidget
@@ -78,6 +81,9 @@ class PipelineWindow(QMainWindow):
def __init__(self):
super().__init__()
+ self._theme = theme_manager()
+ self._theme.theme_changed.connect(self._on_theme_changed)
+
self.setWindowTitle("ORCA Pipeline")
self.resize(1000, 800)
@@ -85,30 +91,83 @@ def __init__(self):
self._log_handler = None
self.init_ui()
self.setup_logging()
+ self._on_theme_changed(self._theme.tokens)
def init_ui(self):
+ tokens = self._theme.tokens
central_widget = QWidget()
self.setCentralWidget(central_widget)
+ root_layout = QVBoxLayout(central_widget)
+ root_layout.setContentsMargins(
+ tokens.space_4, tokens.space_4, tokens.space_4, tokens.space_4
+ )
+ root_layout.setSpacing(tokens.space_3)
- main_layout = QHBoxLayout()
- central_widget.setLayout(main_layout)
+ # Global controls: run and appearance, shown regardless of the panels below
+ controls_layout = QHBoxLayout()
+ controls_layout.setSpacing(tokens.space_2)
- # Left Panel: Configuration
+ self.btn_run = QPushButton("Run pipeline")
+ self.btn_run.setProperty("primaryAction", True)
+ self.btn_run.setProperty("actionState", "start")
+ self.btn_run.setFixedSize(220, tokens.control_h_lg)
+ self.btn_run.setToolTip(tooltip("run_btn"))
+ self.btn_run.clicked.connect(self.run_pipeline)
+ self._theme.bind_icon(self.btn_run, "play", on_fill=True, size=24)
+
+ self.theme_btn = QPushButton()
+ self.theme_btn.setProperty("flat", True)
+ self.theme_btn.setFixedSize(tokens.control_h_lg, tokens.control_h_lg)
+ self.theme_btn.setAccessibleName("Appearance")
+ self.theme_btn.clicked.connect(self._theme.cycle_mode)
+ self._theme.bind_icon(self.theme_btn, "theme-light-dark", tertiary=True, size=24)
+
+ controls_layout.addStretch()
+ controls_layout.addWidget(self.btn_run)
+ controls_layout.addWidget(self.theme_btn)
+ root_layout.addLayout(controls_layout)
+
+ # Progress: the label names the stage, the bar colour names the state
+ progress_layout = QHBoxLayout()
+ progress_layout.setSpacing(tokens.space_2)
+ self.progress_label = QLabel()
+ self.progress_bar = QProgressBar()
+ self.progress_bar.setRange(0, 100)
+ self.progress_bar.setValue(0)
+ progress_layout.addWidget(self.progress_label)
+ progress_layout.addWidget(self.progress_bar, stretch=1)
+ root_layout.addLayout(progress_layout)
+ self.set_status("Ready")
+
+ panels_layout = QHBoxLayout()
+ panels_layout.setSpacing(tokens.space_3)
+ root_layout.addLayout(panels_layout, stretch=1)
+
+ # Left panel: configuration
config_scroll = QScrollArea()
config_scroll.setWidgetResizable(True)
config_widget = QWidget()
- config_layout = QVBoxLayout()
- config_widget.setLayout(config_layout)
+ config_layout = QVBoxLayout(config_widget)
+ config_layout.setContentsMargins(0, 0, tokens.space_2, 0)
+ config_layout.setSpacing(tokens.space_3)
config_scroll.setWidget(config_widget)
- # Geometry Section
- config_layout.addWidget(QLabel("1. Geometry Selection "))
+ # Geometry section
+ config_layout.addWidget(self._heading("1. Geometry"))
+ geometry_group = QGroupBox()
+ geometry_layout = QVBoxLayout(geometry_group)
+ geometry_layout.setContentsMargins(
+ tokens.space_3, tokens.space_2, tokens.space_3, tokens.space_3
+ )
self.geometry_selector = GeometrySelector()
- config_layout.addWidget(self.geometry_selector)
+ geometry_layout.addWidget(self.geometry_selector)
+ config_layout.addWidget(geometry_group)
- # Stages Section
- config_layout.addWidget(QLabel("2. Pipeline Stages "))
- config_layout.addWidget(QLabel("Select active stages and configure parameters:"))
+ # Stages section
+ config_layout.addWidget(self._heading("2. Pipeline stages"))
+ stages_caption = QLabel("Enable the stages to run and set their parameters")
+ stages_caption.setProperty("role", "muted")
+ config_layout.addWidget(stages_caption)
available_stages = get_available_stages()
for stage_cls in available_stages:
@@ -118,38 +177,57 @@ def init_ui(self):
config_layout.addStretch()
- # Right Panel: Logs and button
- right_panel = QWidget()
- right_layout = QVBoxLayout()
- right_panel.setLayout(right_layout)
-
- # Log output
+ # Right panel: log
+ log_group = QGroupBox("Log")
+ log_layout = QVBoxLayout(log_group)
+ log_layout.setContentsMargins(
+ tokens.space_3, tokens.space_2, tokens.space_3, tokens.space_3
+ )
self.log_output = QPlainTextEdit()
+ self.log_output.setProperty("role", "log")
self.log_output.setReadOnly(True)
- self.log_output.setPlaceholderText("Logs will appear here...")
+ self.log_output.setPlaceholderText("Pipeline messages appear here")
self.log_output.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
- self.log_output.setFont(QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont))
+ self.log_output.setToolTip(tooltip("log_output"))
+ log_layout.addWidget(self.log_output)
- # Progress bar that uses the callbacks
- self.progress_bar = QProgressBar()
- self.progress_label = QLabel("Ready")
+ panels_layout.addWidget(config_scroll, 1)
+ panels_layout.addWidget(log_group, 1)
- # Run Button
- self.btn_run = QPushButton("Run Pipeline")
- self.btn_run.setMinimumHeight(50)
- self.btn_run.setStyleSheet("font-size: 16px; font-weight: bold;")
- self.btn_run.clicked.connect(self.run_pipeline)
+ # ------------------------------------------------------------------
+ # Appearance and status
+ # ------------------------------------------------------------------
+
+ @staticmethod
+ def _heading(text: str) -> QLabel:
+ label = QLabel(text)
+ label.setProperty("role", "heading")
+ return label
+
+ def _on_theme_changed(self, tokens: ThemeTokens) -> None:
+ self.theme_btn.setToolTip(
+ tooltip("theme_btn").format(mode=self._theme.mode, theme=tokens.name)
+ )
+
+ def set_status(self, text: str, role: str = "muted") -> None:
+ """Show *text* next to the progress bar; *role* is ``muted``, ``success`` or ``error``."""
+ self.progress_label.setText(text)
+ self.progress_label.setProperty("role", role)
+ refresh_style(self.progress_label)
+
+ def _set_progress_state(self, state: str) -> None:
+ """Colour the progress bar: ``running`` (tide) or ``finished`` (moss)."""
+ self.progress_bar.setProperty("progressState", state)
+ refresh_style(self.progress_bar)
- # Right side: "Console"-style log output and progress
- right_layout.addWidget(QLabel("Logs & Status "))
- right_layout.addWidget(self.log_output)
- right_layout.addWidget(self.progress_label)
- right_layout.addWidget(self.progress_bar)
- right_layout.addWidget(self.btn_run)
+ def _set_running(self, running: bool) -> None:
+ self.btn_run.setEnabled(not running)
+ self.btn_run.setText("Running…" if running else "Run pipeline")
+ refresh_style(self.btn_run)
- # Add sub-widgets to main layout
- main_layout.addWidget(config_scroll, 1)
- main_layout.addWidget(right_panel, 1)
+ # ------------------------------------------------------------------
+ # Pipeline
+ # ------------------------------------------------------------------
def setup_logging(self):
handler = LogSignalHandler(self.log_signal)
@@ -165,7 +243,8 @@ def append_log_line(self, msg):
def run_pipeline(self):
geometry = self.geometry_selector.get_geometry()
if not geometry:
- QMessageBox.warning(self, "Invalid Geometry", "Please select a valid geometry first.")
+ self.set_status("Select a geometry before running", "error")
+ self.geometry_selector.set_status("Select a preset or load a custom file", "error")
return
stages = []
@@ -175,14 +254,16 @@ def run_pipeline(self):
stages.append(stage_instance)
if not stages:
- QMessageBox.warning(self, "No Stages", "Please select at least one pipeline stage.")
+ self.set_status("Enable at least one pipeline stage", "error")
return
# Instantiate ORCA
orca_instance = ORCA(stages)
- # Disable button
- self.btn_run.setEnabled(False)
+ self._set_running(True)
+ self._set_progress_state("running")
+ self.progress_bar.setValue(0)
+ self.set_status("Starting pipeline")
self.log_output.clear()
self.log_output.appendPlainText("Starting pipeline...")
@@ -194,33 +275,33 @@ def run_pipeline(self):
self.worker.start()
def handle_overwrite_confirmation(self, base_dir):
+ # Existing results would be lost, so this is the one place a confirmation is warranted.
reply = QMessageBox.question(
self,
- "Overwrite Confirmation",
- f"Output directory {base_dir} already exists. Stages may overwrite existing files. Continue?",
+ "Overwrite results",
+ f"The output directory {base_dir} already exists. Stages may overwrite its files. Continue?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No
)
self.worker.set_overwrite_result(reply == QMessageBox.StandardButton.Yes)
def update_progress(self, stage_name, current, total, message):
- self.progress_label.setText(f"{stage_name}: {message}")
+ self.set_status(f"{stage_name}: {message}")
if total > 0:
self.progress_bar.setValue(int(current / total * 100))
else:
self.progress_bar.setValue(0)
def pipeline_finished(self):
- self.btn_run.setEnabled(True)
- self.progress_label.setText("Pipeline Completed Successfully")
+ self._set_running(False)
self.progress_bar.setValue(100)
- QMessageBox.information(self, "Success", "ORCA Pipeline finished successfully!")
+ self._set_progress_state("finished")
+ self.set_status("Pipeline finished", "success")
def pipeline_error(self, error_msg):
- self.btn_run.setEnabled(True)
- self.progress_label.setText("Error Occurred")
+ self._set_running(False)
+ self.set_status("Pipeline failed, see the log", "error")
self.log_output.appendPlainText(f"ERROR: {error_msg}")
- QMessageBox.critical(self, "Pipeline Error", f"An error occurred:\n{error_msg}")
def closeEvent(self, event):
if self._log_handler is not None:
diff --git a/src/orca/gui/theme.py b/src/orca/gui/theme.py
index 7c4bcee..054d8ee 100644
--- a/src/orca/gui/theme.py
+++ b/src/orca/gui/theme.py
@@ -1,112 +1,671 @@
-THEME_STYLESHEET = """
-QMainWindow {
- background-color: #F5F7FA;
+"""Appearance of the ORCA GUI: colour tokens, the two themes, and the stylesheet.
+
+This module is the single owner of every colour used in ``orca.gui``. It must
+stay importable without a display: no widget is created at import time, so the
+token tables and :func:`build_stylesheet` can be unit-tested headless.
+See ``.claude/GUI_DESIGN.md`` for the design rules the tokens implement; the
+token tables are shared with COBRA (``cobra.gui.theme`` is the reference).
+"""
+
+from __future__ import annotations
+
+from contextlib import suppress
+from dataclasses import asdict, dataclass
+from functools import cache
+from pathlib import Path
+from string import Template
+from typing import TYPE_CHECKING
+
+from PySide6.QtCore import QObject, QSettings, QSize, QStandardPaths, Qt, Signal
+from PySide6.QtGui import QColor, QGuiApplication, QIcon, QPalette
+from PySide6.QtWidgets import QApplication, QWidget
+
+if TYPE_CHECKING:
+ from PySide6.QtWidgets import QAbstractButton
+
+#: Values accepted by the persisted ``appearance/mode`` setting.
+MODES: tuple[str, ...] = ("system", "light", "dark")
+
+
+@dataclass(frozen=True)
+class ThemeTokens:
+ name: str
+ dark: bool
+
+ # Surfaces and text
+ canvas: str
+ surface: str
+ surface_alt: str
+ border: str
+ border_strong: str
+ text: str
+ text_muted: str
+ text_disabled: str
+ selection: str
+
+ # Semantic colours
+ tide: str
+ tide_hover: str
+ tide_pressed: str
+ tide_subtle: str
+ on_tide: str
+ moss: str
+ moss_strong: str
+ moss_subtle: str
+ ochre: str
+ ochre_strong: str
+ ochre_subtle: str
+ ember: str
+ ember_strong: str
+ ember_subtle: str
+ slate: str
+
+ #: Plot series, taken by index and cycled.
+ series: tuple[str, ...]
+
+ # Shape and spacing
+ radius_sm: int = 6
+ radius_md: int = 8
+ radius_lg: int = 10
+ space_1: int = 4
+ space_2: int = 8
+ space_3: int = 12
+ space_4: int = 16
+ space_5: int = 24
+ control_h: int = 36
+ control_h_lg: int = 48
+
+ def series_color(self, index: int) -> str:
+ return self.series[index % len(self.series)]
+
+
+SANDBANK = ThemeTokens(
+ name="Sandbank",
+ dark=False,
+ canvas="#F3F4F1",
+ surface="#FBFBF9",
+ surface_alt="#E8EAE5",
+ border="#D3D7D0",
+ border_strong="#B6BCB3",
+ text="#2B3138",
+ text_muted="#5F6870",
+ text_disabled="#9AA2A8",
+ selection="#D6E2EC",
+ tide="#3C5A78",
+ tide_hover="#34506B",
+ tide_pressed="#2C4359",
+ tide_subtle="#E4EBF2",
+ on_tide="#FFFFFF",
+ moss="#6B8F5E",
+ moss_strong="#4E6E44",
+ moss_subtle="#E6EEE1",
+ ochre="#B08A3E",
+ ochre_strong="#7F6226",
+ ochre_subtle="#F3ECDC",
+ ember="#A85A4E",
+ ember_strong="#8A4438",
+ ember_subtle="#F2E3E0",
+ slate="#6B7580",
+ series=("#3C5A78", "#6B8F5E", "#B08A3E", "#A85A4E", "#7A6A93", "#4A8A8A", "#6B7580", "#8A6B4F"),
+)
+
+DEEPWATER = ThemeTokens(
+ name="Deepwater",
+ dark=True,
+ canvas="#1B1F24",
+ surface="#23282E",
+ surface_alt="#2C3239",
+ border="#3A4149",
+ border_strong="#4C5560",
+ text="#E3E6E3",
+ text_muted="#A3ABB2",
+ text_disabled="#6C757D",
+ selection="#34475A",
+ tide="#7FA3C4",
+ tide_hover="#91B1CE",
+ tide_pressed="#6E92B3",
+ tide_subtle="#2A3A4A",
+ on_tide="#101820",
+ moss="#8FAF82",
+ moss_strong="#8FAF82",
+ moss_subtle="#2E3A2C",
+ ochre="#CBA45B",
+ ochre_strong="#CBA45B",
+ ochre_subtle="#3A3222",
+ ember="#C77A6E",
+ ember_strong="#C77A6E",
+ ember_subtle="#3E2A27",
+ slate="#9AA5B0",
+ series=("#7FA3C4", "#8FAF82", "#CBA45B", "#C77A6E", "#A394BE", "#78B3B0", "#9AA5B0", "#B49072"),
+)
+
+THEMES: dict[bool, ThemeTokens] = {False: SANDBANK, True: DEEPWATER}
+
+
+# One QSS template for both themes; ``$token`` placeholders are substituted from
+# ThemeTokens. Elevation is canvas < surface < surface_alt, separated by borders.
+_STYLESHEET = Template("""
+QMainWindow, QDialog, QMenu, QToolTip {
+ background-color: $canvas;
}
QWidget {
font-family: "Segoe UI", "Noto Sans", "Helvetica Neue", Arial, sans-serif;
font-size: 13px;
- color: #263238;
+ color: $text;
+}
+QWidget:disabled {
+ color: $text_disabled;
+}
+QLabel[role="muted"] {
+ color: $text_muted;
+}
+QLabel[role="caption"] {
+ color: $text_muted;
+ font-size: 11px;
+}
+QLabel[role="heading"] {
+ font-size: 15px;
+ font-weight: 600;
+}
+QLabel[role="error"] {
+ color: $ember_strong;
+}
+QLabel[role="success"] {
+ color: $moss_strong;
+}
+QPlainTextEdit[role="log"] {
+ font-family: "JetBrains Mono", "DejaVu Sans Mono", "Consolas", "Menlo", monospace;
+ font-size: 12px;
+}
+QToolTip {
+ color: $text;
+ border: 1px solid $border_strong;
+ padding: ${space_1}px ${space_2}px;
+}
+QMenu {
+ border: 1px solid $border;
+ padding: ${space_1}px;
+}
+QMenu::item {
+ padding: ${space_2}px ${space_4}px;
+ border-radius: ${radius_sm}px;
+}
+QMenu::item:selected {
+ background-color: $selection;
+}
+QScrollArea, QStackedWidget {
+ background-color: transparent;
+ border: none;
}
QGroupBox {
- border: 1px solid #D9E0E6;
- border-radius: 10px;
- margin-top: 12px;
+ background-color: $surface;
+ border: 1px solid $border;
+ border-radius: ${radius_lg}px;
+ margin-top: ${space_3}px;
+ padding-top: ${space_2}px;
font-weight: 600;
}
QGroupBox::title {
subcontrol-origin: margin;
- left: 12px;
- padding: 0 6px;
- color: #455A64;
+ left: ${space_3}px;
+ padding: 0 ${space_2}px;
+ color: $text_muted;
}
-QLineEdit, QComboBox, QSpinBox, QDoubleSpinBox {
- background-color: #FFFFFF;
- border: 1px solid #CFD8DC;
- border-radius: 8px;
+QGroupBox::indicator {
+ width: 18px;
+ height: 18px;
+ border: 1px solid $border_strong;
+ border-radius: ${radius_sm}px;
+ background-color: $surface;
+}
+QGroupBox::indicator:hover {
+ border: 1px solid $tide;
+}
+QGroupBox::indicator:checked {
+ background-color: $tide;
+ border: 1px solid $tide;
+ $check_image
+}
+QGroupBox QGroupBox {
+ background-color: $canvas;
+}
+QLineEdit, QComboBox, QSpinBox, QDoubleSpinBox, QTextBrowser, QListWidget, QPlainTextEdit {
+ background-color: $surface;
+ border: 1px solid $border;
+ border-radius: ${radius_md}px;
padding: 6px 8px;
+ selection-background-color: $selection;
+ selection-color: $text;
+}
+QLineEdit, QComboBox, QSpinBox, QDoubleSpinBox {
+ min-height: ${input_min_h}px;
+}
+QLineEdit:hover, QComboBox:hover, QSpinBox:hover, QDoubleSpinBox:hover {
+ border: 1px solid $border_strong;
}
-QLineEdit:focus, QComboBox:focus, QSpinBox:focus, QDoubleSpinBox:focus {
- border: 1px solid #64B5F6;
+QLineEdit:focus, QComboBox:focus, QSpinBox:focus, QDoubleSpinBox:focus, QListWidget:focus,
+QPlainTextEdit:focus {
+ border: 1px solid $tide;
+}
+QLineEdit[invalid="true"] {
+ border: 1px solid $ember;
+}
+QLineEdit:disabled, QComboBox:disabled, QSpinBox:disabled, QDoubleSpinBox:disabled {
+ background-color: $surface_alt;
+}
+QComboBox::drop-down {
+ border: none;
+ width: ${space_5}px;
+}
+QComboBox QAbstractItemView {
+ background-color: $surface;
+ border: 1px solid $border;
+ selection-background-color: $selection;
+ selection-color: $text;
+ outline: none;
+}
+QSpinBox::up-button, QSpinBox::down-button,
+QDoubleSpinBox::up-button, QDoubleSpinBox::down-button {
+ border: none;
+ width: ${space_4}px;
+}
+QListWidget::item {
+ min-height: 28px;
+ padding: 0 ${space_1}px;
+}
+QListWidget::item:selected, QTableWidget::item:selected {
+ background-color: $selection;
+ color: $text;
}
QTableWidget {
- background-color: #FFFFFF;
- border: 1px solid #D9E0E6;
- border-radius: 8px;
+ background-color: $surface;
+ alternate-background-color: $surface;
+ border: 1px solid $border;
+ border-radius: ${radius_md}px;
+ gridline-color: $border;
+ outline: none;
}
QHeaderView::section {
- background-color: #ECEFF1;
+ background-color: $surface_alt;
+ color: $text_muted;
border: none;
+ border-bottom: 1px solid $border;
padding: 6px;
font-weight: 600;
}
+QTableCornerButton::section {
+ background-color: $surface_alt;
+ border: none;
+}
QProgressBar {
- border: 1px solid #CFD8DC;
- border-radius: 8px;
+ background-color: $surface;
+ border: 1px solid $border;
+ border-radius: ${radius_md}px;
text-align: center;
- background: #FFFFFF;
- height: 12px;
+ color: $text;
+ min-height: ${space_4}px;
}
QProgressBar::chunk {
- background-color: #42A5F5;
- border-radius: 8px;
+ background-color: $tide;
+ border-radius: ${radius_sm}px;
+}
+QProgressBar[progressState="paused"]::chunk {
+ background-color: $ochre_strong;
+}
+QProgressBar[progressState="finished"]::chunk {
+ background-color: $moss_strong;
+}
+QCheckBox {
+ spacing: ${space_2}px;
+}
+QCheckBox::indicator {
+ width: 18px;
+ height: 18px;
+ border: 1px solid $border_strong;
+ border-radius: ${radius_sm}px;
+ background-color: $surface;
+}
+QCheckBox::indicator:hover {
+ border: 1px solid $tide;
+}
+QCheckBox::indicator:checked {
+ background-color: $tide;
+ border: 1px solid $tide;
+ $check_image
+}
+QCheckBox::indicator:disabled {
+ background-color: $surface_alt;
+ border: 1px solid $border;
+}
+QTabBar::tab {
+ background-color: $surface_alt;
+ color: $text_muted;
+ border: 1px solid $border;
+ border-bottom: none;
+ border-top-left-radius: ${radius_md}px;
+ border-top-right-radius: ${radius_md}px;
+ padding: ${space_2}px ${space_4}px;
+ margin-right: 2px;
+ font-weight: 600;
+}
+QTabBar::tab:selected {
+ background-color: $surface;
+ color: $tide;
+ border-color: $tide;
}
QPushButton {
- border-radius: 10px;
- padding: 8px 16px;
- border: 1px solid #CFD8DC;
- background-color: #ECEFF1;
- color: #263238;
+ background-color: $surface_alt;
+ color: $text;
+ border: 1px solid $border;
+ border-radius: ${radius_lg}px;
+ padding: ${space_2}px ${space_4}px;
+ min-height: ${button_min_h}px;
font-weight: 600;
}
QPushButton:hover {
- background-color: #E3F2FD;
- border: 1px solid #90CAF9;
+ border: 1px solid $border_strong;
}
QPushButton:pressed {
- background-color: #BBDEFB;
+ background-color: $selection;
}
-QPushButton[tabButton="true"] {
- background-color: #E0E7EF;
+QPushButton:focus {
+ border: 1px solid $tide;
+}
+QPushButton:disabled {
+ background-color: $surface_alt;
+ color: $text_disabled;
+ border: 1px solid $border;
+}
+QPushButton[flat="true"] {
+ background-color: transparent;
+ color: $tide;
+ border: 1px solid transparent;
+}
+QPushButton[flat="true"]:hover {
+ background-color: $tide_subtle;
}
-QPushButton[tabButton="true"][tabActive="true"] {
- background-color: #FFFFFF;
- border: 2px solid #1E88E5;
- color: #1E88E5;
+QPushButton[flat="true"]:pressed {
+ background-color: $selection;
}
+QPushButton[flat="true"]:disabled {
+ background-color: transparent;
+ color: $text_disabled;
+}
+QPushButton[tabButton="true"] {
+ background-color: $surface_alt;
+ color: $text_muted;
+ min-height: ${button_lg_min_h}px;
+}
+QPushButton[tabButton="true"][tabActive="true"],
QPushButton[tabButton="true"]:disabled {
- background-color: #FFFFFF;
- border: 2px solid #1E88E5;
- color: #1E88E5;
+ background-color: $surface;
+ border: 2px solid $tide;
+ color: $tide;
}
QPushButton[primaryAction="true"] {
+ background-color: $tide;
+ color: $on_tide;
border: none;
- background-color: #1E88E5;
- color: #FFFFFF;
+ min-height: ${button_lg_min_h}px;
+}
+QPushButton[primaryAction="true"]:hover {
+ background-color: $tide_hover;
+}
+QPushButton[primaryAction="true"]:pressed {
+ background-color: $tide_pressed;
}
QPushButton[primaryAction="true"][actionState="pause"] {
- background-color: #F9A825;
+ background-color: $ochre_strong;
}
QPushButton[primaryAction="true"][actionState="resume"] {
- background-color: #43A047;
+ background-color: $moss_strong;
}
QPushButton[primaryAction="true"][actionState="stopping"] {
- background-color: #90A4AE;
+ background-color: $slate;
}
QPushButton[primaryAction="true"]:disabled {
- background-color: #B0BEC5;
- color: #ECEFF1;
+ background-color: $surface_alt;
+ color: $text_disabled;
+ border: 1px solid $border;
}
QPushButton[dangerAction="true"] {
- background-color: #E53935;
- color: #FFFFFF;
+ background-color: $ember_strong;
+ color: $on_tide;
border: none;
}
+QPushButton[dangerAction="true"]:hover {
+ background-color: $ember;
+}
QPushButton[dangerAction="true"]:disabled {
- background-color: #EF9A9A;
- color: #FFFFFF;
+ background-color: $surface_alt;
+ color: $text_disabled;
+ border: 1px solid $border;
}
-"""
+QDialogButtonBox QPushButton {
+ min-width: 88px;
+}
+QScrollBar:vertical, QScrollBar:horizontal {
+ background-color: transparent;
+ border: none;
+ margin: 0;
+}
+QScrollBar:vertical {
+ width: ${space_3}px;
+}
+QScrollBar:horizontal {
+ height: ${space_3}px;
+}
+QScrollBar::handle:vertical, QScrollBar::handle:horizontal {
+ background-color: $border_strong;
+ border-radius: ${radius_sm}px;
+ min-height: ${space_5}px;
+ min-width: ${space_5}px;
+ margin: 2px;
+}
+QScrollBar::handle:hover {
+ background-color: $text_disabled;
+}
+QScrollBar::add-line, QScrollBar::sub-line, QScrollBar::add-page, QScrollBar::sub-page {
+ background: none;
+ border: none;
+ height: 0;
+ width: 0;
+}
+QSplitter::handle {
+ background-color: $border;
+}
+QStatusBar {
+ color: $text_muted;
+}
+QStatusBar::item {
+ border: none;
+}
+""")
+
+# Check mark drawn in ``on_tide``; QSS cannot colour the native indicator and
+# ``url()`` needs a file, so ThemeManager.apply() writes it to the cache directory.
+_CHECK_SVG = Template(
+ ""
+ " "
+)
+
+
+def build_stylesheet(tokens: ThemeTokens, check_icon: Path | None = None) -> str:
+ """The application stylesheet for *tokens*; raises ``KeyError`` on a missing token.
+
+ *check_icon* is the SVG drawn inside a checked checkbox or group-box
+ indicator; without it the indicator is a plain ``tide`` square.
+ """
+ values = asdict(tokens)
+ values["check_image"] = f"image: url({check_icon.as_posix()});" if check_icon else ""
+ # QSS min-height is the content box: subtract the padding and 1 px border
+ # declared in the template so controls end up at control_h / control_h_lg.
+ values["input_min_h"] = tokens.control_h - 2 * 6 - 2
+ values["button_min_h"] = tokens.control_h - 2 * tokens.space_2 - 2
+ values["button_lg_min_h"] = tokens.control_h_lg - 2 * tokens.space_2 - 2
+ return _STYLESHEET.substitute(values)
+
+
+def build_palette(tokens: ThemeTokens) -> QPalette:
+ """A palette for the parts Qt draws natively (dialogs, combo popups, indicators)."""
+ palette = QPalette()
+ role = QPalette.ColorRole
+ for color_role, value in (
+ (role.Window, tokens.canvas),
+ (role.WindowText, tokens.text),
+ (role.Base, tokens.surface),
+ (role.AlternateBase, tokens.surface_alt),
+ (role.Text, tokens.text),
+ (role.Button, tokens.surface_alt),
+ (role.ButtonText, tokens.text),
+ (role.ToolTipBase, tokens.canvas),
+ (role.ToolTipText, tokens.text),
+ (role.PlaceholderText, tokens.text_disabled),
+ (role.Highlight, tokens.selection),
+ (role.HighlightedText, tokens.text),
+ (role.Link, tokens.tide),
+ (role.Light, tokens.surface),
+ (role.Midlight, tokens.surface_alt),
+ (role.Mid, tokens.border_strong),
+ (role.Dark, tokens.border_strong),
+ (role.Shadow, tokens.border_strong),
+ ):
+ palette.setColor(color_role, QColor(value))
+ disabled = QPalette.ColorGroup.Disabled
+ for color_role in (role.WindowText, role.Text, role.ButtonText):
+ palette.setColor(disabled, color_role, QColor(tokens.text_disabled))
+ return palette
+
+
+def icon(name: str, tokens: ThemeTokens, *, on_fill: bool = False, tertiary: bool = False) -> QIcon:
+ """A Material Design icon (``mdi6.``) coloured for the current theme.
+
+ Icons are ``text_muted`` on surfaces, ``on_tide`` on filled buttons and
+ ``tide`` on tertiary (flat) buttons.
+ """
+ import qtawesome
+
+ if on_fill:
+ color = tokens.on_tide
+ elif tertiary:
+ color = tokens.tide
+ else:
+ color = tokens.text_muted
+ return qtawesome.icon(f"mdi6.{name}", color=color, color_disabled=tokens.text_disabled)
+
+
+def refresh_style(widget: QWidget) -> None:
+ """Re-polish *widget* after one of its dynamic QSS properties changed."""
+ widget.style().unpolish(widget)
+ widget.style().polish(widget)
+ widget.update()
+
+
+def system_prefers_dark() -> bool:
+ app = QGuiApplication.instance()
+ if app is None:
+ return False
+ return QGuiApplication.styleHints().colorScheme() == Qt.ColorScheme.Dark
+
+
+class ThemeManager(QObject):
+ """Resolves the appearance mode, applies the stylesheet, and announces changes.
+
+ The mode (``system`` / ``light`` / ``dark``) is persisted in
+ ``QSettings("ORCA", "ORCA")`` under ``appearance/mode``. ``system``
+ follows the OS colour scheme live; the manual modes ignore it.
+ """
+
+ theme_changed = Signal(object) # ThemeTokens
+
+ def __init__(self, parent: QObject | None = None):
+ super().__init__(parent)
+ self._settings = QSettings("ORCA", "ORCA")
+ mode = str(self._settings.value("appearance/mode", "system"))
+ self._mode = mode if mode in MODES else "system"
+ self._tokens = self._resolve()
+ app = QGuiApplication.instance()
+ if app is not None:
+ QGuiApplication.styleHints().colorSchemeChanged.connect(self._on_system_scheme_changed)
+
+ @property
+ def mode(self) -> str:
+ return self._mode
+
+ @property
+ def tokens(self) -> ThemeTokens:
+ return self._tokens
+
+ def _resolve(self) -> ThemeTokens:
+ if self._mode == "system":
+ return THEMES[system_prefers_dark()]
+ return THEMES[self._mode == "dark"]
+
+ def set_mode(self, mode: str) -> None:
+ if mode not in MODES:
+ raise ValueError(f"Unknown appearance mode '{mode}'; expected one of {', '.join(MODES)}")
+ self._mode = mode
+ self._settings.setValue("appearance/mode", mode)
+ self.apply()
+
+ def cycle_mode(self) -> str:
+ """Advance system → light → dark → system and return the new mode."""
+ self.set_mode(MODES[(MODES.index(self._mode) + 1) % len(MODES)])
+ return self._mode
+
+ def apply(self) -> None:
+ """Apply the resolved theme to the application and notify listeners."""
+ self._tokens = self._resolve()
+ app = QApplication.instance()
+ if isinstance(app, QApplication):
+ app.setPalette(build_palette(self._tokens))
+ app.setStyleSheet(build_stylesheet(self._tokens, self._check_icon()))
+ self.theme_changed.emit(self._tokens)
+
+ def _check_icon(self) -> Path | None:
+ cache_dir = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.CacheLocation)
+ if not cache_dir:
+ return None
+ path = Path(cache_dir) / f"check-{self._tokens.on_tide.lstrip('#')}.svg"
+ try:
+ if not path.exists():
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(_CHECK_SVG.substitute(color=self._tokens.on_tide))
+ except OSError:
+ return None
+ return path
+
+ def bind_icon(
+ self,
+ button: QAbstractButton,
+ name: str,
+ *,
+ on_fill: bool = False,
+ tertiary: bool = False,
+ size: int = 20,
+ ) -> None:
+ """Give *button* a themed icon now and again whenever the theme changes."""
+ button.setIconSize(QSize(size, size))
+
+ def refresh(tokens: ThemeTokens) -> None:
+ button.setIcon(icon(name, tokens, on_fill=on_fill, tertiary=tertiary))
+
+ refresh(self._tokens)
+ self.theme_changed.connect(refresh)
+ button.destroyed.connect(lambda: self._disconnect(refresh))
+
+ def _disconnect(self, slot) -> None:
+ # Already disconnected when the application is shutting down.
+ with suppress(RuntimeError, TypeError):
+ self.theme_changed.disconnect(slot)
+
+ def _on_system_scheme_changed(self, _scheme) -> None:
+ if self._mode == "system":
+ self.apply()
-def apply_theme(widget) -> None:
- widget.setStyleSheet(THEME_STYLESHEET)
+@cache
+def manager() -> ThemeManager:
+ """The process-wide theme manager, created on first use."""
+ return ThemeManager()
diff --git a/src/orca/gui/widgets/geometry_selector.py b/src/orca/gui/widgets/geometry_selector.py
index eb6b929..89a5f69 100644
--- a/src/orca/gui/widgets/geometry_selector.py
+++ b/src/orca/gui/widgets/geometry_selector.py
@@ -1,16 +1,18 @@
from PySide6.QtWidgets import (
QComboBox,
QFileDialog,
+ QFormLayout,
QHBoxLayout,
QLabel,
QLineEdit,
- QMessageBox,
QPushButton,
- QVBoxLayout,
QWidget,
)
from orca.geometry.base_geometry import BaseGeometry
+from orca.gui.help_texts import tooltip
+from orca.gui.theme import manager as theme_manager
+from orca.gui.theme import refresh_style
from orca.gui.utils import get_preset_geometries, load_class_from_file
@@ -28,37 +30,48 @@ def __init__(self, parent=None):
self.load_presets()
def init_ui(self):
- layout = QVBoxLayout()
- self.setLayout(layout)
-
- # Selection Mode
- mode_layout = QHBoxLayout()
+ theme = theme_manager()
+ layout = QFormLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(theme.tokens.space_2)
+
+ # Preset combo box next to the custom-file button
+ source_layout = QHBoxLayout()
+ source_layout.setSpacing(theme.tokens.space_2)
self.combo_presets = QComboBox()
+ self.combo_presets.setToolTip(tooltip("geometry_combo"))
self.combo_presets.currentIndexChanged.connect(self.on_preset_changed)
- self.btn_load_custom = QPushButton("Load Custom .py")
+ self.btn_load_custom = QPushButton("Load custom file")
+ self.btn_load_custom.setToolTip(tooltip("geometry_file_btn"))
self.btn_load_custom.clicked.connect(self.load_custom_file)
+ theme.bind_icon(self.btn_load_custom, "folder-open-outline")
- layout.addWidget(QLabel("Select Geometry:"))
- layout.addLayout(mode_layout)
- mode_layout.addWidget(self.combo_presets)
- mode_layout.addWidget(self.btn_load_custom)
+ source_layout.addWidget(self.combo_presets, 1)
+ source_layout.addWidget(self.btn_load_custom)
+ layout.addRow("Geometry", source_layout)
- # Name Override
- name_layout = QHBoxLayout()
+ # Name override
self.name_input = QLineEdit()
- self.name_input.setPlaceholderText("Geometry Name")
-
- name_layout.addWidget(QLabel("Geometry Name:"))
- name_layout.addWidget(self.name_input)
- layout.addLayout(name_layout)
-
- self.lbl_status = QLabel("No geometry loaded")
- layout.addWidget(self.lbl_status)
+ self.name_input.setPlaceholderText("Geometry name")
+ self.name_input.setToolTip(tooltip("geometry_name_edit"))
+ layout.addRow("Name", self.name_input)
+
+ # Status: colour follows the role property, the text says the same thing.
+ self.lbl_status = QLabel()
+ self.lbl_status.setWordWrap(True)
+ layout.addRow(self.lbl_status)
+ self.set_status("No geometry loaded", "muted")
+
+ def set_status(self, text: str, role: str = "muted") -> None:
+ """Show *text* under the form; *role* is ``muted``, ``success`` or ``error``."""
+ self.lbl_status.setText(text)
+ self.lbl_status.setProperty("role", role)
+ refresh_style(self.lbl_status)
def load_presets(self):
self.combo_presets.clear()
- self.combo_presets.addItem("Select a preset...", None)
+ self.combo_presets.addItem("Select a preset…", None)
presets = get_preset_geometries()
for cls in presets:
@@ -74,14 +87,13 @@ def on_preset_changed(self, index):
def load_custom_file(self):
file_path, _ = QFileDialog.getOpenFileName(
- self, "Open Geometry File", "", "Python Files (*.py)"
+ self, "Open geometry file", "", "Python files (*.py)"
)
if file_path:
self.combo_presets.setCurrentIndex(0) # Reset preset selection
cls = load_class_from_file(file_path, BaseGeometry)
if not cls:
- QMessageBox.warning(self, "Error", f"Could not find valid BaseGeometry subclass in {file_path}")
- self.lbl_status.setText("Error loading geometry")
+ self.set_status(f"No BaseGeometry subclass found in {file_path}", "error")
return
self.load_geometry_from_class(cls)
@@ -92,10 +104,9 @@ def load_geometry_from_class(self, cls):
self.current_geometry_class = cls
self.current_geometry_instance = instance
self.name_input.setText(instance.name)
- self.lbl_status.setText(f"Loaded: {cls.__name__}")
+ self.set_status(f"Loaded {cls.__name__}", "success")
except Exception as e: # noqa: BLE001 - user-supplied class: show the error instead of crashing
- QMessageBox.critical(self, "Error", f"Failed to instantiate geometry class: {e}")
- self.lbl_status.setText("Error instantiating class")
+ self.set_status(f"Could not instantiate {cls.__name__}: {e}", "error")
def get_geometry(self):
if not self.current_geometry_instance:
diff --git a/src/orca/gui/widgets/stage_widget.py b/src/orca/gui/widgets/stage_widget.py
index 7e8e36f..7dec0d4 100644
--- a/src/orca/gui/widgets/stage_widget.py
+++ b/src/orca/gui/widgets/stage_widget.py
@@ -1,5 +1,7 @@
import inspect
import json
+import types
+import typing
from PySide6.QtWidgets import (
QCheckBox,
@@ -13,6 +15,8 @@
QWidget,
)
+from orca.gui.help_texts import parameter_tooltips, tooltip
+from orca.gui.theme import manager as theme_manager
from orca.pipeline.pipeline_stage import PipelineStage
@@ -28,25 +32,36 @@ def __init__(self, stage_class: type[PipelineStage], parent=None):
self.init_ui()
def init_ui(self):
+ tokens = theme_manager().tokens
layout = QVBoxLayout()
+ layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(layout)
self.group_box = QGroupBox(self.stage_class.__name__)
self.group_box.setCheckable(True)
self.group_box.setChecked(True)
+ self.group_box.setToolTip(tooltip("stage_group"))
form_layout = QFormLayout()
+ form_layout.setSpacing(tokens.space_2)
+ form_layout.setContentsMargins(
+ tokens.space_3, tokens.space_2, tokens.space_3, tokens.space_3
+ )
self.group_box.setLayout(form_layout)
- # Introspect __init__
+ # Introspect __init__; the Args docstring supplies the tooltips
sig = inspect.signature(self.stage_class.__init__)
+ tips = parameter_tooltips(self.stage_class)
for name, param in sig.parameters.items():
if name == "self":
continue
- label = QLabel(name)
+ label = QLabel(name.replace("_", " ").capitalize())
input_widget = self.create_input_widget(param)
+ if name in tips:
+ label.setToolTip(tips[name])
+ input_widget.setToolTip(tips[name])
self.parameter_inputs[name] = {"widget": input_widget, "type": param.annotation}
form_layout.addRow(label, input_widget)
@@ -94,6 +109,14 @@ def get_widget_value(self, widget, annotation):
return widget.isChecked()
if isinstance(widget, QLineEdit):
text = widget.text()
+ # Optional parameters (`int | None`) are edited as text; an empty field or
+ # the literal "None" means None, anything else is parsed as the other type.
+ if typing.get_origin(annotation) in (types.UnionType, typing.Union):
+ members = [m for m in typing.get_args(annotation) if m is not type(None)]
+ if text.strip() in ("", "None"):
+ return None
+ if len(members) == 1:
+ annotation = members[0]
if annotation is int:
return int(text)
if annotation is float:
diff --git a/src/orca/pipeline/gds_gen_stage.py b/src/orca/pipeline/gds_gen_stage.py
index c5c957d..b5b054b 100644
--- a/src/orca/pipeline/gds_gen_stage.py
+++ b/src/orca/pipeline/gds_gen_stage.py
@@ -27,6 +27,11 @@ class GDSGenerator(PipelineStage):
"""
def __init__(self, num_samples: int = 1000, seed: int | None = None):
+ """
+ Args:
+ num_samples (int): Number of parameter samples, and thus GDS layouts, to generate.
+ seed (int | None): Seed of the parameter sampler; None draws a fresh sample each run.
+ """
super().__init__(name="GDS Generator", index=0)
self.num_samples = num_samples
self.seed = seed
diff --git a/src/orca/training/models/__init__.py b/src/orca/training/models/__init__.py
index e69de29..1ba5e98 100644
--- a/src/orca/training/models/__init__.py
+++ b/src/orca/training/models/__init__.py
@@ -0,0 +1,8 @@
+"""Neural-network architectures for the surrogate models.
+
+Importing the built-in models registers them under their short names
+(``"mlp"``, ...), so ``ModelTrainer(model="mlp")`` resolves without the caller
+importing the class itself. User models register through ``register_model``.
+"""
+
+from orca.training.models import mlp # noqa: F401 - registration side effect
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/uv.lock b/uv.lock
index 2c20a8a..59380a7 100644
--- a/uv.lock
+++ b/uv.lock
@@ -969,6 +969,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3e/2d/ca050652104bab2cf55e569db2a178b1b61cb041fef28307f2db383f6d9f/imageio-2.37.4-py3-none-any.whl", hash = "sha256:1ab2e22c8debf700f24c3ac43e8f95f3b3a8110c83b93411e97b4b0b2cd1c7e6", size = 318000, upload-time = "2026-07-20T05:26:09.874Z" },
]
+[[package]]
+name = "iniconfig"
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
+]
+
[[package]]
name = "ipyevents"
version = "2.0.4"
@@ -2243,6 +2252,7 @@ dependencies = [
{ name = "pandas" },
{ name = "pebble" },
{ name = "pyside6" },
+ { name = "qtawesome" },
{ name = "scikit-rf" },
{ name = "tqdm" },
]
@@ -2273,6 +2283,7 @@ train = [
[package.dev-dependencies]
dev = [
+ { name = "pytest" },
{ name = "ruff" },
{ name = "ty" },
]
@@ -2293,6 +2304,7 @@ requires-dist = [
{ name = "pandas" },
{ name = "pebble", specifier = ">=5.2.2" },
{ name = "pyside6" },
+ { name = "qtawesome" },
{ name = "scikit-learn", marker = "extra == 'train'" },
{ name = "scikit-rf" },
{ name = "torch", marker = "extra == 'cpu'", index = "https://download.pytorch.org/whl/cpu", conflict = { package = "orca", extra = "cpu" } },
@@ -2305,6 +2317,7 @@ provides-extras = ["train", "cpu", "cu126", "cu130"]
[package.metadata.requires-dev]
dev = [
+ { name = "pytest" },
{ name = "ruff" },
{ name = "ty" },
]
@@ -2480,6 +2493,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/e1/5b7b8bbb55084d1425bcb9bc823ff519e1b2be05f6ebb0089e2eacc38413/platformdirs-4.11.8-py3-none-any.whl", hash = "sha256:52f2f181bbfde907966932cc8312d967d02976422d66d537ea16092b8e291081", size = 24027, upload-time = "2026-09-08T22:20:41.537Z" },
]
+[[package]]
+name = "pluggy"
+version = "1.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
+]
+
[[package]]
name = "prompt-toolkit"
version = "3.0.53"
@@ -2969,6 +2991,22 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cd/87/4dd2a6468cea600da1fcf9c7f02bc248eeaa98cdc75658e48e669d95326a/pyside6_essentials-6.11.2-cp310-abi3-win_arm64.whl", hash = "sha256:fadd75c5c20800d64dd0a586ea8cb337c5e630aa2246df0e5105738afa46e02c", size = 58125520, upload-time = "2026-08-18T06:36:59.073Z" },
]
+[[package]]
+name = "pytest"
+version = "9.1.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32' or (extra == 'extra-4-orca-cpu' and extra == 'extra-4-orca-cu126') or (extra == 'extra-4-orca-cpu' and extra == 'extra-4-orca-cu130') or (extra == 'extra-4-orca-cu126' and extra == 'extra-4-orca-cu130')" },
+ { name = "iniconfig" },
+ { name = "packaging" },
+ { name = "pluggy" },
+ { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
+]
+
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
@@ -3079,6 +3117,30 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/dd/b8/d2d6d731733f51684bbf76bf34dab3b70a9148e8f2cef2bb544fccec681a/qrcode-8.2-py3-none-any.whl", hash = "sha256:16e64e0716c14960108e85d853062c9e8bba5ca8252c0b4d0231b9df4060ff4f", size = 45986, upload-time = "2025-05-01T15:44:22.781Z" },
]
+[[package]]
+name = "qtawesome"
+version = "1.4.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "qtpy" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/cf/bc/475c8df94de4f358bbf7703747c13b16ae3c0c4cda50d5243b99cb45e2a8/qtawesome-1.4.2.tar.gz", hash = "sha256:b2bf9351beb335095006892796f072ffd9755a2d7e5113dc71918dcd9ba4ef4a", size = 2614207, upload-time = "2026-04-10T18:49:46.037Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/25/b1/da1d826ccc9258674b26dd0abcbbb1c55cab06e7fbf2697518b67edc79fc/qtawesome-1.4.2-py3-none-any.whl", hash = "sha256:dbf08524428fa2df73918ce362153254cd44f089380576d84bfaad8f40eece45", size = 2593557, upload-time = "2026-04-10T18:49:44.219Z" },
+]
+
+[[package]]
+name = "qtpy"
+version = "2.4.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "packaging" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/70/01/392eba83c8e47b946b929d7c46e0f04b35e9671f8bb6fc36b6f7945b4de8/qtpy-2.4.3.tar.gz", hash = "sha256:db744f7832e6d3da90568ba6ccbca3ee2b3b4a890c3d6fbbc63142f6e4cdf5bb", size = 66982, upload-time = "2025-02-11T15:09:25.759Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/69/76/37c0ccd5ab968a6a438f9c623aeecc84c202ab2fabc6a8fd927580c15b5a/QtPy-2.4.3-py3-none-any.whl", hash = "sha256:72095afe13673e017946cc258b8d5da43314197b741ed2890e563cf384b51aa1", size = 95045, upload-time = "2025-02-11T15:09:24.162Z" },
+]
+
[[package]]
name = "rapidfuzz"
version = "3.14.6"