From 92a65753590f62bfd4d394708697c75547e3836c Mon Sep 17 00:00:00 2001 From: David Lurz Date: Mon, 21 Sep 2026 20:26:41 +0200 Subject: [PATCH] Updated GUI to fit COBRA theme --- .claude/CLAUDE.md | 4 +- .claude/GUI_DESIGN.md | 27 +- README.md | 7 +- docs/running_orca.md | 10 +- pyproject.toml | 4 +- src/orca/gui/README.md | 2 +- src/orca/gui/app.py | 7 +- src/orca/gui/help_texts.py | 64 ++ src/orca/gui/pipeline_window.py | 181 ++++-- src/orca/gui/theme.py | 673 ++++++++++++++++++++-- src/orca/gui/widgets/geometry_selector.py | 69 ++- src/orca/gui/widgets/stage_widget.py | 27 +- src/orca/pipeline/gds_gen_stage.py | 5 + src/orca/training/models/__init__.py | 8 + tests/__init__.py | 0 uv.lock | 62 ++ 16 files changed, 983 insertions(+), 167 deletions(-) create mode 100644 src/orca/gui/help_texts.py create mode 100644 tests/__init__.py 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"