refactor(panels): migrate PropertyPanel onto op-editor-core EditorState

PropertyPanel now reads the selected node(s) off EditorState: the
NodeSnapshot is built from the canonical PenNode (geometry via
op-editor-core aggregate_bounds, fill/stroke via the fills helpers),
and the panel state (focus/draft/flex/size/fill-type/tab/export) reads
EditorState.ui + editor_ui. PropertyLabels::for_document becomes
for_editor_ui.

The widget keeps shell-core's document-typed NodeKind / PropertyFocus /
FillType / FlexLayout / PropertyTab in its paint + hit-test surface;
variant-identical converters were added to editor_state_ext so the
hosts' input/hit-test path is untouched. Added op-editor-core
fills::node_stroke_width for the Stroke section's scalar width.
Both hosts' paint passes and the input-path hit-test sites updated.
This commit is contained in:
Kayshen-X 2026-05-16 21:20:42 +08:00
parent 9049946794
commit e9861a0e21
9 changed files with 279 additions and 177 deletions

View file

@ -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<f64> {
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 {

View file

@ -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,
}
}

View file

@ -23,12 +23,48 @@
//! Host calls [`PropertyPanel::for_selection`] which returns
//! `Option<Self>`; `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<Color> {
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<Self> {
// 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<Self> {
// 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> {
Self::for_selection_at(doc, 0)
pub fn for_selection(state: &EditorState) -> Option<Self> {
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<Self> {
if doc.selection_count() == 1 {
let node = doc.selected_node()?;
pub fn for_selection_at(state: &EditorState, now_ms: u64) -> Option<Self> {
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,
}
}

View file

@ -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 {

View file

@ -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());
}

View file

@ -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;

View file

@ -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,

View file

@ -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(

View file

@ -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,