refactor(editor-ui): re-export scene from jian-scene/op-editor-core

Delete the moved modules (layout_scene, layout_scene_hit, scene_vars + their
tests); shim re-export jian_scene::{layout_scene,layout_scene_hit} +
op_editor_core::scene_vars so every consumer path (incl. dirty hosts) resolves
unchanged. Paint imports path geometry from jian-scene. 647 tests green.
This commit is contained in:
Kayshen-X 2026-06-19 23:39:02 +08:00
parent d76ba4528d
commit 71fa9baa5b
10 changed files with 18 additions and 2604 deletions

View file

@ -22,6 +22,8 @@ accesskit = "0.24"
jian-core = { path = "../../vendor/jian/crates/jian-core", version = "0.0.1" }
jian-ops-schema = { path = "../../vendor/jian/crates/jian-ops-schema" }
jian-widgets = { path = "../../vendor/jian/crates/jian-widgets", version = "0.0.1" }
# Canonical render scene (LayoutScene / hit-test / path geometry).
jian-scene = { path = "../../vendor/jian/crates/jian-scene", version = "0.0.1" }
# i18n string tables + the canonical editor state model.
op-i18n = { path = "../op-i18n" }
@ -34,3 +36,8 @@ op-editor-core = { path = "../op-editor-core" }
base64 = "0.22"
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
[dev-dependencies]
# Enable jian-scene's find-visit instrumentation so the canvas-viewport
# paint tests can assert scene-traversal visit counts.
jian-scene = { path = "../../vendor/jian/crates/jian-scene", features = ["test-support"] }

View file

@ -1,741 +0,0 @@
//! `LayoutScene` — a paint-only, layout-resolved render scene.
//!
//! This is the migration target for `CanvasViewport`: a tree of
//! resolved render nodes that a painter can walk and reproduce the
//! current canvas pixel-for-pixel, WITHOUT depending on the editor's
//! `Document` (which mixes in selection / chat / history / UI state).
//!
//! Distinctions from [`op_editor_core::EditorState`]:
//!
//! - **No editor state.** `LayoutScene` carries no `selected`,
//! `tool`, `viewport`, `chat`, `history`, `components`, `ui`. The
//! selection overlay + grid + viewport transform are the host
//! painter's concern, layered on top of the scene.
//! - **Layout-resolved geometry.** Every [`SceneNode::bounds`] is the
//! absolute doc-space AABB produced by jian's taffy `LayoutEngine` —
//! NOT the authored `(x, y, w, h)`. A future painter draws straight
//! from `bounds` with no second layout pass.
//! - **Fills are concrete.** Variable `$ref` fills / strokes are
//! resolved against the editor's variables + active theme at build
//! time, so [`SceneNode::fill`] is always a final paintable colour.
//!
//! The builder lives in `op-pen-loader`
//! (`editor_state_to_layout_scene`); nothing consumes `LayoutScene`
//! yet — `CanvasViewport` is flipped onto it in a later step.
//!
//! wasm32-clean: only `crate::{Color, Point2D, Rect}` + the scene's
//! own paint enums (`NodeKind` / `Effect`). The web host builds
//! scenes too.
use crate::{Color, Point2D, Rect};
#[cfg(test)]
use std::cell::Cell;
#[cfg(test)]
thread_local! {
static FIND_VISIT_COUNT: Cell<usize> = const { Cell::new(0) };
}
#[cfg(test)]
pub(crate) fn reset_find_visit_count() {
FIND_VISIT_COUNT.with(|count| count.set(0));
}
#[cfg(test)]
pub(crate) fn find_visit_count() -> usize {
FIND_VISIT_COUNT.with(Cell::get)
}
#[cfg(test)]
fn record_find_visit() {
FIND_VISIT_COUNT.with(|count| count.set(count.get() + 1));
}
/// Node kinds the canvas painter draws. `Other` round-trips unknown
/// kinds so an unfamiliar serialized node never errors the painter.
///
/// This is a paint-time scene enum — it lives with [`LayoutScene`]
/// because the resolved render tree is its only consumer. It is
/// deliberately NOT the canonical `jian_ops_schema` node model; the
/// scene builder maps `PenNode` variants onto it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NodeKind {
Frame,
Group,
Rect,
Ellipse,
Polygon,
Line,
/// Pen-tool polyline. Geometry in `SceneNode.points`.
Path,
Text,
Other(String),
}
impl NodeKind {
/// Human-facing label for LayerPanel + PropertyPanel.
pub fn label(&self) -> &str {
match self {
NodeKind::Frame => "Frame",
NodeKind::Group => "Group",
NodeKind::Rect => "Rect",
NodeKind::Ellipse => "Ellipse",
NodeKind::Polygon => "Polygon",
NodeKind::Line => "Line",
NodeKind::Path => "Path",
NodeKind::Text => "Text",
NodeKind::Other(s) => s.as_str(),
}
}
}
/// Shadow effect — offset + blur + colour, doc-px units. Mirrors the
/// TS `PenEffect` shadow variant (`offsetX` / `offsetY` / `blur` /
/// `color` / `inner`). An outer shadow paints behind the node's fill;
/// an `inner` shadow paints inset, clipped to the node silhouette.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct DropShadow {
pub offset_x: f32,
pub offset_y: f32,
pub blur: f32,
pub color: Color,
/// `true` = inset (inner) shadow; `false` = outer drop shadow.
pub inner: bool,
}
/// One resolved gradient stop — offset 0.0..=1.0 plus the stop's
/// concrete colour (already hex-parsed and `$ref`-resolved by the
/// scene builder).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SceneGradientStop {
pub offset: f32,
pub color: Color,
}
/// A paintable gradient — either linear (canonical `.op` angle in
/// degrees, where 0° = bottom→top, 90° = left→right, 180° = top→bottom
/// — i.e. CSS `to-top` convention; the renderer subtracts 90° before
/// projecting endpoints) or radial (centre + radius as 0.0..=1.0
/// fractions of the node's bounds, with radius scaled against
/// `max(w, h)` for parity with the TS `pen-renderer`). Carries the
/// per-fill `opacity` so the painter can fold it into every stop's
/// alpha without mutating the stops themselves.
#[derive(Debug, Clone, PartialEq)]
pub enum SceneGradient {
Linear {
/// Canonical `.op` angle (degrees, 0° = bottom→top).
angle_deg: f32,
opacity: f32,
stops: Vec<SceneGradientStop>,
},
Radial {
/// Centre x as a 0.0..=1.0 fraction of bounds width (0.5 = centre).
cx: f32,
/// Centre y as a 0.0..=1.0 fraction of bounds height.
cy: f32,
/// Outer radius as a 0.0..=1.0 fraction of `max(w, h)` — matches
/// `pen-renderer/src/node-renderer.ts::node_renderer` so `.op`
/// files import / export at the same radial size as the TS app.
radius: f32,
opacity: f32,
stops: Vec<SceneGradientStop>,
},
}
/// A visual effect painted with a [`SceneNode`]. v1 ships drop
/// shadow (what the property panel's effects section needs).
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Effect {
DropShadow(DropShadow),
}
/// A paint-only, layout-resolved render scene.
///
/// Built from an `op_editor_core::EditorState` by running jian's flex
/// layout pass and resolving variable `$ref` colours. Carries the
/// multi-page structure the canvas lays out plus, per page, the
/// resolved render-node tree.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct LayoutScene {
/// Pages, in document order. The canvas paints one page at a time
/// (`active_page_index`), but every page is resolved so page
/// switches don't need a rebuild.
pub pages: Vec<ScenePage>,
/// Index into `pages` of the page the editor currently shows.
/// Clamped into range by the builder.
pub active_page_index: usize,
}
impl LayoutScene {
/// The page the editor currently shows, or `None` when the scene
/// has no pages.
pub fn active_page(&self) -> Option<&ScenePage> {
self.pages.get(self.active_page_index)
}
/// Union AABB of every top-level node on the active page, in
/// document space — the extent a "zoom to fit" frames. `None`
/// when the page is empty (nothing to fit).
pub fn content_bounds(&self) -> Option<Rect> {
let page = self.active_page()?;
let mut acc: Option<Rect> = None;
for child in &page.children {
let b = child.aggregate_bounds();
if b.size.x <= 0.0 && b.size.y <= 0.0 {
continue;
}
acc = Some(match acc {
None => b,
Some(a) => {
let min_x = a.origin.x.min(b.origin.x);
let min_y = a.origin.y.min(b.origin.y);
let max_x = (a.origin.x + a.size.x).max(b.origin.x + b.size.x);
let max_y = (a.origin.y + a.size.y).max(b.origin.y + b.size.y);
Rect {
origin: Point2D::new(min_x, min_y),
size: Point2D::new(max_x - min_x, max_y - min_y),
}
}
});
}
acc
}
/// Translate selected render subtrees in-place without rebuilding the
/// full layout scene. Used by live node drags: the canonical
/// `EditorState` is still updated first, but simple absolute moves can
/// keep paint in sync by patching the current scene until release-time
/// layout reconciliation.
pub fn translate_nodes(&mut self, ids: &[String], dx: f32, dy: f32) -> bool {
if ids.is_empty() || (dx == 0.0 && dy == 0.0) {
return false;
}
let Some(page) = self.pages.get_mut(self.active_page_index) else {
return false;
};
page.translate_nodes(ids, dx, dy)
}
}
impl ScenePage {
/// Depth-first search for the node with `id` anywhere in this
/// page's render tree. Mirrors `ScenePage::find` so the
/// selection-overlay + pen-rubber-band painters can map an editor
/// selection id onto the resolved scene node.
pub fn find(&self, id: &str) -> Option<&SceneNode> {
for child in &self.children {
if let Some(found) = child.find(id) {
return Some(found);
}
}
None
}
fn translate_nodes(&mut self, ids: &[String], dx: f32, dy: f32) -> bool {
translate_matching_nodes(&mut self.children, ids, dx, dy)
}
}
fn translate_matching_nodes(nodes: &mut [SceneNode], ids: &[String], dx: f32, dy: f32) -> bool {
let mut changed = false;
for node in nodes {
if ids.iter().any(|id| id == &node.id) {
node.translate_subtree(dx, dy);
changed = true;
} else if translate_matching_nodes(&mut node.children, ids, dx, dy) {
changed = true;
}
}
changed
}
/// One resolved page — an artboard / page id + name + the top-level
/// resolved render nodes.
#[derive(Debug, Clone, PartialEq)]
pub struct ScenePage {
/// Page id (the `.op` page id, or `"page-1"` for the single-page
/// fallback). Identity only — the painter does not key off it.
pub id: String,
/// Page name — surfaced by the layer panel, not painted on canvas.
pub name: String,
/// Top-level resolved render nodes for this page.
pub children: Vec<SceneNode>,
}
/// A resolved render node — everything the canvas painter reads to
/// draw one node, with geometry already baked by the layout pass and
/// fills already resolved to concrete colours.
///
/// Mirrors the fields `CanvasViewport`'s painter reads off
/// `SceneNode` today (`kind`, `bounds`, `fill`, `stroke`,
/// `rotation`, `corner_radius`, `text`, `font_size`, `font_weight`,
/// `text_wrap`, `points`, `effects`, `children`, `hidden`) so a
/// painter over `LayoutScene` can reproduce the current canvas
/// pixel-for-pixel.
/// How a path anchor's two control handles relate.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScenePointType {
/// Handles independent; no smoothing across the anchor.
Corner,
/// Handles collinear + equal length.
Mirrored,
/// Handles move freely + independently.
Independent,
}
/// A path bezier anchor resolved into absolute doc coords — the
/// anchor point plus its (optional) incoming / outgoing control
/// handles. Handle positions are absolute, not anchor-relative.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SceneAnchor {
pub pos: Point2D,
pub handle_in: Option<Point2D>,
pub handle_out: Option<Point2D>,
pub point_type: ScenePointType,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SceneTextAlign {
Left,
Center,
Right,
Justify,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SceneTextVerticalAlign {
Top,
Middle,
Bottom,
}
/// One styled run of a Text node's flattened content — a byte range
/// into [`SceneNode::text`] plus the per-segment style overrides the
/// canonical schema's `TextContent::Styled` segments carry. Inherited
/// (unset) attributes use the sentinel conventions of the node-level
/// fields (`0.0` font size / `0` weight / `None` fill = inherit).
///
/// Runs exist ALONGSIDE the flat string: line wrapping, the text
/// editor, and hit-testing all stay on the flat text; the painter maps
/// each wrapped line back onto run byte ranges so painted slices carry
/// their segment's style. A wrapped line that splits mid-segment keeps
/// per-slice styling exact; only the WRAP POSITION itself is computed
/// with node-level metrics (documented v1 approximation).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SceneTextRun {
/// Inclusive start byte offset into the node's flat `text`.
pub start: usize,
/// Exclusive end byte offset into the node's flat `text`.
pub end: usize,
/// Per-run font size in doc-px. `0.0` = inherit the node's.
pub font_size: f32,
/// Per-run CSS-style weight (100-900). `0` = inherit the node's.
pub font_weight: u16,
/// Per-run fill colour. `None` = inherit the node's resolved fill.
pub fill: Option<Color>,
/// Render this run with an italic (slanted) face.
pub italic: bool,
/// Stroke an underline beneath this run's painted slices.
pub underline: bool,
/// Stroke a strikethrough across this run's painted slices.
pub strikethrough: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub struct SceneNode {
/// Stable node id (the `.op` schema id). Identity for hit-test /
/// selection mapping done by the host on top of the scene.
pub id: String,
/// Node kind — drives per-kind paint (Frame fill, Ellipse oval,
/// Polygon triangle, Line diagonal, Path polyline, Text run, …).
/// `Other("icon_font")` carries a lucide glyph name in `text`.
pub kind: NodeKind,
/// Layout-resolved absolute doc-space rect. Already offset by the
/// page-root's authored `(x, y)` — paint applies only the
/// viewport transform.
pub bounds: Rect,
/// Precomputed aggregate subtree bounds for unbounded containers.
/// `Rect::ZERO` means "not precomputed" so hand-built tests and
/// helpers that mutate `children` after [`SceneNode::leaf`] keep
/// the existing recursive behaviour. The loader fills this once,
/// bottom up, so hot paint / hit-test paths do not repeatedly
/// union large subtrees every frame.
pub aggregate_bounds_cache: Rect,
/// Cumulative node opacity (0.0..=1.0), already multiplied down
/// the subtree. Fill / stroke / gradient / shadow colours have it
/// baked into their alpha at scene-build; the painter applies it
/// directly only for raster images (which carry no colour to
/// bake into). 1.0 = fully opaque.
pub opacity: f32,
/// Rotation in radians, clockwise about the node's bounds centre.
pub rotation: f32,
/// Mirror horizontally around the node's aggregate-bounds centre.
pub flip_x: bool,
/// Mirror vertically around the node's aggregate-bounds centre.
pub flip_y: bool,
/// Corner radius in doc-px — honoured by Rect / Frame paint.
pub corner_radius: f32,
/// Whether this container clips its children to its bounds
/// (rounded by `corner_radius`, clamped to half the height — TS
/// flattener rule). The scene builder also sets this for root
/// frames, which clip like artboards even without an authored
/// `clipContent: true` (TS `document-flattener.ts` parity).
pub clip_content: bool,
/// Resolved fill colour. `$ref` variable fills are already
/// resolved against the editor's variables + active theme; a
/// gradient keeps its first stop here (parity with the current
/// canvas, which paints the first solid colour). `None` = no fill.
pub fill: Option<Color>,
/// Fill paint mode — `Solid` / `LinearGradient` / `RadialGradient`
/// / `Image`. The current canvas paints all of them as the solid
/// `fill` colour; carried so a richer painter can branch later.
pub fill_type: SceneFillType,
/// Resolved gradient body — populated when `fill_type` is
/// `LinearGradient` or `RadialGradient`. `fill` still holds the
/// first stop's colour so non-gradient code paths (Polygon /
/// Path / Ellipse / Line painters that have no gradient overload
/// yet) keep a sensible fallback.
pub gradient: Option<SceneGradient>,
/// Resolved stroke (colour + width). `$ref` stroke colours are
/// resolved at build time. `None` = no stroke.
pub stroke: Option<SceneStroke>,
/// Text content — `Some` for Text nodes (and the lucide glyph
/// name for `icon_font`). `None` for non-text kinds. Styled text
/// is flattened into this string; per-segment styling rides in
/// `text_runs`.
pub text: Option<String>,
/// Per-segment style runs over `text` (byte ranges). Empty for
/// plain (single-style) text — the painter then uses the
/// node-level style for the whole content.
pub text_runs: Vec<SceneTextRun>,
/// CSS font-family stack for text nodes. Empty = renderer default.
pub font_family: String,
/// Text size in doc-px. `0.0` = the painter's default (13 px).
pub font_size: f32,
/// CSS-style font weight (100-900). `0` = default (400).
pub font_weight: u16,
/// Node-level italic (`fontStyle: italic`). Per-run overrides ride
/// in `text_runs`.
pub italic: bool,
/// Node-level underline decoration.
pub underline: bool,
/// Node-level strikethrough decoration.
pub strikethrough: bool,
/// Line-height multiplier (`1.2` = 120%). `0.0` = renderer default.
pub line_height: f32,
/// Extra tracking in doc-px between glyphs.
pub letter_spacing: f32,
/// Horizontal text alignment inside `bounds`.
pub text_align: SceneTextAlign,
/// Vertical text alignment inside `bounds`.
pub text_vertical_align: SceneTextVerticalAlign,
/// Whether the painter wraps the text to `bounds.size.x`.
pub text_wrap: bool,
/// Polyline / path geometry in absolute doc-space coords —
/// populated for `Path` (and any kind the painter walks as
/// points). Empty otherwise.
pub points: Vec<Point2D>,
/// Bezier anchors for `Path` nodes — parallel to `points` but
/// carrying the editable control handles + point type. Empty for
/// non-Path kinds.
pub path_anchors: Vec<SceneAnchor>,
/// Whether a `Path` node is closed (last anchor links to first).
pub path_closed: bool,
/// Preserved SVG path data for imported Path nodes. Coordinates
/// are local doc-px relative to `bounds.origin`.
pub svg_path: Option<String>,
/// Ellipse arc start angle in degrees. `None` = full ellipse.
pub arc_start_angle: Option<f32>,
/// Ellipse arc sweep angle in degrees. `None` = full ellipse.
pub arc_sweep_angle: Option<f32>,
/// Ellipse donut-hole radius (0.0..=1.0 fraction). `None` / 0 =
/// solid.
pub arc_inner_radius: Option<f32>,
/// Polygon side count. `3` is the canonical triangle default.
pub polygon_sides: u32,
/// Image source for nodes that paint a bitmap (`PenNode::Image`).
/// Carries the canonical schema's `src` field verbatim — usually
/// a `data:image/...;base64,...` URL produced by the host's file
/// picker, or a plain file path / remote URL on documents that
/// reference external media. `None` for non-image nodes.
pub image_src: Option<String>,
/// Stable content hash for `image_src`. The canvas painter uses it
/// as a per-frame cache key without hashing large data URLs again.
pub image_src_id: u64,
/// How `image_src` is placed into `bounds`.
pub image_fit: SceneImageFit,
/// Per-image colour adjustments from the image-fill editor.
pub image_adjustments: crate::ImageAdjustments,
/// Drop-shadow / effects painted behind the node's fill.
pub effects: Vec<Effect>,
/// Whether the node (and its subtree) is hidden — the painter
/// skips hidden nodes entirely.
pub hidden: bool,
/// Whether the node is locked. Paint ignores this; the host's
/// canvas hit-test reads it so a locked node's body opts out of
/// selection while its children stay hittable (parity with the
/// `Document`-bound `hit_test_walk`).
pub locked: bool,
/// Composite-widget descriptor for the interactive node family
/// (switch / checkbox / slider / progress / select / radio_group /
/// text_input / text_area / number_input / tabs). When set, the
/// design-surface painter draws a recognizable static visual
/// (track + knob, box + check, chevron, bar, …) instead of the
/// bare degraded rect / text. `None` for ordinary shapes.
pub widget: Option<SceneWidget>,
/// Child render nodes, in paint order.
pub children: Vec<SceneNode>,
}
/// Static composite-widget props carried on a [`SceneNode`]. Mirrors
/// `op_pen_loader::WidgetPayload`; the canvas painter reads it to draw
/// the non-interactive design-time visual that matches the widget's
/// preview-mode rendering (jian-core's `emit_widget_visual`). Numeric
/// sentinels fall back to the runtime defaults (`min=0`, `max=100`).
#[derive(Debug, Clone, Default, PartialEq)]
pub struct SceneWidget {
/// Widget family tag (`switch` / `checkbox` / `slider` / `progress`
/// / `select` / `radio_group` / `text_input` / `text_area` /
/// `number_input` / `tabs`).
pub kind: String,
/// On/off state for switch / checkbox.
pub checked: Option<bool>,
/// Numeric value for slider / progress / number_input.
pub value_num: Option<f32>,
/// String value for select / radio_group / tabs / text inputs.
pub value_str: Option<String>,
/// Placeholder text for inputs / select.
pub placeholder: Option<String>,
/// Leading / trailing lucide glyph for input widgets (Phase 1). The
/// painter draws the icon and insets the text/caret past it.
pub leading_icon: Option<String>,
pub trailing_icon: Option<String>,
/// Adjacent label (checkbox).
pub label: Option<String>,
/// Range minimum (slider / number_input). `None` = 0.
pub min: Option<f32>,
/// Range maximum (slider / progress / number_input). `None` = 100.
pub max: Option<f32>,
/// Range step. `None` = 1.
pub step: Option<f32>,
/// `(value, label)` rows for select / radio_group / tabs.
pub options: Vec<SceneWidgetOption>,
}
/// One `value` + `label` option for select / radio_group / tabs.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct SceneWidgetOption {
pub value: String,
pub label: String,
}
impl SceneNode {
/// Depth-first search for the node with `id` in this subtree
/// (self included). Mirrors `SceneNode::find`.
pub fn find(&self, id: &str) -> Option<&SceneNode> {
#[cfg(test)]
record_find_visit();
if self.id == id {
return Some(self);
}
for child in &self.children {
if let Some(found) = child.find(id) {
return Some(found);
}
}
None
}
fn translate_subtree(&mut self, dx: f32, dy: f32) {
self.bounds.origin.x += dx;
self.bounds.origin.y += dy;
if rect_has_extent(self.aggregate_bounds_cache) {
self.aggregate_bounds_cache.origin.x += dx;
self.aggregate_bounds_cache.origin.y += dy;
}
for point in &mut self.points {
point.x += dx;
point.y += dy;
}
for anchor in &mut self.path_anchors {
anchor.pos.x += dx;
anchor.pos.y += dy;
if let Some(handle) = anchor.handle_in.as_mut() {
handle.x += dx;
handle.y += dy;
}
if let Some(handle) = anchor.handle_out.as_mut() {
handle.x += dx;
handle.y += dy;
}
}
for child in &mut self.children {
child.translate_subtree(dx, dy);
}
}
/// Resolved bounds for selection / rotation-pivot math: the node's
/// own `bounds` when it is bounded, otherwise a loader-precomputed
/// subtree rect when available, otherwise the union of children's
/// aggregate bounds for hand-built scenes.
pub fn aggregate_bounds(&self) -> Rect {
if rect_has_extent(self.bounds) {
return self.bounds;
}
if rect_has_extent(self.aggregate_bounds_cache) {
return self.aggregate_bounds_cache;
}
Self::compute_aggregate_bounds(self.bounds, &self.children)
}
/// Compute the aggregate subtree rect from resolved own bounds and
/// children. Used by the loader during scene construction and by
/// [`aggregate_bounds`](Self::aggregate_bounds) as the uncached
/// fallback for hand-built test scenes.
pub fn compute_aggregate_bounds(bounds: Rect, children: &[SceneNode]) -> Rect {
if rect_has_extent(bounds) {
return bounds;
}
let mut iter = children
.iter()
.map(SceneNode::aggregate_bounds)
.filter(|r| rect_has_extent(*r));
let Some(first) = iter.next() else {
return Rect::ZERO;
};
let (mut min_x, mut min_y) = (first.origin.x, first.origin.y);
let (mut max_x, mut max_y) = (first.origin.x + first.size.x, first.origin.y + first.size.y);
for r in iter {
min_x = min_x.min(r.origin.x);
min_y = min_y.min(r.origin.y);
max_x = max_x.max(r.origin.x + r.size.x);
max_y = max_y.max(r.origin.y + r.size.y);
}
Rect::xywh(min_x, min_y, max_x - min_x, max_y - min_y)
}
/// Construct a leaf render node with all paint fields cleared.
/// Builders set `bounds` / `fill` / `text` / … after.
pub fn leaf(id: impl Into<String>, kind: NodeKind) -> Self {
Self {
id: id.into(),
kind,
bounds: Rect::ZERO,
aggregate_bounds_cache: Rect::ZERO,
opacity: 1.0,
rotation: 0.0,
flip_x: false,
flip_y: false,
corner_radius: 0.0,
clip_content: false,
fill: None,
fill_type: SceneFillType::Solid,
gradient: None,
stroke: None,
text: None,
text_runs: Vec::new(),
font_family: String::new(),
font_size: 0.0,
font_weight: 0,
italic: false,
underline: false,
strikethrough: false,
line_height: 0.0,
letter_spacing: 0.0,
text_align: SceneTextAlign::Left,
text_vertical_align: SceneTextVerticalAlign::Top,
text_wrap: false,
points: Vec::new(),
path_anchors: Vec::new(),
path_closed: false,
svg_path: None,
arc_start_angle: None,
arc_sweep_angle: None,
arc_inner_radius: None,
polygon_sides: 3,
image_src: None,
image_src_id: 0,
image_fit: SceneImageFit::Fill,
image_adjustments: crate::ImageAdjustments::default(),
effects: Vec::new(),
hidden: false,
locked: false,
widget: None,
children: Vec::new(),
}
}
}
fn rect_has_extent(rect: Rect) -> bool {
rect.size.x > 0.0 || rect.size.y > 0.0
}
pub fn stable_image_source_id(src: &str) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
src.hash(&mut h);
h.finish()
}
/// Vertices for a regular polygon fitted inside `rect`.
pub fn regular_polygon_points(rect: Rect, sides: u32) -> Vec<Point2D> {
let n = sides.clamp(3, 100) as usize;
let cx = rect.origin.x + rect.size.x / 2.0;
let cy = rect.origin.y + rect.size.y / 2.0;
let rx = rect.size.x / 2.0;
let ry = rect.size.y / 2.0;
let start = -std::f32::consts::FRAC_PI_2;
(0..n)
.map(|i| {
let angle = start + i as f32 * std::f32::consts::TAU / n as f32;
Point2D::new(cx + rx * angle.cos(), cy + ry * angle.sin())
})
.collect()
}
/// Resolved stroke descriptor — colour already `$ref`-resolved.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SceneStroke {
pub color: Color,
pub width: f32,
}
/// Fill paint mode for a [`SceneNode`]. Mirrors
/// `op_editor_core::FillType`; kept as its own enum so the scene
/// type does not re-export an editor-model enum that may diverge.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SceneFillType {
#[default]
Solid,
LinearGradient,
RadialGradient,
Image,
}
/// Image placement mode carried by a [`SceneNode`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SceneImageFit {
#[default]
Fill,
Fit,
Crop,
Tile,
Stretch,
}
impl SceneImageFit {
pub fn to_draw_mode(self) -> crate::ImageDrawMode {
match self {
Self::Fill => crate::ImageDrawMode::Fill,
Self::Fit => crate::ImageDrawMode::Fit,
Self::Crop => crate::ImageDrawMode::Crop,
Self::Tile => crate::ImageDrawMode::Tile,
Self::Stretch => crate::ImageDrawMode::Stretch,
}
}
}
#[cfg(test)]
#[path = "layout_scene_unit_tests.rs"]
mod tests;

View file

@ -1,471 +0,0 @@
//! Canvas hit-test over a [`LayoutScene`].
//!
//! These are the input-path geometry queries that used to live as
//! `&Document`-bound helpers (`Document::node_at_doc_point`,
//! `Document::nodes_intersecting_doc_rect`). The reorg moves the
//! editor hosts' input dispatch off the derived `Document` snapshot
//! and onto the layout-resolved [`LayoutScene`], so the hit-test
//! reads the same resolved geometry the painter walks.
//!
//! Hit semantics are preserved bit-for-bit from `document/walkers.rs`:
//! top-most-first z-order (`children[0]` is frontmost), per-node rotation inverse-transform, the
//! tighter Ellipse / Polygon / Line geometry, the locked-node
//! body-opts-out-children-stay rule, and the hidden-subtree skip.
use crate::layout_scene::NodeKind;
use crate::layout_scene::{regular_polygon_points, LayoutScene, SceneNode};
use crate::widgets::canvas_viewport_paint::{flatten_path_points, PathPoints};
use crate::{Point2D, Rect};
impl LayoutScene {
/// Topmost node id whose geometry contains `point` (doc space)
/// on the active page. Walks children in front-to-back z-order
/// (`children[0]` is frontmost). `None` on dead space.
///
/// `zoom` is the live viewport zoom — it scales the Line stroke
/// hit slack so a thin line stays clickable at any zoom.
pub fn node_at_doc_point(&self, point: Point2D, zoom: f32) -> Option<String> {
let zoom = zoom.max(0.0001);
let page = self.active_page()?;
for child in &page.children {
if let Some(hit) = hit_test_walk(child, point, zoom) {
return Some(hit);
}
}
None
}
/// Top-level node ids on the active page whose aggregate bounds
/// intersect `rect` (doc space). Backs the marquee rect-select.
/// Descends only into top-level children — same as the click
/// hit-test, so the result set selects as a unit.
pub fn nodes_intersecting_doc_rect(&self, rect: Rect) -> Vec<String> {
let Some(page) = self.active_page() else {
return Vec::new();
};
let nx = rect.origin.x.min(rect.origin.x + rect.size.x);
let ny = rect.origin.y.min(rect.origin.y + rect.size.y);
let nw = rect.size.x.abs();
let nh = rect.size.y.abs();
let mut out = Vec::new();
for child in &page.children {
let b = child.aggregate_bounds();
if b.size.x <= 0.0 && b.size.y <= 0.0 {
continue;
}
let bx = b.origin.x.min(b.origin.x + b.size.x);
let by = b.origin.y.min(b.origin.y + b.size.y);
let bw = b.size.x.abs();
let bh = b.size.y.abs();
// AABB intersection test.
if bx + bw < nx || nx + nw < bx || by + bh < ny || ny + nh < by {
continue;
}
out.push(child.id.clone());
}
out
}
}
/// Recursive hit-test — returns the topmost id whose geometry
/// contains `point`. When a node carries rotation, the test point
/// is inverse-rotated about the node's pivot BEFORE testing
/// children + self, so the hit area matches the rendered geometry.
fn hit_test_walk(node: &SceneNode, point: Point2D, zoom: f32) -> Option<String> {
// Hidden nodes skip hit-test entirely — the subtree inherits.
if node.hidden {
return None;
}
let bounds = node.aggregate_bounds();
let local = if node.rotation.abs() > f32::EPSILON {
if let Some(pivot) = rotation_pivot(node, bounds) {
let dx = point.x - pivot.x;
let dy = point.y - pivot.y;
let cos_t = (-node.rotation).cos();
let sin_t = (-node.rotation).sin();
Point2D::new(
pivot.x + dx * cos_t - dy * sin_t,
pivot.y + dx * sin_t + dy * cos_t,
)
} else {
point
}
} else {
point
};
let local = if node.flip_x || node.flip_y {
if let Some(pivot) = rotation_pivot(node, bounds) {
Point2D::new(
if node.flip_x {
2.0 * pivot.x - local.x
} else {
local.x
},
if node.flip_y {
2.0 * pivot.y - local.y
} else {
local.y
},
)
} else {
local
}
} else {
local
};
for child in &node.children {
if let Some(hit) = hit_test_walk(child, local, zoom) {
return Some(hit);
}
}
// Locked nodes can't be selected via canvas hit, but their
// children still can — this check runs AFTER the child walk so
// descendants of a locked Frame remain hittable; only the
// Frame's own body opts out.
if node.locked {
return None;
}
if point_in_node(node, local, bounds, zoom) {
return Some(node.id.clone());
}
None
}
/// Rotation pivot for hit-test. Most kinds rotate around the
/// aggregate-bounds center; Lines need a kind-specific path because
/// a Line with a negative-size dimension collapses its aggregate to
/// `Rect::ZERO` (the segment midpoint is still well defined from
/// `bounds`). Returns `None` when no valid pivot exists.
fn rotation_pivot(node: &SceneNode, bounds: Rect) -> Option<Point2D> {
if matches!(node.kind, NodeKind::Line) {
let raw = node.bounds;
if raw.size.x.abs() < f32::EPSILON && raw.size.y.abs() < f32::EPSILON {
return None;
}
return Some(Point2D::new(
raw.origin.x + raw.size.x / 2.0,
raw.origin.y + raw.size.y / 2.0,
));
}
if bounds.size.x > 0.0 && bounds.size.y > 0.0 {
return Some(Point2D::new(
bounds.origin.x + bounds.size.x / 2.0,
bounds.origin.y + bounds.size.y / 2.0,
));
}
None
}
/// Per-NodeKind hit-test. Frames / Groups / Rects / Text / Other
/// use the axis-aligned bounds; Ellipse / Polygon / Line use tighter
/// geometry so the click area matches what the painter draws.
fn point_in_node(node: &SceneNode, local: Point2D, bounds: Rect, zoom: f32) -> bool {
// Lines get a dedicated path: horizontal / vertical segments
// have a zero-dimension bounds rect, and negative-size bounds
// collapse the aggregate to `Rect::ZERO`, so the Line path reads
// `node.bounds` directly. The distance-to-segment helper is
// sign-independent.
if matches!(node.kind, NodeKind::Line) {
let raw = node.bounds;
if raw.size.x.abs() < f32::EPSILON && raw.size.y.abs() < f32::EPSILON {
return false;
}
let from = raw.origin;
let to = Point2D::new(raw.origin.x + raw.size.x, raw.origin.y + raw.size.y);
let stroke_half = node.stroke.map(|s| s.width / 2.0).unwrap_or(1.0);
// 4 screen px of slack regardless of zoom — the point is in
// doc space, so scale by `1/zoom`.
let screen_slack = 4.0 / zoom.max(0.0001);
return distance_point_to_segment(local, from, to) <= stroke_half + screen_slack;
}
// Paths hit-test against their flattened (bezier-aware) outline,
// not the bounding box — a curved / thin path otherwise selects
// empty bbox space, and a zero-height stroked path (which fails
// the positive-area gate below) stays clickable.
if matches!(node.kind, NodeKind::Path) {
let poly = path_hit_points(node);
let points = poly.as_slice();
if points.len() < 2 {
return false;
}
let stroke_half = node.stroke.map(|s| s.width / 2.0).unwrap_or(1.0);
let slack = 4.0 / zoom.max(0.0001);
for seg in points.windows(2) {
if distance_point_to_segment(local, seg[0], seg[1]) <= stroke_half + slack {
return true;
}
}
// A filled closed path is also hittable across its interior.
return node.path_closed && node.fill.is_some() && point_in_polygon(local, points);
}
// Non-line kinds need real positive area on both axes.
if bounds.size.x <= 0.0 || bounds.size.y <= 0.0 {
return false;
}
let in_bounds = local.x >= bounds.origin.x
&& local.x <= bounds.origin.x + bounds.size.x
&& local.y >= bounds.origin.y
&& local.y <= bounds.origin.y + bounds.size.y;
if !in_bounds {
return false;
}
let cx = bounds.origin.x + bounds.size.x / 2.0;
let cy = bounds.origin.y + bounds.size.y / 2.0;
let rx = (bounds.size.x / 2.0).max(0.0001);
let ry = (bounds.size.y / 2.0).max(0.0001);
match node.kind {
NodeKind::Ellipse => {
let dx = (local.x - cx) / rx;
let dy = (local.y - cy) / ry;
let r2 = dx * dx + dy * dy;
if r2 > 1.0 {
return false;
}
// Arc ellipse: also require the point inside the pie /
// donut sector so a missing wedge is not selectable.
let inner = node.arc_inner_radius.unwrap_or(0.0).clamp(0.0, 1.0);
let has_arc =
node.arc_start_angle.is_some() || node.arc_sweep_angle.is_some() || inner > 0.001;
if !has_arc {
return true;
}
let sweep = node.arc_sweep_angle.unwrap_or(360.0);
if sweep.abs() < 359.9 {
let start = node.arc_start_angle.unwrap_or(0.0);
// Normalise to a forward sweep — a negative sweep
// covers the angular range `[start + sweep, start]`.
let (sector_start, span) = if sweep < 0.0 {
(start + sweep, -sweep)
} else {
(start, sweep)
};
let ang = dy.atan2(dx).to_degrees();
let rel = (ang - sector_start).rem_euclid(360.0);
if rel > span {
return false;
}
}
r2 >= inner * inner
}
NodeKind::Polygon => {
let points = regular_polygon_points(bounds, node.polygon_sides);
point_in_polygon(local, &points)
}
// Frame, Group, Rect, Text, Other, Path — bounds-only hit.
_ => true,
}
}
fn path_hit_points(node: &SceneNode) -> PathPoints<'_> {
flatten_path_points(node)
}
/// Even-odd ray-cast point-in-polygon test over a closed vertex ring.
fn point_in_polygon(p: Point2D, poly: &[Point2D]) -> bool {
if poly.len() < 3 {
return false;
}
let mut inside = false;
let mut j = poly.len() - 1;
for i in 0..poly.len() {
let (a, b) = (poly[i], poly[j]);
if (a.y > p.y) != (b.y > p.y) && p.x < (b.x - a.x) * (p.y - a.y) / (b.y - a.y) + a.x {
inside = !inside;
}
j = i;
}
inside
}
/// Shortest distance from `p` to the segment `a`–`b`.
fn distance_point_to_segment(p: Point2D, a: Point2D, b: Point2D) -> f32 {
let dx = b.x - a.x;
let dy = b.y - a.y;
let len_sq = dx * dx + dy * dy;
if len_sq < f32::EPSILON {
let pdx = p.x - a.x;
let pdy = p.y - a.y;
return (pdx * pdx + pdy * pdy).sqrt();
}
let t = (((p.x - a.x) * dx + (p.y - a.y) * dy) / len_sq).clamp(0.0, 1.0);
let cx = a.x + t * dx;
let cy = a.y + t * dy;
let pdx = p.x - cx;
let pdy = p.y - cy;
(pdx * pdx + pdy * pdy).sqrt()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::layout_scene::{LayoutScene, SceneNode, ScenePage};
fn leaf(id: &str, kind: NodeKind, bounds: Rect) -> SceneNode {
let mut n = SceneNode::leaf(id, kind);
n.bounds = bounds;
n
}
fn one_page(children: Vec<SceneNode>) -> LayoutScene {
LayoutScene {
pages: vec![ScenePage {
id: "p".into(),
name: "P".into(),
children,
}],
active_page_index: 0,
}
}
#[test]
fn node_at_doc_point_treats_first_child_as_frontmost() {
let scene = one_page(vec![
leaf("over", NodeKind::Rect, Rect::xywh(0.0, 0.0, 50.0, 50.0)),
leaf("under", NodeKind::Rect, Rect::xywh(10.0, 10.0, 50.0, 50.0)),
]);
// Overlap region -> children[0] is the top layer.
assert_eq!(
scene
.node_at_doc_point(Point2D::new(20.0, 20.0), 1.0)
.as_deref(),
Some("over")
);
// Only "under" covers (55, 55).
assert_eq!(
scene
.node_at_doc_point(Point2D::new(55.0, 55.0), 1.0)
.as_deref(),
Some("under")
);
// Dead space.
assert!(scene
.node_at_doc_point(Point2D::new(200.0, 200.0), 1.0)
.is_none());
}
#[test]
fn hidden_node_is_not_hit() {
let mut n = leaf("h", NodeKind::Rect, Rect::xywh(0.0, 0.0, 50.0, 50.0));
n.hidden = true;
let scene = one_page(vec![n]);
assert!(scene
.node_at_doc_point(Point2D::new(25.0, 25.0), 1.0)
.is_none());
}
#[test]
fn locked_node_body_opts_out_but_child_stays_hittable() {
let child = leaf("c", NodeKind::Rect, Rect::xywh(10.0, 10.0, 10.0, 10.0));
let mut frame = leaf("f", NodeKind::Frame, Rect::xywh(0.0, 0.0, 100.0, 100.0));
frame.locked = true;
frame.children = vec![child];
let scene = one_page(vec![frame]);
// Click on the child → child id even though parent is locked.
assert_eq!(
scene
.node_at_doc_point(Point2D::new(15.0, 15.0), 1.0)
.as_deref(),
Some("c")
);
// Click on the locked frame's bare body → no hit.
assert!(scene
.node_at_doc_point(Point2D::new(80.0, 80.0), 1.0)
.is_none());
}
#[test]
fn ellipse_uses_tight_oval_geometry() {
let scene = one_page(vec![leaf(
"e",
NodeKind::Ellipse,
Rect::xywh(0.0, 0.0, 100.0, 100.0),
)]);
// Center → inside the oval.
assert_eq!(
scene
.node_at_doc_point(Point2D::new(50.0, 50.0), 1.0)
.as_deref(),
Some("e")
);
// Corner of the bounds → outside the oval.
assert!(scene
.node_at_doc_point(Point2D::new(2.0, 2.0), 1.0)
.is_none());
}
#[test]
fn line_hit_uses_segment_distance() {
let mut line = leaf("l", NodeKind::Line, Rect::xywh(0.0, 0.0, 100.0, 0.0));
line.stroke = Some(crate::layout_scene::SceneStroke {
color: crate::Color::WHITE,
width: 1.0,
});
let scene = one_page(vec![line]);
// Right on the horizontal segment.
assert_eq!(
scene
.node_at_doc_point(Point2D::new(50.0, 0.0), 1.0)
.as_deref(),
Some("l")
);
// Far from the segment → miss.
assert!(scene
.node_at_doc_point(Point2D::new(50.0, 40.0), 1.0)
.is_none());
}
#[test]
fn rotated_node_hit_tracks_rendered_geometry() {
let mut n = leaf("r", NodeKind::Rect, Rect::xywh(0.0, 0.0, 100.0, 20.0));
n.rotation = std::f32::consts::FRAC_PI_2; // 90°
let scene = one_page(vec![n]);
// After a 90° rotation about (50, 10), the painted rect spans
// roughly x 40..60, y -40..60 — a point at (50, 50) lands
// inside the rotated body though it's outside authored bounds.
assert_eq!(
scene
.node_at_doc_point(Point2D::new(50.0, 50.0), 1.0)
.as_deref(),
Some("r")
);
}
#[test]
fn path_hit_points_borrows_handle_free_open_path() {
let mut path = leaf("p", NodeKind::Path, Rect::xywh(0.0, 0.0, 100.0, 0.0));
path.points = vec![Point2D::new(0.0, 0.0), Point2D::new(100.0, 0.0)];
let points = path_hit_points(&path);
match points {
crate::widgets::canvas_viewport_paint::PathPoints::Borrowed(slice) => {
assert!(std::ptr::eq(slice.as_ptr(), path.points.as_ptr()));
}
crate::widgets::canvas_viewport_paint::PathPoints::Owned(_) => {
panic!("handle-free open path hit-test should borrow points")
}
}
}
#[test]
fn nodes_intersecting_doc_rect_returns_overlapping_top_level_ids() {
let scene = one_page(vec![
leaf("a", NodeKind::Rect, Rect::xywh(0.0, 0.0, 30.0, 30.0)),
leaf("b", NodeKind::Rect, Rect::xywh(100.0, 100.0, 30.0, 30.0)),
]);
let hits = scene.nodes_intersecting_doc_rect(Rect::xywh(10.0, 10.0, 80.0, 80.0));
assert_eq!(hits, vec!["a".to_string()]);
}
#[test]
fn nodes_intersecting_doc_rect_handles_negative_size() {
let scene = one_page(vec![leaf(
"a",
NodeKind::Rect,
Rect::xywh(0.0, 0.0, 30.0, 30.0),
)]);
// Negative-size marquee still intersects.
let hits = scene.nodes_intersecting_doc_rect(Rect::xywh(40.0, 40.0, -30.0, -30.0));
assert_eq!(hits, vec!["a".to_string()]);
}
}

View file

@ -1,184 +0,0 @@
//! Sibling unit tests for `layout_scene.rs` (800-line cap
//! convention).
use super::*;
#[test]
fn empty_scene_has_no_active_page() {
let scene = LayoutScene::default();
assert!(scene.pages.is_empty());
assert!(scene.active_page().is_none());
}
#[test]
fn active_page_indexes_into_pages() {
let scene = LayoutScene {
pages: vec![
ScenePage {
id: "a".into(),
name: "A".into(),
children: Vec::new(),
},
ScenePage {
id: "b".into(),
name: "B".into(),
children: Vec::new(),
},
],
active_page_index: 1,
};
assert_eq!(scene.active_page().map(|p| p.id.as_str()), Some("b"));
}
#[test]
fn find_locates_a_nested_node() {
let mut leaf = SceneNode::leaf("deep", NodeKind::Rect);
leaf.bounds = Rect::xywh(0.0, 0.0, 10.0, 10.0);
let mut group = SceneNode::leaf("g", NodeKind::Group);
group.children = vec![leaf];
let page = ScenePage {
id: "p".into(),
name: "P".into(),
children: vec![group],
};
assert_eq!(page.find("deep").map(|n| n.id.as_str()), Some("deep"));
assert!(page.find("missing").is_none());
}
#[test]
fn aggregate_bounds_unions_children_for_unbounded_container() {
let mut a = SceneNode::leaf("a", NodeKind::Rect);
a.bounds = Rect::xywh(10.0, 10.0, 20.0, 20.0);
let mut b = SceneNode::leaf("b", NodeKind::Rect);
b.bounds = Rect::xywh(50.0, 5.0, 10.0, 40.0);
let mut group = SceneNode::leaf("g", NodeKind::Group);
group.children = vec![a, b];
// Unbounded group → union of children: x 10..60, y 5..45.
assert_eq!(group.aggregate_bounds(), Rect::xywh(10.0, 5.0, 50.0, 40.0));
}
#[test]
fn aggregate_bounds_uses_precomputed_unbounded_container_bounds() {
let mut child = SceneNode::leaf("child", NodeKind::Rect);
child.bounds = Rect::xywh(10.0, 10.0, 20.0, 20.0);
let mut group = SceneNode::leaf("g", NodeKind::Group);
group.children = vec![child];
group.aggregate_bounds_cache = Rect::xywh(1.0, 2.0, 300.0, 400.0);
assert_eq!(
group.aggregate_bounds(),
Rect::xywh(1.0, 2.0, 300.0, 400.0),
"layout-built scenes should answer aggregate bounds from the cached subtree rect"
);
}
#[test]
fn aggregate_bounds_keeps_own_bounds_when_bounded() {
let mut frame = SceneNode::leaf("f", NodeKind::Frame);
frame.bounds = Rect::xywh(0.0, 0.0, 100.0, 200.0);
let mut child = SceneNode::leaf("c", NodeKind::Rect);
child.bounds = Rect::xywh(0.0, 0.0, 999.0, 999.0);
frame.children = vec![child];
assert_eq!(frame.aggregate_bounds(), Rect::xywh(0.0, 0.0, 100.0, 200.0));
}
#[test]
fn translate_nodes_moves_matching_subtree_once() {
let mut child = SceneNode::leaf("child", NodeKind::Rect);
child.bounds = Rect::xywh(10.0, 20.0, 30.0, 40.0);
let mut parent = SceneNode::leaf("parent", NodeKind::Group);
parent.bounds = Rect::xywh(0.0, 0.0, 100.0, 100.0);
parent.children = vec![child];
let mut scene = LayoutScene {
pages: vec![ScenePage {
id: "p".into(),
name: "P".into(),
children: vec![parent],
}],
active_page_index: 0,
};
assert!(scene.translate_nodes(&["parent".into(), "child".into()], 5.0, 7.0));
let page = scene.active_page().expect("active page");
let parent = page.find("parent").expect("parent");
let child = page.find("child").expect("child");
assert_eq!(parent.bounds.origin, Point2D::new(5.0, 7.0));
assert_eq!(child.bounds.origin, Point2D::new(15.0, 27.0));
}
#[test]
fn translate_nodes_moves_path_absolute_geometry() {
let mut path = SceneNode::leaf("path", NodeKind::Path);
path.bounds = Rect::xywh(1.0, 2.0, 30.0, 40.0);
path.points = vec![Point2D::new(3.0, 4.0)];
path.path_anchors = vec![SceneAnchor {
pos: Point2D::new(5.0, 6.0),
handle_in: Some(Point2D::new(7.0, 8.0)),
handle_out: Some(Point2D::new(9.0, 10.0)),
point_type: ScenePointType::Corner,
}];
let mut scene = LayoutScene {
pages: vec![ScenePage {
id: "p".into(),
name: "P".into(),
children: vec![path],
}],
active_page_index: 0,
};
assert!(scene.translate_nodes(&["path".into()], 11.0, 13.0));
let path = scene.active_page().and_then(|p| p.find("path")).unwrap();
assert_eq!(path.bounds.origin, Point2D::new(12.0, 15.0));
assert_eq!(path.points[0], Point2D::new(14.0, 17.0));
assert_eq!(path.path_anchors[0].pos, Point2D::new(16.0, 19.0));
assert_eq!(
path.path_anchors[0].handle_in,
Some(Point2D::new(18.0, 21.0))
);
assert_eq!(
path.path_anchors[0].handle_out,
Some(Point2D::new(20.0, 23.0))
);
}
#[test]
fn leaf_node_clears_paint_fields() {
let n = SceneNode::leaf("n1", NodeKind::Rect);
assert_eq!(n.bounds, Rect::ZERO);
assert!(n.fill.is_none());
assert!(n.stroke.is_none());
assert!(n.children.is_empty());
assert_eq!(n.fill_type, SceneFillType::Solid);
}
#[test]
fn content_bounds_unions_top_level_nodes() {
let mut a = SceneNode::leaf("a", NodeKind::Rect);
a.bounds = Rect::xywh(10.0, 20.0, 30.0, 40.0); // → x[10,40] y[20,60]
let mut b = SceneNode::leaf("b", NodeKind::Rect);
b.bounds = Rect::xywh(100.0, 0.0, 50.0, 10.0); // → x[100,150] y[0,10]
let scene = LayoutScene {
pages: vec![ScenePage {
id: "p".into(),
name: "P".into(),
children: vec![a, b],
}],
active_page_index: 0,
};
let bounds = scene.content_bounds().expect("non-empty page has bounds");
// Union: x[10,150] y[0,60] → origin (10,0) size (140,60).
assert_eq!(bounds, Rect::xywh(10.0, 0.0, 140.0, 60.0));
}
#[test]
fn content_bounds_none_for_empty_page() {
let scene = LayoutScene {
pages: vec![ScenePage {
id: "p".into(),
name: "P".into(),
children: vec![],
}],
active_page_index: 0,
};
assert!(scene.content_bounds().is_none());
}

View file

@ -32,9 +32,12 @@ pub use op_editor_core::render_backend;
pub mod accessibility;
pub mod font_catalog;
pub mod layout_scene;
pub mod layout_scene_hit;
pub mod scene_vars;
// The render scene now lives in the `jian-scene` crate and `scene_vars` in
// op-editor-core. Re-exported here so existing consumer paths keep resolving
// (`op_editor_ui::layout_scene::*` / `::layout_scene_hit::*` / `::scene_vars::*`),
// including the dirty host crates that must not be edited.
pub use jian_scene::{layout_scene, layout_scene_hit};
pub use op_editor_core::scene_vars;
pub mod svg_export;
pub mod theme;
pub mod util;

View file

@ -1,515 +0,0 @@
//! Paint-time design-variable aggregation — `VariableTable`.
//!
//! Mirrors the canonical `jian_ops_schema::variable` model (name →
//! typed value, theme axes, active-theme selection) but is a
//! shell-core-local *paint-time* aggregate: it folds the persisted
//! definitions together with the editor's `fill_refs` / `stroke_refs`
//! resolution caches so the `LayoutScene` builder can resolve every
//! `$ref` fill / stroke to a concrete `Color` at scene-build time.
//!
//! It deliberately stays separate from `op_editor_core` (which owns
//! the canonical editor model on `PenDocument`) so this crate's
//! scene-resolution path keeps its own narrow aggregate type.
use std::collections::BTreeMap;
/// Variable type discriminator. Mirrors `VariableKind` in
/// jian_ops_schema for direct round-trip.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VariableKind {
Color,
Number,
Boolean,
String,
}
/// Single typed scalar — the leaf value of a variable. Untagged
/// because `.op` JSON uses raw scalars: `"#ff0000"` / `12.5` / `true`.
#[derive(Debug, Clone, PartialEq)]
pub enum VariableScalar {
Bool(bool),
Num(f64),
Str(String),
}
/// A scalar value paired with the theme combination it applies to.
/// `theme = None` is the "default" entry used when no themed entry
/// matches the active selection.
#[derive(Debug, Clone, PartialEq)]
pub struct ThemedValue {
pub value: VariableScalar,
/// Axis → axis-value map. e.g. `{"mode": "dark"}`. Match the active
/// theme against this map to pick the right value.
pub theme: Option<BTreeMap<String, String>>,
}
/// Variable value: either a single scalar (theme-agnostic) or a list
/// of themed alternatives.
#[derive(Debug, Clone, PartialEq)]
pub enum VariableValue {
Scalar(VariableScalar),
Themed(Vec<ThemedValue>),
}
/// A named variable definition. `name` is the lookup key referenced
/// by `$color-1` style refs in node fill / stroke fields.
#[derive(Debug, Clone, PartialEq)]
pub struct Variable {
pub name: String,
pub kind: VariableKind,
pub value: VariableValue,
}
impl Variable {
/// Resolve the variable's current scalar under `active_theme`.
/// Themed values pick the entry whose `theme` map is a subset of
/// `active_theme`; falls back to the entry with `theme = None`
/// if no match. Returns None for empty `Themed([])`.
pub fn resolve<'a>(
&'a self,
active_theme: &BTreeMap<String, String>,
) -> Option<&'a VariableScalar> {
match &self.value {
VariableValue::Scalar(s) => Some(s),
VariableValue::Themed(entries) => {
// First pass: pick the entry whose every theme axis
// matches active_theme.
for e in entries {
if let Some(t) = &e.theme {
if t.iter().all(|(k, v)| active_theme.get(k) == Some(v)) {
return Some(&e.value);
}
}
}
// Fallback: entry with theme = None, else the FIRST
// entry (TS `resolveThemedValue` falls back to
// `values[0]` so fully-themed lists still resolve
// before the user picks an axis value).
entries
.iter()
.find(|e| e.theme.is_none())
.or_else(|| entries.first())
.map(|e| &e.value)
}
}
}
}
/// One theme axis (e.g. "mode" with values ["light", "dark"]).
#[derive(Debug, Clone, PartialEq)]
pub struct ThemeAxis {
pub name: String,
pub values: Vec<String>,
}
/// Per-document variable + theme registry. Populated by the
/// canonical `.op` loader from `PenDocument.variables` /
/// `PenDocument.themes`. v1 preserves the data + supports lookup +
/// active-theme selection; paint-time `$ref` substitution is a
/// follow-up.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct VariableTable {
pub variables: Vec<Variable>,
pub themes: Vec<ThemeAxis>,
/// Current selection per axis. e.g. {"mode": "dark"}.
pub active_theme: BTreeMap<String, String>,
/// Map of `node_id → variable name` for nodes whose fill is
/// `$ref:name`. Paint reads this first, falls back to `node.fill`.
/// Side-table avoids touching every `Node { ... }` literal.
/// `HashMap` (not `BTreeMap`): `NodeId` is a string with no
/// meaningful order, and lookups are point queries.
pub fill_refs: std::collections::HashMap<op_editor_core::NodeId, String>,
/// Map of `node_id → variable name` for nodes whose stroke colour
/// follows a `$ref`. Parallel to `fill_refs`; paint pre-resolves
/// via `stroke_color_for(node_id)`.
pub stroke_refs: std::collections::HashMap<op_editor_core::NodeId, String>,
}
impl VariableTable {
/// Register that `node_id`'s fill follows variable `ref_name`.
/// Subsequent `fill_for(node_id)` looks the variable up under
/// the current `active_theme`.
pub fn set_fill_ref(&mut self, node_id: op_editor_core::NodeId, ref_name: impl Into<String>) {
self.fill_refs.insert(node_id, ref_name.into());
}
/// Resolve the paint-time fill color for `node_id`. Returns
/// None when no `fill_ref` is registered or the referenced
/// variable doesn't resolve (paint then falls back to
/// `node.fill`).
pub fn fill_for(&self, node_id: &op_editor_core::NodeId) -> Option<crate::Color> {
let name = self.fill_refs.get(node_id)?;
self.resolve_color(name)
}
/// Stroke parallel to `set_fill_ref` — registers the variable
/// driving a node's stroke colour at paint time.
pub fn set_stroke_ref(&mut self, node_id: op_editor_core::NodeId, ref_name: impl Into<String>) {
self.stroke_refs.insert(node_id, ref_name.into());
}
/// Resolve the paint-time stroke color for `node_id`. Same shape
/// as `fill_for`; paint falls back to `node.stroke.color` when
/// None.
pub fn stroke_color_for(&self, node_id: &op_editor_core::NodeId) -> Option<crate::Color> {
let name = self.stroke_refs.get(node_id)?;
self.resolve_color(name)
}
/// Set the active value of a theme axis (e.g. `("mode", "dark")`).
/// Mirrors the TS theme picker — flips colours across every
/// themed variable in one call. Paint reflows on the next frame.
pub fn set_active_theme(&mut self, axis: impl Into<String>, value: impl Into<String>) {
self.active_theme.insert(axis.into(), value.into());
}
/// Remove an axis from the active theme map; subsequent
/// resolutions fall back to the variable's `theme: None` default.
pub fn clear_active_axis(&mut self, axis: &str) {
self.active_theme.remove(axis);
}
/// Cycle the active value of `axis` to the next entry in the
/// `ThemeAxis::values` list. Returns `false` (no-op) when the
/// axis isn't in `themes` or has no values. When the axis
/// isn't currently in `active_theme`, the first value seeds
/// it. When the current value matches the last entry, wraps
/// back to the first. Used by the VariablesPanel axis-chip
/// click to give users a one-click flip — light↔dark,
/// compact↔comfortable, etc.
pub fn cycle_active_axis_value(&mut self, axis: &str) -> bool {
let Some(theme_axis) = self.themes.iter().find(|t| t.name == axis) else {
return false;
};
if theme_axis.values.is_empty() {
return false;
}
let next = match self.active_theme.get(axis) {
None => theme_axis.values[0].clone(),
Some(current) => {
let idx = theme_axis
.values
.iter()
.position(|v| v == current)
.map(|i| (i + 1) % theme_axis.values.len())
.unwrap_or(0);
theme_axis.values[idx].clone()
}
};
self.active_theme.insert(axis.to_string(), next);
true
}
/// Look up a variable by name. None if unknown.
pub fn find(&self, name: &str) -> Option<&Variable> {
self.variables.iter().find(|v| v.name == name)
}
/// Resolve `$ref` against the table under the active theme. Returns
/// the scalar leaf or None on unknown name / empty Themed.
pub fn resolve(&self, name: &str) -> Option<&VariableScalar> {
self.find(name)?.resolve(&self.active_theme)
}
/// Resolve a `$ref` into a paintable `Color`. Returns None when
/// the variable is unknown, isn't of `Color` kind, or its scalar
/// isn't a parseable hex string (`#rgb`, `#rrggbb`, `#rrggbbaa`).
/// Used by paint-time `$ref` substitution.
pub fn resolve_color(&self, name: &str) -> Option<crate::Color> {
let v = self.find(name)?;
if !matches!(v.kind, VariableKind::Color) {
return None;
}
let scalar = v.resolve(&self.active_theme)?;
if let VariableScalar::Str(s) = scalar {
return parse_hex_color(s);
}
None
}
/// Mutable lookup parallel to `find`. Returns None when no
/// variable with that name exists. Editor surfaces use this to
/// stage an in-progress write back into the live document.
pub fn find_mut(&mut self, name: &str) -> Option<&mut Variable> {
self.variables.iter_mut().find(|v| v.name == name)
}
/// Write a new hex string into a `Color`-kind variable. Returns
/// `true` when the variable existed AND was Color-kind AND the
/// hex parsed cleanly; `false` otherwise (no mutation). For
/// themed variables this overwrites the entry matching the
/// current `active_theme` (or creates one if absent); for
/// scalar variables it overwrites the single value.
///
/// The ColorPicker commits through this helper when the
/// VariablesPanel routes a row click into the picker — the
/// model-layer write path is unified across "edit a node's
/// fill" and "edit a variable", so paint sees the change on
/// the next frame regardless of which surface the user touched.
pub fn set_color_hex(&mut self, name: &str, hex: &str) -> bool {
// Validate the hex up front so a malformed input never
// corrupts the stored scalar.
if parse_hex_color(hex).is_none() {
return false;
}
let active = self.active_theme.clone();
let var = match self.find_mut(name) {
Some(v) if matches!(v.kind, VariableKind::Color) => v,
_ => return false,
};
let normalized = hex.trim().to_string();
match &mut var.value {
VariableValue::Scalar(s) => {
*s = VariableScalar::Str(normalized);
true
}
VariableValue::Themed(entries) => {
// Write-routing rules — the resolve walk picks an
// entry; we must update THAT entry when it's a
// subset match for the active theme. When no subset
// entry matches, the user's intent depends on the
// active theme:
//
// - active_theme EMPTY: the user is editing with
// no theme axis selected, so the write targets
// the `theme: None` default entry (the resolve
// fallback). Creates one if absent.
//
// - active_theme NON-EMPTY: the user is editing
// under a specific theme combo. The edit must
// scope to THAT combo. Pushing a new entry keyed
// to active_theme is correct; mutating the
// `theme: None` default would clobber the value
// under every OTHER theme axis too (codex stop-
// gate flag — fallback writes were silently
// reaching across themes).
let subset_idx = entries.iter().position(|e| match &e.theme {
Some(t) => t.iter().all(|(k, v)| active.get(k) == Some(v)),
None => false,
});
if let Some(i) = subset_idx {
entries[i].value = VariableScalar::Str(normalized);
return true;
}
if active.is_empty() {
// No theme selected — target the default entry.
let default_idx = entries.iter().position(|e| e.theme.is_none());
if let Some(i) = default_idx {
entries[i].value = VariableScalar::Str(normalized);
} else {
entries.push(ThemedValue {
value: VariableScalar::Str(normalized),
theme: None,
});
}
return true;
}
// Active theme set + no subset match — append a
// new entry keyed to the active theme at the END
// of the vec (codex stop-gate: front insertion
// shadowed pre-existing themed entries on OTHER
// axes — e.g. an existing {density:compact} entry
// would never resolve under active=dark+compact if
// a {mode:dark} entry was front-inserted because
// resolve's first-match walk picks the front entry
// for ANY active whose keys are a superset).
//
// End-push is safe because Step 1 (subset match)
// already proved no existing entry is a subset of
// `active`. So under the active theme our new
// entry is the unique subset match regardless of
// position; under OTHER actives, every pre-existing
// entry retains its original resolve precedence.
entries.push(ThemedValue {
value: VariableScalar::Str(normalized),
theme: Some(active),
});
true
}
}
}
/// Set a scalar variable's value with the same theme-routing
/// discipline as `set_color_hex`, but generic over any
/// `VariableScalar` (Number / Str / Bool). Used by the MCP
/// write tools `set_variable_number` / `set_variable_string`
/// / `set_variable_boolean`. Caller is responsible for
/// matching the scalar's variant to the variable's kind
/// (e.g. don't pass a Str to a Number-kind variable) —
/// kind-mismatch returns `false`.
pub fn set_scalar(&mut self, name: &str, scalar: VariableScalar) -> bool {
let active = self.active_theme.clone();
let var = match self.find_mut(name) {
Some(v) => v,
None => return false,
};
// Reject kind/scalar mismatch — a Number variable must
// receive a Num scalar, etc. Color variables are
// FORBIDDEN from this path: they need hex validation
// before mutation, which only `set_color_hex` provides.
// Codex stop-gate flagged that accepting Color+Str here
// would let a misrouted `set_variable_string` call write
// garbage into a Color variable (the tool snapshot
// wouldn't admit the name, but defense in depth says the
// apply layer should reject too).
let kind_matches = match (&var.kind, &scalar) {
(VariableKind::Number, VariableScalar::Num(_)) => true,
(VariableKind::String, VariableScalar::Str(_)) => true,
(VariableKind::Boolean, VariableScalar::Bool(_)) => true,
(VariableKind::Color, _) => false,
_ => false,
};
if !kind_matches {
return false;
}
match &mut var.value {
VariableValue::Scalar(s) => {
*s = scalar;
true
}
VariableValue::Themed(entries) => {
let subset_idx = entries.iter().position(|e| match &e.theme {
Some(t) => t.iter().all(|(k, v)| active.get(k) == Some(v)),
None => false,
});
if let Some(i) = subset_idx {
entries[i].value = scalar;
return true;
}
if active.is_empty() {
let default_idx = entries.iter().position(|e| e.theme.is_none());
if let Some(i) = default_idx {
entries[i].value = scalar;
} else {
entries.push(ThemedValue {
value: scalar,
theme: None,
});
}
return true;
}
entries.push(ThemedValue {
value: scalar,
theme: Some(active),
});
true
}
}
}
/// Create a new theme-agnostic scalar variable. Rejects an
/// empty (post-trim) name or a name that collides with an
/// existing variable. `default` must match `kind`: a Color
/// variable takes a `Str` holding a parseable hex; Number /
/// String / Boolean take the matching scalar variant.
/// Mirrors the TS Variables panel "+ Add" action.
pub fn create_variable(
&mut self,
name: &str,
kind: VariableKind,
default: VariableScalar,
) -> bool {
let trimmed = name.trim();
if trimmed.is_empty() {
return false;
}
if self.variables.iter().any(|v| v.name == trimmed) {
return false;
}
let kind_ok = match (&kind, &default) {
// Color variables persist as a hex string; validate it
// up front so a freshly-created token always resolves.
(VariableKind::Color, VariableScalar::Str(s)) => parse_hex_color(s).is_some(),
(VariableKind::Number, VariableScalar::Num(_)) => true,
(VariableKind::String, VariableScalar::Str(_)) => true,
(VariableKind::Boolean, VariableScalar::Bool(_)) => true,
_ => false,
};
if !kind_ok {
return false;
}
self.variables.push(Variable {
name: trimmed.to_string(),
kind,
value: VariableValue::Scalar(default),
});
true
}
/// Delete a variable by name. Also drops any `fill_refs` /
/// `stroke_refs` pointing at it so paint doesn't keep
/// resolving a now-dangling `$ref`. Returns false when the
/// name doesn't resolve.
pub fn delete_variable(&mut self, name: &str) -> bool {
let Some(idx) = self.variables.iter().position(|v| v.name == name) else {
return false;
};
self.variables.remove(idx);
self.fill_refs.retain(|_, v| v != name);
self.stroke_refs.retain(|_, v| v != name);
true
}
/// Rename a variable. Rejects an unknown `old` name, an empty
/// (post-trim) `new` name, or a `new` name that collides with
/// a different existing variable. Rewrites every `fill_refs` /
/// `stroke_refs` entry so node `$ref`s follow the rename.
/// `old == new` (after trim) is a no-op success.
pub fn rename_variable(&mut self, old: &str, new: &str) -> bool {
let new_trimmed = new.trim();
if new_trimmed.is_empty() {
return false;
}
if old == new_trimmed {
return self.variables.iter().any(|v| v.name == old);
}
if self.variables.iter().any(|v| v.name == new_trimmed) {
return false;
}
let Some(var) = self.variables.iter_mut().find(|v| v.name == old) else {
return false;
};
var.name = new_trimmed.to_string();
for v in self.fill_refs.values_mut() {
if v == old {
*v = new_trimmed.to_string();
}
}
for v in self.stroke_refs.values_mut() {
if v == old {
*v = new_trimmed.to_string();
}
}
true
}
}
/// Parse `#rgb` / `#rrggbb` / `#rrggbbaa` into a `Color`. Mirrors the
/// TS paint helpers — lenient on case, requires the leading `#`.
fn parse_hex_color(s: &str) -> Option<crate::Color> {
let s = s.trim().strip_prefix('#')?;
let (r, g, b, a) = match s.len() {
3 => {
let r = u8::from_str_radix(&s[0..1].repeat(2), 16).ok()?;
let g = u8::from_str_radix(&s[1..2].repeat(2), 16).ok()?;
let b = u8::from_str_radix(&s[2..3].repeat(2), 16).ok()?;
(r, g, b, 255)
}
6 => (
u8::from_str_radix(&s[0..2], 16).ok()?,
u8::from_str_radix(&s[2..4], 16).ok()?,
u8::from_str_radix(&s[4..6], 16).ok()?,
255,
),
8 => (
u8::from_str_radix(&s[0..2], 16).ok()?,
u8::from_str_radix(&s[2..4], 16).ok()?,
u8::from_str_radix(&s[4..6], 16).ok()?,
u8::from_str_radix(&s[6..8], 16).ok()?,
),
_ => return None,
};
Some(crate::Color {
r: r as f32 / 255.0,
g: g as f32 / 255.0,
b: b as f32 / 255.0,
a: a as f32 / 255.0,
})
}
#[cfg(test)]
#[path = "scene_vars_tests.rs"]
mod scene_vars_tests;

View file

@ -1,613 +0,0 @@
//! Tests for `scene_vars`. Kept in a sibling file so the types +
//! impl stay under the 800-line cap.
use super::{
ThemeAxis, ThemedValue, Variable, VariableKind, VariableScalar, VariableTable, VariableValue,
};
use std::collections::BTreeMap;
fn axis(name: &str, value: &str) -> BTreeMap<String, String> {
let mut m = BTreeMap::new();
m.insert(name.to_string(), value.to_string());
m
}
#[test]
fn scalar_variable_resolves_regardless_of_theme() {
let v = Variable {
name: "color-1".into(),
kind: VariableKind::Color,
value: VariableValue::Scalar(VariableScalar::Str("#ff0000".into())),
};
let theme = axis("mode", "dark");
match v.resolve(&theme).unwrap() {
VariableScalar::Str(s) => assert_eq!(s, "#ff0000"),
_ => panic!(),
}
}
#[test]
fn themed_variable_picks_active_axis() {
let v = Variable {
name: "bg".into(),
kind: VariableKind::Color,
value: VariableValue::Themed(vec![
ThemedValue {
value: VariableScalar::Str("#ffffff".into()),
theme: Some(axis("mode", "light")),
},
ThemedValue {
value: VariableScalar::Str("#000000".into()),
theme: Some(axis("mode", "dark")),
},
]),
};
let dark = axis("mode", "dark");
let light = axis("mode", "light");
match v.resolve(&dark).unwrap() {
VariableScalar::Str(s) => assert_eq!(s, "#000000"),
_ => panic!(),
}
match v.resolve(&light).unwrap() {
VariableScalar::Str(s) => assert_eq!(s, "#ffffff"),
_ => panic!(),
}
}
#[test]
fn themed_variable_falls_back_to_default_when_no_match() {
let v = Variable {
name: "accent".into(),
kind: VariableKind::Color,
value: VariableValue::Themed(vec![
ThemedValue {
value: VariableScalar::Str("#888888".into()),
theme: None,
},
ThemedValue {
value: VariableScalar::Str("#000000".into()),
theme: Some(axis("mode", "dark")),
},
]),
};
let light = axis("mode", "light");
match v.resolve(&light).unwrap() {
VariableScalar::Str(s) => assert_eq!(s, "#888888"),
_ => panic!(),
}
}
#[test]
fn resolve_color_parses_rrggbb_hex() {
let mut tbl = super::VariableTable::default();
tbl.variables.push(Variable {
name: "accent".into(),
kind: VariableKind::Color,
value: VariableValue::Scalar(VariableScalar::Str("#ff8040".into())),
});
let c = tbl.resolve_color("accent").unwrap();
assert!((c.r - 1.0).abs() < 0.01);
assert!((c.g - (128.0 / 255.0)).abs() < 0.01);
assert!((c.b - (64.0 / 255.0)).abs() < 0.01);
assert_eq!(c.a, 1.0);
}
#[test]
fn resolve_color_picks_themed_active_value() {
let mut tbl = super::VariableTable::default();
tbl.variables.push(Variable {
name: "bg".into(),
kind: VariableKind::Color,
value: VariableValue::Themed(vec![
ThemedValue {
value: VariableScalar::Str("#ffffff".into()),
theme: Some(axis("mode", "light")),
},
ThemedValue {
value: VariableScalar::Str("#000000".into()),
theme: Some(axis("mode", "dark")),
},
]),
});
tbl.active_theme = axis("mode", "dark");
let c = tbl.resolve_color("bg").unwrap();
assert_eq!(c.r, 0.0);
assert_eq!(c.g, 0.0);
assert_eq!(c.b, 0.0);
}
#[test]
fn resolve_color_rejects_non_color_variables() {
let mut tbl = super::VariableTable::default();
tbl.variables.push(Variable {
name: "spacing".into(),
kind: VariableKind::Number,
value: VariableValue::Scalar(VariableScalar::Num(12.0)),
});
assert!(tbl.resolve_color("spacing").is_none());
}
#[test]
fn fill_for_resolves_registered_node_ref_to_themed_color() {
let mut tbl = super::VariableTable::default();
tbl.variables.push(Variable {
name: "accent".into(),
kind: VariableKind::Color,
value: VariableValue::Themed(vec![
ThemedValue {
value: VariableScalar::Str("#ff0000".into()),
theme: Some(axis("mode", "light")),
},
ThemedValue {
value: VariableScalar::Str("#00ff00".into()),
theme: Some(axis("mode", "dark")),
},
]),
});
let node = op_editor_core::NodeId::new("n42");
tbl.set_fill_ref(node.clone(), "accent");
tbl.active_theme = axis("mode", "dark");
let c = tbl.fill_for(&node).unwrap();
assert!((c.g - 1.0).abs() < 0.01, "dark mode → green; got {c:?}");
// Switch theme; same ref resolves differently.
tbl.active_theme = axis("mode", "light");
let c2 = tbl.fill_for(&node).unwrap();
assert!((c2.r - 1.0).abs() < 0.01, "light mode → red; got {c2:?}");
}
#[test]
fn stroke_color_for_resolves_registered_ref() {
let mut tbl = super::VariableTable::default();
tbl.variables.push(Variable {
name: "border".into(),
kind: VariableKind::Color,
value: VariableValue::Scalar(VariableScalar::Str("#0000ff".into())),
});
let node = op_editor_core::NodeId::new("n7");
tbl.set_stroke_ref(node.clone(), "border");
let c = tbl.stroke_color_for(&node).unwrap();
assert!((c.b - 1.0).abs() < 0.01);
}
#[test]
fn set_active_theme_round_trips_through_axis_picker() {
let mut tbl = super::VariableTable::default();
tbl.set_active_theme("mode", "dark");
assert_eq!(tbl.active_theme.get("mode"), Some(&"dark".to_string()));
tbl.set_active_theme("density", "comfortable");
assert_eq!(tbl.active_theme.len(), 2);
tbl.clear_active_axis("mode");
assert!(!tbl.active_theme.contains_key("mode"));
assert_eq!(tbl.active_theme.len(), 1);
}
#[test]
fn fill_for_returns_none_when_no_ref_registered() {
let tbl = super::VariableTable::default();
assert!(tbl.fill_for(&op_editor_core::NodeId::new("n99")).is_none());
}
#[test]
fn resolve_color_rejects_invalid_hex() {
let mut tbl = super::VariableTable::default();
tbl.variables.push(Variable {
name: "broken".into(),
kind: VariableKind::Color,
value: VariableValue::Scalar(VariableScalar::Str("not-hex".into())),
});
assert!(tbl.resolve_color("broken").is_none());
}
#[test]
fn themed_variable_returns_none_when_empty_and_no_default() {
let v = Variable {
name: "x".into(),
kind: VariableKind::Number,
value: VariableValue::Themed(vec![]),
};
let theme = BTreeMap::new();
assert!(v.resolve(&theme).is_none());
}
#[test]
fn set_color_hex_writes_scalar_variable() {
let mut tbl = super::VariableTable::default();
tbl.variables.push(Variable {
name: "color-1".into(),
kind: VariableKind::Color,
value: VariableValue::Scalar(VariableScalar::Str("#ff0000".into())),
});
assert!(tbl.set_color_hex("color-1", "#00ff00"));
assert_eq!(
tbl.resolve_color("color-1"),
Some(crate::Color {
r: 0.0,
g: 1.0,
b: 0.0,
a: 1.0,
})
);
}
#[test]
fn set_color_hex_rejects_malformed_input() {
let mut tbl = super::VariableTable::default();
tbl.variables.push(Variable {
name: "color-1".into(),
kind: VariableKind::Color,
value: VariableValue::Scalar(VariableScalar::Str("#ff0000".into())),
});
assert!(!tbl.set_color_hex("color-1", "not-hex"));
// Scalar must be unchanged after a rejected write.
assert_eq!(
tbl.resolve_color("color-1"),
Some(crate::Color {
r: 1.0,
g: 0.0,
b: 0.0,
a: 1.0,
})
);
}
#[test]
fn set_color_hex_returns_false_for_unknown_variable() {
let mut tbl = super::VariableTable::default();
assert!(!tbl.set_color_hex("does-not-exist", "#ffffff"));
}
#[test]
fn set_color_hex_returns_false_for_wrong_kind() {
let mut tbl = super::VariableTable::default();
tbl.variables.push(Variable {
name: "spacing".into(),
kind: VariableKind::Number,
value: VariableValue::Scalar(VariableScalar::Num(16.0)),
});
// Number-kind variable: write rejected.
assert!(!tbl.set_color_hex("spacing", "#ffffff"));
}
#[test]
fn set_color_hex_changes_resolved_value_under_subset_active_theme() {
// Codex stop-gate regression: when the themed entry's theme
// is a SUBSET of the active theme (e.g. entry only specifies
// `mode: dark`; active is `mode: dark, density: compact`),
// `resolve` still picks that entry — so a write must update
// that entry in place, NOT push a new entry at the end that
// `resolve` will never reach.
let mut tbl = super::VariableTable::default();
tbl.variables.push(Variable {
name: "bg".into(),
kind: VariableKind::Color,
value: VariableValue::Themed(vec![ThemedValue {
value: VariableScalar::Str("#111111".into()),
theme: Some({
let mut m = BTreeMap::new();
m.insert("mode".into(), "dark".into());
m
}),
}]),
});
tbl.set_active_theme("mode", "dark");
tbl.set_active_theme("density", "compact");
// Sanity: resolves through the subset-matching entry.
assert_eq!(
tbl.resolve_color("bg"),
Some(crate::Color {
r: 0x11 as f32 / 255.0,
g: 0x11 as f32 / 255.0,
b: 0x11 as f32 / 255.0,
a: 1.0,
})
);
// Write under the wider active theme. The original entry
// (only mode=dark) is the resolved one — the write MUST go
// through it, not append a new entry that resolve never
// reaches.
assert!(tbl.set_color_hex("bg", "#22ff44"));
assert_eq!(
tbl.resolve_color("bg"),
Some(crate::Color {
r: 0x22 as f32 / 255.0,
g: 1.0,
b: 0x44 as f32 / 255.0,
a: 1.0,
}),
"themed write must update the entry resolve picks"
);
}
#[test]
fn set_color_hex_under_active_theme_does_not_clobber_default() {
// Codex stop-gate (round 2): when active_theme is non-empty
// and no subset-matching themed entry exists, the write
// must scope to the active theme (push a new entry), NOT
// mutate the `theme: None` default. Mutating the default
// would silently change the value under EVERY OTHER theme
// axis the user hasn't explicitly authored, which is
// never what an editor user clicking a colour swatch in
// dark mode means.
let mut tbl = super::VariableTable::default();
tbl.variables.push(Variable {
name: "bg".into(),
kind: VariableKind::Color,
value: VariableValue::Themed(vec![
ThemedValue {
value: VariableScalar::Str("#ffffff".into()),
theme: Some({
let mut m = BTreeMap::new();
m.insert("mode".into(), "light".into());
m
}),
},
ThemedValue {
value: VariableScalar::Str("#888888".into()),
theme: None,
},
]),
});
tbl.set_active_theme("mode", "dark"); // matches neither
// Pre-write: resolves to the default (#888) under dark.
assert_eq!(
tbl.resolve_color("bg"),
Some(crate::Color {
r: 0x88 as f32 / 255.0,
g: 0x88 as f32 / 255.0,
b: 0x88 as f32 / 255.0,
a: 1.0,
})
);
// Write under active=dark must scope to dark only.
assert!(tbl.set_color_hex("bg", "#000000"));
// Under dark — new value reads back.
assert_eq!(
tbl.resolve_color("bg"),
Some(crate::Color {
r: 0.0,
g: 0.0,
b: 0.0,
a: 1.0,
}),
);
// The default entry must be untouched. Flipping to a
// never-authored axis value (e.g. mode=sepia) should still
// resolve to the original default #888888, NOT the new dark
// value.
tbl.set_active_theme("mode", "sepia");
assert_eq!(
tbl.resolve_color("bg"),
Some(crate::Color {
r: 0x88 as f32 / 255.0,
g: 0x88 as f32 / 255.0,
b: 0x88 as f32 / 255.0,
a: 1.0,
}),
"default entry must NOT have been clobbered by the dark write"
);
// Light entry also untouched.
tbl.set_active_theme("mode", "light");
assert_eq!(
tbl.resolve_color("bg"),
Some(crate::Color {
r: 1.0,
g: 1.0,
b: 1.0,
a: 1.0,
}),
"light entry must NOT have been clobbered"
);
}
#[test]
fn set_color_hex_does_not_shadow_existing_entries_on_other_axes() {
// Codex stop-gate (round 3): when the write pushes a new
// themed entry, it must NOT shadow pre-existing entries
// whose theme references a different axis. The pathological
// case is an existing entry keyed to `{density:compact}`
// and a write under `{mode:dark}`. A naive front-insert
// would mean active=`{mode:dark, density:compact}` resolves
// to the new entry instead of the compact entry, silently
// changing the resolved colour for the combined axis.
let mut tbl = super::VariableTable::default();
tbl.variables.push(Variable {
name: "bg".into(),
kind: VariableKind::Color,
value: VariableValue::Themed(vec![ThemedValue {
value: VariableScalar::Str("#11ccaa".into()),
theme: Some({
let mut m = BTreeMap::new();
m.insert("density".into(), "compact".into());
m
}),
}]),
});
// Active theme = mode:dark only (no compact axis set, so the
// existing entry doesn't subset-match — the write must push
// a new entry).
tbl.set_active_theme("mode", "dark");
assert!(tbl.set_color_hex("bg", "#000000"));
// Under mode:dark — the new entry is reachable.
assert_eq!(
tbl.resolve_color("bg"),
Some(crate::Color {
r: 0.0,
g: 0.0,
b: 0.0,
a: 1.0,
})
);
// Now flip active to the combined axes — mode:dark +
// density:compact. Under that resolve walk:
// - The pre-existing {density:compact} entry must still
// win (it's earlier in the vec AND its theme is a
// subset of the active combo).
// - The new {mode:dark} entry, end-pushed, is also a
// subset of the active combo, but it's later. First
// subset match wins.
// Pre-fix (front-insert) this would have read #000000
// instead — that was the codex BLOCK.
tbl.set_active_theme("density", "compact");
assert_eq!(
tbl.resolve_color("bg"),
Some(crate::Color {
r: 0x11 as f32 / 255.0,
g: 0xcc as f32 / 255.0,
b: 0xaa as f32 / 255.0,
a: 1.0,
}),
"compact entry must not be shadowed by the end-pushed dark entry"
);
}
#[test]
fn set_color_hex_empty_active_theme_targets_default_entry() {
// The flip side: when active_theme IS empty (no axis
// selected), writes target the default `theme: None`
// entry — that's what resolve falls back to in this state.
// Editing with no theme selected is the explicit "edit the
// universal value" path.
let mut tbl = super::VariableTable::default();
tbl.variables.push(Variable {
name: "bg".into(),
kind: VariableKind::Color,
value: VariableValue::Themed(vec![ThemedValue {
value: VariableScalar::Str("#888888".into()),
theme: None,
}]),
});
// No active theme set.
assert!(tbl.set_color_hex("bg", "#aabbcc"));
assert_eq!(
tbl.resolve_color("bg"),
Some(crate::Color {
r: 0xaa as f32 / 255.0,
g: 0xbb as f32 / 255.0,
b: 0xcc as f32 / 255.0,
a: 1.0,
})
);
}
#[test]
fn set_color_hex_writes_themed_entry_matching_active_axis() {
let mut tbl = super::VariableTable::default();
tbl.variables.push(Variable {
name: "bg".into(),
kind: VariableKind::Color,
value: VariableValue::Themed(vec![
ThemedValue {
value: VariableScalar::Str("#ffffff".into()),
theme: Some({
let mut m = BTreeMap::new();
m.insert("mode".into(), "light".into());
m
}),
},
ThemedValue {
value: VariableScalar::Str("#000000".into()),
theme: Some({
let mut m = BTreeMap::new();
m.insert("mode".into(), "dark".into());
m
}),
},
]),
});
tbl.set_active_theme("mode", "dark");
// Resolves to dark first.
assert_eq!(
tbl.resolve_color("bg"),
Some(crate::Color {
r: 0.0,
g: 0.0,
b: 0.0,
a: 1.0,
})
);
// Write under the active theme → updates only the dark entry.
assert!(tbl.set_color_hex("bg", "#3344ff"));
assert_eq!(
tbl.resolve_color("bg"),
Some(crate::Color {
r: 0x33 as f32 / 255.0,
g: 0x44 as f32 / 255.0,
b: 1.0,
a: 1.0,
})
);
// Flip to light — original light entry untouched.
tbl.set_active_theme("mode", "light");
assert_eq!(
tbl.resolve_color("bg"),
Some(crate::Color {
r: 1.0,
g: 1.0,
b: 1.0,
a: 1.0,
})
);
}
#[test]
fn cycle_active_axis_seeds_first_value_when_absent() {
// Axis defined in `themes` but not yet in active_theme →
// cycle plants the first value.
let mut tbl = VariableTable::default();
tbl.themes.push(ThemeAxis {
name: "mode".into(),
values: vec!["light".into(), "dark".into()],
});
assert!(tbl.cycle_active_axis_value("mode"));
assert_eq!(tbl.active_theme.get("mode"), Some(&"light".to_string()));
}
#[test]
fn cycle_active_axis_advances_to_next_value() {
let mut tbl = VariableTable::default();
tbl.themes.push(ThemeAxis {
name: "mode".into(),
values: vec!["light".into(), "dark".into(), "sepia".into()],
});
tbl.set_active_theme("mode", "light");
// light → dark → sepia → light (wrap)
assert!(tbl.cycle_active_axis_value("mode"));
assert_eq!(tbl.active_theme.get("mode"), Some(&"dark".to_string()));
assert!(tbl.cycle_active_axis_value("mode"));
assert_eq!(tbl.active_theme.get("mode"), Some(&"sepia".to_string()));
assert!(tbl.cycle_active_axis_value("mode"));
assert_eq!(tbl.active_theme.get("mode"), Some(&"light".to_string()));
}
#[test]
fn cycle_active_axis_returns_false_for_unknown_axis() {
let mut tbl = VariableTable::default();
assert!(!tbl.cycle_active_axis_value("does-not-exist"));
assert!(tbl.active_theme.is_empty());
}
#[test]
fn cycle_active_axis_returns_false_for_empty_values() {
let mut tbl = VariableTable::default();
tbl.themes.push(ThemeAxis {
name: "empty".into(),
values: Vec::new(),
});
assert!(!tbl.cycle_active_axis_value("empty"));
assert!(!tbl.active_theme.contains_key("empty"));
}
#[test]
fn cycle_active_axis_falls_back_to_first_when_current_unknown() {
// active_theme carries a value that isn't in themes.values
// (e.g. an axis whose options changed since the file loaded).
// Cycle wraps to the first known value.
let mut tbl = VariableTable::default();
tbl.themes.push(ThemeAxis {
name: "mode".into(),
values: vec!["light".into(), "dark".into()],
});
tbl.set_active_theme("mode", "ghost"); // not in values
assert!(tbl.cycle_active_axis_value("mode"));
assert_eq!(tbl.active_theme.get("mode"), Some(&"light".to_string()));
}

View file

@ -17,7 +17,7 @@
use crate::layout_scene::{SceneAnchor, SceneNode};
use crate::theme::Theme;
use crate::widgets::canvas_viewport::path_handle_positions;
use crate::widgets::canvas_viewport_paint::cubic_point;
use jian_scene::path_geometry::cubic_point;
use crate::widgets::PaintCx;
use crate::{Color, Point2D, Rect};
use op_editor_core::Viewport;

View file

@ -22,6 +22,7 @@ use crate::widgets::canvas_viewport_text::paint_text_node;
use crate::widgets::canvas_viewport_widget::paint_widget_visual;
use crate::widgets::PaintCx;
use crate::{Point2D, Rect};
use jian_scene::path_geometry::flatten_path_points;
use std::collections::HashMap;
/// Paint every `Effect::DropShadow` on `node` as a blurred shape
@ -135,49 +136,6 @@ fn paint_ellipse(cx: &mut PaintCx<'_>, node: &SceneNode, world_rect: Rect, zoom:
}
}
/// One point on the cubic Bezier `p0→p3` (control points `p1`,`p2`).
pub(crate) fn cubic_point(p0: Point2D, p1: Point2D, p2: Point2D, p3: Point2D, t: f32) -> Point2D {
let u = 1.0 - t;
let (w0, w1, w2, w3) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t);
Point2D::new(
w0 * p0.x + w1 * p1.x + w2 * p2.x + w3 * p3.x,
w0 * p0.y + w1 * p1.y + w2 * p2.y + w3 * p3.y,
)
}
/// One flattened segment `a → b` appended onto `out` — a cubic when
/// either endpoint carries a handle, else a straight line.
fn flatten_segment(
a: &crate::layout_scene::SceneAnchor,
b: &crate::layout_scene::SceneAnchor,
out: &mut Vec<Point2D>,
) {
let (p0, p3) = (a.pos, b.pos);
let p1 = a.handle_out.unwrap_or(p0);
let p2 = b.handle_in.unwrap_or(p3);
if p1 == p0 && p2 == p3 {
out.push(p3); // straight segment
} else {
for i in 1..=16 {
out.push(cubic_point(p0, p1, p2, p3, i as f32 / 16.0));
}
}
}
pub(crate) enum PathPoints<'a> {
Borrowed(&'a [Point2D]),
Owned(Vec<Point2D>),
}
impl<'a> PathPoints<'a> {
pub(crate) fn as_slice(&self) -> &[Point2D] {
match self {
Self::Borrowed(points) => points,
Self::Owned(points) => points.as_slice(),
}
}
}
const STACK_WORLD_PATH_POINTS: usize = 64;
// The large stack variant is intentional: hot path-overlay painting
@ -231,42 +189,13 @@ pub(crate) fn world_path_points(
)
}
/// Flatten a Path scene node into doc-space points, borrowing the
/// original point slice for the common handle-free open-path case.
pub(crate) fn flatten_path_points(node: &SceneNode) -> PathPoints<'_> {
let anchors = &node.path_anchors;
let has_handle = anchors
.iter()
.any(|a| a.handle_in.is_some() || a.handle_out.is_some());
if anchors.len() < 2 || !has_handle {
if !node.path_closed {
return PathPoints::Borrowed(&node.points);
}
let mut out = node.points.clone();
// Closed handle-free path — link the polyline back to its
// start so the closing edge is drawn.
if node.path_closed && out.len() > 2 {
out.push(out[0]);
}
return PathPoints::Owned(out);
}
let mut out = Vec::with_capacity(anchors.len() * 16 + 16);
out.push(anchors[0].pos);
for pair in anchors.windows(2) {
flatten_segment(&pair[0], &pair[1], &mut out);
}
if node.path_closed {
flatten_segment(&anchors[anchors.len() - 1], &anchors[0], &mut out);
}
PathPoints::Owned(out)
}
/// Flatten a Path scene node into a doc-space polyline — cubic
/// segments whose endpoints carry handles are tessellated; a
/// handle-free path falls back to the straight `points` polyline.
/// A closed path appends the last-anchor → first-anchor segment.
#[cfg(test)]
pub(crate) fn flatten_path(node: &SceneNode) -> Vec<Point2D> {
use jian_scene::path_geometry::PathPoints;
match flatten_path_points(node) {
PathPoints::Borrowed(points) => points.to_vec(),
PathPoints::Owned(points) => points,

View file

@ -509,9 +509,8 @@ mod text_tests {
mod path_tests {
use crate::layout_scene::{NodeKind, SceneAnchor, SceneNode, ScenePointType};
use crate::widgets::canvas_viewport_paint::{
flatten_path, flatten_path_points, world_path_points, PathPoints, WorldPathPoints,
};
use crate::widgets::canvas_viewport_paint::{flatten_path, world_path_points, WorldPathPoints};
use jian_scene::path_geometry::{flatten_path_points, PathPoints};
use crate::{Point2D, Rect};
fn anchor(x: f32, y: f32, hout: Option<Point2D>) -> SceneAnchor {