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:
parent
9049946794
commit
e9861a0e21
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue