diff --git a/crates/op-editor-core/src/command_authored_subtree.rs b/crates/op-editor-core/src/command_authored_subtree.rs index 3f917a802..faea501b6 100644 --- a/crates/op-editor-core/src/command_authored_subtree.rs +++ b/crates/op-editor-core/src/command_authored_subtree.rs @@ -17,6 +17,12 @@ impl EditorState { if nodes.is_empty() { return false; } + let mut nodes = nodes; + let replacement = crate::command_root_replace::prepare_root_frame_replacement( + self.active_children(), + &mut nodes, + parent_id, + ); if parent_id.is_real() { // Accept any container (matches `cmd_insert_subtree`), including an // empty one whose `children` is still `None` — the insert below @@ -29,7 +35,12 @@ impl EditorState { } } - let live = self.collect_node_ids(); + let mut live = self.collect_node_ids(); + if let Some(replacement) = replacement.as_ref() { + live.remove(crate::command_root_replace::replacement_node_id( + replacement, + )); + } let mut incoming = HashSet::new(); if !nodes .iter() @@ -47,7 +58,13 @@ impl EditorState { }; children.extend(nodes); } else { - self.active_children_mut().extend(nodes); + let roots = self.active_children_mut(); + if let Some(replacement) = replacement.as_ref() { + if !crate::command_root_replace::remove_root_frame_replacement(roots, replacement) { + return false; + } + } + roots.extend(nodes); } true } diff --git a/crates/op-editor-core/src/command_authored_subtree_tests.rs b/crates/op-editor-core/src/command_authored_subtree_tests.rs index ea02446dd..4c30528f8 100644 --- a/crates/op-editor-core/src/command_authored_subtree_tests.rs +++ b/crates/op-editor-core/src/command_authored_subtree_tests.rs @@ -48,6 +48,39 @@ fn insert_authored_subtree_preserves_ids_for_layered_workflow() { assert_eq!(section.id_str(), "hero"); } +#[test] +fn insert_authored_root_frame_replaces_empty_root_frame() { + let mut s = state_with(vec![frame( + "default", + "Frame", + 30.0, + 40.0, + 100.0, + 100.0, + vec![], + )]); + + assert!(s.apply(EditorCommand::InsertAuthoredSubtree { + nodes: vec![frame( + "food-home", + "Food App Home", + 0.0, + 0.0, + 402.0, + 874.0, + vec![rect("hero", "Hero", 0.0, 0.0, 402.0, 120.0)], + )], + parent_id: NodeId::NONE, + page_id: None, + })); + + let children = s.active_children(); + assert_eq!(children.len(), 1, "empty default frame should be replaced"); + assert_eq!(children[0].id_str(), "food-home"); + assert_eq!(children[0].base().x, Some(30.0)); + assert_eq!(children[0].base().y, Some(40.0)); +} + #[test] fn insert_authored_subtree_accepts_empty_container_parent() { // Regression: a container whose `children` is still `None` (here a `rect`, diff --git a/crates/op-editor-core/src/command_node.rs b/crates/op-editor-core/src/command_node.rs index 7b953b387..5713688ca 100644 --- a/crates/op-editor-core/src/command_node.rs +++ b/crates/op-editor-core/src/command_node.rs @@ -810,6 +810,11 @@ impl EditorState { }; let mut taken: HashSet = self.collect_node_ids(); let mut nodes = nodes; + let replacement = crate::command_root_replace::prepare_root_frame_replacement( + self.active_children(), + &mut nodes, + parent_id, + ); if !remap_subtree_ids(&mut nodes, &mut next_id, &mut taken) { return false; } @@ -824,7 +829,13 @@ impl EditorState { }; slot.extend(nodes); } else { - self.active_children_mut().extend(nodes); + let roots = self.active_children_mut(); + if let Some(replacement) = replacement.as_ref() { + if !crate::command_root_replace::remove_root_frame_replacement(roots, replacement) { + return false; + } + } + roots.extend(nodes); } true } diff --git a/crates/op-editor-core/src/command_root_replace.rs b/crates/op-editor-core/src/command_root_replace.rs new file mode 100644 index 000000000..2b9fec6a6 --- /dev/null +++ b/crates/op-editor-core/src/command_root_replace.rs @@ -0,0 +1,51 @@ +//! Shared empty-root-frame replacement for generated design inserts. + +use crate::node_id::NodeId; +use crate::pen_node_ext::PenNodeExt; +use jian_ops_schema::node::PenNode; + +pub(crate) struct RootFrameReplacement { + node_id: NodeId, +} + +pub(crate) fn prepare_root_frame_replacement( + roots: &[PenNode], + nodes: &mut [PenNode], + parent_id: &NodeId, +) -> Option { + if parent_id.is_real() || nodes.len() != 1 || !matches!(nodes[0], PenNode::Frame(_)) { + return None; + } + let empty = roots.iter().find(|node| is_empty_frame(node))?; + if let Some(x) = empty.base().x { + nodes[0].base_mut().x = Some(x); + } + if let Some(y) = empty.base().y { + nodes[0].base_mut().y = Some(y); + } + Some(RootFrameReplacement { + node_id: NodeId::new(empty.id_str()), + }) +} + +pub(crate) fn remove_root_frame_replacement( + roots: &mut Vec, + replacement: &RootFrameReplacement, +) -> bool { + let Some(index) = roots + .iter() + .position(|node| node.id_str() == replacement.node_id.as_str()) + else { + return false; + }; + roots.remove(index); + true +} + +pub(crate) fn replacement_node_id(replacement: &RootFrameReplacement) -> &NodeId { + &replacement.node_id +} + +fn is_empty_frame(node: &PenNode) -> bool { + matches!(node, PenNode::Frame(_)) && node.children().map(|c| c.is_empty()).unwrap_or(true) +} diff --git a/crates/op-editor-core/src/command_subtree_tests.rs b/crates/op-editor-core/src/command_subtree_tests.rs index 11eb209d7..51f3f54ec 100644 --- a/crates/op-editor-core/src/command_subtree_tests.rs +++ b/crates/op-editor-core/src/command_subtree_tests.rs @@ -13,7 +13,7 @@ use crate::command::EditorCommand; use crate::command_node::remap_subtree_ids; use crate::node_id::NodeId; use crate::pen_node_ext::{make_group, make_path, PenNodeExt}; -use crate::test_support::state_with; +use crate::test_support::{frame, rect, state_with}; use std::collections::HashSet; // --- remap_subtree_ids ---------------------------------------------- @@ -67,6 +67,40 @@ fn insert_subtree_nests_children_under_root() { assert!(s.find_duplicate_id().is_none()); } +#[test] +fn insert_subtree_root_frame_replaces_empty_root_frame() { + let mut s = state_with(vec![frame( + "default", + "Frame", + 30.0, + 40.0, + 100.0, + 100.0, + vec![], + )]); + + assert!(s.apply(EditorCommand::InsertSubtree { + nodes: vec![frame( + "ext-root", + "Food App Home", + 0.0, + 0.0, + 402.0, + 874.0, + vec![rect("hero", "Hero", 0.0, 0.0, 402.0, 120.0)], + )], + parent_id: NodeId::NONE, + page_id: None, + })); + + let children = s.active_children(); + assert_eq!(children.len(), 1, "empty default frame should be replaced"); + assert_ne!(children[0].id_str(), "default"); + assert_eq!(children[0].base().name.as_deref(), Some("Food App Home")); + assert_eq!(children[0].base().x, Some(30.0)); + assert_eq!(children[0].base().y, Some(40.0)); +} + #[test] fn insert_subtree_rejects_empty() { let mut s = state_with(vec![]); diff --git a/crates/op-editor-core/src/lib.rs b/crates/op-editor-core/src/lib.rs index abe8330e5..0b47078e8 100644 --- a/crates/op-editor-core/src/lib.rs +++ b/crates/op-editor-core/src/lib.rs @@ -34,6 +34,7 @@ pub mod command_node; pub mod command_node_attrs; pub mod command_promote; pub mod command_refine; +mod command_root_replace; pub mod command_style_replace; pub mod component_browser_state; pub mod components; diff --git a/crates/op-editor-ui/src/widgets/property_panel_flex.rs b/crates/op-editor-ui/src/widgets/property_panel_flex.rs index 07d6f1e5c..c65b4a1b5 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_flex.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_flex.rs @@ -292,11 +292,18 @@ pub fn push_flex_action_rects( (LayoutJustifyValue::SpaceAround, false), ]; for (i, (justify_value, circle_only)) in rows.into_iter().enumerate() { - let row_w = if circle_only { RADIO_GUTTER } else { gap_w }; + // The numeric/Start row's target is just the radio circle — centre its + // hover-wash cell on the RADIO_SIZE glyph (the radio paints at gap_x, + // column-aligned with the other rows, so shift the cell left instead). + let (row_x, row_w) = if circle_only { + (gap_x - (RADIO_GUTTER - RADIO_SIZE) / 2.0, RADIO_GUTTER) + } else { + (gap_x, gap_w) + }; out.push(( PropertyPanelAction::SetLayoutJustify(justify_value), Rect { - origin: Point2D::new(gap_x, gap_row_y(grid_y, i)), + origin: Point2D::new(row_x, gap_row_y(grid_y, i)), size: Point2D::new(row_w, GAP_ROW_H), }, )); diff --git a/crates/op-editor-ui/src/widgets/property_panel_input_layout.rs b/crates/op-editor-ui/src/widgets/property_panel_input_layout.rs index 445ae6c0c..f212cd699 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_input_layout.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_input_layout.rs @@ -294,9 +294,13 @@ pub fn editable_input_rects( } else { 0.0 }; - // Stroke width moved to the mode grid below — the hex hit rect now - // fills the row (matches the widened paint in property_panel_stroke). - let stroke_hex_w = usable_w - variable_w; + // Inline width only in Single mode (Axis/Individual use the per-side + // grid); must match paint_stroke_main_row so the hit rects don't + // linger where the width is no longer painted. + let inline = visible.stroke_edit_mode == op_editor_core::PaddingEditMode::Single; + let stroke_width_w = if inline { 60.0 } else { 0.0 }; + let stroke_width_gap = if inline { 8.0 } else { 0.0 }; + let stroke_hex_w = usable_w - stroke_width_w - stroke_width_gap - variable_w; if !visible.stroke_variable_bound { rects.push(( PropertyFocus::StrokeHex, @@ -306,6 +310,15 @@ pub fn editable_input_rects( }, )); } + if inline { + rects.push(( + PropertyFocus::StrokeWidth, + Rect { + origin: Point2D::new(x0 + PAD_X + stroke_hex_w + variable_w + 8.0, y), + size: Point2D::new(stroke_width_w, INPUT_HEIGHT), + }, + )); + } rects.extend(stroke_side_input_rects(x0, y, w, visible.stroke_edit_mode)); } rects diff --git a/crates/op-editor-ui/src/widgets/property_panel_layout.rs b/crates/op-editor-ui/src/widgets/property_panel_layout.rs index edf61e58e..d71fbf68e 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_layout.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_layout.rs @@ -471,10 +471,13 @@ pub fn action_button_rects_with_fill_picker( } else { 0.0 }; - // The stroke width moved to the mode grid below, so the hex fills the - // whole row (matches property_panel_stroke + the input-rect walker) — - // the variable button + picker anchor must use this widened hex_w. - let hex_w = usable_w - variable_w; + // Inline width only in Single mode (matches paint_stroke_main_row); + // in per-side mode the hex fills the row so the variable button + + // colour-picker anchor stay aligned. + let inline = visible.stroke_edit_mode == op_editor_core::PaddingEditMode::Single; + let width_w = if inline { 60.0 } else { 0.0 }; + let width_gap = if inline { 8.0 } else { 0.0 }; + let hex_w = usable_w - width_w - width_gap - variable_w; if !visible.stroke_variable_bound { out.push(( PropertyPanelAction::OpenColorPicker(op_editor_core::ColorTarget::Stroke), diff --git a/crates/op-editor-ui/src/widgets/property_panel_stroke.rs b/crates/op-editor-ui/src/widgets/property_panel_stroke.rs index 7c55bf168..21b24671d 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_stroke.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_stroke.rs @@ -29,6 +29,10 @@ struct StrokeSideGridRefs<'a, 'b> { } pub(crate) fn stroke_section_body_height(mode: PaddingEditMode) -> f32 { + // Single mode shows just the inline main row — no per-side grid below. + if mode == PaddingEditMode::Single { + return INPUT_HEIGHT + SECTION_TRAILING_GAP; + } let rows = if mode == PaddingEditMode::Individual { 2.0 } else { @@ -62,13 +66,8 @@ pub(crate) fn stroke_side_input_rects( size: Point2D::new(half_w, INPUT_HEIGHT), }; match mode { - PaddingEditMode::Single => vec![( - PropertyFocus::StrokeTopWidth, - Rect { - origin: Point2D::new(x + PAD_X, grid_y), - size: Point2D::new(usable_w, INPUT_HEIGHT), - }, - )], + // Single mode keeps the width inline on the main row — no grid input. + PaddingEditMode::Single => vec![], PaddingEditMode::Axis => vec![ (PropertyFocus::StrokeRightWidth, cell(0.0, 0.0)), (PropertyFocus::StrokeTopWidth, cell(1.0, 0.0)), @@ -229,19 +228,24 @@ pub fn paint_stroke_section( x, y, width, - ); - paint_stroke_side_grid( - cx, - StrokeSideGridRefs { - theme, - snapshot, - edit, - }, - x, - y, - width, mode, ); + // Per-side grid only in Axis / Individual mode; Single keeps the width + // inline on the main row above (no duplicate input). + if mode != PaddingEditMode::Single { + paint_stroke_side_grid( + cx, + StrokeSideGridRefs { + theme, + snapshot, + edit, + }, + x, + y, + width, + mode, + ); + } y += stroke_section_body_height(mode); paint_section_divider(cx, theme, x, y, width); y + SECTION_GAP @@ -258,20 +262,25 @@ fn paint_stroke_main_row( x: f32, y: f32, width: f32, + mode: PaddingEditMode, ) { let usable_w = width - PAD_X * 2.0; let stroke_color = snapshot.stroke_swatch_color(); + let stroke_width = snapshot.stroke.map(|s| s.width).unwrap_or(0.0); + // Inline width only in Single mode; Axis / Individual put the widths in + // the per-side grid below, so the main row is colour-only there and the + // hex fills the freed space. + let inline_width = mode == PaddingEditMode::Single; + let width_w = if inline_width { 60.0 } else { 0.0 }; + let width_gap = if inline_width { 8.0 } else { 0.0 }; let variable_w = if show_variable_button { COLOR_VARIABLE_BUTTON_W + COLOR_VARIABLE_GAP } else { 0.0 }; - // The stroke WIDTH lives in the mode grid below (Single = 1 uniform, - // Axis = 2, Individual = 4) — the main row carries only the colour, so - // there is no duplicate width input. Let the hex fill the freed space. let hex_rect = Rect { origin: Point2D::new(x + PAD_X, y), - size: Point2D::new(usable_w - variable_w, INPUT_HEIGHT), + size: Point2D::new(usable_w - width_w - width_gap - variable_w, INPUT_HEIGHT), }; let hex_focused = edit.focus == Some(PropertyFocus::StrokeHex); cx.backend @@ -300,6 +309,27 @@ fn paint_stroke_main_row( stroke_variable_ref.is_some(), ); } + // Inline stroke-width input — Single mode only (Axis/Individual put the + // widths in the per-side grid). PropertyFocus::StrokeWidth edit path. + if inline_width { + let width_rect = Rect { + origin: Point2D::new(hex_rect.origin.x + hex_rect.size.x + variable_w + 8.0, y), + size: Point2D::new(width_w, INPUT_HEIGHT), + }; + let wval = format_panel_number(stroke_width); + paint_input_with_prefix_focused_state( + cx, + theme, + width_rect, + "", + edit.value_for(PropertyFocus::StrokeWidth, &wval), + edit.focus == Some(PropertyFocus::StrokeWidth), + edit.caret_at(PropertyFocus::StrokeWidth), + edit.select_all_at(PropertyFocus::StrokeWidth), + edit.input_at(PropertyFocus::StrokeWidth), + edit.now_ms, + ); + } } fn paint_stroke_hex_text(