From 44a4fe4e24861287c228c37c018626e67ea1ef67 Mon Sep 17 00:00:00 2001 From: Fini Date: Tue, 14 Jul 2026 00:27:18 +0800 Subject: [PATCH] feat(editor): align canvas layout and sizing behavior --- .../src/command_reparent_tests.rs | 59 +++ crates/op-editor-core/src/drag_mutators.rs | 134 ++++--- crates/op-editor-core/src/editor_ui_state.rs | 21 +- crates/op-editor-core/src/mutators.rs | 6 +- .../op-editor-core/src/selection_resolve.rs | 218 +++++++----- .../op-editor-core/src/tests_drag_mutators.rs | 180 ++++++++-- .../src/widgets/ai_chat_panel/tests.rs | 10 +- .../src/widgets/canvas_agent_cursor.rs | 62 ++-- .../src/widgets/canvas_agent_cursor_tests.rs | 44 ++- .../src/widgets/canvas_viewport.rs | 108 +++++- .../src/widgets/canvas_viewport_paint.rs | 36 +- .../src/widgets/canvas_viewport_tests.rs | 335 +++++++++++++++++- .../src/widgets/property_panel.rs | 35 ++ .../widgets/property_panel_input_layout.rs | 47 +-- .../src/widgets/property_panel_layout.rs | 11 +- .../src/widgets/property_panel_sections.rs | 77 ++-- .../src/widgets/property_panel_tests.rs | 185 ++++++---- .../src/widgets/property_panel_visibility.rs | 5 +- crates/op-host-native/src/widget_host.rs | 5 + .../src/widget_host/canvas_select_drag.rs | 153 ++++---- .../widget_host/canvas_select_drag_tests.rs | 117 ++++-- .../op-host-native/src/widget_host/input.rs | 64 +++- .../src/widget_host/input_drag_tests.rs | 165 ++++++++- .../op-host-native/src/widget_host/paint.rs | 6 +- .../op-host-native/src/widget_host/press.rs | 46 ++- .../src/widget_host/property_dispatch.rs | 38 +- .../widget_host/property_layout_dispatch.rs | 27 +- .../widget_host/property_panel_press_tests.rs | 103 +++++- crates/op-host-web/src/widget_host.rs | 2 + .../src/widget_host/canvas_hierarchy_tests.rs | 108 ++++++ .../src/widget_host/cursor_input.rs | 43 ++- .../op-host-web/src/widget_host/node_drag.rs | 78 ++++ crates/op-host-web/src/widget_host/paint.rs | 6 +- crates/op-host-web/src/widget_host/press.rs | 92 ++--- .../src/widget_host/property_dispatch.rs | 31 +- .../src/widget_host/property_focus_press.rs | 6 +- .../widget_host/property_layout_dispatch.rs | 24 +- .../widget_host/property_panel_press_tests.rs | 71 +++- .../src/widget_host/resize_drag.rs | 26 +- .../src/widget_host/resize_drag_tests.rs | 49 ++- crates/op-i18n/src/i18n/en.rs | 4 +- crates/op-i18n/src/i18n/zh_cn.rs | 4 +- crates/op-pen-loader/src/adapter.rs | 5 +- .../op-pen-loader/src/layout_scene_tests.rs | 82 +++++ vendor/jian | 2 +- 45 files changed, 2282 insertions(+), 648 deletions(-) create mode 100644 crates/op-host-web/src/widget_host/canvas_hierarchy_tests.rs diff --git a/crates/op-editor-core/src/command_reparent_tests.rs b/crates/op-editor-core/src/command_reparent_tests.rs index 7506d4c8b..990030c4b 100644 --- a/crates/op-editor-core/src/command_reparent_tests.rs +++ b/crates/op-editor-core/src/command_reparent_tests.rs @@ -6,7 +6,10 @@ use crate::command::EditorCommand; use crate::node_id::NodeId; use crate::pen_node_ext::PenNodeExt; use crate::test_support::{frame, rect, state_with}; +use crate::walkers::find_node; +use jian_ops_schema::node::PenNode; use jian_ops_schema::page::PenPage; +use serde_json::json; fn id(s: &str) -> NodeId { NodeId::new(s) @@ -129,3 +132,59 @@ fn copy_node_applies_root_overrides_without_overriding_fresh_id() { assert_eq!(clone.base().x, Some(42.0)); assert_eq!(clone.width_px(), Some(88.0)); } + +#[test] +fn move_node_accepts_a_childless_container_but_rejects_a_leaf() { + let doc: jian_ops_schema::PenDocument = serde_json::from_value(json!({ + "version": "1.0", + "children": [ + {"type":"text","id":"source","content":"Move me"}, + {"type":"frame","id":"empty-frame","name":"Empty Frame"}, + {"type":"text","id":"leaf","content":"Not a parent"} + ] + })) + .unwrap(); + let mut state = crate::EditorState::from_document(doc); + assert!(state.apply(EditorCommand::MoveNode { + node_id: id("source"), + target_parent: id("empty-frame"), + page_id: None, + index: None, + })); + let frame = find_node(state.active_children(), &id("empty-frame")).unwrap(); + assert!(frame + .children() + .is_some_and(|children| children.iter().any(|node| node.id_str() == "source"))); + + let before = serde_json::to_value(&state.doc).unwrap(); + assert!(!state.apply(EditorCommand::MoveNode { + node_id: id("source"), + target_parent: id("leaf"), + page_id: None, + index: None, + })); + assert_eq!(serde_json::to_value(&state.doc).unwrap(), before); +} + +#[test] +fn copy_node_accepts_a_childless_rectangle_container() { + let doc: jian_ops_schema::PenDocument = serde_json::from_value(json!({ + "version": "1.0", + "children": [ + {"type":"text","id":"source","content":"Copy me"}, + {"type":"rectangle","id":"empty-rectangle","name":"Empty Rectangle"} + ] + })) + .unwrap(); + let mut state = crate::EditorState::from_document(doc); + assert!(state.apply(EditorCommand::CopyNode { + node_id: id("source"), + target_parent: id("empty-rectangle"), + overrides_json: None, + page_id: None, + })); + let rectangle = find_node(state.active_children(), &id("empty-rectangle")).unwrap(); + assert!(rectangle.children().is_some_and(|children| { + children.len() == 1 && matches!(children[0], PenNode::Text(_)) + })); +} diff --git a/crates/op-editor-core/src/drag_mutators.rs b/crates/op-editor-core/src/drag_mutators.rs index fb2a47a51..d81f48bbf 100644 --- a/crates/op-editor-core/src/drag_mutators.rs +++ b/crates/op-editor-core/src/drag_mutators.rs @@ -1,12 +1,13 @@ -//! Canvas-drag mutators: handle-resize (with descendant scaling) + -//! drag-end auto-layout reorder / cross-container reparenting. +//! Canvas-drag mutators: handle-resize + drag-end auto-layout reorder / +//! cross-container reparenting. //! -//! Ports the TS behavior from `skia-interaction.ts` (`handleResizeMove` -//! / `handleDragEnd`) and pen-core `tree-utils.ts::scaleChildrenInPlace`. +//! Resize follows Pencil's frame semantics: mutate the selected node's +//! authored bounds only, then let the layout engine reflow Fill/Hug/flex +//! descendants. It is deliberately not a subtree Scale operation. //! Split out of `mutators.rs` to keep that file under the 800-line //! ceiling. -use crate::geometry::{own_bounds, DocRect}; +use crate::geometry::DocRect; use crate::node_id::NodeId; use crate::pen_node_ext::PenNodeExt; use crate::state::EditorState; @@ -22,6 +23,27 @@ pub enum FlexDirection { Horizontal, } +/// Authored dimensions affected by a selection-handle drag. +/// +/// Edge handles freeze one axis to a number; corner handles freeze both. +/// The untouched axis keeps its existing Number / Fill / Hug mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ResizeAxes { + Width, + Height, + Both, +} + +impl ResizeAxes { + fn width(self) -> bool { + matches!(self, Self::Width | Self::Both) + } + + fn height(self) -> bool { + matches!(self, Self::Height | Self::Both) + } +} + /// Canonical drop destination for canvas node dragging. The host /// resolves hit-testing and absolute container bounds; core owns the /// document-tree mutation and coordinate conversion. @@ -79,32 +101,21 @@ pub fn parent_of(children: &[PenNode], target: &NodeId) -> Option { None } -/// Recursively scale child relative positions and explicit pixel -/// sizes by `(sx, sy)` — a faithful port of pen-core -/// `tree-utils.ts::scaleChildrenInPlace`. Flex-flow children carry no -/// authored `x` / `y`, so only their sizes scale and the layout -/// engine reflows their positions, matching the TS semantics. -pub fn scale_children_in_place(children: &mut [PenNode], sx: f64, sy: f64) { - for child in children.iter_mut() { - { - let base = child.base_mut(); - if let Some(x) = base.x { - base.x = Some(x * sx); - } - if let Some(y) = base.y { - base.y = Some(y * sy); - } - } - if let Some(w) = child.width_px() { - child.set_width_px(w * sx); - } - if let Some(h) = child.height_px() { - child.set_height_px(h * sy); - } - if let Some(grand) = child.children_mut() { - scale_children_in_place(grand, sx, sy); - } - } +/// A child participates in its parent's auto-layout flow only when the parent +/// is flex and the child has no explicit position. Jian treats either `x` or +/// `y` as an absolute-position contract, so overlays inside a flex container +/// must still accept parent-relative left/top resize writes. +fn selected_is_flow_child(children: &[PenNode], target: &NodeId) -> bool { + let Some((Some(parent_id), _)) = walkers::find_parent_and_index(children, target) else { + return false; + }; + let Some(parent) = walkers::find_node(children, &parent_id) else { + return false; + }; + let Some(node) = walkers::find_node(children, target) else { + return false; + }; + parent.is_auto_layout_container() && node.base().x.is_none() && node.base().y.is_none() } impl EditorState { @@ -112,46 +123,55 @@ impl EditorState { /// `x`/`y` + `width`/`height`). No-op when the node is locked / /// hidden / missing. /// - /// TS parity (`skia-interaction.ts:626-744` + pen-core - /// `scaleChildrenInPlace`): resizing a container scales the whole - /// subtree proportionally, and resizing an auto-grow text node - /// pins it to `textGrowth: fixed-width` so the new width sticks. + /// Convenience for operations that author a complete new rectangle + /// (shape creation and direct geometry tests). Handle drags should call + /// [`Self::resize_selected_bounds`] so an untouched Fill/Hug axis stays + /// authored as Fill/Hug. pub fn set_selected_bounds(&mut self, bounds: DocRect) { + self.resize_selected_bounds(bounds, ResizeAxes::Both, Some(bounds.x), Some(bounds.y)); + } + + /// Resize only the selected node. Descendant authored geometry is never + /// multiplied; normal layout re-resolution is solely responsible for + /// adapting Fill/Hug children, text wrapping, alignment, and flex gaps. + /// + /// `new_x` / `new_y` are parent-relative authored coordinates supplied + /// only when the dragged left/top edge moves. Flow children ignore them + /// and keep `x/y: None`; explicit overlays inside auto-layout are not flow + /// children and therefore retain correct relative positioning. + pub fn resize_selected_bounds( + &mut self, + bounds: DocRect, + axes: ResizeAxes, + new_x: Option, + new_y: Option, + ) { let sel = self.selection.anchor.clone(); if !sel.is_real() || !self.is_editable(&sel) { return; } - let is_flow_child = walkers::is_flow_child_of_flex(self.active_children(), &sel); + let is_flow_child = selected_is_flow_child(self.active_children(), &sel); if let Some(node) = find_node_mut(self.active_children_mut(), &sel) { - // Only write a real rect — container nodes that derive - // their size from children keep deriving it. - let own = own_bounds(node); - if own.w > 0.0 || own.h > 0.0 { - if !is_flow_child { - node.base_mut().x = Some(bounds.x); - node.base_mut().y = Some(bounds.y); + if !is_flow_child { + if let Some(x) = new_x.filter(|value| value.is_finite()) { + node.base_mut().x = Some(x); } + if let Some(y) = new_y.filter(|value| value.is_finite()) { + node.base_mut().y = Some(y); + } + } + if axes.width() && bounds.w.is_finite() && bounds.w > 0.0 { node.set_width_px(bounds.w); + } + if axes.height() && bounds.h.is_finite() && bounds.h > 0.0 { node.set_height_px(bounds.h); + } + if axes.width() { if let PenNode::Text(text) = &mut *node { if text.text_growth.is_none() { text.text_growth = Some(TextGrowth::FixedWidth); } } - // Container resize carries the subtree with it. Each - // incremental drag step scales current → next, so the - // composition over the whole drag equals the total - // scale TS commits once at drag end. - let sx = if own.w > 0.0 { bounds.w / own.w } else { 1.0 }; - let sy = if own.h > 0.0 { bounds.h / own.h } else { 1.0 }; - if sx != 1.0 || sy != 1.0 { - if let Some(children) = node.children_mut() { - scale_children_in_place(children, sx, sy); - } - } - } else if !is_flow_child { - node.base_mut().x = Some(bounds.x); - node.base_mut().y = Some(bounds.y); } } } diff --git a/crates/op-editor-core/src/editor_ui_state.rs b/crates/op-editor-core/src/editor_ui_state.rs index 605086ce1..4d7dbe240 100644 --- a/crates/op-editor-core/src/editor_ui_state.rs +++ b/crates/op-editor-core/src/editor_ui_state.rs @@ -1279,8 +1279,10 @@ pub struct EditorUiState { pub collapsed_layers: HashSet, /// Last LayerPanel click target + ms; 400 ms re-press → rename. pub last_layer_click: Option<(LayerContextTarget, u64)>, - /// Last canvas left-click target + ms; 400 ms same-node re-press - /// on a Text node promotes to inline text edit. + /// Deepest canvas hit + ms for the first half of a possible + /// double-click. A same-hit re-press within 400 ms drills exactly + /// one hierarchy level (or enters inline edit when no child level + /// remains on a Text node). pub last_canvas_click: Option<(NodeId, u64)>, /// Last VariablesPanel name-cell click + ms; 400 ms same-row /// re-press promotes to variable rename. @@ -1340,14 +1342,15 @@ pub struct EditorUiState { // --- Component browser ------------------------------------------ /// Whether the floating Component-Browser panel is shown. pub component_browser_open: bool, - /// Canvas node under the cursor (Select tool, no drag) — drives - /// the dashed hover outline (TS `hoveredNodeId`). + /// Current hierarchy focus under the cursor (Select tool, no + /// drag). Canvas paint outlines the focus solid and all of its + /// direct visible children dashed. pub canvas_hover_node: Option, - /// Frame/group the user "entered" by double-clicking it while - /// selected (TS `enteredFrameId`). While set, canvas-click parent - /// promotion stops at this container's children instead of - /// promoting to the page-root level. Escape and selecting outside - /// the container exit it. Transient: never serialized. + /// Sibling scope entered by a one-level canvas double-click. While + /// set, the scope's direct children are the current single-click + /// targets and their children are the next drill candidates. + /// Escape and selecting outside the scope exit it. Transient: + /// never serialized. pub entered_container: Option, /// Top-left corner of the Component-Browser panel in logical px; /// `None` until first opened — the host then centres it. diff --git a/crates/op-editor-core/src/mutators.rs b/crates/op-editor-core/src/mutators.rs index d1978340b..1d8f9629c 100644 --- a/crates/op-editor-core/src/mutators.rs +++ b/crates/op-editor-core/src/mutators.rs @@ -321,9 +321,9 @@ impl EditorState { } } - // `set_selected_bounds` (handle-drag resize, incl. descendant - // scaling) lives in `drag_mutators.rs` — split out to keep this - // file under the 800-line ceiling. + // Selection-handle resize lives in `drag_mutators.rs` — split out to + // keep this file under the 800-line ceiling. It changes only the selected + // node; descendants adapt through normal layout resolution. /// Translate every node in the selection set by `(dx, dy)` doc /// px. Containers carry their subtree (child coords are parent- diff --git a/crates/op-editor-core/src/selection_resolve.rs b/crates/op-editor-core/src/selection_resolve.rs index 3c9f488eb..5e3380f14 100644 --- a/crates/op-editor-core/src/selection_resolve.rs +++ b/crates/op-editor-core/src/selection_resolve.rs @@ -1,38 +1,57 @@ -//! Canvas click selection semantics: parent promotion + enter-group. +//! Canvas hover and click depth resolution. //! -//! Ports the TS behavior from `skia-interaction.ts:368-405` (click -//! promotion / click-child-of-selected) generalized to the audit's -//! agreed semantics: a click on a nested node promotes to its -//! outermost frame/group ancestor below the page root (unit -//! selection), UNLESS the user has "entered" that container via -//! double-click — then promotion stops at the entered container's -//! child. The entered container lives on -//! `EditorUiState::entered_container`; Escape and selecting outside -//! the container exit it. -//! -//! DELIBERATE DIVERGENCE from TS: promotion climbs transitively to -//! the highest contiguous frame/group ancestor below the page root -//! (Figma-style unit selection). TS's literal code promotes only one -//! level under a grandparent condition — the climb is the intended -//! UX per the parity audit. Shift+click toggles the RESOLVED target -//! (deselects an already-selected hit); TS's shift branch no-ops on -//! selected hits, which reads as a quirk rather than intent. +//! A scene hit supplies a path ordered from the design root to the +//! deepest painted node under the pointer. The design root is an +//! implicit scope: a normal interaction targets its direct child, +//! while an entered container becomes the scope and targets its +//! direct child. The next path element is exposed separately for +//! double-click selection and dashed secondary hover feedback. use crate::node_id::NodeId; use crate::pen_node_ext::PenNodeExt; use crate::state::EditorState; use crate::walkers::{descendant_contains, find_node}; use jian_ops_schema::node::PenNode; -use jian_ops_schema::style::PenFill; -/// Outcome of resolving a canvas hit against the current selection. +/// The two node depths relevant to one pointer hit. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CanvasDepthTargets { + /// Direct child of the active scope, or the scope itself when the + /// path has no deeper node. + pub primary: NodeId, + /// Direct child of `primary` under the pointer, when present. + pub secondary_under_pointer: Option, +} + +/// Resolve one root-to-deepest scene hit path without skipping levels. +/// +/// With no entered container, the first path item is the implicit +/// design scope. When `entered` occurs in the path, it replaces that +/// scope. An entered container outside this hit path (for example a +/// sibling) has no effect on the result. +pub fn resolve_canvas_depth_targets( + path: &[NodeId], + entered: Option<&NodeId>, +) -> Option { + let last_index = path.len().checked_sub(1)?; + let scope_index = entered + .and_then(|entered| path.iter().position(|id| id == entered)) + .unwrap_or(0); + let primary_index = (scope_index + 1).min(last_index); + + Some(CanvasDepthTargets { + primary: path[primary_index].clone(), + secondary_under_pointer: path.get(primary_index + 1).cloned(), + }) +} + +/// Compatibility outcome for the older id-only selection entry point. #[derive(Debug, Clone, PartialEq, Eq)] pub enum SelectionResolution { - /// The hit is a child of an already-selected node — keep the - /// selection unchanged (TS "click child of selected" rule); the - /// press still drags the existing set. + /// Legacy no-op outcome retained for downstream API compatibility. + /// Depth-based resolution no longer emits it. Keep, - /// Select this node (the promoted ancestor, or the hit itself). + /// Select the resolved primary depth. Select(NodeId), } @@ -48,18 +67,6 @@ pub fn is_strict_descendant(children: &[PenNode], ancestor: &NodeId, node: &Node descendant_contains(anc, node) } -/// TS `hasImageVisual`: an Image node, or any node carrying an image -/// fill. Such nodes select directly — promotion would make images -/// inside frames impossible to grab. -pub fn node_has_image_visual(node: &PenNode) -> bool { - if matches!(node, PenNode::Image(_)) { - return true; - } - crate::fills::node_fills(node) - .map(|fills| fills.iter().any(|f| matches!(f, PenFill::Image(_)))) - .unwrap_or(false) -} - /// Ancestor chain from the top-level node down to `target` /// (inclusive): `[top, …, parent, target]`. fn ancestor_path(children: &[PenNode], target: &NodeId) -> Option> { @@ -79,63 +86,24 @@ fn ancestor_path(children: &[PenNode], target: &NodeId) -> Option> { None } -/// Resolve the node a canvas press should select, given the deepest -/// hit, the current selection, and the entered container. +/// Resolve the primary selection target for callers that only have a +/// document-tree hit id rather than a scene hit path. /// -/// Rules (TS `skia-interaction.ts:368-405`, generalized): -/// 1. A hit that is already selected re-selects itself (the host -/// keeps multi-set membership on plain click). -/// 2. A hit that is a strict descendant of any selected node keeps -/// the selection unchanged — clicking a child of a selected -/// container drags the container. -/// 3. Image-visual hits select directly (TS `hasImageVisual` guard). -/// 4. Otherwise the hit promotes to its highest contiguous -/// frame/group ancestor below the page root. Climbing stops at -/// the entered container, so inside an entered container the -/// promotion lands on that container's child. +/// This compatibility entry point reconstructs the root-to-deepest +/// path and applies [`resolve_canvas_depth_targets`]. Node type, +/// image fills, and the current selection never skip a path level. pub fn resolve_canvas_selection_target( children: &[PenNode], deepest: &NodeId, entered: Option<&NodeId>, - selection: &[NodeId], + _selection: &[NodeId], ) -> SelectionResolution { - if selection.iter().any(|s| s == deepest) { - return SelectionResolution::Select(deepest.clone()); - } - if selection - .iter() - .any(|sel| is_strict_descendant(children, sel, deepest)) - { - return SelectionResolution::Keep; - } - // Clicking the entered container's own surface selects it as-is. - if entered == Some(deepest) { - return SelectionResolution::Select(deepest.clone()); - } - if let Some(node) = find_node(children, deepest) { - if node_has_image_visual(node) { - return SelectionResolution::Select(deepest.clone()); - } - } let Some(path) = ancestor_path(children, deepest) else { return SelectionResolution::Select(deepest.clone()); }; - // Climb from the nearest ancestor towards the page root while the - // ancestors stay frame/group containers; never climb up to (or - // past) the entered container. - let mut target = deepest.clone(); - for anc in path.iter().rev().skip(1) { - if entered == Some(anc) { - break; - } - let Some(anc_node) = find_node(children, anc) else { - break; - }; - if !matches!(anc_node, PenNode::Frame(_) | PenNode::Group(_)) { - break; - } - target = anc.clone(); - } + let target = resolve_canvas_depth_targets(&path, entered) + .map(|targets| targets.primary) + .unwrap_or_else(|| deepest.clone()); SelectionResolution::Select(target) } @@ -160,3 +128,85 @@ impl EditorState { } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn ids(values: &[&str]) -> Vec { + values.iter().map(|value| NodeId::new(*value)).collect() + } + + #[test] + fn four_level_path_exposes_only_the_next_two_depths() { + let path = ids(&["root", "level-1", "level-2", "level-3"]); + + assert_eq!( + resolve_canvas_depth_targets(&path, None), + Some(CanvasDepthTargets { + primary: NodeId::new("level-1"), + secondary_under_pointer: Some(NodeId::new("level-2")), + }) + ); + } + + #[test] + fn entered_scope_advances_exactly_one_level_without_skipping() { + let path = ids(&["root", "level-1", "level-2", "level-3"]); + let entered = NodeId::new("level-1"); + + assert_eq!( + resolve_canvas_depth_targets(&path, Some(&entered)), + Some(CanvasDepthTargets { + primary: NodeId::new("level-2"), + secondary_under_pointer: Some(NodeId::new("level-3")), + }) + ); + } + + #[test] + fn deepest_entered_scope_resolves_to_itself() { + let path = ids(&["root", "level-1", "level-2"]); + let entered = NodeId::new("level-2"); + + assert_eq!( + resolve_canvas_depth_targets(&path, Some(&entered)), + Some(CanvasDepthTargets { + primary: NodeId::new("level-2"), + secondary_under_pointer: None, + }) + ); + } + + #[test] + fn entered_sibling_does_not_change_the_implicit_root_scope() { + let path = ids(&["root", "level-1", "level-2"]); + let sibling = NodeId::new("other-branch"); + + assert_eq!( + resolve_canvas_depth_targets(&path, Some(&sibling)), + Some(CanvasDepthTargets { + primary: NodeId::new("level-1"), + secondary_under_pointer: Some(NodeId::new("level-2")), + }) + ); + } + + #[test] + fn root_surface_resolves_to_root_without_a_secondary() { + let path = ids(&["root"]); + + assert_eq!( + resolve_canvas_depth_targets(&path, None), + Some(CanvasDepthTargets { + primary: NodeId::new("root"), + secondary_under_pointer: None, + }) + ); + } + + #[test] + fn empty_path_has_no_depth_targets() { + assert_eq!(resolve_canvas_depth_targets(&[], None), None); + } +} diff --git a/crates/op-editor-core/src/tests_drag_mutators.rs b/crates/op-editor-core/src/tests_drag_mutators.rs index 14ea8700e..3dc90012b 100644 --- a/crates/op-editor-core/src/tests_drag_mutators.rs +++ b/crates/op-editor-core/src/tests_drag_mutators.rs @@ -1,12 +1,12 @@ //! Tests for the canvas-drag P1 gaps: selection resolution (parent -//! promotion + enter-group), container-resize descendant scaling, and +//! promotion + enter-group), Pencil-like container resize, and //! drag-end reorder / reparent mutators. #![cfg(test)] use crate::drag_mutators::{ auto_layout_direction, parent_of, should_auto_reparent_outside_parent, DragDropTarget, - FlexDirection, + FlexDirection, ResizeAxes, }; use crate::geometry::DocRect; use crate::node_id::NodeId; @@ -29,7 +29,7 @@ fn nested_state() -> crate::EditorState { // --- Selection resolution (GAP A) ------------------------------------- #[test] -fn click_on_nested_child_promotes_to_top_level_container() { +fn click_on_nested_child_selects_the_roots_direct_child() { let s = nested_state(); let resolved = resolve_canvas_selection_target( s.active_children(), @@ -37,11 +37,11 @@ fn click_on_nested_child_promotes_to_top_level_container() { None, &s.selection.set, ); - assert_eq!(resolved, SelectionResolution::Select(NodeId::new("f1"))); + assert_eq!(resolved, SelectionResolution::Select(NodeId::new("g1"))); } #[test] -fn click_on_child_of_selected_container_keeps_selection() { +fn click_on_child_of_selected_root_advances_to_the_next_depth() { let mut s = nested_state(); s.set_single_selection(NodeId::new("f1")); let resolved = resolve_canvas_selection_target( @@ -50,11 +50,11 @@ fn click_on_child_of_selected_container_keeps_selection() { None, &s.selection.set, ); - assert_eq!(resolved, SelectionResolution::Keep); + assert_eq!(resolved, SelectionResolution::Select(NodeId::new("g1"))); } #[test] -fn click_on_already_selected_node_reselects_it() { +fn current_deep_selection_does_not_bypass_primary_depth() { let mut s = nested_state(); s.set_single_selection(NodeId::new("r1")); let resolved = resolve_canvas_selection_target( @@ -63,7 +63,7 @@ fn click_on_already_selected_node_reselects_it() { None, &s.selection.set, ); - assert_eq!(resolved, SelectionResolution::Select(NodeId::new("r1"))); + assert_eq!(resolved, SelectionResolution::Select(NodeId::new("g1"))); } #[test] @@ -107,12 +107,11 @@ fn top_level_hit_resolves_to_itself() { } #[test] -fn image_visual_hit_skips_promotion() { - // TS `hasImageVisual` guard: an image-filled child selects - // directly instead of promoting to its frame. +fn image_visual_hit_obeys_the_same_depth_rule() { let mut img = rect("img", "Photo", 10.0, 10.0, 50.0, 50.0); crate::fills::set_primary_fill_type(&mut img, crate::FillType::Image); - let f1 = frame("f1", "Card", 0.0, 0.0, 200.0, 200.0, vec![img]); + let g1 = group("g1", "Media", vec![img]); + let f1 = frame("f1", "Card", 0.0, 0.0, 200.0, 200.0, vec![g1]); let s = state_with(vec![f1]); let resolved = resolve_canvas_selection_target( s.active_children(), @@ -120,7 +119,7 @@ fn image_visual_hit_skips_promotion() { None, &s.selection.set, ); - assert_eq!(resolved, SelectionResolution::Select(NodeId::new("img"))); + assert_eq!(resolved, SelectionResolution::Select(NodeId::new("g1"))); } #[test] @@ -486,36 +485,38 @@ fn parent_of_and_direction_helpers_walk_the_tree() { assert_eq!(auto_layout_direction(&free), None); } -// --- Container resize scales descendants (GAP C) ----------------------- +// --- Container resize preserves descendants (Pencil parity) ------------ #[test] -fn container_resize_scales_free_descendants_recursively() { +fn container_resize_keeps_free_descendant_authored_geometry() { let leaf = rect("leaf", "Leaf", 5.0, 5.0, 10.0, 10.0); let inner = frame("inner", "Inner", 20.0, 40.0, 100.0, 50.0, vec![leaf]); let root = frame("root", "Root", 100.0, 100.0, 200.0, 100.0, vec![inner]); let mut s = state_with(vec![root]); s.set_single_selection(NodeId::new("root")); - // 200×100 → 400×300: sx = 2, sy = 3. s.set_selected_bounds(DocRect { x: 100.0, y: 100.0, w: 400.0, h: 300.0, }); + let root = find_node(s.active_children(), &NodeId::new("root")).unwrap(); + assert_eq!(root.width_px(), Some(400.0)); + assert_eq!(root.height_px(), Some(300.0)); let inner = find_node(s.active_children(), &NodeId::new("inner")).unwrap(); - assert_eq!(inner.base().x, Some(40.0)); - assert_eq!(inner.base().y, Some(120.0)); - assert_eq!(inner.width_px(), Some(200.0)); - assert_eq!(inner.height_px(), Some(150.0)); + assert_eq!(inner.base().x, Some(20.0)); + assert_eq!(inner.base().y, Some(40.0)); + assert_eq!(inner.width_px(), Some(100.0)); + assert_eq!(inner.height_px(), Some(50.0)); let leaf = find_node(s.active_children(), &NodeId::new("leaf")).unwrap(); - assert_eq!(leaf.base().x, Some(10.0)); - assert_eq!(leaf.base().y, Some(15.0)); - assert_eq!(leaf.width_px(), Some(20.0)); - assert_eq!(leaf.height_px(), Some(30.0)); + assert_eq!(leaf.base().x, Some(5.0)); + assert_eq!(leaf.base().y, Some(5.0)); + assert_eq!(leaf.width_px(), Some(10.0)); + assert_eq!(leaf.height_px(), Some(10.0)); } #[test] -fn container_resize_scales_flex_children_sizes_only() { +fn container_resize_keeps_fixed_flex_child_size() { let f = flex_frame( "f1", "Stack", @@ -534,21 +535,18 @@ fn container_resize_scales_flex_children_sizes_only() { h: 200.0, }); let a = find_node(s.active_children(), &NodeId::new("a")).unwrap(); - // Flow children keep layout-engine positions (no materialized x/y)… assert_eq!(a.base().x, None); assert_eq!(a.base().y, None); - // …but their explicit sizes scale with the container. - assert_eq!(a.width_px(), Some(200.0)); - assert_eq!(a.height_px(), Some(80.0)); + assert_eq!(a.width_px(), Some(100.0)); + assert_eq!(a.height_px(), Some(40.0)); } #[test] -fn incremental_resize_steps_compose_to_the_total_scale() { +fn incremental_container_resize_never_accumulates_descendant_changes() { let leaf = rect("leaf", "Leaf", 10.0, 10.0, 10.0, 10.0); let root = frame("root", "Root", 0.0, 0.0, 100.0, 100.0, vec![leaf]); let mut s = state_with(vec![root]); s.set_single_selection(NodeId::new("root")); - // Two drag steps: 100 → 150 → 200 must equal one 100 → 200 step. for w in [150.0, 200.0] { s.set_selected_bounds(DocRect { x: 0.0, @@ -558,12 +556,103 @@ fn incremental_resize_steps_compose_to_the_total_scale() { }); } let leaf = find_node(s.active_children(), &NodeId::new("leaf")).unwrap(); - assert_eq!(leaf.base().x, Some(20.0)); - assert_eq!(leaf.width_px(), Some(20.0)); + assert_eq!(leaf.base().x, Some(10.0)); + assert_eq!(leaf.width_px(), Some(10.0)); assert_eq!(leaf.base().y, Some(10.0)); assert_eq!(leaf.height_px(), Some(10.0)); } +#[test] +fn single_axis_resize_freezes_only_that_axis_and_preserves_child_keywords() { + let doc = jian_ops_schema::load_str( + r#"{"version":"0.8.0","children":[{"type":"frame","id":"root","width":"fill_container","height":"fit_content","layout":"vertical","children":[{"type":"rectangle","id":"child","width":"fill_container","height":40}]}]}"#, + ) + .expect("keyword fixture parses") + .value; + let mut s = crate::EditorState::from_document(doc); + s.set_single_selection(NodeId::new("root")); + + s.resize_selected_bounds( + DocRect { + x: 0.0, + y: 0.0, + w: 360.0, + h: 240.0, + }, + ResizeAxes::Width, + None, + None, + ); + + let root = find_node(s.active_children(), &NodeId::new("root")).unwrap(); + let root_json = serde_json::to_value(root).unwrap(); + assert_eq!(root_json["width"], serde_json::json!(360.0)); + assert_eq!(root_json["height"], serde_json::json!("fit_content")); + let child = find_node(s.active_children(), &NodeId::new("child")).unwrap(); + let child_json = serde_json::to_value(child).unwrap(); + assert_eq!(child_json["width"], serde_json::json!("fill_container")); + assert_eq!(child_json["height"], serde_json::json!(40.0)); +} + +#[test] +fn nested_free_resize_accepts_parent_relative_left_and_top() { + let child = rect("child", "Child", 20.0, 30.0, 80.0, 60.0); + let root = frame("root", "Root", 100.0, 200.0, 300.0, 300.0, vec![child]); + let mut s = state_with(vec![root]); + s.set_single_selection(NodeId::new("child")); + + s.resize_selected_bounds( + DocRect { + x: 130.0, + y: 240.0, + w: 70.0, + h: 50.0, + }, + ResizeAxes::Both, + Some(30.0), + Some(40.0), + ); + + let child = find_node(s.active_children(), &NodeId::new("child")).unwrap(); + assert_eq!(child.base().x, Some(30.0)); + assert_eq!(child.base().y, Some(40.0)); + assert_eq!(child.width_px(), Some(70.0)); + assert_eq!(child.height_px(), Some(50.0)); +} + +#[test] +fn flow_child_resize_never_materializes_position() { + let f = flex_frame( + "root", + "Stack", + 0.0, + 0.0, + 200.0, + 200.0, + vec![flow_rect("child", "Child", 80.0, 40.0)], + ); + let mut s = state_with(vec![f]); + s.set_single_selection(NodeId::new("child")); + + s.resize_selected_bounds( + DocRect { + x: 100.0, + y: 100.0, + w: 120.0, + h: 999.0, + }, + ResizeAxes::Width, + Some(100.0), + Some(100.0), + ); + + let child = find_node(s.active_children(), &NodeId::new("child")).unwrap(); + assert_eq!(child.base().x, None); + assert_eq!(child.base().y, None); + assert_eq!(child.width_px(), Some(120.0)); + assert_eq!(child.height_px(), Some(40.0)); +} + #[test] fn resizing_auto_grow_text_pins_fixed_width_growth() { let t = text("t1", "Title", 0.0, 0.0, 100.0, 20.0, "Hello"); @@ -582,6 +671,31 @@ fn resizing_auto_grow_text_pins_fixed_width_growth() { } } +#[test] +fn height_only_text_resize_does_not_change_text_growth() { + let t = text("t1", "Title", 0.0, 0.0, 100.0, 20.0, "Hello"); + let mut s = state_with(vec![t]); + s.set_single_selection(NodeId::new("t1")); + s.resize_selected_bounds( + DocRect { + x: 0.0, + y: 0.0, + w: 999.0, + h: 40.0, + }, + ResizeAxes::Height, + None, + None, + ); + let n = find_node(s.active_children(), &NodeId::new("t1")).unwrap(); + assert_eq!(n.width_px(), Some(100.0)); + assert_eq!(n.height_px(), Some(40.0)); + match n { + PenNode::Text(t) => assert_eq!(t.text_growth, None), + other => panic!("expected text node, got {other:?}"), + } +} + #[test] fn resizing_text_with_explicit_growth_keeps_it() { let mut t = text("t1", "Title", 0.0, 0.0, 100.0, 20.0, "Hello"); diff --git a/crates/op-editor-ui/src/widgets/ai_chat_panel/tests.rs b/crates/op-editor-ui/src/widgets/ai_chat_panel/tests.rs index 2b48ec9b3..c2b304178 100644 --- a/crates/op-editor-ui/src/widgets/ai_chat_panel/tests.rs +++ b/crates/op-editor-ui/src/widgets/ai_chat_panel/tests.rs @@ -26,20 +26,20 @@ fn examples_grid_has_four_cards() { } #[test] -fn second_example_is_music_streaming_home_prompt() { +fn second_example_uses_short_title_and_full_music_prompt() { let en = example_cards(op_editor_core::Locale::EnUs); + assert_eq!(en[1].title, "Dark music streaming mobile app"); assert_eq!( - en[1].title, + en[1].prompt, "Design a dark-themed music streaming mobile app home screen. Include a greeting \"Good evening\", horizontal scrollable \"Recently Played\" album art cards, \"Made For You\" section with 3 playlist cards showing cover art and playlist names, \"New Releases\" section with 4 album cards in a 2x2 grid, and a floating mini player bar at the bottom showing current track with play/pause controls. Bottom tab bar (Home, Search, Library, Premium). Dark background with lime green accent." ); - assert_eq!(en[1].prompt, en[1].title); let zh = example_cards(op_editor_core::Locale::ZhCn); + assert_eq!(zh[1].title, "暗色音乐流媒体 App 首页"); assert_eq!( - zh[1].title, + zh[1].prompt, "设计一个暗色音乐流媒体App首页。包含问候语\"晚上好\"、\"最近播放\"横向滑动专辑封面卡片、\"为你推荐\"区3张歌单卡片(封面和歌单名)、\"新发行\"区4张专辑卡片2x2网格、底部悬浮迷你播放器(当前曲目+播放/暂停控件)。底部导航栏(首页、搜索、音乐库、会员)。深色背景搭配荧光绿强调。" ); - assert_eq!(zh[1].prompt, zh[1].title); } #[test] diff --git a/crates/op-editor-ui/src/widgets/canvas_agent_cursor.rs b/crates/op-editor-ui/src/widgets/canvas_agent_cursor.rs index 74d206af8..46f895d95 100644 --- a/crates/op-editor-ui/src/widgets/canvas_agent_cursor.rs +++ b/crates/op-editor-ui/src/widgets/canvas_agent_cursor.rs @@ -209,15 +209,6 @@ pub(crate) fn silhouette_for(style: op_editor_core::PencilCursorStyle) -> Silhou /// Fallback for reveals not owned by any tagged agent (the same red the /// retired dashed-reveal border used as its untagged default). -/// O4 outline halo — near-black slate, drawn wide and translucent -/// OUTSIDE the white rim. -const PENCIL_HALO: Color = Color { - r: 0.067, - g: 0.094, - b: 0.153, - a: 1.0, -}; - /// Chubby variant's eraser butt. const PENCIL_ERASER_PINK: Color = Color { r: 1.0, @@ -643,7 +634,7 @@ pub(crate) fn paint_cursor_swatch( .collect() }; let body = at(silhouette.body); - paint_soft_halo(cx, &body, 1.0); + paint_soft_shadow(cx, &body, 1.0); paint_rim(cx, &body, 0.95); cx.backend.fill_polygon(&body, color); if let Some(eraser) = silhouette.eraser { @@ -662,11 +653,11 @@ pub(crate) fn paint_cursor_swatch( } /// Uniformly outset a silhouette by `offset` px about its centroid. Used for -/// both the halo layers and the white rim: a filled outset paints the rim as +/// both the shadow layers and the white rim: a filled outset paints the rim as /// GEOMETRY, so its width is exact everywhere and no stroke joins can notch it /// (the trait's fallback polygon stroke drew each edge as its own capped /// segment — every vertex of the densely-sampled arc showed a jaggy). -fn outset(body: &[Point2D], offset: f32, drop: f32) -> Vec { +fn outset(body: &[Point2D], offset: f32, shift_x: f32, shift_y: f32) -> Vec { let n = body.len() as f32; let (mut sum_x, mut sum_y) = (0.0f32, 0.0f32); for p in body { @@ -680,30 +671,27 @@ fn outset(body: &[Point2D], offset: f32, drop: f32) -> Vec { .fold(1.0f32, f32::max); let k = 1.0 + offset / radius; body.iter() - .map(|p| Point2D::new(cx + (p.x - cx) * k, cy + (p.y - cy) * k + drop)) + .map(|p| Point2D::new(cx + (p.x - cx) * k + shift_x, cy + (p.y - cy) * k + shift_y)) .collect() } /// Width of the white rim (px of outset beyond the body silhouette). const RIM: f32 = 1.6; -/// Soft halo as concentric FILLED expansions of the silhouette, largest -/// first with per-layer alpha stacking smoothly toward the body. Filled -/// polygons have no stroke joins, so sharp silhouette corners cannot spike -/// (three concentric STROKES read as a dirty banded ring - user feedback -/// 2026-07-12, twice). The slight downward bias doubles as the contact -/// shadow, replacing the old hard-edged offset copy. -fn paint_soft_halo(cx: &mut PaintCx<'_>, body: &[Point2D], alpha_scale: f32) { - // The halo sits OUTSIDE the white rim, so every layer clears it. +/// Pencil-style contact shadow: narrow neutral-black feather, shifted a +/// half-pixel left/down. Filled expansions keep the fallback painter soft +/// without introducing the jagged polygon joins produced by strokes. +fn paint_soft_shadow(cx: &mut PaintCx<'_>, body: &[Point2D], alpha_scale: f32) { + // The shadow sits outside the white rim; largest/faintest paints first. for (offset, alpha) in [ - (RIM + 3.2, 0.030), - (RIM + 2.4, 0.040), - (RIM + 1.6, 0.050), - (RIM + 0.8, 0.060), + (RIM + 1.6, 0.030), + (RIM + 1.2, 0.040), + (RIM + 0.8, 0.050), + (RIM + 0.4, 0.060), ] { - let ring = outset(body, offset, 0.7); + let ring = outset(body, offset, -0.5, 0.5); cx.backend - .fill_polygon(&ring, PENCIL_HALO.with_alpha(alpha * alpha_scale)); + .fill_polygon(&ring, Color::BLACK.with_alpha(alpha * alpha_scale)); } } @@ -711,7 +699,7 @@ fn paint_soft_halo(cx: &mut PaintCx<'_>, body: &[Point2D], alpha_scale: f32) { /// ring of exactly `RIM` px with no stroke joins to notch it. fn paint_rim(cx: &mut PaintCx<'_>, body: &[Point2D], alpha: f32) { cx.backend - .fill_polygon(&outset(body, RIM, 0.0), Color::WHITE.with_alpha(alpha)); + .fill_polygon(&outset(body, RIM, 0.0, 0.0), Color::WHITE.with_alpha(alpha)); } fn paint_sprite(cx: &mut PaintCx<'_>, sprite: &CursorSprite, now_ms: u64, silhouette: &Silhouette) { @@ -740,9 +728,8 @@ fn paint_sprite(cx: &mut PaintCx<'_>, sprite: &CursorSprite, now_ms: u64, silhou .collect() }; let body = at(silhouette.body); - // Dark soft halo OUTSIDE the white rim (user-picked "O4", the macOS - // pointer treatment) - filled-expansion feather, see paint_soft_halo. - paint_soft_halo(cx, &body, sprite.alpha); + // Narrow neutral contact shadow outside the white rim. + paint_soft_shadow(cx, &body, sprite.alpha); paint_rim(cx, &body, 0.95 * sprite.alpha); cx.backend .fill_polygon(&body, sprite.color.with_alpha(sprite.alpha)); @@ -807,6 +794,19 @@ fn paint_name_pill(cx: &mut PaintCx<'_>, sprite: &CursorSprite, name: &str) { name_w + PAD_X * 2.0, PILL_H, ); + // Pencil's label uses the same compact contact shadow as its pointer: + // nearly centred, neutral black, and just soft enough to lift the tag. + cx.backend.fill_drop_shadow( + Rect::xywh( + pill.origin.x - 0.5, + pill.origin.y + 0.5, + pill.size.x, + pill.size.y, + ), + PILL_H / 2.0, + 1.5, + Color::BLACK.with_alpha(0.28 * sprite.alpha), + ); cx.backend.fill_round_rect( pill, PILL_H / 2.0, diff --git a/crates/op-editor-ui/src/widgets/canvas_agent_cursor_tests.rs b/crates/op-editor-ui/src/widgets/canvas_agent_cursor_tests.rs index 07d14c9ab..a480527b8 100644 --- a/crates/op-editor-ui/src/widgets/canvas_agent_cursor_tests.rs +++ b/crates/op-editor-ui/src/widgets/canvas_agent_cursor_tests.rs @@ -469,9 +469,11 @@ mod paint_tests { struct CursorCaptureBackend { polygons: Vec<(Vec, Color)>, polygon_strokes: Vec<(Vec, Color)>, + drop_shadows: Vec<(Rect, f32, f32, Color)>, round_fills: Vec<(Rect, Color)>, round_strokes: Vec<(Rect, Color, f32)>, labels: Vec, + paint_ops: Vec<&'static str>, } impl RenderBackend for CursorCaptureBackend { @@ -480,6 +482,7 @@ mod paint_tests { fn fill_rect(&mut self, _: Rect, _: Color) {} fn stroke_rect(&mut self, _: Rect, _: Color, _: f32) {} fn draw_text(&mut self, layout: &TextLayout, _: Point2D) { + self.paint_ops.push("text"); if let Some(run) = layout.runs().first() { self.labels.push(run.content.clone()); } @@ -490,8 +493,13 @@ mod paint_tests { fn translate(&mut self, _: Point2D) {} fn stroke_line(&mut self, _: Point2D, _: Point2D, _: Color, _: f32) {} fn fill_round_rect(&mut self, rect: Rect, _: f32, color: Color) { + self.paint_ops.push("pill"); self.round_fills.push((rect, color)); } + fn fill_drop_shadow(&mut self, rect: Rect, radius: f32, blur: f32, color: Color) { + self.paint_ops.push("shadow"); + self.drop_shadows.push((rect, radius, blur, color)); + } fn stroke_round_rect(&mut self, rect: Rect, _: f32, color: Color, width: f32) { self.round_strokes.push((rect, color, width)); } @@ -545,7 +553,7 @@ mod paint_tests { assert_eq!( backend.polygons.len(), 7, - "4 halo feather layers + the white rim + body + tip wedge" + "4 shadow feather layers + the white rim + body + tip wedge" ); let (pts, color) = &backend.polygons[5]; assert_eq!( @@ -558,11 +566,22 @@ mod paint_tests { (pts[0].x - 40.0).abs() < 0.01 && (pts[0].y - 60.0).abs() < 0.01, "pencil tip sits on the current element's centre" ); + let centroid = |polygon: &[Point2D]| { + let count = polygon.len() as f32; + Point2D::new( + polygon.iter().map(|p| p.x).sum::() / count, + polygon.iter().map(|p| p.y).sum::() / count, + ) + }; + let shadow_center = centroid(&backend.polygons[0].0); + let body_center = centroid(pts); + assert!((shadow_center.x - body_center.x + 0.5).abs() < 0.01); + assert!((shadow_center.y - body_center.y - 0.5).abs() < 0.01); // The rim is FILLED geometry, not a stroke: the trait's fallback // polygon stroke drew each edge as its own capped segment, which // notched every vertex of the densely-sampled arc (user report // 2026-07-12: "不要有锯齿感"). It must still be white, and sit - // between the halo and the body. + // between the shadow and the body. let (rim_pts, rim_color) = &backend.polygons[4]; assert!( (rim_color.r - 1.0).abs() < 0.01 @@ -581,7 +600,21 @@ mod paint_tests { skeleton owns the working-area affordance now" ); assert_eq!(backend.labels, vec!["Mochi".to_string()]); - assert!(!backend.round_fills.is_empty(), "name pill paints"); + let (pill, _) = backend.round_fills.last().expect("name pill paints"); + let (shadow, radius, blur, color) = backend + .drop_shadows + .last() + .expect("name pill paints a contact shadow"); + assert!((shadow.origin.x - pill.origin.x + 0.5).abs() < 0.01); + assert!((shadow.origin.y - pill.origin.y - 0.5).abs() < 0.01); + assert_eq!(shadow.size, pill.size); + assert!((*radius - 8.5).abs() < 0.01 && (*blur - 1.5).abs() < 0.01); + assert!(color.r < 0.01 && color.g < 0.01 && color.b < 0.01); + assert!((color.a - 0.28).abs() < 0.01); + assert_eq!( + &backend.paint_ops[backend.paint_ops.len() - 3..], + &["shadow", "pill", "text"] + ); } #[test] @@ -609,6 +642,10 @@ mod paint_tests { "fallback red fill" ); assert!(backend.labels.is_empty(), "no name pill without a tag"); + assert!( + backend.drop_shadows.is_empty(), + "no pill shadow without a tag" + ); assert!(backend.round_fills.is_empty(), "no pill capsule either"); assert!( backend.round_strokes.is_empty(), @@ -635,6 +672,7 @@ mod paint_tests { ); assert!( backend.polygons.is_empty() + && backend.drop_shadows.is_empty() && backend.round_fills.is_empty() && backend.round_strokes.is_empty() ); diff --git a/crates/op-editor-ui/src/widgets/canvas_viewport.rs b/crates/op-editor-ui/src/widgets/canvas_viewport.rs index 43a6a5b9d..2bfc0462e 100644 --- a/crates/op-editor-ui/src/widgets/canvas_viewport.rs +++ b/crates/op-editor-ui/src/widgets/canvas_viewport.rs @@ -50,6 +50,39 @@ pub enum SelectionHandle { Left, } +impl SelectionHandle { + /// Authored dimensions changed by this handle. + pub fn resize_axes(self) -> op_editor_core::drag_mutators::ResizeAxes { + use op_editor_core::drag_mutators::ResizeAxes; + match (self.resizes_width(), self.resizes_height()) { + (true, true) => ResizeAxes::Both, + (true, false) => ResizeAxes::Width, + (false, true) => ResizeAxes::Height, + (false, false) => unreachable!("every selection handle resizes at least one axis"), + } + } + + /// Whether this handle authors the selected node's width. + pub fn resizes_width(self) -> bool { + !matches!(self, Self::Top | Self::Bottom) + } + + /// Whether this handle authors the selected node's height. + pub fn resizes_height(self) -> bool { + !matches!(self, Self::Left | Self::Right) + } + + /// Whether dragging this handle moves the selected node's left edge. + pub fn moves_left_edge(self) -> bool { + matches!(self, Self::Left | Self::TopLeft | Self::BottomLeft) + } + + /// Whether dragging this handle moves the selected node's top edge. + pub fn moves_top_edge(self) -> bool { + matches!(self, Self::Top | Self::TopLeft | Self::TopRight) + } +} + /// Radius (screen px) of the rotation ring that sits OUTSIDE the /// 4 selection corners. Matches the TS `ROTATE_OUTER_RADIUS`. const ROTATE_OUTER_RADIUS: f32 = 16.0; @@ -339,8 +372,8 @@ pub struct CanvasViewport<'a> { pub theme: Theme, /// Host ms clock — text-edit caret blink. pub now_ms: u64, - /// Node under the cursor (excluding selected nodes) — paints the - /// dashed hover outline (TS `drawHoverOutline`). + /// Hierarchy focus under the cursor. An unselected focus paints a + /// solid outline; its direct visible children paint dashed hints. pub(super) hovered: Option, /// Top-level frame labels: (scene id, display name, label colour). /// Collected from the canonical tree at build time (the scene @@ -412,7 +445,6 @@ impl<'a> CanvasViewport<'a> { // The outline is a Select-tool affordance; a stale id // from a previous tool must not paint. .filter(|_| matches!(state.tool, op_editor_core::Tool::Select)) - .filter(|id| !state.selection.set.iter().any(|s| s == *id)) .map(|id| id.as_str().to_string()), frame_labels: collect_frame_labels(state), } @@ -625,6 +657,31 @@ impl<'a> Widget for CanvasViewport<'a> { } else { None }; + let hovered_focus_selected = hovered_lookup + .is_some_and(|hovered| self.selected_set.iter().any(|selected| selected == hovered)); + // Root labels carry their durable generating/selection colour + // from construction. Apply the transient hover tint here so + // `node_drag_active` suppresses it together with every other + // hierarchy-hover affordance. + let hovered_frame_labels = hovered_lookup.map(|hovered| { + self.frame_labels + .iter() + .map(|(id, label, color)| { + ( + id.clone(), + label.clone(), + if id == hovered { + self.theme.primary + } else { + *color + }, + ) + }) + .collect::>() + }); + let frame_labels = hovered_frame_labels + .as_deref() + .unwrap_or(&self.frame_labels); let selected_root_frame_label = selection_chrome_visible && show_handles && self.scene.active_page().is_some_and(|page| { @@ -750,21 +807,34 @@ impl<'a> Widget for CanvasViewport<'a> { self.pencil_cursor_style, ); } - if let Some(screen) = paint_hits.hover_rect { - const HOVER: Color = Color { - r: 0.231, - g: 0.51, - b: 0.965, - a: 1.0, - }; - // Replay the hovered node's root→node flip/rotation - // chain so the dashed outline lands on the rendered - // geometry, not the unrotated doc-space bounds. - let hover_transformed = super::canvas_overlay_transform::replay_on_backend( - cx, - &paint_hits.hover_transforms, - ); - paint_dashed_rect(cx, screen, HOVER, 1.5); + const HOVER: Color = Color { + r: 0.231, + g: 0.51, + b: 0.965, + a: 1.0, + }; + if !hovered_focus_selected { + if let Some(screen) = paint_hits.hover_rect { + // Replay the focus node's root→node flip/rotation + // chain so its solid outline lands on the rendered + // geometry, not the unrotated doc-space bounds. + let hover_transformed = super::canvas_overlay_transform::replay_on_backend( + cx, + &paint_hits.hover_transforms, + ); + cx.backend.stroke_rect(screen, HOVER, 1.5); + if hover_transformed { + cx.backend.restore(); + } + } + } + for (screen, transforms) in &paint_hits.hover_child_rects { + // A direct child can add its own flip/rotation after + // the focus node's ancestor chain, so replay each hint + // independently instead of sharing the focus transform. + let hover_transformed = + super::canvas_overlay_transform::replay_on_backend(cx, transforms); + paint_dashed_rect(cx, *screen, HOVER, 1.5); if hover_transformed { cx.backend.restore(); } @@ -772,7 +842,7 @@ impl<'a> Widget for CanvasViewport<'a> { super::canvas_frame_labels::paint_frame_labels( cx, &page.children, - &self.frame_labels, + frame_labels, if selected_root_frame_label { &[] } else { diff --git a/crates/op-editor-ui/src/widgets/canvas_viewport_paint.rs b/crates/op-editor-ui/src/widgets/canvas_viewport_paint.rs index d067dded1..2dac718d7 100644 --- a/crates/op-editor-ui/src/widgets/canvas_viewport_paint.rs +++ b/crates/op-editor-ui/src/widgets/canvas_viewport_paint.rs @@ -290,6 +290,10 @@ pub struct PaintNodeHits<'a> { /// Root→node transform chain active where the hovered node paints; /// empty when `hover_rect` is `None` or the chain is identity. pub(crate) hover_transforms: Vec, + /// Direct visible children of the hovered focus node. Each child + /// keeps the transform chain active at its own paint site so the + /// dashed hierarchy hint follows rotated/flipped descendants. + pub(crate) hover_child_rects: Vec<(Rect, Vec)>, pub(crate) selected_node: Option<&'a SceneNode>, pub(crate) selected_transforms: Vec, pub(crate) pen_node: Option<&'a SceneNode>, @@ -300,8 +304,13 @@ impl<'a> PaintNodeHits<'a> { node: &'a SceneNode, options: &PaintNodeOptions<'_, '_>, transforms: &[OverlayTransform], + parent_hovered: bool, ) -> Self { - let hover_rect = hovered_outline_rect(node, options); + let is_hovered = options.hovered == Some(node.id.as_str()); + let outline_rect = (is_hovered || parent_hovered) + .then(|| node_outline_rect(node, options)) + .flatten(); + let hover_rect = is_hovered.then_some(outline_rect).flatten(); let selected_node = (options.selected == Some(node.id.as_str())).then_some(node); Self { hover_transforms: if hover_rect.is_some() { @@ -310,6 +319,13 @@ impl<'a> PaintNodeHits<'a> { Vec::new() }, hover_rect, + hover_child_rects: if parent_hovered { + outline_rect + .map(|rect| vec![(rect, transforms.to_vec())]) + .unwrap_or_default() + } else { + Vec::new() + }, selected_transforms: if selected_node.is_some() { transforms.to_vec() } else { @@ -325,6 +341,7 @@ impl<'a> PaintNodeHits<'a> { self.hover_rect = child.hover_rect; self.hover_transforms = child.hover_transforms; } + self.hover_child_rects.extend(child.hover_child_rects); if self.selected_node.is_none() { self.selected_node = child.selected_node; self.selected_transforms = child.selected_transforms; @@ -435,7 +452,7 @@ pub(crate) fn paint_node_with_options_hiding<'a>( generation_accent, queued_shell_ids, }; - paint_node_inner(cx, node, &options, &mut Vec::new()) + paint_node_inner(cx, node, &options, &mut Vec::new(), false) } /// Paint a resolved scene page's node tree with the editor viewport @@ -482,6 +499,7 @@ fn paint_node_inner<'a>( node: &'a SceneNode, options: &PaintNodeOptions<'_, '_>, transforms: &mut Vec, + parent_hovered: bool, ) -> PaintNodeHits<'a> { if options.hidden == Some(node.id.as_str()) { return PaintNodeHits::default(); @@ -586,7 +604,8 @@ fn paint_node_inner<'a>( }); } } - let mut hits = PaintNodeHits::for_node(node, options, transforms); + let is_hovered = options.hovered == Some(node.id.as_str()); + let mut hits = PaintNodeHits::for_node(node, options, transforms, parent_hovered); // Gaussian layer blur (Figma "Layer blur"): capture the node's // whole rendered output — shadows, fill, stroke, children — into @@ -659,7 +678,7 @@ fn paint_node_inner<'a>( } let clipped = push_clip_content(cx, node, world_rect, zoom); for child in node.children.iter().rev() { - let child_hover = paint_node_inner(cx, child, options, transforms); + let child_hover = paint_node_inner(cx, child, options, transforms, is_hovered); hits.merge_missing(child_hover); } if clipped { @@ -679,7 +698,7 @@ fn paint_node_inner<'a>( // every recursing container branch, not just Frame. let clipped = push_clip_content(cx, node, world_rect, zoom); for child in node.children.iter().rev() { - let child_hover = paint_node_inner(cx, child, options, transforms); + let child_hover = paint_node_inner(cx, child, options, transforms, is_hovered); hits.merge_missing(child_hover); } if clipped { @@ -713,7 +732,7 @@ fn paint_node_inner<'a>( // all rendered as blank cards). let clipped = push_clip_content(cx, node, world_rect, zoom); for child in node.children.iter().rev() { - let child_hover = paint_node_inner(cx, child, options, transforms); + let child_hover = paint_node_inner(cx, child, options, transforms, is_hovered); hits.merge_missing(child_hover); } if clipped { @@ -848,10 +867,7 @@ fn paint_node_inner<'a>( hits } -fn hovered_outline_rect(node: &SceneNode, options: &PaintNodeOptions<'_, '_>) -> Option { - if options.hovered != Some(node.id.as_str()) { - return None; - } +fn node_outline_rect(node: &SceneNode, options: &PaintNodeOptions<'_, '_>) -> Option { let bounds = node.aggregate_bounds(); if bounds.size.x <= 0.0 || bounds.size.y <= 0.0 { return None; diff --git a/crates/op-editor-ui/src/widgets/canvas_viewport_tests.rs b/crates/op-editor-ui/src/widgets/canvas_viewport_tests.rs index 13bf789fa..5f92d36ec 100644 --- a/crates/op-editor-ui/src/widgets/canvas_viewport_tests.rs +++ b/crates/op-editor-ui/src/widgets/canvas_viewport_tests.rs @@ -54,8 +54,14 @@ struct RecordingBackend { shader_fills: usize, /// One `(radians, pivot)` per [`Op::Rotate`], in op order. rotations: Vec<(f32, Point2D)>, + /// One `(scale, pivot)` per [`Op::Scale`], in op order. + scales: Vec<(Point2D, Point2D)>, /// One color per [`Op::Stroke`], in op order. stroke_colors: Vec, + /// Whether each stroke was emitted through `stroke_line`. + stroke_is_line: Vec, + stroke_rects: Vec<(Rect, Color)>, + stroke_lines: Vec<(Point2D, Point2D, Color)>, } impl crate::RenderBackend for RecordingBackend { @@ -66,9 +72,11 @@ impl crate::RenderBackend for RecordingBackend { self.rects += 1; self.ops.push(Op::Fill); } - fn stroke_rect(&mut self, _: Rect, color: Color, _: f32) { + fn stroke_rect(&mut self, rect: Rect, color: Color, _: f32) { self.strokes += 1; self.stroke_colors.push(color); + self.stroke_is_line.push(false); + self.stroke_rects.push((rect, color)); self.ops.push(Op::Stroke); } fn draw_text(&mut self, layout: &TextLayout, point: Point2D) { @@ -91,16 +99,19 @@ impl crate::RenderBackend for RecordingBackend { self.ops.push(Op::Restore); } fn translate(&mut self, _: Point2D) {} - fn scale(&mut self, _: Point2D, _: Point2D) { + fn scale(&mut self, scale: Point2D, pivot: Point2D) { + self.scales.push((scale, pivot)); self.ops.push(Op::Scale); } fn rotate(&mut self, radians: f32, pivot: Point2D) { self.rotations.push((radians, pivot)); self.ops.push(Op::Rotate); } - fn stroke_line(&mut self, _: Point2D, _: Point2D, color: Color, _: f32) { + fn stroke_line(&mut self, from: Point2D, to: Point2D, color: Color, _: f32) { self.strokes += 1; self.stroke_colors.push(color); + self.stroke_is_line.push(true); + self.stroke_lines.push((from, to, color)); self.ops.push(Op::Stroke); } fn fill_round_rect(&mut self, rect: Rect, _: f32, _: Color) { @@ -115,6 +126,7 @@ impl crate::RenderBackend for RecordingBackend { fn stroke_round_rect(&mut self, _: Rect, _: f32, color: Color, _: f32) { self.strokes += 1; self.stroke_colors.push(color); + self.stroke_is_line.push(false); self.ops.push(Op::Stroke); } fn fill_oval(&mut self, _: Rect, _: Color) { @@ -123,11 +135,13 @@ impl crate::RenderBackend for RecordingBackend { fn stroke_oval(&mut self, _: Rect, color: Color, _: f32) { self.strokes += 1; self.stroke_colors.push(color); + self.stroke_is_line.push(false); self.ops.push(Op::StrokeOval); } fn stroke_svg_path(&mut self, _: &str, _: Point2D, _: f32, color: Color, _: f32) { self.strokes += 1; self.stroke_colors.push(color); + self.stroke_is_line.push(false); self.ops.push(Op::Stroke); } fn fill_round_rect_mesh_gradient( @@ -260,6 +274,47 @@ fn sample_scene() -> LayoutScene { } } +/// A focus frame with two visible direct children, one hidden direct +/// child, and one grandchild. Hover hierarchy tests use the distinct +/// edge coordinates to prove that only immediate visible children +/// receive dashed hints. +fn hover_hierarchy_scene() -> LayoutScene { + let direct_a = leaf( + "direct-a", + NodeKind::Rect, + Rect::xywh(30.0, 40.0, 48.0, 32.0), + None, + ); + let grandchild = leaf( + "grandchild", + NodeKind::Rect, + Rect::xywh(122.0, 78.0, 24.0, 20.0), + None, + ); + let mut direct_b = SceneNode::leaf("direct-b", NodeKind::Frame); + direct_b.bounds = Rect::xywh(100.0, 40.0, 80.0, 100.0); + direct_b.children = vec![grandchild]; + let mut hidden = leaf( + "hidden-direct", + NodeKind::Rect, + Rect::xywh(188.0, 40.0, 24.0, 32.0), + None, + ); + hidden.hidden = true; + + let mut focus = SceneNode::leaf("focus", NodeKind::Frame); + focus.bounds = Rect::xywh(10.0, 10.0, 220.0, 180.0); + focus.children = vec![direct_a, direct_b, hidden]; + LayoutScene { + pages: vec![ScenePage { + id: "p".into(), + name: "Page".into(), + children: vec![focus], + }], + active_page_index: 0, + } +} + fn sample_state() -> EditorState { EditorState::sample() } @@ -350,15 +405,16 @@ fn active_node_drag_hides_selection_chrome_and_dimension_label() { } #[test] -fn active_node_drag_suppresses_stale_child_hover_outline() { +fn active_node_drag_suppresses_focus_and_child_hover_outlines() { + let _guard = crate::agent_indicator_test_support::lock(); + op_editor_core::agent_indicators::clear(); let mut state = EditorState::new(); state.doc.children = vec![named_frame_node("n1", "Frame")]; - state.set_single_selection(op_editor_core::NodeId::new("n1")); - state.editor_ui.canvas_hover_node = Some(op_editor_core::NodeId::new("n4")); + state.set_single_selection(op_editor_core::NodeId::new("n2")); + state.editor_ui.canvas_hover_node = Some(op_editor_core::NodeId::new("n1")); let scene = sample_scene(); let mut viewport = CanvasViewport::from_editor(&state, &scene); viewport.node_drag_active = true; - viewport.frame_labels.clear(); let mut backend = RecordingBackend::default(); { let mut cx = PaintCx { @@ -367,9 +423,23 @@ fn active_node_drag_suppresses_stale_child_hover_outline() { viewport.paint(&mut cx, Rect::xywh(0.0, 0.0, 800.0, 600.0)); } - assert_eq!( - backend.strokes, 1, - "dragging should suppress stale child hover outlines, leaving only the frame stroke" + assert!( + backend + .stroke_colors + .iter() + .all(|color| *color != HOVER_OUTLINE_COLOR), + "dragging should suppress both the focus outline and all direct-child hierarchy hints" + ); + let frame_label_color = backend + .texts + .iter() + .zip(backend.text_colors.iter()) + .find_map(|(text, color)| (text == "Frame").then_some(*color)) + .expect("root frame label should paint"); + assert_ne!( + frame_label_color, + viewport.theme.primary.to_jian(), + "dragging should suppress the transient root-title hover tint too" ); } @@ -958,6 +1028,33 @@ fn selected_root_frame_label_uses_primary_active_color() { assert_eq!(color, viewport.theme.primary); } +#[test] +fn hovered_root_frame_label_uses_primary_active_color() { + let _guard = crate::agent_indicator_test_support::lock(); + op_editor_core::agent_indicators::clear(); + let scene = sample_scene(); + let mut state = EditorState::new(); + state.doc.children = vec![named_frame_node("n1", "Frame")]; + state.editor_ui.canvas_hover_node = Some(op_editor_core::NodeId::new("n1")); + let viewport = CanvasViewport::from_editor(&state, &scene); + let mut backend = RecordingBackend::default(); + { + let mut cx = PaintCx { + backend: &mut backend, + }; + viewport.paint(&mut cx, Rect::xywh(0.0, 0.0, 800.0, 600.0)); + } + + let color = backend + .texts + .iter() + .zip(backend.text_colors.iter()) + .find_map(|(text, color)| (text == "Frame").then_some(*color)) + .expect("root frame label should paint"); + + assert_eq!(color, viewport.theme.primary.to_jian()); +} + #[test] fn frame_label_paint_matches_roots_linearly() { let _guard = crate::agent_indicator_test_support::lock(); @@ -1108,6 +1205,130 @@ const SELECTION_BLUE: Color = Color { a: 1.0, }; +fn line_lies_on_rect_edge(from: Point2D, to: Point2D, rect: Rect) -> bool { + const EPSILON: f32 = 0.01; + let left = rect.origin.x; + let right = rect.origin.x + rect.size.x; + let top = rect.origin.y; + let bottom = rect.origin.y + rect.size.y; + let in_x = |x: f32| x >= left - EPSILON && x <= right + EPSILON; + let in_y = |y: f32| y >= top - EPSILON && y <= bottom + EPSILON; + let same = |a: f32, b: f32| (a - b).abs() <= EPSILON; + ((same(from.y, top) && same(to.y, top)) || (same(from.y, bottom) && same(to.y, bottom))) + && in_x(from.x) + && in_x(to.x) + || ((same(from.x, left) && same(to.x, left)) || (same(from.x, right) && same(to.x, right))) + && in_y(from.y) + && in_y(to.y) +} + +#[test] +fn hover_focus_is_solid_and_only_direct_visible_children_are_dashed() { + let _guard = crate::agent_indicator_test_support::lock(); + op_editor_core::agent_indicators::clear(); + let scene = hover_hierarchy_scene(); + let mut state = EditorState::new(); + state.editor_ui.canvas_hover_node = Some(op_editor_core::NodeId::new("focus")); + let viewport = CanvasViewport::from_editor(&state, &scene); + let mut backend = RecordingBackend::default(); + { + let mut cx = PaintCx { + backend: &mut backend, + }; + viewport.paint(&mut cx, Rect::xywh(0.0, 0.0, 300.0, 240.0)); + } + + let focus_solids: Vec = backend + .stroke_rects + .iter() + .filter_map(|(rect, color)| (*color == HOVER_OUTLINE_COLOR).then_some(*rect)) + .collect(); + assert_eq!( + focus_solids, + vec![Rect::xywh(10.0, 10.0, 220.0, 180.0)], + "an unselected hierarchy focus should paint one solid outline" + ); + + let direct_a = Rect::xywh(30.0, 40.0, 48.0, 32.0); + let direct_b = Rect::xywh(100.0, 40.0, 80.0, 100.0); + let child_lines: Vec<(Point2D, Point2D)> = backend + .stroke_lines + .iter() + .filter_map(|(from, to, color)| (*color == HOVER_OUTLINE_COLOR).then_some((*from, *to))) + .collect(); + assert!( + !child_lines.is_empty(), + "direct children should paint dashed hints" + ); + assert!(child_lines + .iter() + .any(|(from, to)| line_lies_on_rect_edge(*from, *to, direct_a))); + assert!(child_lines + .iter() + .any(|(from, to)| line_lies_on_rect_edge(*from, *to, direct_b))); + assert!( + child_lines.iter().all(|(from, to)| { + line_lies_on_rect_edge(*from, *to, direct_a) + || line_lies_on_rect_edge(*from, *to, direct_b) + }), + "hidden direct children and visible grandchildren must not receive hierarchy hints" + ); +} + +#[test] +fn selected_hover_focus_keeps_child_hints_selection_handles_and_dimensions() { + let _guard = crate::agent_indicator_test_support::lock(); + op_editor_core::agent_indicators::clear(); + let scene = hover_hierarchy_scene(); + let mut state = EditorState::new(); + state.doc.children = vec![named_frame_node("focus", "Focus")]; + state.set_single_selection(op_editor_core::NodeId::new("focus")); + state.editor_ui.canvas_hover_node = Some(op_editor_core::NodeId::new("focus")); + let mut viewport = CanvasViewport::from_editor(&state, &scene); + viewport.selection_label = Some("220 × 180".into()); + assert_eq!( + viewport.hovered.as_deref(), + Some("focus"), + "selected nodes must remain eligible as hierarchy hover focus" + ); + + let mut backend = RecordingBackend::default(); + { + let mut cx = PaintCx { + backend: &mut backend, + }; + viewport.paint(&mut cx, Rect::xywh(0.0, 0.0, 300.0, 240.0)); + } + + assert!( + backend + .stroke_rects + .iter() + .all(|(_, color)| *color != HOVER_OUTLINE_COLOR), + "a selected focus should not duplicate its solid outline" + ); + assert!( + backend + .stroke_lines + .iter() + .any(|(_, _, color)| *color == HOVER_OUTLINE_COLOR), + "a selected focus should still expose dashed direct-child hints" + ); + assert!( + backend + .stroke_colors + .iter() + .filter(|color| **color == viewport.theme.primary) + .count() + >= 8, + "single selection handles should still paint above hierarchy hover" + ); + assert!( + backend.texts.iter().any(|text| text == "220 × 180"), + "the selected dimensions capsule should remain visible" + ); +} + /// Replay the recorded op stream up to the first stroke painted in /// `color`, tracking the save/restore transform stack, and return the /// `(radians, pivot)` rotations active at that stroke. `None` when no @@ -1184,6 +1405,59 @@ fn active_rotations_at_first_oval_stroke( None } +#[derive(Clone, Default)] +struct ActiveTransforms { + rotations: Vec<(f32, Point2D)>, + scales: Vec<(Point2D, Point2D)>, +} + +fn active_transforms_at_first_line_stroke( + backend: &RecordingBackend, + color: Color, +) -> Option { + let mut stroke_i = 0usize; + let mut rot_i = 0usize; + let mut scale_i = 0usize; + let mut stack = vec![ActiveTransforms::default()]; + for op in &backend.ops { + match op { + Op::Save => { + let top = stack.last().cloned().unwrap_or_default(); + stack.push(top); + } + Op::Restore => { + stack.pop(); + } + Op::Scale => { + stack + .last_mut() + .expect("unbalanced save/restore") + .scales + .push(backend.scales[scale_i]); + scale_i += 1; + } + Op::Rotate => { + stack + .last_mut() + .expect("unbalanced save/restore") + .rotations + .push(backend.rotations[rot_i]); + rot_i += 1; + } + Op::Stroke | Op::StrokeOval => { + let is_match = + backend.stroke_is_line[stroke_i] && backend.stroke_colors[stroke_i] == color; + if is_match { + return stack.last().cloned(); + } + stroke_i += 1; + } + _ => {} + } + } + None +} + #[test] fn hover_outline_rotates_with_rotated_frame() { let _guard = crate::agent_indicator_test_support::lock(); @@ -1267,6 +1541,47 @@ fn hover_outline_on_child_applies_ancestor_rotation() { ); } +#[test] +fn direct_child_hover_hint_replays_parent_and_child_rotation_flip_chain() { + let _guard = crate::agent_indicator_test_support::lock(); + op_editor_core::agent_indicators::clear(); + let mut scene = hover_hierarchy_scene(); + let focus = &mut scene.pages[0].children[0]; + let rotation = 0.4_f32; + focus.rotation = rotation; + focus.flip_y = true; + // Children paint in reverse order; the hidden child is skipped, so + // direct-b supplies the first dashed hierarchy stroke. + focus.children[1].flip_x = true; + + let mut state = EditorState::new(); + state.editor_ui.canvas_hover_node = Some(op_editor_core::NodeId::new("focus")); + let viewport = CanvasViewport::from_editor(&state, &scene); + let mut backend = RecordingBackend::default(); + { + let mut cx = PaintCx { + backend: &mut backend, + }; + viewport.paint(&mut cx, Rect::xywh(0.0, 0.0, 300.0, 240.0)); + } + + let transforms = active_transforms_at_first_line_stroke(&backend, HOVER_OUTLINE_COLOR) + .expect("a direct child should paint dashed hierarchy strokes"); + assert_eq!( + transforms.rotations.len(), + 1, + "the child hint should inherit the focus rotation once" + ); + assert!((transforms.rotations[0].0 - rotation).abs() < 1e-4); + assert_eq!( + transforms.scales.len(), + 2, + "the child hint should replay both the focus and child flips" + ); + assert_eq!(transforms.scales[0].0, Point2D::new(1.0, -1.0)); + assert_eq!(transforms.scales[1].0, Point2D::new(-1.0, 1.0)); +} + #[test] fn selection_overlay_on_child_applies_ancestor_rotation() { let _guard = crate::agent_indicator_test_support::lock(); diff --git a/crates/op-editor-ui/src/widgets/property_panel.rs b/crates/op-editor-ui/src/widgets/property_panel.rs index 3f0a9ab45..9ec9731d7 100644 --- a/crates/op-editor-ui/src/widgets/property_panel.rs +++ b/crates/op-editor-ui/src/widgets/property_panel.rs @@ -271,6 +271,41 @@ impl PropertyPanel { Self::for_selection_at(state, 0) } + /// Build the panel and replace a single selection's displayed W/H with + /// its layout-resolved canvas size. This matters for Fill/Hug nodes: the + /// canonical node stores a sizing keyword, while the inspector must show + /// the concrete size the user is about to freeze by typing a number. + pub fn for_selection_with_scene( + state: &EditorState, + scene: &crate::layout_scene::LayoutScene, + ) -> Option { + Self::for_selection_at_with_scene(state, scene, 0) + } + + /// Clocked variant of [`Self::for_selection_with_scene`]. + pub fn for_selection_at_with_scene( + state: &EditorState, + scene: &crate::layout_scene::LayoutScene, + now_ms: u64, + ) -> Option { + let mut panel = Self::for_selection_at(state, now_ms)?; + if state.selection_count() == 1 { + if let Some(node) = scene + .active_page() + .and_then(|page| page.find(state.selection.anchor.as_str())) + { + let bounds = node.aggregate_bounds(); + if bounds.size.x.is_finite() && bounds.size.x >= 0.0 { + panel.snapshot.width = bounds.size.x.round() as i32; + } + if bounds.size.y.is_finite() && bounds.size.y >= 0.0 { + panel.snapshot.height = bounds.size.y.round() as i32; + } + } + } + Some(panel) + } + /// 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. 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 e75072e2f..2f705891f 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 @@ -117,38 +117,21 @@ pub fn editable_input_rects( } if visible.size_options { y += SECTION_HEADER_HEIGHT; - // Mirror paint_size_section: omit the W/H hit-rect when its - // dimension is fill/hug, and reflow H into the left slot when W - // is hidden — but keep the row's vertical advance fixed so later - // sections don't shift. (TS size-section.tsx: input rendered - // only when the dimension is a concrete number.) - let w_left = Point2D::new(x0 + PAD_X, y); - let h_right = Point2D::new(x0 + PAD_X + half_w + 8.0, y); - let w_visible = !visible.size_fill_width && !visible.size_hug_width; - let h_visible = !visible.size_fill_height && !visible.size_hug_height; - if w_visible { - rects.push(( - PropertyFocus::SizeW, - Rect { - origin: w_left, - size: Point2D::new(half_w, INPUT_HEIGHT), - }, - )); - } - if h_visible { - rects.push(( - PropertyFocus::SizeH, - Rect { - origin: if w_visible { h_right } else { w_left }, - size: Point2D::new(half_w, INPUT_HEIGHT), - }, - )); - } - // Collapse the input row when both dimensions are fill/hug (same - // rule as paint_size_section) so the checkboxes shift up. - if w_visible || h_visible { - y += INPUT_HEIGHT + 10.0; - } + rects.push(( + PropertyFocus::SizeW, + Rect { + origin: Point2D::new(x0 + PAD_X, y), + size: Point2D::new(half_w, INPUT_HEIGHT), + }, + )); + rects.push(( + PropertyFocus::SizeH, + Rect { + origin: Point2D::new(x0 + PAD_X + half_w + 8.0, y), + size: Point2D::new(half_w, INPUT_HEIGHT), + }, + )); + y += INPUT_HEIGHT + 10.0; let check_h = 22.0; y += check_h * if visible.clip_content { 3.0 } else { 2.0 }; y += 12.0; 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 8ea07628b..c4573148f 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_layout.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_layout.rs @@ -345,14 +345,9 @@ pub fn action_button_rects_with_fill_picker( if visible.size_options { y += SECTION_HEADER_HEIGHT; - // The W/H input row collapses when both dimensions are fill/hug - // (matches paint + editable_input_rects), so the size checkbox - // rects below shift up by the row height. - let w_visible = !visible.size_fill_width && !visible.size_hug_width; - let h_visible = !visible.size_fill_height && !visible.size_hug_height; - if w_visible || h_visible { - y += INPUT_HEIGHT + 10.0; - } + // W/H remain editable in every sizing mode. Committing a number + // switches only that axis back to fixed sizing. + y += INPUT_HEIGHT + 10.0; let row_h = 22.0; out.push(( PropertyPanelAction::ToggleSizeFillWidth, diff --git a/crates/op-editor-ui/src/widgets/property_panel_sections.rs b/crates/op-editor-ui/src/widgets/property_panel_sections.rs index 6a6174db4..37ed08379 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_sections.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_sections.rs @@ -657,53 +657,36 @@ pub fn paint_size_section( origin: Point2D::new(x + PAD_X + half_w + 8.0, y), size: Point2D::new(half_w, INPUT_HEIGHT), }; - // Hide the W/H box entirely when its dimension is fill/hug — - // matching TS size-section.tsx, which renders the NumberInput only - // when the dimension is a concrete number. Visible dimensions flow - // left-to-right, so when W is hidden, H slides into the left slot - // (no dangling empty half). The fixed `y += INPUT_HEIGHT + 10.0` - // below keeps the row height (and every later section's offset) - // unchanged regardless of how many boxes paint. - let w_visible = !flags.fill_width && !flags.hug_width; - let h_visible = !flags.fill_height && !flags.hug_height; - if w_visible { - let w_value = snapshot.width.to_string(); - paint_input_with_prefix_focused_state( - cx, - theme, - w_rect, - "W", - edit.value_for(PropertyFocus::SizeW, &w_value), - edit.focus == Some(PropertyFocus::SizeW), - edit.caret_at(PropertyFocus::SizeW), - edit.select_all_at(PropertyFocus::SizeW), - edit.input_at(PropertyFocus::SizeW), - edit.now_ms, - ); - } - if h_visible { - let h_value = snapshot.height.to_string(); - let h_target = if w_visible { h_rect } else { w_rect }; - paint_input_with_prefix_focused_state( - cx, - theme, - h_target, - "H", - edit.value_for(PropertyFocus::SizeH, &h_value), - edit.focus == Some(PropertyFocus::SizeH), - edit.caret_at(PropertyFocus::SizeH), - edit.select_all_at(PropertyFocus::SizeH), - edit.input_at(PropertyFocus::SizeH), - edit.now_ms, - ); - } - // Collapse the whole input row when BOTH dimensions are fill/hug — - // the section shrinks up so the checkboxes sit under the label with - // no dangling empty row. `size_input_row_h` keeps the layout - // walkers in lockstep with this advance. - if w_visible || h_visible { - y += INPUT_HEIGHT + 10.0; - } + // Sizing mode and the numeric editor are complementary. Fill / Hug + // stays selected below while this row exposes the resolved snapshot + // size; committing a number replaces that axis with fixed sizing. + let w_value = snapshot.width.to_string(); + paint_input_with_prefix_focused_state( + cx, + theme, + w_rect, + "W", + edit.value_for(PropertyFocus::SizeW, &w_value), + edit.focus == Some(PropertyFocus::SizeW), + edit.caret_at(PropertyFocus::SizeW), + edit.select_all_at(PropertyFocus::SizeW), + edit.input_at(PropertyFocus::SizeW), + edit.now_ms, + ); + let h_value = snapshot.height.to_string(); + paint_input_with_prefix_focused_state( + cx, + theme, + h_rect, + "H", + edit.value_for(PropertyFocus::SizeH, &h_value), + edit.focus == Some(PropertyFocus::SizeH), + edit.caret_at(PropertyFocus::SizeH), + edit.select_all_at(PropertyFocus::SizeH), + edit.input_at(PropertyFocus::SizeH), + edit.now_ms, + ); + y += INPUT_HEIGHT + 10.0; let row_h = 22.0; paint_check_row( cx, diff --git a/crates/op-editor-ui/src/widgets/property_panel_tests.rs b/crates/op-editor-ui/src/widgets/property_panel_tests.rs index ab4be7f5c..90b89b2b0 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_tests.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_tests.rs @@ -6,7 +6,8 @@ use super::property_panel::{PropertyPanel, PropertyPanelAction}; use super::property_panel_sections as sections; -use super::property_panel_test_support::{state_from, visible_for}; +use super::property_panel_test_support::{state_from, visible_for, CountingBackend}; +use crate::layout_scene::{LayoutScene, NodeKind, SceneNode, ScenePage}; use crate::widgets::{PaintCx, Widget}; use crate::{Color, Point2D, Rect, TextLayout}; use jian_ops_schema::variable::{VariableKind, VariableScalar}; @@ -59,6 +60,62 @@ fn for_selection_with_real_node_builds_snapshot() { assert_eq!(panel.snapshot.height, 28); } +#[test] +fn scene_aware_panel_reports_resolved_fill_and_hug_dimensions() { + let mut state = state_from( + r##"{ "version": "0.8.0", "children": [ + {"type":"frame","id":"ff","name":"Frame", + "width":"fill_container","height":"fit_content","children":[]} + ]}"##, + ); + state.set_single_selection(NodeId::new("ff")); + let mut resolved = SceneNode::leaf("ff", NodeKind::Frame); + resolved.bounds = Rect::xywh(0.0, 0.0, 390.0, 710.0); + let scene = LayoutScene { + pages: vec![ScenePage { + id: "p1".into(), + name: "Page 1".into(), + children: vec![resolved], + }], + active_page_index: 0, + }; + + let panel = PropertyPanel::for_selection_with_scene(&state, &scene) + .expect("scene-aware fill/hug panel"); + assert_eq!((panel.snapshot.width, panel.snapshot.height), (390, 710)); + assert!(panel.snapshot.size_fill_width); + assert!(panel.snapshot.size_hug_height); +} + +#[test] +fn scene_aware_panel_keeps_unbounded_group_aggregate_dimensions() { + let mut state = state_from( + r##"{ "version": "0.8.0", "children": [ + {"type":"group","id":"g","name":"Group","children":[ + {"type":"rectangle","id":"child","x":10,"y":20, + "width":70,"height":30} + ]} + ]}"##, + ); + state.set_single_selection(NodeId::new("g")); + let mut child = SceneNode::leaf("child", NodeKind::Rect); + child.bounds = Rect::xywh(10.0, 20.0, 70.0, 30.0); + let mut group = SceneNode::leaf("g", NodeKind::Group); + group.children = vec![child]; + let scene = LayoutScene { + pages: vec![ScenePage { + id: "p1".into(), + name: "Page 1".into(), + children: vec![group], + }], + active_page_index: 0, + }; + + let panel = + PropertyPanel::for_selection_with_scene(&state, &scene).expect("scene-aware group panel"); + assert_eq!((panel.snapshot.width, panel.snapshot.height), (70, 30)); +} + #[test] fn for_selection_without_selection_returns_none() { let state = EditorState::new(); @@ -450,7 +507,7 @@ fn color_variable_picker_emits_bind_and_unbind_rows() { } #[test] -fn fill_width_hides_the_w_input_but_keeps_h_and_row_height() { +fn fill_width_keeps_both_numeric_inputs_visible_and_hittable() { use op_editor_core::PropertyFocus; let fill = { let mut s = state_from( @@ -464,68 +521,27 @@ fn fill_width_hides_the_w_input_but_keeps_h_and_row_height() { PropertyPanel::for_selection(&s).expect("fill-width frame panel") }; assert!(fill.snapshot.size_fill_width, "width sizing should be fill"); - assert!( - !fill.snapshot.size_fill_height, - "height stays a concrete number" - ); let rect = Rect { origin: Point2D::new(0.0, 0.0), size: Point2D::new(280.0, 1200.0), }; let fill_rects = sections::editable_input_rects(rect, visible_for(&fill), &fill.snapshot.fills); - // W is omitted (fill); H remains (numeric). - assert!( - !fill_rects.iter().any(|(f, _)| *f == PropertyFocus::SizeW), - "SizeW must be hidden when width is fill" - ); - let fill_h = fill_rects - .iter() - .find(|(f, _)| *f == PropertyFocus::SizeH) - .map(|(_, r)| *r) - .expect("SizeH must remain"); - - // A fixed-width frame keeps both — and SizeH sits at the SAME y, so - // hiding W never collapses the row / shifts later sections. - let fixed = { - let mut s = state_from( - r##"{ "version": "0.8.0", "children": [ - {"type":"frame","id":"ff","name":"Frame", - "x":40,"y":40,"width":360,"height":240, - "layout":"vertical","children":[]} - ]}"##, + for focus in [PropertyFocus::SizeW, PropertyFocus::SizeH] { + let target = fill_rects + .iter() + .find(|(candidate, _)| *candidate == focus) + .map(|(_, rect)| *rect) + .expect("fill sizing must keep the numeric input"); + let center = Point2D::new( + target.origin.x + target.size.x / 2.0, + target.origin.y + target.size.y / 2.0, ); - s.set_single_selection(NodeId::new("ff")); - PropertyPanel::for_selection(&s).expect("fixed-width frame panel") - }; - let fixed_rects = - sections::editable_input_rects(rect, visible_for(&fixed), &fixed.snapshot.fills); - assert!( - fixed_rects.iter().any(|(f, _)| *f == PropertyFocus::SizeW), - "fixed width keeps SizeW" - ); - let fixed_w = fixed_rects - .iter() - .find(|(f, _)| *f == PropertyFocus::SizeW) - .map(|(_, r)| *r) - .expect("SizeW present for fixed width"); - let fixed_h = fixed_rects - .iter() - .find(|(f, _)| *f == PropertyFocus::SizeH) - .map(|(_, r)| *r) - .expect("SizeH present for fixed width"); - assert!( - (fill_h.origin.y - fixed_h.origin.y).abs() < 0.01, - "hiding W must not move H's row (row height preserved)" - ); - // With W hidden, H reflows into the (now-empty) LEFT slot. - assert!( - (fill_h.origin.x - fixed_w.origin.x).abs() < 0.01, - "H must slide into the left slot when W is hidden" - ); + assert_eq!(fill.hit_test(rect, center), Some(focus)); + } } #[test] -fn both_dimensions_fill_collapses_the_size_input_row() { +fn both_dimensions_fill_keep_the_size_input_row() { use op_editor_core::PropertyFocus; let rect = Rect { origin: Point2D::new(0.0, 0.0), @@ -543,9 +559,6 @@ fn both_dimensions_fill_collapses_the_size_input_row() { s.set_single_selection(NodeId::new("ff")); PropertyPanel::for_selection(&s).expect("frame panel") }; - // The size checkboxes sit BELOW the W/H input row. When both - // dimensions are fill, the whole input row collapses, so the first - // checkbox (填充宽度) shifts up by exactly one input row. let chk_y = |p: &PropertyPanel| { sections::action_button_rects_with_fill_picker( rect, @@ -565,25 +578,59 @@ fn both_dimensions_fill_collapses_the_size_input_row() { .map(|(_, r)| r.origin.y) .expect("fill-width checkbox rect") }; - // One dimension numeric (row present) vs both fill (row collapsed). let one = panel_for("\"fill_container\"", "240"); let both = panel_for("\"fill_container\"", "\"fill_container\""); assert!(one.snapshot.size_fill_width && both.snapshot.size_fill_height); - // INPUT_HEIGHT (30) + 10 gap = 40 px of collapse. - let delta = chk_y(&one) - chk_y(&both); assert!( - (delta - 40.0).abs() < 0.01, - "both-hidden must collapse the input row (~40px up), got {delta}" + (chk_y(&one) - chk_y(&both)).abs() < 0.01, + "checkbox rows must not jump when both axes use fill" ); - // Neither W nor H emits a focus rect when both are hidden. let both_inputs = sections::editable_input_rects(rect, visible_for(&both), &both.snapshot.fills); - assert!( - !both_inputs - .iter() - .any(|(f, _)| matches!(f, PropertyFocus::SizeW | PropertyFocus::SizeH)), - "no W/H hit-rect when both dimensions are fill" + for focus in [PropertyFocus::SizeW, PropertyFocus::SizeH] { + assert!( + both_inputs.iter().any(|(candidate, _)| *candidate == focus), + "both fill axes must still emit the numeric hit rect" + ); + } +} + +#[test] +fn fill_and_hug_inputs_paint_snapshot_size_and_numeric_commit_makes_fixed() { + use op_editor_core::PropertyFocus; + let mut state = state_from( + r##"{ "version": "0.8.0", "children": [ + {"type":"frame","id":"ff","name":"Frame", + "x":40,"y":40,"width":"fill_container","height":"fit_content", + "layout":"vertical","children":[ + {"type":"rectangle","id":"child","x":0,"y":0, + "width":180,"height":90} + ]} + ]}"##, ); + state.set_single_selection(NodeId::new("ff")); + let panel = PropertyPanel::for_selection(&state).expect("fill/hug frame panel"); + assert_eq!((panel.snapshot.width, panel.snapshot.height), (180, 90)); + assert!(panel.snapshot.size_fill_width && panel.snapshot.size_hug_height); + + let mut backend = CountingBackend::default(); + let mut cx = PaintCx { + backend: &mut backend, + }; + panel.paint( + &mut cx, + Rect { + origin: Point2D::ZERO, + size: Point2D::new(280.0, 1200.0), + }, + ); + assert!(backend.texts.iter().any(|text| text == "180")); + assert!(backend.texts.iter().any(|text| text == "90")); + assert!(state.commit_property_edit(PropertyFocus::SizeW, 220.0)); + assert!(state.commit_property_edit(PropertyFocus::SizeH, 130.0)); + let fixed = PropertyPanel::for_selection(&state).expect("fixed frame panel"); + assert_eq!((fixed.snapshot.width, fixed.snapshot.height), (220, 130)); + assert!(!fixed.snapshot.size_fill_width && !fixed.snapshot.size_hug_height); } #[test] diff --git a/crates/op-editor-ui/src/widgets/property_panel_visibility.rs b/crates/op-editor-ui/src/widgets/property_panel_visibility.rs index adb764744..6f12aa3f7 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_visibility.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_visibility.rs @@ -201,9 +201,8 @@ pub struct VisibleSections { pub layout_justify: LayoutJustifyValue, pub layout_align: LayoutAlignValue, pub size_options: bool, - /// Per-dimension sizing masks — when set, the matching W/H input is - /// hidden (fill/hug replaces the numeric field, TS size-section - /// parity). Read by `editable_input_rects` to gate the hit-rect. + /// Per-dimension sizing modes. Numeric W/H editors remain visible + /// and show the resolved snapshot size in every mode. pub size_fill_width: bool, pub size_fill_height: bool, pub size_hug_width: bool, diff --git a/crates/op-host-native/src/widget_host.rs b/crates/op-host-native/src/widget_host.rs index c62f4ee49..a5c53d5db 100644 --- a/crates/op-host-native/src/widget_host.rs +++ b/crates/op-host-native/src/widget_host.rs @@ -424,6 +424,11 @@ pub(in crate::widget_host) struct HandleDragState { pub(in crate::widget_host) start_screen_x: f32, pub(in crate::widget_host) start_screen_y: f32, pub(in crate::widget_host) start_bounds: Rect, + /// Authored parent-relative origin at press time. Kept separate from the + /// resolved absolute scene bounds so left/top drags of nested free nodes + /// do not jump into document coordinates. + pub(in crate::widget_host) start_authored_x: Option, + pub(in crate::widget_host) start_authored_y: Option, } /// Active rotation drag — `center_screen` is the screen-space diff --git a/crates/op-host-native/src/widget_host/canvas_select_drag.rs b/crates/op-host-native/src/widget_host/canvas_select_drag.rs index b277cfa2a..6cbf2e02d 100644 --- a/crates/op-host-native/src/widget_host/canvas_select_drag.rs +++ b/crates/op-host-native/src/widget_host/canvas_select_drag.rs @@ -1,4 +1,4 @@ -//! Canvas selection semantics (enter-group on double-click) + the +//! Canvas selection semantics (one-level drill on double-click) + the //! node-drag release commit (auto-layout reorder / cross-container //! reparenting). //! @@ -34,51 +34,64 @@ struct ContainerDropCandidate { } impl WidgetHostNative { - /// Select-tool press on a canvas node (the deepest hit). Routes - /// double-clicks to enter-group / text-edit, then applies the TS - /// click-resolution rules (`skia-interaction.ts:368-405`): promote - /// a nested hit to its outermost frame/group ancestor (unit - /// selection) unless the user entered that container; a hit on a - /// child of an already-selected node keeps the selection. Always - /// consumes the press. + /// Select-tool press over a resolved root-to-deepest hit path. + /// A plain click selects the current level's primary node; a + /// double-click drills exactly one level to the child under the + /// pointer. The primary then becomes the entered sibling scope. + /// This is the same hierarchy Pencil exposes with a solid primary + /// hover outline and dashed direct-child guides. pub(in crate::widget_host) fn apply_canvas_node_press( &mut self, - ec_id: NodeId, + hit_path: Vec, x: f32, y: f32, text_edit_was_active: bool, viewport_height: f32, ) -> bool { - use op_editor_core::selection_resolve::{ - resolve_canvas_selection_target, SelectionResolution, + let Some(deepest) = hit_path.last().cloned() else { + return false; }; - // Canvas double-click: 400 ms same-node → enter a selected - // container, else text-edit on Text nodes. + let Some(targets) = op_editor_core::selection_resolve::resolve_canvas_depth_targets( + &hit_path, + self.editor_state.editor_ui.entered_container.as_ref(), + ) else { + return false; + }; + // Canvas double-click: 400 ms over the same deepest geometry. + // Shift and an existing multi-selection deliberately disable + // drill-down so a set-edit gesture cannot unexpectedly enter a + // container. let is_double = matches!( &self.editor_state.editor_ui.last_canvas_click, - Some((prev, t)) if *prev == ec_id + Some((prev, t)) if *prev == deepest && self.now_ms.saturating_sub(*t) < 400 - ); - self.editor_state.editor_ui.last_canvas_click = Some((ec_id.clone(), self.now_ms)); + ) && !self.shift_held + && self.editor_state.selection_count() <= 1; + self.editor_state.editor_ui.last_canvas_click = if self.shift_held || is_double { + None + } else { + Some((deepest, self.now_ms)) + }; if is_double && !text_edit_was_active { - // TS dblclick order (skia-interaction.ts:1279-1296): - // entering a selected frame/group wins over the - // text-edit fallback. - if self.try_enter_selected_container_on_double_click(&ec_id, viewport_height) { + if let Some(secondary) = targets.secondary_under_pointer { + self.editor_state.set_single_selection(secondary.clone()); + self.editor_state.editor_ui.entered_container = Some(targets.primary); + // Rebase the stationary-pointer hover immediately. The + // native 3 px probe cache would otherwise retain the old + // level until the mouse moved again. + self.editor_state.editor_ui.canvas_hover_node = Some(secondary); + self.last_hover_probe = None; + self.scroll_layer_panel_selection_into_view(viewport_height); + self.mark_dirty(); return true; } - if self.editor_state.start_text_edit(ec_id.clone()) { + if self.editor_state.start_text_edit(targets.primary.clone()) { self.editor_state.ui.text_edit_input.touch(self.now_ms); self.mark_dirty(); return true; } } - let resolved = resolve_canvas_selection_target( - self.editor_state.active_children(), - &ec_id, - self.editor_state.editor_ui.entered_container.as_ref(), - &self.editor_state.selection.set, - ); + let target = targets.primary; let fresh_drag = NodeDragState { last_screen_x: x, last_screen_y: y, @@ -91,77 +104,45 @@ impl WidgetHostNative { }; self.option_drag_source_ids.clear(); if self.shift_held { - // Shift+click toggles set membership of the resolved - // target; a child of a selected node keeps the set and - // just drags it. - if let SelectionResolution::Select(target) = resolved { - let was_in_set = self.editor_state.is_selected(&target); - self.editor_state.toggle_selection(target); - self.editor_state.sync_entered_container_with_selection(); - if !was_in_set { - self.node_drag = Some(fresh_drag); - } - } else { + let was_in_set = self.editor_state.is_selected(&target); + self.editor_state.toggle_selection(target); + if !was_in_set { self.node_drag = Some(fresh_drag); } + if self + .editor_state + .editor_ui + .entered_container + .as_ref() + .is_some_and(|entered| !hit_path.contains(entered)) + { + self.editor_state.editor_ui.entered_container = None; + } self.scroll_layer_panel_selection_into_view(viewport_height); return true; } - // Plain click: keep a multi-set when clicking inside it (TS - // parity), else single-select the resolved target. - if let SelectionResolution::Select(target) = resolved { - let already_in_set = self.editor_state.is_selected(&target); - if !already_in_set || self.editor_state.selection_count() == 1 { - self.editor_state.set_single_selection(target); - } + // Plain click selects the solid-outline primary. Clicking a + // sibling inside an entered scope therefore moves selection at + // that level instead of dragging an arbitrary deepest leaf. + let already_in_set = self.editor_state.is_selected(&target); + if !already_in_set || self.editor_state.selection_count() == 1 { + self.editor_state.set_single_selection(target); + } + if self + .editor_state + .editor_ui + .entered_container + .as_ref() + .is_some_and(|entered| !hit_path.contains(entered)) + { + self.editor_state.editor_ui.entered_container = None; } - self.editor_state.sync_entered_container_with_selection(); self.scroll_layer_panel_selection_into_view(viewport_height); self.editor_state.commit_history(); self.node_drag = Some(fresh_drag); true } - /// TS double-click enter-group (`skia-interaction.ts:1279-1294`): - /// when exactly one frame/group with children is selected, a - /// double-click selects the deepest hit under the cursor; when - /// that hit is inside the selected container, the container - /// becomes the entered context (promotion then stops at its - /// children). Returns `true` when the double-click was consumed. - pub(in crate::widget_host) fn try_enter_selected_container_on_double_click( - &mut self, - deepest: &NodeId, - viewport_height: f32, - ) -> bool { - if self.editor_state.selection_count() != 1 { - return false; - } - let anchor = self.editor_state.selection.anchor.clone(); - if !anchor.is_real() || anchor == *deepest { - return false; - } - let children = self.editor_state.active_children(); - let Some(node) = op_editor_core::walkers::find_node(children, &anchor) else { - return false; - }; - if !matches!(node, PenNode::Frame(_) | PenNode::Group(_)) { - return false; - } - if node.children().map(|c| c.is_empty()).unwrap_or(true) { - return false; - } - let enters = - op_editor_core::selection_resolve::is_strict_descendant(children, &anchor, deepest); - if enters { - self.editor_state.editor_ui.entered_container = Some(anchor); - } - self.editor_state.set_single_selection(deepest.clone()); - self.editor_state.sync_entered_container_with_selection(); - self.scroll_layer_panel_selection_into_view(viewport_height); - self.mark_dirty(); - true - } - pub(in crate::widget_host) fn update_node_drag_preview(&mut self, drag: &NodeDragState) { let id = self.editor_state.selection.anchor.clone(); let next = if id.is_real() { diff --git a/crates/op-host-native/src/widget_host/canvas_select_drag_tests.rs b/crates/op-host-native/src/widget_host/canvas_select_drag_tests.rs index b2165bd11..c141d3fc0 100644 --- a/crates/op-host-native/src/widget_host/canvas_select_drag_tests.rs +++ b/crates/op-host-native/src/widget_host/canvas_select_drag_tests.rs @@ -30,6 +30,23 @@ const NESTED: &str = r#"{"version":"0.8.0","children":[ {"type":"rectangle","id":"other","name":"Other","x":650,"y":60,"width":40,"height":40} ]}"#; +/// Four selection depths at the shared probe point: root > l1 > l2 > l3. +/// Every nested node contains doc (470, 130), while `other` remains available +/// for multi-selection and outside-scope tests. +const FOUR_LEVEL: &str = r#"{"version":"0.8.0","children":[ + {"type":"frame","id":"root","name":"Root","x":400,"y":60,"width":240,"height":240, + "children":[ + {"type":"frame","id":"l1","name":"Level 1","x":20,"y":20,"width":200,"height":200, + "children":[ + {"type":"frame","id":"l2","name":"Level 2","x":20,"y":20,"width":160,"height":160, + "children":[ + {"type":"rectangle","id":"l3","name":"Level 3","x":20,"y":20,"width":60,"height":60} + ]} + ]} + ]}, + {"type":"rectangle","id":"other","name":"Other","x":700,"y":60,"width":40,"height":40} +]}"#; + /// Screen point for a doc point (zoom 1, pan 0 in a fresh host). fn screen_at(host: &WidgetHostNative, doc_x: f32, doc_y: f32) -> (f32, f32) { let (cx0, cy0, _cw, _ch) = host.canvas_region(VIEWPORT_W, VIEWPORT_H); @@ -45,44 +62,68 @@ fn release(host: &mut WidgetHostNative) { let _ = host.apply_release_with_viewport(VIEWPORT_W, VIEWPORT_H); } -// --- GAP A: promotion / enter-group / Escape --------------------------- +// --- Relative-depth selection / enter-group / Escape ------------------- #[test] -fn click_on_nested_child_promotes_to_top_level_frame() { +fn first_press_on_four_level_hit_selects_level_one() { let mut host = WidgetHostNative::new(); - seed(&mut host, NESTED); - press_doc(&mut host, 450.0, 110.0); // over `leaf` - assert_eq!(host.editor_state().selection.anchor, NodeId::new("card")); + seed(&mut host, FOUR_LEVEL); + + press_doc(&mut host, 470.0, 130.0); + + assert_eq!(host.editor_state().selection.anchor, NodeId::new("l1")); assert_eq!(host.editor_state().editor_ui.entered_container, None); } #[test] -fn click_on_child_of_selected_multi_set_keeps_the_set() { +fn click_on_selected_primary_in_multi_set_keeps_the_set() { let mut host = WidgetHostNative::new(); - seed(&mut host, NESTED); - host.editor_state_mut().selection.set = vec![NodeId::new("card"), NodeId::new("other")]; + seed(&mut host, FOUR_LEVEL); + host.editor_state_mut().selection.set = vec![NodeId::new("l1"), NodeId::new("other")]; host.editor_state_mut().selection.anchor = NodeId::new("other"); host.mark_paint_dirty_for_test(); - press_doc(&mut host, 450.0, 110.0); // child of selected `card` + press_doc(&mut host, 470.0, 130.0); assert_eq!(host.editor_state().selection_count(), 2, "set preserved"); assert!(host.node_drag.is_some(), "press still drags the set"); } #[test] -fn double_click_selected_container_enters_and_selects_child() { +fn double_click_drills_one_level_and_third_click_does_not_chain() { let mut host = WidgetHostNative::new(); - seed(&mut host, NESTED); - press_doc(&mut host, 450.0, 110.0); // first click → selects `card` + seed(&mut host, FOUR_LEVEL); + host.set_now_ms(1_000); + + press_doc(&mut host, 470.0, 130.0); release(&mut host); - assert_eq!(host.editor_state().selection.anchor, NodeId::new("card")); - press_doc(&mut host, 450.0, 110.0); // double-click (same node, <400 ms) + assert_eq!(host.editor_state().selection.anchor, NodeId::new("l1")); + + host.set_now_ms(1_200); + press_doc(&mut host, 470.0, 130.0); release(&mut host); + assert_eq!(host.editor_state().selection.anchor, NodeId::new("l2")); assert_eq!( host.editor_state().editor_ui.entered_container, - Some(NodeId::new("card")), - "double-click on the selected container enters it" + Some(NodeId::new("l1")), + "double-click enters exactly the primary level" + ); + assert_eq!( + host.editor_state().editor_ui.canvas_hover_node, + Some(NodeId::new("l2")), + "stationary hover rebases to the newly selected second level" + ); + + host.set_now_ms(1_300); + press_doc(&mut host, 470.0, 130.0); + release(&mut host); + assert_eq!( + host.editor_state().selection.anchor, + NodeId::new("l2"), + "the click after a consumed double-click must not chain into l3" + ); + assert_eq!( + host.editor_state().editor_ui.entered_container, + Some(NodeId::new("l1")) ); - assert_eq!(host.editor_state().selection.anchor, NodeId::new("leaf")); } #[test] @@ -133,23 +174,49 @@ fn blank_canvas_press_exits_the_entered_container() { #[test] fn clicking_root_frame_label_selects_that_root() { let mut host = WidgetHostNative::new(); - seed( - &mut host, - r#"{"version":"0.8.0","children":[ - {"type":"frame","id":"music","name":"Music App Home","x":400,"y":60,"width":240,"height":200, - "children":[]} - ]}"#, - ); + seed(&mut host, FOUR_LEVEL); press_doc(&mut host, 424.0, 42.0); - assert_eq!(host.editor_state().selection.anchor, NodeId::new("music")); + assert_eq!(host.editor_state().selection.anchor, NodeId::new("root")); assert!( host.node_drag.is_some(), "label press should behave like a root press" ); } +#[test] +fn cursor_hover_inside_four_level_tree_resolves_to_level_one() { + let mut host = WidgetHostNative::new(); + seed(&mut host, FOUR_LEVEL); + let _ = host.layout_scene(); + host.last_viewport_w = VIEWPORT_W; + host.last_viewport_h = VIEWPORT_H; + let (x, y) = screen_at(&host, 470.0, 130.0); + + assert!(host.apply_cursor_move(x, y)); + assert_eq!( + host.editor_state().editor_ui.canvas_hover_node, + Some(NodeId::new("l1")) + ); +} + +#[test] +fn cursor_hover_on_frame_label_resolves_to_root() { + let mut host = WidgetHostNative::new(); + seed(&mut host, FOUR_LEVEL); + let _ = host.layout_scene(); + host.last_viewport_w = VIEWPORT_W; + host.last_viewport_h = VIEWPORT_H; + let (x, y) = screen_at(&host, 424.0, 42.0); + + assert!(host.apply_cursor_move(x, y)); + assert_eq!( + host.editor_state().editor_ui.canvas_hover_node, + Some(NodeId::new("root")) + ); +} + fn overlapping_rect_stack(count: usize) -> String { let children = (0..count) .map(|i| { diff --git a/crates/op-host-native/src/widget_host/input.rs b/crates/op-host-native/src/widget_host/input.rs index c2084f7be..8b0a370b1 100644 --- a/crates/op-host-native/src/widget_host/input.rs +++ b/crates/op-host-native/src/widget_host/input.rs @@ -4,8 +4,8 @@ use super::helpers::{resize_bounds, PANEL_MAX_WIDTH, PANEL_MIN_WIDTH}; use super::{DragState, PanelResizeKind, WidgetHostNative}; use op_editor_core::codegen::CodeSelection; use op_editor_ui::widgets::{ - AIChatHit, AIChatPlaceholder, ChatResizeEdge, AI_CHAT_MAX_RATIO, AI_CHAT_MIN_HEIGHT, - AI_CHAT_MIN_WIDTH, + AIChatHit, AIChatPlaceholder, CanvasViewport, ChatResizeEdge, AI_CHAT_MAX_RATIO, + AI_CHAT_MIN_HEIGHT, AI_CHAT_MIN_WIDTH, }; use op_editor_ui::{Point2D, Rect}; @@ -195,6 +195,9 @@ impl WidgetHostNative { let total_dx = ((x - drag.press_screen_x) / zoom) as f64; let total_dy = ((y - drag.press_screen_y) / zoom) as f64; if !drag.moved { + // Once the gesture becomes a drag it cannot be the first + // half of a later double-click drill. + self.editor_state.editor_ui.last_canvas_click = None; let option_source_ids: Vec = self.editor_state.selection.set.to_vec(); if self.alt_held @@ -1037,8 +1040,20 @@ impl WidgetHostNative { let dx = (x - drag.start_screen_x) / zoom; let dy = (y - drag.start_screen_y) / zoom; let new_bounds = resize_bounds(drag.start_bounds, drag.handle, dx, dy); - self.editor_state - .set_selected_bounds(rect_to_doc_rect(new_bounds)); + let new_x = drag.handle.moves_left_edge().then(|| { + drag.start_authored_x.unwrap_or(0.0) + + f64::from(new_bounds.origin.x - drag.start_bounds.origin.x) + }); + let new_y = drag.handle.moves_top_edge().then(|| { + drag.start_authored_y.unwrap_or(0.0) + + f64::from(new_bounds.origin.y - drag.start_bounds.origin.y) + }); + self.editor_state.resize_selected_bounds( + rect_to_doc_rect(new_bounds), + drag.handle.resize_axes(), + new_x, + new_y, + ); self.mark_dirty(); return true; } @@ -1351,11 +1366,11 @@ impl WidgetHostNative { self.mark_dirty(); return true; } - // Canvas hover outline (TS `hoveredNodeId`): track the node - // under the cursor while the Select tool idles over the - // canvas. Reads the CURRENT layout scene without refreshing - // (same discipline as layer-row hover — hover must not - // rebuild a stale scene). + // Canvas hierarchy hover: resolve the current level's focus + // from the root-to-deepest scene path. Shared paint outlines + // the focus solid and all direct children dashed. Reads the + // CURRENT layout scene without refreshing (same discipline as + // layer-row hover — hover must not rebuild a stale scene). let hover_eligible = !over_topmost && matches!(self.editor_state.tool, op_editor_core::Tool::Select) && self.over_canvas(x, y, self.last_viewport_w, self.last_viewport_h); @@ -1371,12 +1386,31 @@ impl WidgetHostNative { } } self.last_hover_probe = Some((x, y)); - let (cx0, cy0) = self.canvas_origin(); - let canvas_local = Point2D::new(x - cx0, y - cy0); - let doc = self.editor_state.viewport.to_document(canvas_local); - self.layout_scene - .node_at_doc_point(doc, self.editor_state.viewport.zoom) - .map(|id| op_editor_core::NodeId::new(&id)) + let (cx0, cy0, cw, ch) = self.canvas_region(self.last_viewport_w, self.last_viewport_h); + let canvas_rect = Rect { + origin: Point2D::new(cx0, cy0), + size: Point2D::new(cw, ch), + }; + let canvas = CanvasViewport::from_editor(&self.editor_state, &self.layout_scene); + if let Some(root) = canvas.frame_label_at_point(canvas_rect, Point2D::new(x, y)) { + Some(op_editor_core::NodeId::new(root)) + } else { + let canvas_local = Point2D::new(x - cx0, y - cy0); + let doc = self.editor_state.viewport.to_document(canvas_local); + self.layout_scene + .node_path_at_doc_point(doc, self.editor_state.viewport.zoom) + .and_then(|path| { + let path = path + .into_iter() + .map(op_editor_core::NodeId::new) + .collect::>(); + op_editor_core::selection_resolve::resolve_canvas_depth_targets( + &path, + self.editor_state.editor_ui.entered_container.as_ref(), + ) + .map(|targets| targets.primary) + }) + } } else { self.last_hover_probe = None; None diff --git a/crates/op-host-native/src/widget_host/input_drag_tests.rs b/crates/op-host-native/src/widget_host/input_drag_tests.rs index f2add95c7..f44b58cd8 100644 --- a/crates/op-host-native/src/widget_host/input_drag_tests.rs +++ b/crates/op-host-native/src/widget_host/input_drag_tests.rs @@ -1,8 +1,9 @@ //! Drag/release tests split from `input_tests.rs` so each test module //! stays under the repository file-size ceiling. -use super::{NodeDragState, WidgetHostNative}; +use super::{HandleDragState, NodeDragState, WidgetHostNative}; use op_editor_core::{NodeId, PenNodeExt}; +use op_editor_ui::{widgets::SelectionHandle, Point2D, Rect}; /// Seed a host's `editor_state` from a canonical `.op` JSON snippet. fn seed(host: &mut WidgetHostNative, json: &str) { @@ -30,6 +31,153 @@ fn three_rects(boxes: [(f64, f64, f64, f64); 3], ids: [&str; 3]) -> String { ) } +#[test] +fn bottom_right_handle_resizes_only_selected_container() { + let mut host = WidgetHostNative::new(); + seed( + &mut host, + r#"{"version":"0.8.0","children":[{ + "type":"frame","id":"frame","name":"frame","x":100,"y":80, + "width":200,"height":160,"layout":"none","children":[ + {"type":"rectangle","id":"child","name":"child","x":12,"y":18, + "width":60,"height":30} + ] + }]}"#, + ); + host.editor_state_mut() + .set_single_selection(NodeId::new("frame")); + let child_before = authored_geometry(&host, "child"); + host.handle_drag = Some(HandleDragState { + handle: SelectionHandle::BottomRight, + start_screen_x: 500.0, + start_screen_y: 500.0, + start_bounds: Rect { + origin: Point2D::new(100.0, 80.0), + size: Point2D::new(200.0, 160.0), + }, + start_authored_x: Some(100.0), + start_authored_y: Some(80.0), + }); + + assert!(host.apply_cursor_move(540.0, 525.0)); + + assert_eq!( + authored_geometry(&host, "frame"), + (Some(100.0), Some(80.0), Some(240.0), Some(185.0)) + ); + assert_eq!( + authored_geometry(&host, "child"), + child_before, + "normal container resize must not scale or translate fixed descendants" + ); +} + +#[test] +fn edge_handle_freezes_only_its_axis_and_preserves_descendants() { + for (handle, move_to, expected_width, expected_height) in [ + ( + SelectionHandle::Right, + Point2D::new(550.0, 500.0), + serde_json::json!(250.0), + serde_json::json!("fit_content"), + ), + ( + SelectionHandle::Bottom, + Point2D::new(500.0, 540.0), + serde_json::json!("fill_container"), + serde_json::json!(120.0), + ), + ] { + let mut host = WidgetHostNative::new(); + seed( + &mut host, + r#"{"version":"0.8.0","children":[{ + "type":"frame","id":"frame","name":"frame","x":100,"y":80, + "width":"fill_container","height":"fit_content","layout":"vertical", + "children":[ + {"type":"rectangle","id":"child","name":"child","x":12,"y":18, + "width":60,"height":30} + ] + }]}"#, + ); + host.editor_state_mut() + .set_single_selection(NodeId::new("frame")); + let child_before = authored_geometry(&host, "child"); + host.handle_drag = Some(HandleDragState { + handle, + start_screen_x: 500.0, + start_screen_y: 500.0, + start_bounds: Rect { + origin: Point2D::new(100.0, 80.0), + size: Point2D::new(200.0, 80.0), + }, + start_authored_x: Some(100.0), + start_authored_y: Some(80.0), + }); + + assert!(host.apply_cursor_move(move_to.x, move_to.y)); + + let frame = op_editor_core::walkers::find_node( + host.editor_state().active_children(), + &NodeId::new("frame"), + ) + .expect("frame present"); + let frame_json = serde_json::to_value(frame).expect("frame serializes"); + assert_eq!(frame_json["width"], expected_width, "handle: {handle:?}"); + assert_eq!(frame_json["height"], expected_height, "handle: {handle:?}"); + assert_eq!( + authored_geometry(&host, "child"), + child_before, + "edge resize must leave fixed descendant geometry untouched; handle: {handle:?}" + ); + } +} + +#[test] +fn fill_child_reflows_to_resized_parent_without_authored_mutation() { + let mut host = WidgetHostNative::new(); + seed( + &mut host, + r#"{"version":"0.8.0","children":[{ + "type":"frame","id":"frame","name":"frame","x":100,"y":80, + "width":200,"height":160,"layout":"vertical","children":[ + {"type":"rectangle","id":"child","name":"child", + "width":"fill_container","height":30} + ] + }]}"#, + ); + host.editor_state_mut() + .set_single_selection(NodeId::new("frame")); + host.handle_drag = Some(HandleDragState { + handle: SelectionHandle::Right, + start_screen_x: 500.0, + start_screen_y: 500.0, + start_bounds: Rect { + origin: Point2D::new(100.0, 80.0), + size: Point2D::new(200.0, 160.0), + }, + start_authored_x: Some(100.0), + start_authored_y: Some(80.0), + }); + + assert!(host.apply_cursor_move(560.0, 500.0)); + host.refresh_layout_scene(); + + let child = op_editor_core::walkers::find_node( + host.editor_state().active_children(), + &NodeId::new("child"), + ) + .expect("child present"); + assert_eq!(child.width_px(), None, "Fill keyword must stay authored"); + let resolved = host + .layout_scene + .active_page() + .and_then(|page| page.find("child")) + .expect("resolved child present"); + assert_eq!(resolved.bounds.size.x, 260.0); + assert_eq!(resolved.bounds.size.y, 30.0); +} + #[test] fn anchor_press_release_without_motion_does_not_push_history() { // Codex CONCERN: a press-release on an anchor without any @@ -454,6 +602,21 @@ fn scene_node_xy(host: &WidgetHostNative, id: &str) -> (f32, f32) { (n.bounds.origin.x, n.bounds.origin.y) } +fn authored_geometry( + host: &WidgetHostNative, + id: &str, +) -> (Option, Option, Option, Option) { + let node = + op_editor_core::walkers::find_node(host.editor_state().active_children(), &NodeId::new(id)) + .expect("node present"); + ( + node.base().x, + node.base().y, + node.width_px(), + node.height_px(), + ) +} + /// Read a path anchor's `(x, y)` from the host's `editor_state.doc`. fn anchor_at(host: &WidgetHostNative, id: &str, idx: usize) -> (f64, f64) { let n = host diff --git a/crates/op-host-native/src/widget_host/paint.rs b/crates/op-host-native/src/widget_host/paint.rs index 920fe5e49..a9b2f65b7 100644 --- a/crates/op-host-native/src/widget_host/paint.rs +++ b/crates/op-host-native/src/widget_host/paint.rs @@ -226,7 +226,11 @@ impl WidgetHostNative { } // 5. PropertyPanel — only when selection. - let property_panel = PropertyPanel::for_selection_at(&self.editor_state, self.now_ms); + let property_panel = PropertyPanel::for_selection_at_with_scene( + &self.editor_state, + &self.layout_scene, + self.now_ms, + ); let property_panel_width = ui.property_panel_width; let right_rail_x = viewport_width - property_panel_width; if let Some(panel) = property_panel.as_ref() { diff --git a/crates/op-host-native/src/widget_host/press.rs b/crates/op-host-native/src/widget_host/press.rs index eb8304fcb..9cbc0504c 100644 --- a/crates/op-host-native/src/widget_host/press.rs +++ b/crates/op-host-native/src/widget_host/press.rs @@ -14,6 +14,7 @@ use super::{ PanelResizeKind, RotateDragState, WidgetHostNative, }; use op_editor_core::codegen::CodeSelection; +use op_editor_core::pen_node_ext::PenNodeExt; use op_editor_ui::widgets::{ rotation_corner_at_point, selection_handle_at_point, AIChatHit, AIChatPlaceholder, CanvasViewport, LayoutCx, LocalePicker, PropertyPanel, Toolbar, TopBar, TopBarHit, Widget, @@ -701,7 +702,8 @@ impl WidgetHostNative { // 0c. PropertyPanel input row. self.refresh_layout_scene(); if let Some(panel) = - PropertyPanel::for_selection(&self.editor_state).filter(|_| !in_git_panel) + PropertyPanel::for_selection_with_scene(&self.editor_state, &self.layout_scene) + .filter(|_| !in_git_panel) { let property_rect = Rect { origin: Point2D::new( @@ -783,7 +785,16 @@ impl WidgetHostNative { } if let Some(focus) = panel.hit_test(property_rect, Point2D::new(x, y)) { self.commit_property_focus_if_any(); - let initial = super::press_helpers::property_focus_initial(focus, &panel); + // Committing the previous W/H field can change layout. Rebuild + // the scene before seeding the newly focused field so Fill/Hug + // reads the concrete post-commit canvas size, not a stale one. + self.refresh_layout_scene(); + let resolved_panel = + PropertyPanel::for_selection_with_scene(&self.editor_state, &self.layout_scene); + let initial = resolved_panel + .as_ref() + .map(|panel| super::press_helpers::property_focus_initial(focus, panel)) + .unwrap_or_default(); // shell-core `PropertyFocus` → op-editor-core. self.editor_state.ui.property_focus = Some(focus); self.editor_state @@ -1032,6 +1043,11 @@ impl WidgetHostNative { &self.editor_state, Point2D::new(x, y), ) { + let (start_authored_x, start_authored_y) = self + .editor_state + .selected_node() + .map(|node| (node.base().x, node.base().y)) + .unwrap_or((None, None)); if let Some(node) = self .layout_scene .active_page() @@ -1046,6 +1062,8 @@ impl WidgetHostNative { start_screen_x: x, start_screen_y: y, start_bounds: raw, + start_authored_x, + start_authored_y, }); return true; } @@ -1090,22 +1108,25 @@ impl WidgetHostNative { { let ec_id = op_editor_core::NodeId::new(&node_id); return self.apply_canvas_node_press( - ec_id, + vec![ec_id], x, y, text_edit_was_active, viewport_height, ); } - if let Some(node_id) = self + if let Some(hit_path) = self .layout_scene - .node_at_doc_point(doc_point, self.editor_state.viewport.zoom) + .node_path_at_doc_point(doc_point, self.editor_state.viewport.zoom) { - // Selection promotion / enter-group / drag start — + // Relative-level selection / one-step drill / drag start — // see `canvas_select_drag.rs`. - let ec_id = op_editor_core::NodeId::new(&node_id); + let hit_path = hit_path + .into_iter() + .map(op_editor_core::NodeId::new) + .collect(); return self.apply_canvas_node_press( - ec_id, + hit_path, x, y, text_edit_was_active, @@ -1113,15 +1134,22 @@ impl WidgetHostNative { ); } // Empty canvas press — start a marquee. + self.editor_state.editor_ui.last_canvas_click = None; let cleared_now = if !self.shift_held { let was_set = !self.editor_state.selection.set.is_empty(); + let had_scope = self.editor_state.editor_ui.entered_container.is_some(); if was_set { self.editor_state.clear_selection(); } // Clicking blank canvas steps out of the entered // container (clearing-exits rule). self.editor_state.sync_entered_container_with_selection(); - was_set + let exited_scope = + had_scope && self.editor_state.editor_ui.entered_container.is_none(); + if was_set || exited_scope { + self.mark_dirty(); + } + was_set || exited_scope } else { false }; diff --git a/crates/op-host-native/src/widget_host/property_dispatch.rs b/crates/op-host-native/src/widget_host/property_dispatch.rs index 22d15f81b..bdc94f383 100644 --- a/crates/op-host-native/src/widget_host/property_dispatch.rs +++ b/crates/op-host-native/src/widget_host/property_dispatch.rs @@ -18,6 +18,20 @@ impl WidgetHostNative { action: op_editor_ui::widgets::PropertyPanelAction, ) { use op_editor_ui::widgets::PropertyPanelAction as A; + // A sizing keyword toggle may temporarily swap an instance's merged + // display node into the document below. Capture the real canvas size + // before that scope starts so turning Fill/Hug off freezes exactly + // what the user sees, without rebuilding a scene from the temporary + // instance-write representation. + let resolved_sizing_fallback = match action { + A::ToggleSizeFillWidth | A::ToggleSizeHugWidth => { + self.resolved_selected_sizing_axis(true) + } + A::ToggleSizeFillHeight | A::ToggleSizeHugHeight => { + self.resolved_selected_sizing_axis(false) + } + _ => None, + }; // Instance / component lifecycle actions act on the REAL Ref // node, so they dispatch BEFORE the instance-write redirect // scope below swaps in the merged display node. @@ -70,16 +84,32 @@ impl WidgetHostNative { self.set_selected_layout_mode(mode); } A::ToggleSizeFillWidth => { - self.toggle_selected_sizing(true, SizingKeyword::FillContainer); + self.toggle_selected_sizing( + true, + SizingKeyword::FillContainer, + resolved_sizing_fallback, + ); } A::ToggleSizeFillHeight => { - self.toggle_selected_sizing(false, SizingKeyword::FillContainer); + self.toggle_selected_sizing( + false, + SizingKeyword::FillContainer, + resolved_sizing_fallback, + ); } A::ToggleSizeHugWidth => { - self.toggle_selected_sizing(true, SizingKeyword::FitContent); + self.toggle_selected_sizing( + true, + SizingKeyword::FitContent, + resolved_sizing_fallback, + ); } A::ToggleSizeHugHeight => { - self.toggle_selected_sizing(false, SizingKeyword::FitContent); + self.toggle_selected_sizing( + false, + SizingKeyword::FitContent, + resolved_sizing_fallback, + ); } A::ToggleSizeClipContent => { self.toggle_selected_clip_content(); diff --git a/crates/op-host-native/src/widget_host/property_layout_dispatch.rs b/crates/op-host-native/src/widget_host/property_layout_dispatch.rs index 70ecc0681..92ab0c52c 100644 --- a/crates/op-host-native/src/widget_host/property_layout_dispatch.rs +++ b/crates/op-host-native/src/widget_host/property_layout_dispatch.rs @@ -6,6 +6,29 @@ use jian_ops_schema::sizing::{SizingBehavior, SizingKeyword}; use op_editor_core::ui_draft::PropertyFocus; impl WidgetHostNative { + pub(in crate::widget_host) fn resolved_selected_sizing_axis( + &mut self, + width: bool, + ) -> Option { + let id = self.editor_state.selection.anchor.clone(); + if !id.is_real() { + return None; + } + self.refresh_layout_scene(); + self.layout_scene + .active_page() + .and_then(|page| page.find(id.as_str())) + .map(|node| node.aggregate_bounds()) + .map(|bounds| { + if width { + f64::from(bounds.size.x) + } else { + f64::from(bounds.size.y) + } + }) + .filter(|value| value.is_finite() && *value >= 0.0) + } + pub(in crate::widget_host) fn set_selected_layout_mode( &mut self, mode: op_editor_core::FlexLayout, @@ -33,12 +56,13 @@ impl WidgetHostNative { &mut self, width: bool, keyword: SizingKeyword, + resolved_fallback: Option, ) { let id = self.editor_state.selection.anchor.clone(); if !id.is_real() { return; } - let (is_current, fallback) = { + let (is_current, aggregate_fallback) = { let Some(node) = self.editor_state.selected_node() else { return; }; @@ -47,6 +71,7 @@ impl WidgetHostNative { let bounds = op_editor_core::aggregate_bounds(node); (is_current, if width { bounds.w } else { bounds.h }) }; + let fallback = resolved_fallback.unwrap_or(aggregate_fallback); self.editor_state.commit_history(); if is_current { let focus = if width { diff --git a/crates/op-host-native/src/widget_host/property_panel_press_tests.rs b/crates/op-host-native/src/widget_host/property_panel_press_tests.rs index 0fb5fa157..eef42d5cf 100644 --- a/crates/op-host-native/src/widget_host/property_panel_press_tests.rs +++ b/crates/op-host-native/src/widget_host/property_panel_press_tests.rs @@ -1,7 +1,7 @@ use super::WidgetHostNative; use op_editor_core::codegen::{CodegenHover, CodegenPhase}; use op_editor_core::PropertyTab; -use op_editor_core::{ButtonPressTarget, NodeId}; +use op_editor_core::{ButtonPressTarget, NodeId, PropertyFocus}; use op_editor_ui::widgets::property_panel_action::CodegenAction; use op_editor_ui::widgets::{PropertyPanel, PropertyPanelAction}; use op_editor_ui::Point2D; @@ -42,6 +42,24 @@ fn point_for_action( panic!("no property-panel action point maps to requested action"); } +fn point_for_focus(host: &WidgetHostNative, want: PropertyFocus) -> Point2D { + let panel = PropertyPanel::for_selection(host.editor_state()).expect("property panel"); + let rect = host.property_rect(VIEWPORT_W, VIEWPORT_H); + let mut y = rect.origin.y + 2.0; + while y < rect.origin.y + rect.size.y { + let mut x = rect.origin.x + 2.0; + while x < rect.origin.x + rect.size.x { + let point = Point2D::new(x, y); + if panel.hit_test(rect, point) == Some(want) { + return point; + } + x += 2.0; + } + y += 2.0; + } + panic!("no property-panel input point maps to {want:?}"); +} + fn point_inside_property_panel_without_target(host: &WidgetHostNative) -> Point2D { let panel = PropertyPanel::for_selection(host.editor_state()).expect("property panel"); let rect = host.property_rect(VIEWPORT_W, VIEWPORT_H); @@ -62,6 +80,16 @@ fn point_inside_property_panel_without_target(host: &WidgetHostNative) -> Point2 panic!("no empty property-panel point found"); } +fn selected_scene_size(host: &mut WidgetHostNative) -> (f32, f32) { + let id = host.editor_state().selection.anchor.as_str().to_string(); + let node = host + .layout_scene() + .active_page() + .and_then(|page| page.find(&id)) + .expect("selected scene node present"); + (node.bounds.size.x, node.bounds.size.y) +} + #[test] fn property_panel_action_press_sets_and_release_clears_pressed_button() { let mut host = WidgetHostNative::new(); @@ -95,6 +123,79 @@ fn property_panel_action_press_sets_and_release_clears_pressed_button() { assert_eq!(host.editor_state().editor_ui.pressed_button, None); } +#[test] +fn fill_width_input_seeds_from_resolved_canvas_width() { + let mut host = WidgetHostNative::new(); + seed( + &mut host, + r##"{ "version": "0.8.0", "children": [ + {"type":"frame","id":"root","width":390,"height":710, + "layout":"vertical","children":[ + {"type":"frame","id":"fill","width":"fill_container", + "height":"fit_content","layout":"vertical","children":[ + {"type":"rectangle","id":"child","width":180,"height":90} + ]} + ]} + ]}"##, + ); + host.editor_state_mut() + .set_single_selection(NodeId::new("fill")); + + let point = point_for_focus(&host, PropertyFocus::SizeW); + assert!(host.apply_press(point.x, point.y, VIEWPORT_W, VIEWPORT_H)); + + assert_eq!(host.editor_state().ui.property_input.text(), "390"); +} + +#[test] +fn disabling_fill_height_freezes_resolved_height_then_numeric_input_resizes_scene() { + let mut host = WidgetHostNative::new(); + seed( + &mut host, + r##"{ "version": "0.8.0", "children": [ + {"type":"frame","id":"screen","width":390,"height":710, + "layout":"vertical","gap":0,"children":[ + {"type":"frame","id":"content","name":"Content Wrapper", + "width":"fill_container","height":"fill_container", + "layout":"vertical","children":[ + {"type":"rectangle","id":"body","width":"fill_container","height":100} + ]}, + {"type":"frame","id":"nav","width":"fill_container","height":94} + ]} + ]}"##, + ); + host.editor_state_mut() + .set_single_selection(NodeId::new("content")); + + assert_eq!(selected_scene_size(&mut host), (390.0, 616.0)); + + host.apply_property_action(PropertyPanelAction::ToggleSizeFillHeight); + + let content = op_editor_core::walkers::find_node( + host.editor_state().active_children(), + &NodeId::new("content"), + ) + .expect("content present"); + let content_json = serde_json::to_value(content).expect("content serializes"); + assert_eq!( + content_json["height"], + serde_json::json!(616.0), + "turning Fill Height off must freeze the current resolved height" + ); + assert_eq!(selected_scene_size(&mut host), (390.0, 616.0)); + + let point = point_for_focus(&host, PropertyFocus::SizeH); + assert!(host.apply_press(point.x, point.y, VIEWPORT_W, VIEWPORT_H)); + assert_eq!(host.editor_state().ui.property_input.text(), "616"); + assert!(host.apply_select_all()); + assert!(host.apply_text('2')); + assert!(host.apply_text('0')); + assert!(host.apply_text('0')); + assert!(host.apply_send()); + + assert_eq!(selected_scene_size(&mut host), (390.0, 200.0)); +} + #[test] fn property_panel_background_consumes_clicks() { let mut host = WidgetHostNative::new(); diff --git a/crates/op-host-web/src/widget_host.rs b/crates/op-host-web/src/widget_host.rs index 3db18feb8..3e634a4e5 100644 --- a/crates/op-host-web/src/widget_host.rs +++ b/crates/op-host-web/src/widget_host.rs @@ -57,6 +57,8 @@ mod blur_inputs; mod blur_inputs_tests; #[cfg(test)] mod boolean_toolbar_tests; +#[cfg(test)] +mod canvas_hierarchy_tests; mod chat_design_apply; #[cfg(test)] mod chat_design_apply_tests; diff --git a/crates/op-host-web/src/widget_host/canvas_hierarchy_tests.rs b/crates/op-host-web/src/widget_host/canvas_hierarchy_tests.rs new file mode 100644 index 000000000..a3f2c6d7b --- /dev/null +++ b/crates/op-host-web/src/widget_host/canvas_hierarchy_tests.rs @@ -0,0 +1,108 @@ +//! Web host regression coverage for Pencil-style canvas hierarchy depth. + +use super::WidgetHost; +use op_editor_core::{NodeId, Tool}; +use op_editor_ui::Point2D; + +const VIEWPORT_W: f32 = 1200.0; +const VIEWPORT_H: f32 = 800.0; + +const FOUR_LEVELS: &str = r#"{"version":"0.8.0","children":[ + {"type":"frame","id":"root","name":"Root Frame","x":500,"y":100,"width":300,"height":300, + "children":[ + {"type":"frame","id":"level-1","name":"Level 1","x":20,"y":20,"width":240,"height":240, + "children":[ + {"type":"frame","id":"level-2","name":"Level 2","x":20,"y":20,"width":180,"height":180, + "children":[ + {"type":"rectangle","id":"level-3","name":"Level 3","x":20,"y":20,"width":100,"height":100} + ]} + ]} + ]} +]}"#; + +fn seed() -> WidgetHost { + let doc = jian_ops_schema::load_str(FOUR_LEVELS) + .expect("four-level fixture parses") + .value; + let mut host = WidgetHost::new(); + host.editor_state = op_editor_core::EditorState::from_document(doc); + host.editor_state.tool = Tool::Select; + host.editor_state_dirty = true; + host.last_viewport_w = VIEWPORT_W; + host.last_viewport_h = VIEWPORT_H; + host +} + +fn screen_at(host: &WidgetHost, doc_x: f32, doc_y: f32) -> Point2D { + let (cx0, cy0, _, _) = host.canvas_region(VIEWPORT_W, VIEWPORT_H); + Point2D::new(cx0 + doc_x, cy0 + doc_y) +} + +#[test] +fn hover_and_double_press_drill_exactly_one_level_then_consume_stamp() { + let mut host = seed(); + // Absolute bounds nest at root 500, level-1 520, level-2 540, + // level-3 560. This point is inside all four rendered nodes. + let point = screen_at(&host, 580.0, 180.0); + + assert!(host.apply_cursor_move(point.x, point.y)); + assert_eq!( + host.editor_state.editor_ui.canvas_hover_node, + Some(NodeId::new("level-1")), + "idle hover resolves to the root scope's direct child" + ); + + host.set_now_ms(1_000); + assert!(host.apply_press(point.x, point.y, VIEWPORT_W, VIEWPORT_H)); + assert_eq!(host.editor_state.selection.anchor, NodeId::new("level-1")); + assert_eq!(host.editor_state.editor_ui.entered_container, None); + assert!(host.apply_release_with_viewport(VIEWPORT_W, VIEWPORT_H)); + + host.set_now_ms(1_200); + assert!(host.apply_press(point.x, point.y, VIEWPORT_W, VIEWPORT_H)); + assert_eq!( + host.editor_state.selection.anchor, + NodeId::new("level-2"), + "the second press drills only to the direct child under the pointer" + ); + assert_eq!( + host.editor_state.editor_ui.entered_container, + Some(NodeId::new("level-1")) + ); + assert_eq!( + host.editor_state.editor_ui.canvas_hover_node, + Some(NodeId::new("level-2")), + "stationary-pointer hover rebases to the newly entered depth" + ); + assert_eq!( + host.editor_state.editor_ui.last_canvas_click, None, + "a completed double press consumes its stamp" + ); + let _ = host.apply_release_with_viewport(VIEWPORT_W, VIEWPORT_H); + + host.set_now_ms(1_300); + assert!(host.apply_press(point.x, point.y, VIEWPORT_W, VIEWPORT_H)); + assert_eq!( + host.editor_state.selection.anchor, + NodeId::new("level-2"), + "a third press starts a fresh click instead of drilling to level-3" + ); + assert_eq!( + host.editor_state.editor_ui.entered_container, + Some(NodeId::new("level-1")) + ); +} + +#[test] +fn frame_label_hover_targets_the_root() { + let mut host = seed(); + // Frame labels span y = root_top - 32 .. root_top - 4 and begin + // four pixels left of the frame. Pick a point over "Root Frame". + let label_point = screen_at(&host, 524.0, 82.0); + + assert!(host.apply_cursor_move(label_point.x, label_point.y)); + assert_eq!( + host.editor_state.editor_ui.canvas_hover_node, + Some(NodeId::new("root")) + ); +} diff --git a/crates/op-host-web/src/widget_host/cursor_input.rs b/crates/op-host-web/src/widget_host/cursor_input.rs index 14a64dda7..286464f49 100644 --- a/crates/op-host-web/src/widget_host/cursor_input.rs +++ b/crates/op-host-web/src/widget_host/cursor_input.rs @@ -5,7 +5,7 @@ //! the spine under the repo's 800-line cap (mirrors the native //! host's `widget_host/input.rs` split). -use op_editor_ui::widgets::TOP_BAR_HEIGHT; +use op_editor_ui::widgets::{CanvasViewport, TOP_BAR_HEIGHT}; use op_editor_ui::{Point2D, Rect}; use super::WidgetHost; @@ -795,6 +795,47 @@ impl WidgetHost { self.mark_dirty(); return true; } + // Canvas hierarchy hover. The scene hit path is resolved to + // the current level's primary target; shared canvas paint then + // draws that node solid plus all of its direct children dashed. + // Frame labels participate as explicit root targets. + let hover_eligible = !over_topmost + && matches!(self.editor_state.tool, op_editor_core::Tool::Select) + && self.over_canvas(x, y, self.last_viewport_w, self.last_viewport_h); + let new_canvas_hover = if hover_eligible { + let (cx0, cy0, cw, ch) = self.canvas_region(self.last_viewport_w, self.last_viewport_h); + let canvas_rect = Rect { + origin: Point2D::new(cx0, cy0), + size: Point2D::new(cw, ch), + }; + let canvas = CanvasViewport::from_editor(&self.editor_state, &self.layout_scene); + if let Some(root) = canvas.frame_label_at_point(canvas_rect, Point2D::new(x, y)) { + Some(op_editor_core::NodeId::new(root)) + } else { + let local = Point2D::new(x - cx0, y - cy0); + let doc = self.editor_state.viewport.to_document(local); + self.layout_scene + .node_path_at_doc_point(doc, self.editor_state.viewport.zoom) + .and_then(|path| { + let path = path + .into_iter() + .map(op_editor_core::NodeId::new) + .collect::>(); + op_editor_core::selection_resolve::resolve_canvas_depth_targets( + &path, + self.editor_state.editor_ui.entered_container.as_ref(), + ) + .map(|targets| targets.primary) + }) + } + } else { + None + }; + if new_canvas_hover != self.editor_state.editor_ui.canvas_hover_node { + self.editor_state.editor_ui.canvas_hover_node = new_canvas_hover; + self.mark_dirty(); + return true; + } false } diff --git a/crates/op-host-web/src/widget_host/node_drag.rs b/crates/op-host-web/src/widget_host/node_drag.rs index 6963cb156..83ac3f0ef 100644 --- a/crates/op-host-web/src/widget_host/node_drag.rs +++ b/crates/op-host-web/src/widget_host/node_drag.rs @@ -37,6 +37,83 @@ pub(in crate::widget_host) struct NodeDragState { } impl WidgetHost { + /// Resolve a canvas press from the rendered root-to-deepest hit + /// path. Plain click selects the solid-outline primary; a second + /// press inside 400 ms drills exactly one level to the direct child + /// under the pointer and enters the primary as the sibling scope. + pub(in crate::widget_host) fn apply_canvas_node_press( + &mut self, + hit_path: Vec, + x: f32, + y: f32, + text_edit_was_active: bool, + viewport_height: f32, + ) -> bool { + let Some(deepest) = hit_path.last().cloned() else { + return false; + }; + let Some(targets) = op_editor_core::selection_resolve::resolve_canvas_depth_targets( + &hit_path, + self.editor_state.editor_ui.entered_container.as_ref(), + ) else { + return false; + }; + let is_double = matches!( + &self.editor_state.editor_ui.last_canvas_click, + Some((prev, t)) if *prev == deepest + && self.now_ms.saturating_sub(*t) < 400 + ) && !self.shift_held + && self.editor_state.selection_count() <= 1; + self.editor_state.editor_ui.last_canvas_click = if self.shift_held || is_double { + None + } else { + Some((deepest, self.now_ms)) + }; + if is_double && !text_edit_was_active { + if let Some(secondary) = targets.secondary_under_pointer { + self.editor_state.set_single_selection(secondary.clone()); + self.editor_state.editor_ui.entered_container = Some(targets.primary); + self.editor_state.editor_ui.canvas_hover_node = Some(secondary); + self.scroll_layer_panel_selection_into_view(viewport_height); + self.mark_dirty(); + return true; + } + if self.editor_state.start_text_edit(targets.primary.clone()) { + self.editor_state.ui.text_edit_input.touch(self.now_ms); + self.mark_dirty(); + return true; + } + } + + let target = targets.primary; + let mut should_start_drag = true; + if self.shift_held { + let was_in_set = self.editor_state.is_selected(&target); + self.editor_state.toggle_selection(target); + should_start_drag = !was_in_set; + } else { + let already_in_set = self.editor_state.is_selected(&target); + if !already_in_set || self.editor_state.selection_count() == 1 { + self.editor_state.set_single_selection(target); + } + } + if self + .editor_state + .editor_ui + .entered_container + .as_ref() + .is_some_and(|entered| !hit_path.contains(entered)) + { + self.editor_state.editor_ui.entered_container = None; + } + self.scroll_layer_panel_selection_into_view(viewport_height); + if should_start_drag { + self.start_node_drag(x, y); + } + self.mark_dirty(); + true + } + pub(in crate::widget_host) fn start_node_drag(&mut self, x: f32, y: f32) { self.editor_state.commit_history(); self.option_drag_source_ids.clear(); @@ -68,6 +145,7 @@ impl WidgetHost { let total_dx = ((x - drag.press_screen_x) / zoom) as f64; let total_dy = ((y - drag.press_screen_y) / zoom) as f64; if !drag.moved { + self.editor_state.editor_ui.last_canvas_click = None; let option_source_ids: Vec = self.editor_state.selection.set.to_vec(); if self.alt_held && !option_source_ids.is_empty() diff --git a/crates/op-host-web/src/widget_host/paint.rs b/crates/op-host-web/src/widget_host/paint.rs index 2fc60bf3f..934905ae8 100644 --- a/crates/op-host-web/src/widget_host/paint.rs +++ b/crates/op-host-web/src/widget_host/paint.rs @@ -180,7 +180,11 @@ impl WidgetHost { canvas.paint(&mut cx, canvas_rect); } - let property_panel = PropertyPanel::for_selection_at(&self.editor_state, self.now_ms); + let property_panel = PropertyPanel::for_selection_at_with_scene( + &self.editor_state, + &self.layout_scene, + self.now_ms, + ); if let Some(panel) = property_panel.as_ref() { let property_rect = Rect { origin: Point2D::new(viewport_width - ui.property_panel_width, TOP_BAR_HEIGHT), diff --git a/crates/op-host-web/src/widget_host/press.rs b/crates/op-host-web/src/widget_host/press.rs index aaf478681..26929081c 100644 --- a/crates/op-host-web/src/widget_host/press.rs +++ b/crates/op-host-web/src/widget_host/press.rs @@ -666,7 +666,9 @@ 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.editor_state) { + if let Some(panel) = + PropertyPanel::for_selection_with_scene(&self.editor_state, &self.layout_scene) + { let property_rect = Rect { origin: Point2D::new( viewport_width - self.editor_state.editor_ui.property_panel_width, @@ -932,71 +934,47 @@ impl WidgetHost { canvas.frame_label_at_point(canvas_rect, Point2D::new(x, y)) { let node_id = op_editor_core::NodeId::new(&sc_node_id); - if self.shift_held { - let was_in_set = self.editor_state.is_selected(&node_id); - self.editor_state.toggle_selection(node_id); - if !was_in_set { - self.start_node_drag(x, y); - } - } else { - let already_in_set = self.editor_state.is_selected(&node_id); - if !already_in_set || self.editor_state.selection_count() == 1 { - self.editor_state.set_single_selection(node_id); - } - self.start_node_drag(x, y); - } - self.scroll_layer_panel_selection_into_view(viewport_height); - self.mark_dirty(); - return true; - } - let hit = self - .layout_scene - .node_at_doc_point(doc_point, self.editor_state.viewport.zoom); - if let Some(sc_node_id) = hit { - let node_id = op_editor_core::NodeId::new(&sc_node_id); - // Canvas double-click: 400 ms same-node → enter - // text-edit on Text nodes. - let is_double = matches!( - self.editor_state.editor_ui.last_canvas_click.clone(), - Some((prev, t)) - if prev == node_id && self.now_ms.saturating_sub(t) < 400 + return self.apply_canvas_node_press( + vec![node_id], + x, + y, + text_edit_was_active, + viewport_height, + ); + } + let hit_path = self + .layout_scene + .node_path_at_doc_point(doc_point, self.editor_state.viewport.zoom); + if let Some(hit_path) = hit_path { + let hit_path = hit_path + .into_iter() + .map(op_editor_core::NodeId::new) + .collect(); + return self.apply_canvas_node_press( + hit_path, + x, + y, + text_edit_was_active, + viewport_height, ); - self.editor_state.editor_ui.last_canvas_click = - Some((node_id.clone(), self.now_ms)); - if is_double - && !text_edit_was_active - && self.editor_state.start_text_edit(node_id.clone()) - { - self.editor_state.ui.text_edit_input.touch(self.now_ms); - self.mark_dirty(); - return true; - } - let mut should_start_drag = true; - if self.shift_held { - let was_in_set = self.editor_state.is_selected(&node_id); - self.editor_state.toggle_selection(node_id); - should_start_drag = !was_in_set; - } else { - let already_in_set = self.editor_state.is_selected(&node_id); - if !already_in_set || self.editor_state.selection_count() == 1 { - self.editor_state.set_single_selection(node_id); - } - } - self.scroll_layer_panel_selection_into_view(viewport_height); - if should_start_drag { - self.start_node_drag(x, y); - } - self.mark_dirty(); - return true; } // Empty canvas with Select → marquee. + self.editor_state.editor_ui.last_canvas_click = None; let cleared_now = if !self.shift_held { let was_set = !self.editor_state.selection.set.is_empty(); + let exited_scope = self + .editor_state + .editor_ui + .entered_container + .take() + .is_some(); if was_set { self.editor_state.clear_selection(); + } + if was_set || exited_scope { self.mark_dirty(); } - was_set + was_set || exited_scope } else { false }; diff --git a/crates/op-host-web/src/widget_host/property_dispatch.rs b/crates/op-host-web/src/widget_host/property_dispatch.rs index 33cc82274..30173b612 100644 --- a/crates/op-host-web/src/widget_host/property_dispatch.rs +++ b/crates/op-host-web/src/widget_host/property_dispatch.rs @@ -97,6 +97,13 @@ impl WidgetHost { } _ => {} } + // Resolve Fill/Hug pixels against the real scene before an instance + // write scope temporarily swaps a Ref anchor for its display node. + let resolved_sizing_fallback = match action { + A::ToggleSizeFillWidth | A::ToggleSizeHugWidth => self.selected_resolved_size(true), + A::ToggleSizeFillHeight | A::ToggleSizeHugHeight => self.selected_resolved_size(false), + _ => None, + }; let instance_scope = self.editor_state.begin_instance_write_for_anchor(); match action { A::SetPropertyTab(tab) => { @@ -106,16 +113,32 @@ impl WidgetHost { self.set_selected_layout_mode(mode); } A::ToggleSizeFillWidth => { - self.toggle_selected_sizing(true, SizingKeyword::FillContainer); + self.toggle_selected_sizing( + true, + SizingKeyword::FillContainer, + resolved_sizing_fallback, + ); } A::ToggleSizeFillHeight => { - self.toggle_selected_sizing(false, SizingKeyword::FillContainer); + self.toggle_selected_sizing( + false, + SizingKeyword::FillContainer, + resolved_sizing_fallback, + ); } A::ToggleSizeHugWidth => { - self.toggle_selected_sizing(true, SizingKeyword::FitContent); + self.toggle_selected_sizing( + true, + SizingKeyword::FitContent, + resolved_sizing_fallback, + ); } A::ToggleSizeHugHeight => { - self.toggle_selected_sizing(false, SizingKeyword::FitContent); + self.toggle_selected_sizing( + false, + SizingKeyword::FitContent, + resolved_sizing_fallback, + ); } A::ToggleSizeClipContent => { self.toggle_selected_clip_content(); diff --git a/crates/op-host-web/src/widget_host/property_focus_press.rs b/crates/op-host-web/src/widget_host/property_focus_press.rs index 367af225f..bf31cf6b4 100644 --- a/crates/op-host-web/src/widget_host/property_focus_press.rs +++ b/crates/op-host-web/src/widget_host/property_focus_press.rs @@ -11,7 +11,11 @@ impl WidgetHost { point: Point2D, ) -> bool { self.commit_property_focus_if_any(); - let (focus, initial) = if let Some(panel) = PropertyPanel::for_selection(&self.editor_state) + // The previous property commit may have changed the selected node's + // resolved Fill/Hug size. Refresh before taking the next input seed. + self.refresh_layout_scene(); + let (focus, initial) = if let Some(panel) = + PropertyPanel::for_selection_with_scene(&self.editor_state, &self.layout_scene) { let focus = panel.hit_test(property_rect, point).unwrap_or(focus); ( diff --git a/crates/op-host-web/src/widget_host/property_layout_dispatch.rs b/crates/op-host-web/src/widget_host/property_layout_dispatch.rs index 0f19d7313..386cc3e3f 100644 --- a/crates/op-host-web/src/widget_host/property_layout_dispatch.rs +++ b/crates/op-host-web/src/widget_host/property_layout_dispatch.rs @@ -6,6 +6,23 @@ use jian_ops_schema::sizing::{SizingBehavior, SizingKeyword}; use op_editor_core::PropertyFocus; impl WidgetHost { + pub(in crate::widget_host) fn selected_resolved_size(&mut self, width: bool) -> Option { + self.refresh_layout_scene(); + let id = self.editor_state.selection.anchor.as_str(); + self.layout_scene + .active_page() + .and_then(|page| page.find(id)) + .map(|node| node.aggregate_bounds()) + .map(|bounds| { + if width { + f64::from(bounds.size.x) + } else { + f64::from(bounds.size.y) + } + }) + .filter(|value| value.is_finite() && *value >= 0.0) + } + pub(in crate::widget_host) fn set_selected_layout_mode( &mut self, mode: op_editor_core::FlexLayout, @@ -33,12 +50,16 @@ impl WidgetHost { &mut self, width: bool, keyword: SizingKeyword, + resolved_fallback: Option, ) { let id = self.editor_state.selection.anchor.clone(); if !id.is_real() { return; } - let (is_current, fallback) = { + // Leaving Fill / Hug must freeze the layout-resolved canvas size. + // The canonical keyword has no literal pixels, so aggregate_bounds + // can collapse to the much smaller descendant-content union. + let (is_current, aggregate_fallback) = { let Some(node) = self.editor_state.selected_node() else { return; }; @@ -47,6 +68,7 @@ impl WidgetHost { let bounds = op_editor_core::aggregate_bounds(node); (is_current, if width { bounds.w } else { bounds.h }) }; + let fallback = resolved_fallback.unwrap_or(aggregate_fallback); self.editor_state.commit_history(); if is_current { let focus = if width { diff --git a/crates/op-host-web/src/widget_host/property_panel_press_tests.rs b/crates/op-host-web/src/widget_host/property_panel_press_tests.rs index 1e897f113..b2f8abc1e 100644 --- a/crates/op-host-web/src/widget_host/property_panel_press_tests.rs +++ b/crates/op-host-web/src/widget_host/property_panel_press_tests.rs @@ -7,7 +7,7 @@ use jian_ops_schema::sizing::{SizingBehavior, SizingKeyword}; use jian_ops_schema::style::PenFill; use op_editor_core::codegen::{CodegenHover, CodegenPhase}; use op_editor_core::image_panel_state::{ImageAssetCheck, ImageAssetStatus}; -use op_editor_core::{ButtonPressTarget, NodeId}; +use op_editor_core::{ButtonPressTarget, NodeId, PropertyFocus}; use op_editor_core::{FlexLayout, PaddingEditMode, PropertyTab}; use op_editor_ui::widgets::property_panel_action::CodegenAction; use op_editor_ui::widgets::property_panel_action::{ @@ -57,6 +57,24 @@ fn point_for_action(host: &WidgetHost, want: impl Fn(&PropertyPanelAction) -> bo panic!("no property-panel action point maps to requested action"); } +fn point_for_focus(host: &WidgetHost, want: PropertyFocus) -> Point2D { + let panel = PropertyPanel::for_selection(&host.editor_state).expect("property panel"); + let rect = property_rect(host); + let mut y = rect.origin.y + 2.0; + while y < rect.origin.y + rect.size.y { + let mut x = rect.origin.x + 2.0; + while x < rect.origin.x + rect.size.x { + let point = Point2D::new(x, y); + if panel.hit_test(rect, point) == Some(want) { + return point; + } + x += 2.0; + } + y += 2.0; + } + panic!("no property-panel input point maps to {want:?}"); +} + fn point_inside_property_panel_without_target(host: &WidgetHost) -> Point2D { let panel = PropertyPanel::for_selection(&host.editor_state).expect("property panel"); let rect = property_rect(host); @@ -120,6 +138,17 @@ fn ref_node<'a>(host: &'a WidgetHost, id: &str) -> &'a jian_ops_schema::node::Re } } +fn selected_scene_size(host: &mut WidgetHost) -> (f32, f32) { + let id = host.editor_state.selection.anchor.as_str().to_string(); + host.refresh_layout_scene(); + let node = host + .layout_scene + .active_page() + .and_then(|page| page.find(&id)) + .expect("selected scene node present"); + (node.bounds.size.x, node.bounds.size.y) +} + #[test] fn property_panel_action_press_sets_and_release_clears_pressed_button() { let mut host = WidgetHost::new(); @@ -152,6 +181,46 @@ fn property_panel_action_press_sets_and_release_clears_pressed_button() { assert_eq!(host.editor_state.editor_ui.pressed_button, None); } +#[test] +fn web_disabling_fill_height_freezes_resolved_height_then_numeric_input_resizes_scene() { + let mut host = WidgetHost::new(); + seed( + &mut host, + r##"{ "version": "0.8.0", "children": [ + {"type":"frame","id":"screen","width":390,"height":710, + "layout":"vertical","gap":0,"children":[ + {"type":"frame","id":"content","name":"Content Wrapper", + "width":"fill_container","height":"fill_container", + "layout":"vertical","children":[ + {"type":"rectangle","id":"body","width":"fill_container","height":100} + ]}, + {"type":"frame","id":"nav","width":"fill_container","height":94} + ]} + ]}"##, + ); + host.editor_state + .set_single_selection(NodeId::new("content")); + + assert_eq!(selected_scene_size(&mut host), (390.0, 616.0)); + host.apply_property_action(PropertyPanelAction::ToggleSizeFillHeight); + + let content = selected_frame(&host); + assert_eq!( + content.container.height, + Some(SizingBehavior::Number(616.0)), + "turning Fill Height off must freeze the current resolved height" + ); + assert_eq!(selected_scene_size(&mut host), (390.0, 616.0)); + + let point = point_for_focus(&host, PropertyFocus::SizeH); + assert!(host.apply_press(point.x, point.y, VIEWPORT_W, VIEWPORT_H)); + assert_eq!(host.editor_state.ui.property_input.text(), "616"); + host.editor_state.ui.property_input.set_text("200"); + assert!(host.apply_send()); + + assert_eq!(selected_scene_size(&mut host), (390.0, 200.0)); +} + #[test] fn web_property_panel_component_button_creates_and_detaches_component() { let mut host = WidgetHost::new(); diff --git a/crates/op-host-web/src/widget_host/resize_drag.rs b/crates/op-host-web/src/widget_host/resize_drag.rs index 939fe47d0..819353ff5 100644 --- a/crates/op-host-web/src/widget_host/resize_drag.rs +++ b/crates/op-host-web/src/widget_host/resize_drag.rs @@ -1,3 +1,4 @@ +use op_editor_core::pen_node_ext::PenNodeExt; use op_editor_ui::util::resize_bounds; use op_editor_ui::widgets::{selection_handle_at_point, SelectionHandle}; use op_editor_ui::{Point2D, Rect}; @@ -10,6 +11,8 @@ pub(in crate::widget_host) struct HandleDragState { pub(in crate::widget_host) start_screen_x: f32, pub(in crate::widget_host) start_screen_y: f32, pub(in crate::widget_host) start_bounds: Rect, + pub(in crate::widget_host) start_authored_x: Option, + pub(in crate::widget_host) start_authored_y: Option, } impl WidgetHost { @@ -41,6 +44,11 @@ impl WidgetHost { else { return false; }; + let (start_authored_x, start_authored_y) = self + .editor_state + .selected_node() + .map(|node| (node.base().x, node.base().y)) + .unwrap_or((None, None)); let raw = node.bounds; if raw.size.x <= 0.0 && raw.size.y <= 0.0 { return false; @@ -51,6 +59,8 @@ impl WidgetHost { start_screen_x: x, start_screen_y: y, start_bounds: raw, + start_authored_x, + start_authored_y, }); true } @@ -67,8 +77,20 @@ impl WidgetHost { let dx = (x - drag.start_screen_x) / zoom; let dy = (y - drag.start_screen_y) / zoom; let new_bounds = resize_bounds(drag.start_bounds, drag.handle, dx, dy); - self.editor_state - .set_selected_bounds(rect_to_doc_rect(new_bounds)); + let new_x = drag.handle.moves_left_edge().then(|| { + drag.start_authored_x.unwrap_or(0.0) + + f64::from(new_bounds.origin.x - drag.start_bounds.origin.x) + }); + let new_y = drag.handle.moves_top_edge().then(|| { + drag.start_authored_y.unwrap_or(0.0) + + f64::from(new_bounds.origin.y - drag.start_bounds.origin.y) + }); + self.editor_state.resize_selected_bounds( + rect_to_doc_rect(new_bounds), + drag.handle.resize_axes(), + new_x, + new_y, + ); self.mark_dirty(); true } diff --git a/crates/op-host-web/src/widget_host/resize_drag_tests.rs b/crates/op-host-web/src/widget_host/resize_drag_tests.rs index 33bbfac28..821ac0c33 100644 --- a/crates/op-host-web/src/widget_host/resize_drag_tests.rs +++ b/crates/op-host-web/src/widget_host/resize_drag_tests.rs @@ -1,6 +1,5 @@ use super::WidgetHost; -use jian_ops_schema::node::PenNode; -use op_editor_core::{own_bounds, walkers::find_node, NodeId, Tool}; +use op_editor_core::{own_bounds, walkers::find_node, NodeId, PenNodeExt, Tool}; use op_editor_ui::Point2D; const VW: f32 = 1200.0; @@ -21,6 +20,24 @@ fn seed(host: &mut WidgetHost) { host.editor_state_dirty = true; } +fn seed_container(host: &mut WidgetHost) { + let doc = jian_ops_schema::load_str( + r##"{"version":"0.8.0","children":[ + {"type":"frame","id":"box","name":"Frame","x":100,"y":100, + "width":120,"height":80,"layout":"none","children":[ + {"type":"rectangle","id":"child","name":"Child","x":10,"y":12, + "width":30,"height":20,"fill":[{"type":"solid","color":"#22C55E"}]} + ]} + ]}"##, + ) + .expect("container fixture JSON parses") + .value; + host.editor_state = op_editor_core::EditorState::from_document(doc); + host.editor_state.tool = Tool::Select; + host.editor_state.set_single_selection(NodeId::new("box")); + host.editor_state_dirty = true; +} + fn screen(host: &WidgetHost, doc_x: f32, doc_y: f32) -> Point2D { let (cx0, cy0, _, _) = host.canvas_region(VW, VH); Point2D::new(cx0 + doc_x, cy0 + doc_y) @@ -29,10 +46,7 @@ fn screen(host: &WidgetHost, doc_x: f32, doc_y: f32) -> Point2D { fn box_bounds(host: &WidgetHost) -> op_editor_core::DocRect { let node = find_node(host.editor_state.active_children(), &NodeId::new("box")) .expect("box remains in document"); - match node { - PenNode::Rectangle(_) => own_bounds(node), - _ => panic!("fixture box is not a rectangle"), - } + own_bounds(node) } #[test] @@ -53,3 +67,26 @@ fn select_tool_dragging_bottom_right_handle_resizes_selected_shape_like_native() assert_eq!(bounds.w, 160.0); assert_eq!(bounds.h, 105.0); } + +#[test] +fn dragging_container_handle_resizes_only_the_container() { + let mut host = WidgetHost::new(); + seed_container(&mut host); + + let press = screen(&host, 220.0, 180.0); + let move_to = screen(&host, 260.0, 205.0); + + assert!(host.apply_press(press.x, press.y, VW, VH)); + assert!(host.apply_cursor_move(move_to.x, move_to.y)); + assert!(host.apply_release_with_viewport(VW, VH)); + + let bounds = box_bounds(&host); + assert_eq!(bounds.w, 160.0); + assert_eq!(bounds.h, 105.0); + let child = find_node(host.editor_state.active_children(), &NodeId::new("child")) + .expect("child remains in document"); + assert_eq!(child.base().x, Some(10.0)); + assert_eq!(child.base().y, Some(12.0)); + assert_eq!(child.width_px(), Some(30.0)); + assert_eq!(child.height_px(), Some(20.0)); +} diff --git a/crates/op-i18n/src/i18n/en.rs b/crates/op-i18n/src/i18n/en.rs index f068c1c0f..a55dea337 100644 --- a/crates/op-i18n/src/i18n/en.rs +++ b/crates/op-i18n/src/i18n/en.rs @@ -288,9 +288,7 @@ pub fn lookup(key: &str) -> Option<&'static str> { "ai.quickAction.bottomNavPrompt" => "Design a mobile app bottom navigation bar with 5 tabs: Home, Search, Add, Messages, Profile", "ai.quickAction.colorPalette" => "Suggest a color palette for my app", "ai.quickAction.colorPalettePrompt" => "Suggest a modern color palette for a pet care app", - "ai.quickAction.dashboard" => { - "Design a dark-themed music streaming mobile app home screen. Include a greeting \"Good evening\", horizontal scrollable \"Recently Played\" album art cards, \"Made For You\" section with 3 playlist cards showing cover art and playlist names, \"New Releases\" section with 4 album cards in a 2x2 grid, and a floating mini player bar at the bottom showing current track with play/pause controls. Bottom tab bar (Home, Search, Library, Premium). Dark background with lime green accent." - } + "ai.quickAction.dashboard" => "Dark music streaming mobile app", "ai.quickAction.dashboardPrompt" => { "Design a dark-themed music streaming mobile app home screen. Include a greeting \"Good evening\", horizontal scrollable \"Recently Played\" album art cards, \"Made For You\" section with 3 playlist cards showing cover art and playlist names, \"New Releases\" section with 4 album cards in a 2x2 grid, and a floating mini player bar at the bottom showing current track with play/pause controls. Bottom tab bar (Home, Search, Library, Premium). Dark background with lime green accent." } diff --git a/crates/op-i18n/src/i18n/zh_cn.rs b/crates/op-i18n/src/i18n/zh_cn.rs index a4eeb7c56..b0179bea8 100644 --- a/crates/op-i18n/src/i18n/zh_cn.rs +++ b/crates/op-i18n/src/i18n/zh_cn.rs @@ -292,9 +292,7 @@ pub fn lookup(key: &str) -> Option<&'static str> { } "ai.quickAction.colorPalette" => "为我的应用推荐配色方案", "ai.quickAction.colorPalettePrompt" => "为一个宠物护理应用推荐一套现代配色方案", - "ai.quickAction.dashboard" => { - "设计一个暗色音乐流媒体App首页。包含问候语\"晚上好\"、\"最近播放\"横向滑动专辑封面卡片、\"为你推荐\"区3张歌单卡片(封面和歌单名)、\"新发行\"区4张专辑卡片2x2网格、底部悬浮迷你播放器(当前曲目+播放/暂停控件)。底部导航栏(首页、搜索、音乐库、会员)。深色背景搭配荧光绿强调。" - } + "ai.quickAction.dashboard" => "暗色音乐流媒体 App 首页", "ai.quickAction.dashboardPrompt" => { "设计一个暗色音乐流媒体App首页。包含问候语\"晚上好\"、\"最近播放\"横向滑动专辑封面卡片、\"为你推荐\"区3张歌单卡片(封面和歌单名)、\"新发行\"区4张专辑卡片2x2网格、底部悬浮迷你播放器(当前曲目+播放/暂停控件)。底部导航栏(首页、搜索、音乐库、会员)。深色背景搭配荧光绿强调。" } diff --git a/crates/op-pen-loader/src/adapter.rs b/crates/op-pen-loader/src/adapter.rs index 0d8a96290..a847fee56 100644 --- a/crates/op-pen-loader/src/adapter.rs +++ b/crates/op-pen-loader/src/adapter.rs @@ -879,7 +879,10 @@ fn text_to_payload(n: &TextNode) -> NodePayload { p.font_family = n.font_family.clone().unwrap_or_default(); p.font_size = n.font_size.unwrap_or(0.0) as f32; p.font_weight = resolve_font_weight(n.font_weight.as_ref()); - p.line_height = n.line_height.unwrap_or(0.0) as f32; + // Keep paint on the same canonical multiplier used by layout measurement. + // In particular, text carrying a pixel-like lineHeight must not measure + // with the default and then paint with hundreds of pixels of leading. + p.line_height = n.layout_line_height_multiplier().unwrap_or(0.0) as f32; p.letter_spacing = n.letter_spacing.unwrap_or(0.0) as f32; p.text_align = n .text_align diff --git a/crates/op-pen-loader/src/layout_scene_tests.rs b/crates/op-pen-loader/src/layout_scene_tests.rs index e3259dd2a..81d61d577 100644 --- a/crates/op-pen-loader/src/layout_scene_tests.rs +++ b/crates/op-pen-loader/src/layout_scene_tests.rs @@ -274,6 +274,88 @@ fn fit_content_text_resolves_scene_bounds_to_measured_text() { ); } +#[test] +fn omitted_height_text_uses_content_height_for_fixed_and_fill_width() { + // Live M3 regression: the model emitted pixel-like lineHeight values while + // omitting text height. They are outside the canonical multiplier range; + // auto-height text must fall back to content measurement instead of + // becoming fontSize * lineHeight hundreds of pixels tall. + let src = r##"{ + "version":"1.0.0","children":[{ + "type":"frame","id":"root","width":320,"height":300, + "layout":"vertical","gap":4,"children":[ + {"type":"text","id":"fixed","width":144, + "content":"Neon Lights Live","fontSize":13,"lineHeight":17}, + {"type":"text","id":"fill","width":"fill_container", + "content":"Sunset Jazz on Pier 17","fontSize":14,"lineHeight":18}, + {"type":"rectangle","id":"after","width":"fill_container","height":20} + ] + }] + }"##; + let scene = editor_state_to_layout_scene(&state_from(src)); + let page = scene.active_page().expect("active page"); + let fixed = page.find("fixed").expect("fixed-width text"); + let fill = page.find("fill").expect("fill-width text"); + let after = page.find("after").expect("following sibling"); + + assert_eq!( + fixed.line_height, 0.0, + "paint should use default line height" + ); + assert_eq!( + fill.line_height, 0.0, + "paint should use default line height" + ); + assert!( + (10.0..40.0).contains(&fixed.bounds.size.y), + "fixed-width omitted-height text should use content height, got {:?}", + fixed.bounds + ); + assert!( + (10.0..40.0).contains(&fill.bounds.size.y), + "fill-width omitted-height text should use content height, got {:?}", + fill.bounds + ); + assert!( + after.bounds.origin.y >= fill.bounds.origin.y + fill.bounds.size.y, + "following sibling must come after auto-height text: fixed={:?} fill={:?} after={:?}", + fixed.bounds, + fill.bounds, + after.bounds + ); + assert!( + after.bounds.origin.y < 100.0, + "auto-height text must not consume the clipped parent: {:?}", + after.bounds + ); +} + +#[test] +fn explicit_height_multiline_text_rejects_pixel_like_line_height_for_paint() { + // An explicit box height is a geometry contract, not permission to change + // lineHeight from a multiplier into pixels. Layout and paint must therefore + // use the same fallback semantics for multi-line fixed-height text. + let src = r##"{ + "version":"1.0.0","children":[{ + "type":"text","id":"explicit","width":180,"height":52, + "textGrowth":"fixed-width-height", + "content":"First line\nSecond line","fontSize":14,"lineHeight":17 + }] + }"##; + let scene = editor_state_to_layout_scene(&state_from(src)); + let node = scene + .active_page() + .expect("active page") + .find("explicit") + .expect("explicit-height text"); + + assert_eq!(node.bounds.size.y, 52.0, "authored box height is preserved"); + assert_eq!( + node.line_height, 0.0, + "paint must use the default multiplier instead of treating 17 as 17x" + ); +} + #[test] fn fit_content_stack_reserves_missing_height_text_before_next_section() { let src = r##"{ diff --git a/vendor/jian b/vendor/jian index dcc033356..91ac895f1 160000 --- a/vendor/jian +++ b/vendor/jian @@ -1 +1 @@ -Subproject commit dcc033356e23c59ef5edf60f3c6d83e3a3356cc4 +Subproject commit 91ac895f11c1b70b8df7f8abff91751efd323b15