diff --git a/crates/op-editor-core/src/fills.rs b/crates/op-editor-core/src/fills.rs index 6a1a74e1a..3c8136517 100644 --- a/crates/op-editor-core/src/fills.rs +++ b/crates/op-editor-core/src/fills.rs @@ -135,6 +135,19 @@ pub fn first_solid_stroke_hex(node: &PenNode) -> Option<&str> { }) } +/// Uniform stroke width (doc-px) for the node, when it carries a +/// stroke. A `PerSide` thickness reads as its first (top) edge — the +/// editor's property panel exposes a single scalar width. `None` +/// when the variant carries no stroke or the node has none set. +pub fn node_stroke_width(node: &PenNode) -> Option { + use jian_ops_schema::style::StrokeThickness; + match &node_stroke(node)?.thickness { + StrokeThickness::Uniform(w) => Some(*w as f64), + StrokeThickness::PerSide(sides) => Some(sides[0] as f64), + StrokeThickness::Sided(s) => Some(s.top.unwrap_or(0.0) as f64), + } +} + /// Build a bare `Solid` fill from a hex string. fn solid_fill(hex: String) -> PenFill { PenFill::Solid(SolidFillBody { diff --git a/crates/openpencil-shell-core/src/widgets/editor_state_ext.rs b/crates/openpencil-shell-core/src/widgets/editor_state_ext.rs index 4938fa128..43eac1674 100644 --- a/crates/openpencil-shell-core/src/widgets/editor_state_ext.rs +++ b/crates/openpencil-shell-core/src/widgets/editor_state_ext.rs @@ -117,3 +117,63 @@ pub fn doc_export_format( O::Pdf => D::Pdf, } } + +/// Map an `op_editor_core::FillType` onto shell-core's +/// `document::FillType`. Variant-identical — single bridge point +/// for the PropertyPanel fill-type dropdown. +pub fn doc_fill_type(f: op_editor_core::FillType) -> crate::document::FillType { + use crate::document::FillType as D; + use op_editor_core::FillType as O; + match f { + O::Solid => D::Solid, + O::LinearGradient => D::LinearGradient, + O::RadialGradient => D::RadialGradient, + O::Image => D::Image, + } +} + +/// Map an `op_editor_core::FlexLayout` onto shell-core's +/// `document::FlexLayout`. Variant-identical. +pub fn doc_flex_layout(f: op_editor_core::FlexLayout) -> crate::document::FlexLayout { + use crate::document::FlexLayout as D; + use op_editor_core::FlexLayout as O; + match f { + O::Free => D::Free, + O::Vertical => D::Vertical, + O::Horizontal => D::Horizontal, + } +} + +/// Map an `op_editor_core::PropertyTab` onto shell-core's +/// `document::PropertyTab`. Variant-identical. +pub fn doc_property_tab(t: op_editor_core::PropertyTab) -> crate::document::PropertyTab { + use crate::document::PropertyTab as D; + use op_editor_core::PropertyTab as O; + match t { + O::Design => D::Design, + O::Code => D::Code, + } +} + +/// Map an `op_editor_core::ui_draft::PropertyFocus` onto shell-core's +/// `document::PropertyFocus`. Variant-identical — the editor-state +/// layer owns its own focus enum; the widget hit-test API still +/// speaks `document::PropertyFocus`. +pub fn doc_property_focus( + f: op_editor_core::ui_draft::PropertyFocus, +) -> crate::document::PropertyFocus { + use crate::document::PropertyFocus as D; + use op_editor_core::ui_draft::PropertyFocus as O; + match f { + O::PositionX => D::PositionX, + O::PositionY => D::PositionY, + O::Rotation => D::Rotation, + O::PositionR => D::PositionR, + O::SizeW => D::SizeW, + O::SizeH => D::SizeH, + O::Opacity => D::Opacity, + O::FillHex => D::FillHex, + O::StrokeHex => D::StrokeHex, + O::StrokeWidth => D::StrokeWidth, + } +} diff --git a/crates/openpencil-shell-core/src/widgets/property_panel.rs b/crates/openpencil-shell-core/src/widgets/property_panel.rs index 5323bb31e..58e029653 100644 --- a/crates/openpencil-shell-core/src/widgets/property_panel.rs +++ b/crates/openpencil-shell-core/src/widgets/property_panel.rs @@ -23,12 +23,48 @@ //! Host calls [`PropertyPanel::for_selection`] which returns //! `Option`; `None` = panel hidden entirely. -use crate::document::{Document, Node, PropertyFocus, Stroke}; +use crate::document::{NodeKind, PropertyFocus, Stroke}; use crate::theme::Theme; +use crate::widgets::editor_state_ext::{ + doc_export_format, doc_fill_type, doc_flex_layout, doc_property_focus, doc_property_tab, + theme_for, +}; use crate::widgets::property_panel_sections as sections; use crate::widgets::{LayoutBox, LayoutCx, PaintCx, Widget, WidgetId}; use crate::{Color, Point2D, Rect, TextLayout}; +use jian_ops_schema::node::PenNode; +use op_editor_core::pen_node_ext::PenNodeExt; +use op_editor_core::EditorState; + +/// Map a `PenNode` variant onto shell-core's `document::NodeKind`, +/// which drives the per-kind section-capability filtering. The +/// canonical schema's extra variants degrade onto the closest +/// shell-core kind (TextInput → Text; Image / IconFont / Ref → +/// `Other(tag)` so the section mask treats them structurally). +fn node_kind_of(node: &PenNode) -> NodeKind { + match node { + PenNode::Frame(_) => NodeKind::Frame, + PenNode::Group(_) => NodeKind::Group, + PenNode::Rectangle(_) => NodeKind::Rect, + PenNode::Ellipse(_) => NodeKind::Ellipse, + PenNode::Polygon(_) => NodeKind::Polygon, + PenNode::Line(_) => NodeKind::Line, + PenNode::Path(_) => NodeKind::Path, + PenNode::Text(_) | PenNode::TextInput(_) => NodeKind::Text, + PenNode::Image(_) => NodeKind::Other("image".to_string()), + PenNode::IconFont(_) => NodeKind::Other("icon_font".to_string()), + PenNode::Ref(_) => NodeKind::Other("ref".to_string()), + } +} + +/// Parse a `#RRGGBB` / `#RGB` hex string into a `Color`. Reuses the +/// editor-state colour parser (RGB only — opacity stays 1.0). +fn color_from_hex(hex: &str) -> Option { + let (r, g, b) = op_editor_core::parse_hex_rgb(hex)?; + Some(Color { r, g, b, a: 1.0 }) +} + pub const PROPERTY_PANEL_WIDTH: f32 = 280.0; /// Button / checkbox actions in the property panel that don't @@ -180,24 +216,30 @@ impl NodeSnapshot { /// "Mixed" or per-axis aggregation is a follow-up; the panel /// hides those inputs anyway since `is_multi` flips them /// inert. - fn from_multi_selection(doc: &Document) -> Option { - // Confirm at least one selected id resolves on the active - // page — bails on cross-page selections (where nothing - // resolves) but NOT on all-zero-size selections (matches - // single-select semantics, which paint the panel even - // for a 0x0 node). - if !doc.property_panel_visible() || doc.selection_count() < 2 { + fn from_multi_selection(state: &EditorState) -> Option { + // Confirm at least 2 selected ids resolve on the active + // page — bails on cross-page selections but NOT on + // all-zero-size selections (matches single-select + // semantics, which paint the panel even for a 0x0 node). + if state.selection_count() < 2 { return None; } - let bounds = doc.selection_bounds().unwrap_or(crate::Rect::ZERO); - let n = doc.selection_count(); + // `selection_bounds` returns `None` when nothing resolves; + // an empty union still paints (zeroed) like single-select. + if state.selected_node().is_none() && state.selection_bounds().is_none() { + return None; + } + let bounds = state + .selection_bounds() + .unwrap_or(op_editor_core::DocRect::ZERO); + let n = state.selection_count(); Some(Self { kind: format!("{} items", n), name: format!("{} selected", n), - x: bounds.origin.x.round() as i32, - y: bounds.origin.y.round() as i32, - width: bounds.size.x.round() as i32, - height: bounds.size.y.round() as i32, + x: bounds.x.round() as i32, + y: bounds.y.round() as i32, + width: bounds.w.round() as i32, + height: bounds.h.round() as i32, rotation_deg: 0.0, corner_radius: 0.0, fill: None, @@ -207,32 +249,63 @@ impl NodeSnapshot { // driven by `SectionCapabilities::for_multi()` instead // of `for_kind`, see `paint`. Frame chosen so any // future kind-specific lookups paint a neutral default. - kind_variant: crate::document::NodeKind::Frame, + kind_variant: NodeKind::Frame, }) } - fn from_node(node: &Node) -> Self { - // Use `aggregate_bounds` so Group / unbounded container - // nodes (Frame without explicit bounds, Other(_)) report - // the visual extent of their subtree instead of "0 × 0" - // (codex Step 6 stop-hook fix). - let bounds = node.aggregate_bounds(); + /// Build the snapshot from a canonical `PenNode`. Geometry uses + /// `aggregate_bounds` so Group / unbounded container nodes report + /// the visual extent of their subtree instead of "0 × 0". + fn from_node(node: &PenNode) -> Self { + let base = node.base(); + let kind = node_kind_of(node); + let bounds = op_editor_core::aggregate_bounds(node); + // Corner radius — only the container variants carry one; + // a `PerCorner` radius reports its top-left corner. + let corner_radius = container_corner_radius(node); + let fill = op_editor_core::first_solid_fill_hex(node).and_then(color_from_hex); + let stroke = op_editor_core::first_solid_stroke_hex(node) + .and_then(color_from_hex) + .map(|color| Stroke { + color, + width: op_editor_core::fills::node_stroke_width(node).unwrap_or(1.0) as f32, + }); Self { - kind: node.kind.label().to_string(), - name: node.name.clone(), - x: bounds.origin.x.round() as i32, - y: bounds.origin.y.round() as i32, - width: bounds.size.x.round() as i32, - height: bounds.size.y.round() as i32, - rotation_deg: node.rotation.to_degrees(), - corner_radius: node.corner_radius, - fill: node.fill, - stroke: node.stroke, - kind_variant: node.kind.clone(), + kind: kind.label().to_string(), + name: base.name.clone().unwrap_or_default(), + x: bounds.x.round() as i32, + y: bounds.y.round() as i32, + width: bounds.w.round() as i32, + height: bounds.h.round() as i32, + // `base.rotation` is stored in degrees by the canonical + // schema; the snapshot's `rotation_deg` wants degrees. + rotation_deg: base.rotation.unwrap_or(0.0) as f32, + corner_radius, + fill, + stroke, + kind_variant: kind, } } } +/// Uniform corner radius (doc-px) for a container variant — Frame / +/// Group / Rectangle carry a `CornerRadius`. A `PerCorner` radius +/// reports its top-left value. Non-container variants read 0. +fn container_corner_radius(node: &PenNode) -> f32 { + use jian_ops_schema::node::container::CornerRadius; + let cr = match node { + PenNode::Frame(n) => n.container.corner_radius.as_ref(), + PenNode::Group(n) => n.container.corner_radius.as_ref(), + PenNode::Rectangle(n) => n.container.corner_radius.as_ref(), + _ => None, + }; + match cr { + Some(CornerRadius::Uniform(r)) => *r as f32, + Some(CornerRadius::PerCorner(c)) => c[0] as f32, + None => 0.0, + } +} + pub struct PropertyPanel { pub id: WidgetId, pub snapshot: NodeSnapshot, @@ -290,30 +363,31 @@ impl PropertyPanel { } impl PropertyPanel { - /// Conditional builder — returns `Some` only when the document + /// Conditional builder — returns `Some` only when the editor /// has an active selection. Mirrors TS `{hasSelection && ...}`. - pub fn for_selection(doc: &Document) -> Option { - Self::for_selection_at(doc, 0) + pub fn for_selection(state: &EditorState) -> Option { + Self::for_selection_at(state, 0) } /// Same as [`for_selection`] but threads the host's monotonic /// millisecond clock through so the focused-input caret can /// blink off the same animation timer as the chat input. - pub fn for_selection_at(doc: &Document, now_ms: u64) -> Option { - if doc.selection_count() == 1 { - let node = doc.selected_node()?; + pub fn for_selection_at(state: &EditorState, now_ms: u64) -> Option { + if state.selection_count() == 1 { + let node = state.selected_node()?; + let fill_type = doc_fill_type(op_editor_core::first_fill_type(node)); return Some(Self::build_from_snapshot( - doc, + state, NodeSnapshot::from_node(node), - node.fill_type, + fill_type, now_ms, false, )); } - if doc.selection_count() >= 2 { - let snapshot = NodeSnapshot::from_multi_selection(doc)?; + if state.selection_count() >= 2 { + let snapshot = NodeSnapshot::from_multi_selection(state)?; return Some(Self::build_from_snapshot( - doc, + state, snapshot, crate::document::FillType::Solid, now_ms, @@ -324,17 +398,18 @@ impl PropertyPanel { } fn build_from_snapshot( - doc: &Document, + state: &EditorState, snapshot: NodeSnapshot, fill_type: crate::document::FillType, now_ms: u64, is_multi: bool, ) -> Self { + let ui = &state.editor_ui; Self { id: WidgetId::new(2000), snapshot, - theme: doc.theme(), - labels: sections::PropertyLabels::for_document(doc), + theme: theme_for(ui), + labels: sections::PropertyLabels::for_editor_ui(ui), // Multi-select inputs are inert in v1 — broadcast edits // to all selected nodes lands later. Force focus to None // so the panel paints all values muted and hit_test @@ -342,29 +417,29 @@ impl PropertyPanel { focus: if is_multi { None } else { - doc.ui.property_focus + state.ui.property_focus.map(doc_property_focus) }, draft: if is_multi { String::new() } else { - doc.ui.property_input_draft.clone() + state.ui.property_input_draft.clone() }, - caret_anchor_ms: doc.ui.property_caret_anchor_ms, + caret_anchor_ms: state.ui.property_caret_anchor_ms, now_ms, - flex_layout: doc.ui.flex_layout, + flex_layout: doc_flex_layout(ui.flex_layout), size_flags: sections::SizeFlags { - fill_width: doc.ui.size_fill_width, - fill_height: doc.ui.size_fill_height, - hug_width: doc.ui.size_hug_width, - hug_height: doc.ui.size_hug_height, - clip_content: doc.ui.size_clip_content, + fill_width: ui.size_fill_width, + fill_height: ui.size_fill_height, + hug_width: ui.size_hug_width, + hug_height: ui.size_hug_height, + clip_content: ui.size_clip_content, }, fill_type, - fill_type_picker_open: doc.ui.fill_type_picker_open, + fill_type_picker_open: ui.fill_type_picker_open, is_multi, - tab: doc.ui.property_tab, - export_format: doc.ui.export_format, - export_scale: doc.ui.export_scale, + tab: doc_property_tab(ui.property_tab), + export_format: doc_export_format(ui.export_format), + export_scale: ui.export_scale, } } diff --git a/crates/openpencil-shell-core/src/widgets/property_panel_sections.rs b/crates/openpencil-shell-core/src/widgets/property_panel_sections.rs index 4aac8af10..3cae98502 100644 --- a/crates/openpencil-shell-core/src/widgets/property_panel_sections.rs +++ b/crates/openpencil-shell-core/src/widgets/property_panel_sections.rs @@ -68,13 +68,15 @@ pub struct PropertyLabels { } impl PropertyLabels { - pub fn for_document(doc: &crate::document::Document) -> Self { + /// Resolve every PropertyPanel chrome string against the editor's + /// active locale (`EditorUiState.locale`). + pub fn for_editor_ui(ui: &op_editor_core::editor_ui_state::EditorUiState) -> Self { // `pick` returns either the localised value or the English // fallback when the key isn't in the table. Both branches // are `&'static str` so the whole struct is `Copy` and // zero-allocation per build. let pick = |key: &'static str, fallback: &'static str| -> &'static str { - let translated = doc.t(key); + let translated = crate::widgets::editor_state_ext::translate(ui, key); if translated == key { fallback } else { diff --git a/crates/openpencil-shell-core/src/widgets/property_panel_tests.rs b/crates/openpencil-shell-core/src/widgets/property_panel_tests.rs index f6355c844..8b9cd3828 100644 --- a/crates/openpencil-shell-core/src/widgets/property_panel_tests.rs +++ b/crates/openpencil-shell-core/src/widgets/property_panel_tests.rs @@ -1,17 +1,27 @@ //! Tests for `widgets::property_panel` — moved to a sibling file to //! keep `property_panel.rs` under the 800-line cap. +//! +//! Phase 6: the panel builds from `op_editor_core::EditorState`, so +//! the fixtures construct `EditorState` values. use super::property_panel::{PropertyPanel, PropertyPanelAction, SectionCapabilities}; use super::property_panel_sections as sections; -use crate::document::{Document, Node, NodeId, NodeKind, PropertyFocus}; -use crate::widgets::{LayoutBox, LayoutCx, PaintCx, Widget}; +use crate::widgets::{PaintCx, Widget}; use crate::{Color, Point2D, Rect}; +use op_editor_core::{EditorState, NodeId}; +/// Build an `EditorState` from a canonical `.op` JSON string. +fn state_from(src: &str) -> EditorState { + let doc = jian_ops_schema::load_str(src) + .expect("property-panel fixture parses") + .value; + EditorState::from_document(doc) +} #[test] fn for_selection_with_real_node_builds_snapshot() { - let doc = Document::sample(); - let panel = PropertyPanel::for_selection(&doc).expect("sample doc has a selection"); + let state = EditorState::sample(); + let panel = PropertyPanel::for_selection(&state).expect("sample doc has a selection"); assert_eq!(panel.snapshot.kind, "Text"); assert_eq!(panel.snapshot.name, "Title"); // Title node bounds: (60, 60, 240, 28). @@ -23,21 +33,21 @@ fn for_selection_with_real_node_builds_snapshot() { #[test] fn for_selection_without_selection_returns_none() { - let doc = Document::empty(); - assert!(PropertyPanel::for_selection(&doc).is_none()); + let state = EditorState::new(); + assert!(PropertyPanel::for_selection(&state).is_none()); } #[test] fn for_selection_with_stale_selection_returns_none() { - let mut doc = Document::sample(); - doc.set_single_selection(NodeId::new("n9999")); - assert!(PropertyPanel::for_selection(&doc).is_none()); + let mut state = EditorState::sample(); + state.set_single_selection(NodeId::new("n9999")); + assert!(PropertyPanel::for_selection(&state).is_none()); } #[test] fn access_node_advertises_group_with_kind_label() { - let doc = Document::sample(); - let panel = PropertyPanel::for_selection(&doc).unwrap(); + let state = EditorState::sample(); + let panel = PropertyPanel::for_selection(&state).unwrap(); let node = panel.access_node(); assert_eq!(node.role(), accesskit::Role::Group); assert_eq!(node.label(), Some("Text")); @@ -45,17 +55,11 @@ fn access_node_advertises_group_with_kind_label() { #[test] fn group_snapshot_aggregates_child_bounds() { - // Codex Step 6 stop-hook fix: a Group has bounds = ZERO, - // so `from_node` must derive W/H from children — else - // the panel shows "0 × 0" for any container. - let doc = Document::sample(); - // Select the "Button" group (id 12). Its children: - // - Button background rect (60, 130, 180, 36) - // - Click me text (76, 152, 160, 16) - // Aggregate bounds: (60, 130, 240-60=180, 168-130=38). - let mut doc = doc; - doc.set_single_selection(NodeId::new("n12")); - let panel = PropertyPanel::for_selection(&doc).unwrap(); + // A Group has no own bounds, so `from_node` must derive W/H + // from children — else the panel shows "0 × 0" for a container. + let mut state = EditorState::sample(); + state.set_single_selection(NodeId::new("n12")); + let panel = PropertyPanel::for_selection(&state).unwrap(); assert_eq!(panel.snapshot.kind, "Group"); assert_eq!(panel.snapshot.x, 60); assert_eq!(panel.snapshot.y, 130); @@ -66,24 +70,13 @@ fn group_snapshot_aggregates_child_bounds() { #[test] fn hit_test_action_export_section_returns_open_dialog() { // Single-frame selection paints every section + Export. - // The walker now extends through Stroke + Effects so the - // Export row's hit-test rect resolves to OpenExportDialog. - let mut doc = Document::sample(); - // NodeId 10 is the Frame at the root of the sample document - // (mutators.rs::sample). Frame paints every section including - // Stroke + Effects + Export so the walker has to advance past - // all of them to reach the Export row. - doc.set_single_selection(NodeId::new("n10")); - let panel = PropertyPanel::for_selection(&doc).expect("frame panel"); - // Tall panel rect so every section fits without clipping. + let mut state = EditorState::sample(); + state.set_single_selection(NodeId::new("n10")); + let panel = PropertyPanel::for_selection(&state).expect("frame panel"); let rect = Rect { origin: Point2D::new(0.0, 0.0), size: Point2D::new(280.0, 1600.0), }; - // The Export section is the last section painted. Walk - // the action-button rects looking for the OpenExportDialog - // rect, then click its center and assert we get back the - // OpenExportDialog action. let caps = SectionCapabilities::for_kind(&panel.snapshot.kind_variant); let visible = sections::VisibleSections { flex_layout: caps.flex_layout, @@ -98,9 +91,7 @@ fn hit_test_action_export_section_returns_open_dialog() { let rects = sections::action_button_rects_with_fill_picker(rect, visible, false); let export_rect = rects .iter() - .find(|(action, _)| { - matches!(action, PropertyPanelAction::OpenExportDialog) - }) + .find(|(action, _)| matches!(action, PropertyPanelAction::OpenExportDialog)) .map(|(_, r)| *r) .expect("export section must emit an OpenExportDialog rect"); let center = Point2D::new( @@ -125,40 +116,32 @@ fn format_color_hex_pads_to_six_chars() { #[test] fn multi_selection_panel_shows_union_bounds_and_is_inert() { - let mut doc = Document::sample(); - // Select Title (id 11, bounds 60,60,240,28) + Button (id 12, - // aggregate bounds 60,130,180,38). Union: x=60, y=60, - // w=240-60+? = 60+240→300; the button right edge is 60+180=240 - // → max_x = 300 (from title). Union: x=60, y=60, w=240, h=108. - doc.set_single_selection(NodeId::new("n11")); - doc.toggle_selection(NodeId::new("n12")); - assert_eq!(doc.selection_count(), 2); + let mut state = EditorState::sample(); + state.set_single_selection(NodeId::new("n11")); + state.toggle_selection(NodeId::new("n12")); + assert_eq!(state.selection_count(), 2); - let panel = PropertyPanel::for_selection(&doc).expect("multi-select must paint"); + let panel = PropertyPanel::for_selection(&state).expect("multi-select must paint"); assert!(panel.is_multi); assert_eq!(panel.snapshot.kind, "2 items"); assert_eq!(panel.snapshot.x, 60); assert_eq!(panel.snapshot.y, 60); - // Union is at least as wide / tall as the larger node. + // Union spans Title (y 60..88) + Button group (y 130..166) → + // x=60, w=240, h≈106. assert!(panel.snapshot.width >= 240); - assert!(panel.snapshot.height >= 108); - // Inputs inert. + assert!(panel.snapshot.height >= 100); assert!(panel.focus.is_none()); let rect = Rect { origin: Point2D::new(0.0, 0.0), size: Point2D::new(280.0, 600.0), }; - // Center of the panel — over an input row in single-select. - // In multi-select, hit_test must return None. assert!(panel.hit_test(rect, Point2D::new(140.0, 100.0)).is_none()); assert!(panel .hit_test_action(rect, Point2D::new(140.0, 100.0)) .is_none()); } -/// Minimal `RenderBackend` that counts paint ops — used by the -/// next test to observe what `paint` actually emits. Mirrors the -/// canvas_viewport tests' `RecordingBackend`. +/// Minimal `RenderBackend` that counts paint ops. #[derive(Default)] struct CountingBackend { text: usize, @@ -190,17 +173,12 @@ impl crate::RenderBackend for CountingBackend { #[test] fn multi_select_paint_diverges_from_full_section_paint() { - // Paint-output integration test: multi-select hides - // fill+stroke+flex via `for_multi`; single-Frame paints all - // sections via `for_kind`. If `paint` regressed to bypass - // `capabilities()` for the multi panel, the two would emit - // identical ops. - let mut doc = Document::sample(); - doc.set_single_selection(NodeId::new("n11")); - doc.toggle_selection(NodeId::new("n12")); - let panel_multi = PropertyPanel::for_selection(&doc).expect("multi"); - doc.set_single_selection(NodeId::new("n10")); - let panel_frame = PropertyPanel::for_selection(&doc).expect("frame"); + let mut state = EditorState::sample(); + state.set_single_selection(NodeId::new("n11")); + state.toggle_selection(NodeId::new("n12")); + let panel_multi = PropertyPanel::for_selection(&state).expect("multi"); + state.set_single_selection(NodeId::new("n10")); + let panel_frame = PropertyPanel::for_selection(&state).expect("frame"); assert!(!panel_frame.is_multi); let rect = Rect { @@ -226,25 +204,19 @@ fn paint_and_count(panel: &PropertyPanel, rect: Rect) -> (usize, usize) { #[test] fn multi_select_caps_keep_size_hide_fill_and_stroke() { - // Codex CONCERN: asserting `SectionCapabilities::for_multi()` - // directly would self-verify the function — a regression in - // `paint` that swapped back to `for_kind` would still pass. - // Instead drive through `panel.capabilities()`, which is the - // single source of truth that `paint` calls. - let mut doc = Document::sample(); - doc.set_single_selection(NodeId::new("n11")); - doc.toggle_selection(NodeId::new("n12")); - let panel = PropertyPanel::for_selection(&doc).expect("multi-select panel"); + let mut state = EditorState::sample(); + state.set_single_selection(NodeId::new("n11")); + state.toggle_selection(NodeId::new("n12")); + let panel = PropertyPanel::for_selection(&state).expect("multi-select panel"); assert!(panel.is_multi); let caps = panel.capabilities(); assert!(caps.size_options, "multi-select must paint W/H"); assert!(!caps.fill, "multi-select must hide fill section"); assert!(!caps.stroke, "multi-select must hide stroke section"); assert!(!caps.flex_layout, "multi-select hides flex"); - // Cross-check the single-select fallback: a Rect selection - // routes through `for_kind`, which exposes fill/stroke. - doc.set_single_selection(NodeId::new("n13")); // Button background (Rect) - let single = PropertyPanel::for_selection(&doc).expect("single-select panel"); + // A Rect selection routes through `for_kind`, exposing fill/stroke. + state.set_single_selection(NodeId::new("n13")); + let single = PropertyPanel::for_selection(&state).expect("single-select panel"); let caps_single = single.capabilities(); assert!(caps_single.fill, "single Rect must paint fill"); assert!(caps_single.stroke, "single Rect must paint stroke"); @@ -252,38 +224,18 @@ fn multi_select_caps_keep_size_hide_fill_and_stroke() { #[test] fn multi_select_panel_shows_even_when_all_zero_size() { - // Symmetry with single-select: a 0x0 node still shows the - // panel, so two 0x0 nodes selected together must too. - let mut doc = Document::empty(); - let p = doc.active_page_index; - // Two leaf nodes whose `aggregate_bounds` is `Rect::ZERO` - // (no bounds, no children). `Node::leaf` defaults to - // zero-sized bounds. - doc.pages[p].children = vec![ - Node::leaf("n50", NodeKind::Rect, "A"), - Node::leaf("n51", NodeKind::Rect, "B"), - ]; - doc.set_single_selection(NodeId::new("n50")); - doc.toggle_selection(NodeId::new("n51")); - assert_eq!(doc.selection_count(), 2); - // Visible despite the union being None. - assert!(doc.property_panel_visible()); - let panel = PropertyPanel::for_selection(&doc).expect("0x0 multi-select must paint"); + // Symmetry with single-select: a 0x0 node still shows the panel. + let mut state = state_from( + r##"{ "version": "0.8.0", "children": [ + {"type":"rectangle","id":"n50","name":"A"}, + {"type":"rectangle","id":"n51","name":"B"} + ]}"##, + ); + state.set_single_selection(NodeId::new("n50")); + state.toggle_selection(NodeId::new("n51")); + assert_eq!(state.selection_count(), 2); + let panel = PropertyPanel::for_selection(&state).expect("0x0 multi-select must paint"); assert!(panel.is_multi); assert_eq!(panel.snapshot.width, 0); assert_eq!(panel.snapshot.height, 0); } - -#[test] -fn property_panel_visible_handles_multi() { - let mut doc = Document::sample(); - // Empty → not visible. - doc.clear_selection(); - assert!(!doc.property_panel_visible()); - // Single → visible (existing behavior). - doc.set_single_selection(NodeId::new("n11")); - assert!(doc.property_panel_visible()); - // Multi with valid union bounds → visible. - doc.toggle_selection(NodeId::new("n12")); - assert!(doc.property_panel_visible()); -} diff --git a/crates/openpencil-shell-native/src/widget_host/paint.rs b/crates/openpencil-shell-native/src/widget_host/paint.rs index 5d921b7dc..23a6f304e 100644 --- a/crates/openpencil-shell-native/src/widget_host/paint.rs +++ b/crates/openpencil-shell-native/src/widget_host/paint.rs @@ -98,7 +98,7 @@ impl WidgetHostNative { } // 4. PropertyPanel — only when selection. - let property_panel = PropertyPanel::for_selection_at(doc, self.now_ms); + let property_panel = PropertyPanel::for_selection_at(&self.editor_state, self.now_ms); let has_property = property_panel.is_some(); let property_panel_width = doc.ui.property_panel_width; let right_rail_x = viewport_width - property_panel_width; diff --git a/crates/openpencil-shell-native/src/widget_host/press.rs b/crates/openpencil-shell-native/src/widget_host/press.rs index ea4667238..ddd802f56 100644 --- a/crates/openpencil-shell-native/src/widget_host/press.rs +++ b/crates/openpencil-shell-native/src/widget_host/press.rs @@ -366,7 +366,7 @@ impl WidgetHostNative { // 0c0. Fill-type picker — outside-click dismiss. if self.editor_state.editor_ui.fill_type_picker_open { self.refresh_paint_doc(); - if let Some(panel) = PropertyPanel::for_selection(&self.paint_doc) { + if let Some(panel) = PropertyPanel::for_selection(&self.editor_state) { let property_rect = Rect { origin: Point2D::new( viewport_width - self.editor_state.editor_ui.property_panel_width, @@ -402,7 +402,7 @@ impl WidgetHostNative { // 0c. PropertyPanel input row. self.refresh_paint_doc(); - if let Some(panel) = PropertyPanel::for_selection(&self.paint_doc) { + if let Some(panel) = PropertyPanel::for_selection(&self.editor_state) { let property_rect = Rect { origin: Point2D::new( viewport_width - self.editor_state.editor_ui.property_panel_width, diff --git a/crates/openpencil-shell-web/src/widget_host/paint.rs b/crates/openpencil-shell-web/src/widget_host/paint.rs index 20b80771c..d01123551 100644 --- a/crates/openpencil-shell-web/src/widget_host/paint.rs +++ b/crates/openpencil-shell-web/src/widget_host/paint.rs @@ -104,7 +104,7 @@ impl WidgetHost { layer_panel.paint(&mut cx, layer_panel_rect); } - let property_panel = PropertyPanel::for_selection(doc); + let property_panel = PropertyPanel::for_selection(&self.editor_state); if let Some(panel) = property_panel.as_ref() { let property_rect = Rect { origin: Point2D::new( diff --git a/crates/openpencil-shell-web/src/widget_host/press.rs b/crates/openpencil-shell-web/src/widget_host/press.rs index 00b8eccd4..47e8ed4f9 100644 --- a/crates/openpencil-shell-web/src/widget_host/press.rs +++ b/crates/openpencil-shell-web/src/widget_host/press.rs @@ -271,7 +271,7 @@ impl WidgetHost { // 0c. PropertyPanel button / checkbox — flex modes + size // flags. Runs AFTER locale picker + TopBar so the // dropdown overlays still win. - if let Some(panel) = PropertyPanel::for_selection(&self.paint_doc) { + if let Some(panel) = PropertyPanel::for_selection(&self.editor_state) { let property_rect = Rect { origin: Point2D::new( viewport_width - self.editor_state.editor_ui.property_panel_width,