feat: Options → Plot Properties dialog with live preview and persistence
Adds a table-driven dialog over RenderStyle.STYLE_FIELDS (colour swatches, an opacity spin and a label font size), reachable from Options. Edits preview immediately on every canvas, Cancel restores the style the dialog opened with, and only OK persists — as JSON under QSettings plot/props, reloaded on the next launch. Both canvases gain set_style(); the dialog deliberately exposes plot_style() rather than style() so QWidget.style() keeps its Qt meaning.
This commit is contained in:
parent
56f1f6c08b
commit
bd365be77d
9 changed files with 348 additions and 2 deletions
|
|
@ -20,6 +20,7 @@ Strict one-way MVVM + services: `views → viewmodels → services → core`.
|
||||||
- `services/` (`opensees_runner.py`, `persistence.py`, `results.py`, ...): may use core + h5py + openseespy. **No Qt.**
|
- `services/` (`opensees_runner.py`, `persistence.py`, `results.py`, ...): may use core + h5py + openseespy. **No Qt.**
|
||||||
- `views/`: PySide6/pyvistaqt only. **No direct `import openseespy`** — go through a service.
|
- `views/`: PySide6/pyvistaqt only. **No direct `import openseespy`** — go through a service.
|
||||||
- `views/canvas3d/` (**PyVista/VTK**, default) and `views/canvas_plotly/` (plotly.js in a `QWebEngineView`) are two backends for the same central 3D view. Both satisfy the `CanvasBackend` protocol in `views/canvas_base.py`, share one `SelectionState` owned by `MainWindow`, and are swapped live via **Options → Canvas Backend** (persisted in `QSettings` under `canvas/backend`). `MainWindow._activate_canvas` keeps both widgets in a `QStackedWidget` — never destroy a canvas mid-session (VTK leaves dangling make-current callbacks). Backend-specific gaps are declared by `CanvasCapabilities` (e.g. Plotly has no force-diagram overlay or video export yet); gate UI on `canvas.capabilities`, never on the backend name. `canvas_plotly/trace_builder.py` is pure (no Qt, no pyvista) and unit-tested headless.
|
- `views/canvas3d/` (**PyVista/VTK**, default) and `views/canvas_plotly/` (plotly.js in a `QWebEngineView`) are two backends for the same central 3D view. Both satisfy the `CanvasBackend` protocol in `views/canvas_base.py`, share one `SelectionState` owned by `MainWindow`, and are swapped live via **Options → Canvas Backend** (persisted in `QSettings` under `canvas/backend`). `MainWindow._activate_canvas` keeps both widgets in a `QStackedWidget` — never destroy a canvas mid-session (VTK leaves dangling make-current callbacks). Backend-specific gaps are declared by `CanvasCapabilities` (e.g. Plotly has no force-diagram overlay or video export yet); gate UI on `canvas.capabilities`, never on the backend name. `canvas_plotly/trace_builder.py` is pure (no Qt, no pyvista) and unit-tested headless.
|
||||||
|
- `views/canvas3d/style.py` (`RenderStyle`) is the single source of truth for colours/sizes on **both** backends; renderers must read it rather than hard-coding a colour. The `STYLE_FIELDS` subset is user-editable via **Options → Plot Properties…** (`views/dialogs/plot_properties.py`), previews live through `MainWindow.set_plot_style(..., persist=False)`, and persists as JSON in `QSettings` under `plot/props`.
|
||||||
- `viewmodels/` bridges core↔Qt (signals, `QUndoStack`); `commands/` holds `QUndoCommand` subclasses.
|
- `viewmodels/` bridges core↔Qt (signals, `QUndoStack`); `commands/` holds `QUndoCommand` subclasses.
|
||||||
- Rules: public functions need type hints + docstring; new domain entities go through Pydantic validation; ops >50 ms run off the GUI thread (`AnalysisWorker` in QThread, cancel via `isInterruptionRequested()`, results cross threads as lightweight `ResultsHandle` to HDF5).
|
- Rules: public functions need type hints + docstring; new domain entities go through Pydantic validation; ops >50 ms run off the GUI thread (`AnalysisWorker` in QThread, cancel via `isInterruptionRequested()`, results cross threads as lightweight `ResultsHandle` to HDF5).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -342,6 +342,17 @@ class ModelCanvas(QtInteractor): # type: ignore[misc]
|
||||||
self._renderer.set_show_local_axes(enabled)
|
self._renderer.set_show_local_axes(enabled)
|
||||||
self.render()
|
self.render()
|
||||||
|
|
||||||
|
def set_style(self, style: RenderStyle) -> None:
|
||||||
|
"""Swap the visual style and repaint the scene.
|
||||||
|
|
||||||
|
Applies the background immediately (it lives outside the renderer)
|
||||||
|
and rebuilds the actors whose colours come from the style LUTs.
|
||||||
|
"""
|
||||||
|
self._style = style
|
||||||
|
self.set_background(style.background_bottom, top=style.background_top)
|
||||||
|
self._renderer.set_style(style)
|
||||||
|
self.render()
|
||||||
|
|
||||||
def set_display_options(self, *, show_node_labels: bool, show_element_labels: bool) -> None:
|
def set_display_options(self, *, show_node_labels: bool, show_element_labels: bool) -> None:
|
||||||
"""Toggle viewport labels such as node and element names."""
|
"""Toggle viewport labels such as node and element names."""
|
||||||
self._renderer.set_display_options(
|
self._renderer.set_display_options(
|
||||||
|
|
|
||||||
|
|
@ -271,6 +271,12 @@ class PlotlyCanvas(QWidget):
|
||||||
self._options = replace(self._options, show_local_axes=bool(enabled))
|
self._options = replace(self._options, show_local_axes=bool(enabled))
|
||||||
self.render()
|
self.render()
|
||||||
|
|
||||||
|
def set_style(self, style: RenderStyle) -> None:
|
||||||
|
"""Swap the visual style and repaint the figure."""
|
||||||
|
self._style = style
|
||||||
|
self._builder.set_style(style)
|
||||||
|
self.render()
|
||||||
|
|
||||||
def set_display_options(self, *, show_node_labels: bool, show_element_labels: bool) -> None:
|
def set_display_options(self, *, show_node_labels: bool, show_element_labels: bool) -> None:
|
||||||
self._options = replace(
|
self._options = replace(
|
||||||
self._options,
|
self._options,
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ from otko.views.dialogs.move import MoveDialog
|
||||||
from otko.views.dialogs.path_time_series import PathTimeSeriesDialog
|
from otko.views.dialogs.path_time_series import PathTimeSeriesDialog
|
||||||
from otko.views.dialogs.pattern_loads import PatternLoadsDialog
|
from otko.views.dialogs.pattern_loads import PatternLoadsDialog
|
||||||
from otko.views.dialogs.plain_pattern import PlainPatternDialog
|
from otko.views.dialogs.plain_pattern import PlainPatternDialog
|
||||||
|
from otko.views.dialogs.plot_properties import PlotPropertiesDialog
|
||||||
from otko.views.dialogs.quick_grid_lines import QuickGridLinesDialog
|
from otko.views.dialogs.quick_grid_lines import QuickGridLinesDialog
|
||||||
from otko.views.dialogs.quick_guide import QuickGuideDialog
|
from otko.views.dialogs.quick_guide import QuickGuideDialog
|
||||||
from otko.views.dialogs.replicate import ReplicateDialog
|
from otko.views.dialogs.replicate import ReplicateDialog
|
||||||
|
|
@ -69,6 +70,7 @@ __all__ = [
|
||||||
"PathTimeSeriesDialog",
|
"PathTimeSeriesDialog",
|
||||||
"PatternLoadsDialog",
|
"PatternLoadsDialog",
|
||||||
"PlainPatternDialog",
|
"PlainPatternDialog",
|
||||||
|
"PlotPropertiesDialog",
|
||||||
"QuickGridLinesDialog",
|
"QuickGridLinesDialog",
|
||||||
"QuickGuideDialog",
|
"QuickGuideDialog",
|
||||||
"ReplicateDialog",
|
"ReplicateDialog",
|
||||||
|
|
|
||||||
139
src/otko/views/dialogs/plot_properties.py
Normal file
139
src/otko/views/dialogs/plot_properties.py
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
"""Plot Properties dialog — the user-facing subset of :class:`RenderStyle`.
|
||||||
|
|
||||||
|
The field table lives in :mod:`otko.views.canvas3d.style` (``STYLE_FIELDS``),
|
||||||
|
so adding an editable property is a one-line change there plus a default on
|
||||||
|
the dataclass. Edits are emitted through :attr:`PlotPropertiesDialog.changed`
|
||||||
|
so the canvas can preview them live; ``style()`` returns the composed result.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PySide6.QtCore import Signal
|
||||||
|
from PySide6.QtGui import QColor
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QColorDialog,
|
||||||
|
QDialog,
|
||||||
|
QDialogButtonBox,
|
||||||
|
QDoubleSpinBox,
|
||||||
|
QFormLayout,
|
||||||
|
QLabel,
|
||||||
|
QPushButton,
|
||||||
|
QSpinBox,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from otko.views.canvas3d.style import STYLE_FIELDS, RenderStyle
|
||||||
|
|
||||||
|
|
||||||
|
class _ColorButton(QPushButton):
|
||||||
|
"""A swatch that opens :class:`QColorDialog` and reports the hex value."""
|
||||||
|
|
||||||
|
colorChanged = Signal(str)
|
||||||
|
|
||||||
|
def __init__(self, color: str, parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self._color = color
|
||||||
|
self.setFixedWidth(96)
|
||||||
|
self._refresh()
|
||||||
|
self.clicked.connect(self._pick)
|
||||||
|
|
||||||
|
def color(self) -> str:
|
||||||
|
"""Current colour as a ``#rrggbb`` string."""
|
||||||
|
return self._color
|
||||||
|
|
||||||
|
def set_color(self, color: str) -> None:
|
||||||
|
"""Set the swatch colour (does not emit :attr:`colorChanged`)."""
|
||||||
|
self._color = color
|
||||||
|
self._refresh()
|
||||||
|
|
||||||
|
def _refresh(self) -> None:
|
||||||
|
self.setText(self._color.upper())
|
||||||
|
self.setStyleSheet(f"background-color: {self._color}; color: {self._text_color()};")
|
||||||
|
|
||||||
|
def _text_color(self) -> str:
|
||||||
|
color = QColor(self._color)
|
||||||
|
luminance = 0.299 * color.red() + 0.587 * color.green() + 0.114 * color.blue()
|
||||||
|
return "#000000" if luminance > 140 else "#ffffff"
|
||||||
|
|
||||||
|
def _pick(self) -> None:
|
||||||
|
chosen = QColorDialog.getColor(QColor(self._color), self, "Select colour")
|
||||||
|
if chosen.isValid():
|
||||||
|
self.set_color(chosen.name())
|
||||||
|
self.colorChanged.emit(self._color)
|
||||||
|
|
||||||
|
|
||||||
|
class PlotPropertiesDialog(QDialog):
|
||||||
|
"""Modeless-free editor for the persisted plot style."""
|
||||||
|
|
||||||
|
#: Emitted with the composed :class:`RenderStyle` on every edit.
|
||||||
|
changed = Signal(object)
|
||||||
|
|
||||||
|
def __init__(self, style: RenderStyle, parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle("Plot Properties")
|
||||||
|
self._base = style
|
||||||
|
self._colors: dict[str, _ColorButton] = {}
|
||||||
|
self._numbers: dict[str, QDoubleSpinBox | QSpinBox] = {}
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
form = QFormLayout()
|
||||||
|
for name, label, kind in STYLE_FIELDS:
|
||||||
|
widget = self._build_widget(name, getattr(style, name), kind)
|
||||||
|
form.addRow(QLabel(f"{label}:"), widget)
|
||||||
|
layout.addLayout(form)
|
||||||
|
|
||||||
|
buttons = QDialogButtonBox(
|
||||||
|
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
|
||||||
|
)
|
||||||
|
reset = buttons.addButton("Reset to Defaults", QDialogButtonBox.ButtonRole.ResetRole)
|
||||||
|
assert reset is not None
|
||||||
|
reset.clicked.connect(self._reset)
|
||||||
|
buttons.accepted.connect(self.accept)
|
||||||
|
buttons.rejected.connect(self.reject)
|
||||||
|
layout.addWidget(buttons)
|
||||||
|
|
||||||
|
# ── public ──────────────────────────────────────────────────────
|
||||||
|
def plot_style(self) -> RenderStyle:
|
||||||
|
"""The composed style for the current widget values.
|
||||||
|
|
||||||
|
Named ``plot_style`` (not ``style``) because :meth:`QWidget.style`
|
||||||
|
already exists and must keep its Qt meaning.
|
||||||
|
"""
|
||||||
|
updates: dict[str, object] = {name: w.color() for name, w in self._colors.items()}
|
||||||
|
updates.update({name: w.value() for name, w in self._numbers.items()})
|
||||||
|
return self._base.with_updates(**updates)
|
||||||
|
|
||||||
|
# ── internals ───────────────────────────────────────────────────
|
||||||
|
def _build_widget(self, name: str, value: object, kind: str) -> QWidget:
|
||||||
|
if kind == "color":
|
||||||
|
button = _ColorButton(str(value))
|
||||||
|
button.colorChanged.connect(self._emit_changed)
|
||||||
|
self._colors[name] = button
|
||||||
|
return button
|
||||||
|
if kind == "float":
|
||||||
|
spin = QDoubleSpinBox()
|
||||||
|
spin.setRange(0.0, 1.0)
|
||||||
|
spin.setSingleStep(0.05)
|
||||||
|
spin.setDecimals(2)
|
||||||
|
spin.setValue(float(value)) # type: ignore[arg-type]
|
||||||
|
spin.valueChanged.connect(self._emit_changed)
|
||||||
|
self._numbers[name] = spin
|
||||||
|
return spin
|
||||||
|
int_spin = QSpinBox()
|
||||||
|
int_spin.setRange(6, 32)
|
||||||
|
int_spin.setValue(int(value)) # type: ignore[call-overload]
|
||||||
|
int_spin.valueChanged.connect(self._emit_changed)
|
||||||
|
self._numbers[name] = int_spin
|
||||||
|
return int_spin
|
||||||
|
|
||||||
|
def _emit_changed(self, *_args: object) -> None:
|
||||||
|
self.changed.emit(self.plot_style())
|
||||||
|
|
||||||
|
def _reset(self) -> None:
|
||||||
|
defaults = RenderStyle()
|
||||||
|
for name, button in self._colors.items():
|
||||||
|
button.set_color(getattr(defaults, name))
|
||||||
|
for name, spin in self._numbers.items():
|
||||||
|
spin.setValue(getattr(defaults, name))
|
||||||
|
self._emit_changed()
|
||||||
|
|
@ -13,6 +13,7 @@ The ``projectChanged`` / ``modelMutated`` signals live on the
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
@ -25,6 +26,7 @@ from otko.views.action_handlers import ActionHandlers
|
||||||
from otko.views.canvas3d import ModelCanvas
|
from otko.views.canvas3d import ModelCanvas
|
||||||
from otko.views.canvas3d.diagram_renderer import DiagramRenderer
|
from otko.views.canvas3d.diagram_renderer import DiagramRenderer
|
||||||
from otko.views.canvas3d.selection import SelectionState
|
from otko.views.canvas3d.selection import SelectionState
|
||||||
|
from otko.views.canvas3d.style import RenderStyle
|
||||||
from otko.views.canvas_base import plotly_available
|
from otko.views.canvas_base import plotly_available
|
||||||
from otko.views.dock_manager import DockManager
|
from otko.views.dock_manager import DockManager
|
||||||
from otko.views.menu_builder import MenuBuilder
|
from otko.views.menu_builder import MenuBuilder
|
||||||
|
|
@ -72,6 +74,8 @@ class MainWindow(
|
||||||
|
|
||||||
self._settings = settings if settings is not None else QSettings("OTKO", "OTKO")
|
self._settings = settings if settings is not None else QSettings("OTKO", "OTKO")
|
||||||
self._canvas_backend = self._stored_canvas_backend()
|
self._canvas_backend = self._stored_canvas_backend()
|
||||||
|
#: Shared, user-editable visual style (both backends read it).
|
||||||
|
self._style = self._load_plot_style()
|
||||||
self._canvases: dict[str, Any] = {}
|
self._canvases: dict[str, Any] = {}
|
||||||
self._diagram_renderers: dict[str, DiagramRenderer] = {}
|
self._diagram_renderers: dict[str, DiagramRenderer] = {}
|
||||||
# A stack holds every backend's canvas: swapping is just
|
# A stack holds every backend's canvas: swapping is just
|
||||||
|
|
@ -115,9 +119,9 @@ class MainWindow(
|
||||||
# Imported lazily so a plotly-less install still boots PyVista.
|
# Imported lazily so a plotly-less install still boots PyVista.
|
||||||
from otko.views.canvas_plotly import PlotlyCanvas
|
from otko.views.canvas_plotly import PlotlyCanvas
|
||||||
|
|
||||||
canvas = PlotlyCanvas(self, selection=self._selection)
|
canvas = PlotlyCanvas(self, style=self._style, selection=self._selection)
|
||||||
else:
|
else:
|
||||||
canvas = ModelCanvas(self, selection=self._selection)
|
canvas = ModelCanvas(self, style=self._style, selection=self._selection)
|
||||||
self._canvases[backend] = canvas
|
self._canvases[backend] = canvas
|
||||||
return canvas
|
return canvas
|
||||||
|
|
||||||
|
|
@ -145,6 +149,35 @@ class MainWindow(
|
||||||
"""Name of the active canvas backend (``"pyvista"`` or ``"plotly"``)."""
|
"""Name of the active canvas backend (``"pyvista"`` or ``"plotly"``)."""
|
||||||
return self._canvas_backend
|
return self._canvas_backend
|
||||||
|
|
||||||
|
# ── plot style ───────────────────────────────────────────────────
|
||||||
|
def _load_plot_style(self) -> RenderStyle:
|
||||||
|
"""Style from the last session, ignoring anything unrecognised."""
|
||||||
|
raw = self._settings.value("plot/props")
|
||||||
|
if not raw:
|
||||||
|
return RenderStyle()
|
||||||
|
try:
|
||||||
|
data = json.loads(str(raw))
|
||||||
|
except ValueError:
|
||||||
|
return RenderStyle()
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return RenderStyle()
|
||||||
|
allowed = {name: value for name, value in data.items() if name in RenderStyle.EDITABLE}
|
||||||
|
try:
|
||||||
|
return RenderStyle(**allowed)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return RenderStyle()
|
||||||
|
|
||||||
|
def _save_plot_style(self) -> None:
|
||||||
|
self._settings.setValue("plot/props", json.dumps(self._style.editable_values()))
|
||||||
|
|
||||||
|
def set_plot_style(self, style: RenderStyle, *, persist: bool = True) -> None:
|
||||||
|
"""Apply a style to every canvas (live preview when ``persist`` is off)."""
|
||||||
|
self._style = style
|
||||||
|
for canvas in self._canvases.values():
|
||||||
|
canvas.set_style(style)
|
||||||
|
if persist:
|
||||||
|
self._save_plot_style()
|
||||||
|
|
||||||
def _build_tooling(self) -> None:
|
def _build_tooling(self) -> None:
|
||||||
"""(Re)build the tool controller and tools against the current canvas."""
|
"""(Re)build the tool controller and tools against the current canvas."""
|
||||||
self._tool_controller = ToolController(self._canvas, self._vm, self)
|
self._tool_controller = ToolController(self._canvas, self._vm, self)
|
||||||
|
|
@ -261,6 +294,7 @@ class MainWindow(
|
||||||
self._act_about.triggered.connect(self._on_about)
|
self._act_about.triggered.connect(self._on_about)
|
||||||
self._act_quick_guide.triggered.connect(self._on_quick_guide)
|
self._act_quick_guide.triggered.connect(self._on_quick_guide)
|
||||||
self._act_set_units.triggered.connect(self._on_set_units)
|
self._act_set_units.triggered.connect(self._on_set_units)
|
||||||
|
self._act_plot_properties.triggered.connect(self._on_plot_properties)
|
||||||
|
|
||||||
# Edit
|
# Edit
|
||||||
self._act_delete.triggered.connect(self._on_delete)
|
self._act_delete.triggered.connect(self._on_delete)
|
||||||
|
|
|
||||||
|
|
@ -192,6 +192,10 @@ class MenuBuilder:
|
||||||
# No shortcut: F1/F2/F3/F5 are taken by the draw tools and Run.
|
# No shortcut: F1/F2/F3/F5 are taken by the draw tools and Run.
|
||||||
self._act_quick_guide = QAction("&Quick Guide", self)
|
self._act_quick_guide = QAction("&Quick Guide", self)
|
||||||
self._act_set_units = QAction("Set Display &Units…", self)
|
self._act_set_units = QAction("Set Display &Units…", self)
|
||||||
|
self._act_plot_properties = QAction("&Plot Properties…", self)
|
||||||
|
self._act_plot_properties.setToolTip(
|
||||||
|
"Colours and sizes used by both canvas backends (persisted)."
|
||||||
|
)
|
||||||
|
|
||||||
# Canvas backend — exclusive radio pair live-switching the 3D view.
|
# Canvas backend — exclusive radio pair live-switching the 3D view.
|
||||||
from otko.views.canvas_base import plotly_available
|
from otko.views.canvas_base import plotly_available
|
||||||
|
|
@ -512,6 +516,7 @@ class MenuBuilder:
|
||||||
|
|
||||||
m_options = mb.addMenu("&Options")
|
m_options = mb.addMenu("&Options")
|
||||||
m_options.addAction(self._act_set_units)
|
m_options.addAction(self._act_set_units)
|
||||||
|
m_options.addAction(self._act_plot_properties)
|
||||||
m_backend = m_options.addMenu("Canvas &Backend")
|
m_backend = m_options.addMenu("Canvas &Backend")
|
||||||
m_backend.addAction(self._act_canvas_pyvista)
|
m_backend.addAction(self._act_canvas_pyvista)
|
||||||
m_backend.addAction(self._act_canvas_plotly)
|
m_backend.addAction(self._act_canvas_plotly)
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,23 @@ class RenderControls:
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── SAP2000-style view switching + working level ───────────────
|
# ── SAP2000-style view switching + working level ───────────────
|
||||||
|
def _on_plot_properties(self) -> None:
|
||||||
|
"""Edit the shared visual style.
|
||||||
|
|
||||||
|
Edits preview live on both canvases; Cancel restores the style the
|
||||||
|
dialog opened with, and only OK persists to ``QSettings``.
|
||||||
|
"""
|
||||||
|
from otko.views.dialogs import PlotPropertiesDialog
|
||||||
|
|
||||||
|
original = self._style
|
||||||
|
dialog = PlotPropertiesDialog(original, self)
|
||||||
|
dialog.changed.connect(lambda style: self.set_plot_style(style, persist=False))
|
||||||
|
if dialog.exec() == QDialog.DialogCode.Accepted:
|
||||||
|
self.set_plot_style(dialog.plot_style())
|
||||||
|
self._log("Plot properties updated.")
|
||||||
|
else:
|
||||||
|
self.set_plot_style(original, persist=False)
|
||||||
|
|
||||||
def _on_toggle_parallel(self, on: bool) -> None:
|
def _on_toggle_parallel(self, on: bool) -> None:
|
||||||
cam = self._canvas.camera
|
cam = self._canvas.camera
|
||||||
cam.parallel_projection = on
|
cam.parallel_projection = on
|
||||||
|
|
|
||||||
131
tests/gui/test_plot_properties.py
Normal file
131
tests/gui/test_plot_properties.py
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
"""GUI tests for Options → Plot Properties.
|
||||||
|
|
||||||
|
Covers the dialog itself plus MainWindow's live-apply / persist / revert
|
||||||
|
wiring. QSettings are injected so nothing touches real user state.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
pytest.importorskip("PySide6")
|
||||||
|
pytest.importorskip("pyvistaqt")
|
||||||
|
|
||||||
|
from PySide6.QtCore import QObject, QSettings, Signal
|
||||||
|
from PySide6.QtWidgets import QDialog
|
||||||
|
|
||||||
|
|
||||||
|
def _settings(tmp_path): # type: ignore[no-untyped-def]
|
||||||
|
return QSettings(str(tmp_path / "otko.ini"), QSettings.Format.IniFormat)
|
||||||
|
|
||||||
|
|
||||||
|
def _window(tmp_path, qtbot): # type: ignore[no-untyped-def]
|
||||||
|
from otko.views.main_window import MainWindow
|
||||||
|
|
||||||
|
window = MainWindow(settings=_settings(tmp_path))
|
||||||
|
qtbot.addWidget(window)
|
||||||
|
return window
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_style_is_the_documented_palette(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def]
|
||||||
|
from otko.views.canvas3d.style import RenderStyle
|
||||||
|
|
||||||
|
window = _window(tmp_path, qtbot)
|
||||||
|
assert window._style == RenderStyle()
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_plot_style_reaches_both_canvases_and_persists(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def]
|
||||||
|
window = _window(tmp_path, qtbot)
|
||||||
|
window.swap_canvas_backend("plotly") # build the second canvas too
|
||||||
|
|
||||||
|
window.set_plot_style(
|
||||||
|
window._style.with_updates(element_beam_color="#ff0000", label_font_size=20)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert set(window._canvases) == {"pyvista", "plotly"}
|
||||||
|
for canvas in window._canvases.values():
|
||||||
|
assert canvas._style.element_beam_color == "#ff0000"
|
||||||
|
assert canvas._style.label_font_size == 20
|
||||||
|
|
||||||
|
# A fresh window sharing the settings file reloads the customisation.
|
||||||
|
from otko.views.main_window import MainWindow
|
||||||
|
|
||||||
|
fresh = MainWindow(settings=_settings(tmp_path))
|
||||||
|
qtbot.addWidget(fresh)
|
||||||
|
assert fresh._style.element_beam_color == "#ff0000"
|
||||||
|
assert fresh._style.label_font_size == 20
|
||||||
|
|
||||||
|
|
||||||
|
def test_preview_does_not_persist(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def]
|
||||||
|
window = _window(tmp_path, qtbot)
|
||||||
|
window.set_plot_style(window._style.with_updates(node_color="#010203"), persist=False)
|
||||||
|
|
||||||
|
assert window._canvas._style.node_color == "#010203"
|
||||||
|
assert window._settings.value("plot/props") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_cancel_reverts_the_style(tmp_path, qtbot, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||||
|
window = _window(tmp_path, qtbot)
|
||||||
|
original = window._style
|
||||||
|
preview = original.with_updates(node_color="#010203")
|
||||||
|
|
||||||
|
class _RejectingDialog(QObject):
|
||||||
|
changed = Signal(object)
|
||||||
|
|
||||||
|
def __init__(self, style: object, parent: QObject | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self._style = preview
|
||||||
|
|
||||||
|
def exec(self) -> QDialog.DialogCode:
|
||||||
|
self.changed.emit(preview) # live preview happens...
|
||||||
|
return QDialog.DialogCode.Rejected # ...but the user cancels
|
||||||
|
|
||||||
|
def plot_style(self): # type: ignore[no-untyped-def]
|
||||||
|
return self._style
|
||||||
|
|
||||||
|
monkeypatch.setattr("otko.views.dialogs.PlotPropertiesDialog", _RejectingDialog)
|
||||||
|
window._on_plot_properties()
|
||||||
|
|
||||||
|
assert window._style == original
|
||||||
|
assert window._canvas._style.node_color == original.node_color
|
||||||
|
assert window._settings.value("plot/props") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_accept_applies_and_persists(tmp_path, qtbot, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||||
|
window = _window(tmp_path, qtbot)
|
||||||
|
preview = window._style.with_updates(node_color="#010203")
|
||||||
|
|
||||||
|
class _AcceptingDialog(QObject):
|
||||||
|
changed = Signal(object)
|
||||||
|
|
||||||
|
def __init__(self, style: object, parent: QObject | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self._style = preview
|
||||||
|
|
||||||
|
def exec(self) -> QDialog.DialogCode:
|
||||||
|
return QDialog.DialogCode.Accepted
|
||||||
|
|
||||||
|
def plot_style(self): # type: ignore[no-untyped-def]
|
||||||
|
return self._style
|
||||||
|
|
||||||
|
monkeypatch.setattr("otko.views.dialogs.PlotPropertiesDialog", _AcceptingDialog)
|
||||||
|
window._on_plot_properties()
|
||||||
|
|
||||||
|
assert window._style.node_color == "#010203"
|
||||||
|
assert window._canvas._style.node_color == "#010203"
|
||||||
|
assert "#010203" in str(window._settings.value("plot/props"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_dialog_edits_and_reset(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def]
|
||||||
|
from otko.views.canvas3d.style import RenderStyle
|
||||||
|
from otko.views.dialogs import PlotPropertiesDialog
|
||||||
|
|
||||||
|
dialog = PlotPropertiesDialog(RenderStyle())
|
||||||
|
qtbot.addWidget(dialog)
|
||||||
|
assert dialog.plot_style() == RenderStyle()
|
||||||
|
|
||||||
|
dialog._colors["node_color"].set_color("#abcdef")
|
||||||
|
assert dialog.plot_style().node_color == "#abcdef"
|
||||||
|
|
||||||
|
dialog._reset()
|
||||||
|
assert dialog.plot_style() == RenderStyle()
|
||||||
Loading…
Reference in a new issue