diff --git a/crates/op-host-native/src/widget_host.rs b/crates/op-host-native/src/widget_host.rs index f52293afc..a5f5fef15 100644 --- a/crates/op-host-native/src/widget_host.rs +++ b/crates/op-host-native/src/widget_host.rs @@ -55,7 +55,10 @@ mod arc_drag; mod blur_inputs; #[cfg(test)] mod blur_inputs_tests; +#[cfg(test)] +mod canvas_drag_transition_tests; mod canvas_pan_cache; +mod canvas_scene_patch; mod canvas_select_drag; #[cfg(test)] mod canvas_select_drag_tests; @@ -319,6 +322,10 @@ pub struct WidgetHostNative { /// cursor anchor so each `apply_cursor_move` translates the /// selected node by the delta. pub(in crate::widget_host) node_drag: Option, + /// Per-gesture container geometry/index used by canvas drop hit-testing. + /// Built lazily and discarded whenever a live tree mutation reflows the + /// scene, avoiding repeated whole-document DFS work on cursor frames. + pub(in crate::widget_host) canvas_drop_index: Option, /// Original selected ids for an active Option-drag clone move. /// Drop hit-testing skips these so a fresh clone does not /// immediately reparent back into the source it overlaps. @@ -750,6 +757,7 @@ impl WidgetHostNative { panel_resize: None, variables_resize: None, node_drag: None, + canvas_drop_index: None, option_drag_source_ids: Vec::new(), path_anchor_drag: None, arc_handle_drag: None, @@ -1572,6 +1580,17 @@ impl WidgetHostNative { self.now_ms < self.interaction_hot_until_ms } + /// Canvas-only low-cost paint mode for direct manipulation. Unlike + /// `fast_interaction_active`, this does not make the pan bitmap cache + /// eligible: edited geometry changes on every frame. + pub(in crate::widget_host) fn canvas_fast_interaction_active(&self) -> bool { + self.fast_interaction_active() + || self.node_drag.as_ref().is_some_and(|drag| drag.moved) + || self.handle_drag.is_some() + || self.rotate_drag.is_some() + || self.create_drag.is_some() + } + /// Next millisecond at which the host should wake to repaint /// the caret blink phase. `None` = no animation pending. pub fn next_animation_deadline_ms(&self) -> Option { diff --git a/crates/op-host-native/src/widget_host/canvas_drag_transition_tests.rs b/crates/op-host-native/src/widget_host/canvas_drag_transition_tests.rs new file mode 100644 index 000000000..e3416381b --- /dev/null +++ b/crates/op-host-native/src/widget_host/canvas_drag_transition_tests.rs @@ -0,0 +1,57 @@ +use super::{NodeDragState, WidgetHostNative}; +use op_editor_core::NodeId; +use op_editor_ui::Rect; + +#[test] +fn starting_node_drag_cancels_prior_layout_transition() { + let mut host = WidgetHostNative::new(); + let doc = jian_ops_schema::load_str( + r#"{"version":"1.0.0","children":[ + {"type":"rectangle","id":"moving","name":"Moving","x":100,"y":80, + "width":120,"height":60} + ]}"#, + ) + .expect("fixture JSON parses") + .value; + *host.editor_state_mut() = op_editor_core::EditorState::from_document(doc); + host.editor_state_mut() + .set_single_selection(NodeId::new("moving")); + host.mark_paint_dirty_for_test(); + let _ = host.layout_scene(); + + host.set_now_ms(1_000); + host.start_layout_transition_from_bounds( + &NodeId::new("moving"), + Rect::xywh(20.0, 80.0, 120.0, 60.0), + ); + assert!( + host.layout_transition.is_some(), + "fixture needs an active transition" + ); + + host.node_drag = Some(NodeDragState { + last_screen_x: 500.0, + last_screen_y: 500.0, + press_screen_x: 500.0, + press_screen_y: 500.0, + moved: false, + total_dx: 0.0, + total_dy: 0.0, + overlay_bounds: None, + }); + assert!(host.apply_cursor_move(520.0, 500.0)); + + assert!( + host.layout_transition.is_none(), + "direct manipulation must not be offset by a transition from the previous operation" + ); + let scene_x = host + .layout_scene() + .active_page() + .and_then(|page| page.find("moving")) + .expect("moving scene node") + .bounds + .origin + .x; + assert_eq!(scene_x, 120.0, "paint scene must follow the cursor exactly"); +} diff --git a/crates/op-host-native/src/widget_host/canvas_scene_patch.rs b/crates/op-host-native/src/widget_host/canvas_scene_patch.rs new file mode 100644 index 000000000..7e57dfb13 --- /dev/null +++ b/crates/op-host-native/src/widget_host/canvas_scene_patch.rs @@ -0,0 +1,152 @@ +//! Incremental paint-scene patches for live canvas geometry gestures. +//! +//! Drag history advances the document revision at press time. Later cursor +//! frames mutate canonical geometry without another revision bump, so the +//! revision-keyed scene cache cannot rebuild those frames. Simple leaf edits +//! are patched in place; layout-dependent edits fall back to an invalidated +//! full scene rebuild. + +use super::WidgetHostNative; +use op_editor_core::{NodeId, PenNodeExt}; +use op_editor_ui::layout_scene::SceneNode; +use op_editor_ui::Rect; + +impl WidgetHostNative { + /// Prepare the cheap bounds-patch path before the canonical write. Unsafe + /// layout-dependent nodes deliberately skip this refresh so paint performs + /// only one full rebuild after the mutation, rather than one on each side. + pub(in crate::widget_host) fn prepare_live_bounds_update(&mut self) -> bool { + let id = self.editor_state.selection.anchor.clone(); + let patchable = self.bounds_patch_is_safe(&id); + if patchable { + self.refresh_layout_scene(); + } + patchable + } + + /// Synchronize one live bounds write with the paint scene. The caller must + /// call `prepare_live_bounds_update` before mutating the canonical document. + pub(in crate::widget_host) fn finish_live_bounds_update( + &mut self, + bounds: Rect, + patchable: bool, + ) { + let id = self.editor_state.selection.anchor.clone(); + if patchable && patch_scene_bounds(&mut self.layout_scene, &id, bounds) { + self.finish_incremental_scene_patch(); + } else { + self.invalidate_live_scene_for_rebuild(); + } + } + + /// Synchronize one live rotation write. Rotation does not participate in + /// layout, so every resolved scene-node kind can take the cheap path. + pub(in crate::widget_host) fn finish_live_rotation_update(&mut self, rotation: f32) { + let id = self.editor_state.selection.anchor.clone(); + if patch_scene_rotation(&mut self.layout_scene, &id, rotation) { + self.finish_incremental_scene_patch(); + } else { + self.invalidate_live_scene_for_rebuild(); + } + } + + /// Reconcile an incrementally patched gesture against the canonical tree + /// on release. This also covers layout-dependent gestures that rebuilt on + /// each move but whose revision stayed constant. + pub(in crate::widget_host) fn invalidate_live_scene_for_rebuild(&mut self) { + self.scene_cache.invalidate(); + self.mark_dirty(); + } + + fn finish_incremental_scene_patch(&mut self) { + // The scene now intentionally differs from the last cache input. Keep + // paint on the patched tree during the gesture, then force a canonical + // rebuild on release. + self.scene_cache.invalidate(); + self.editor_state_dirty = false; + self.layout_transition = None; + self.drop_pan_cache(); + } + + fn bounds_patch_is_safe(&self, id: &NodeId) -> bool { + // Directly created canvas shapes are top-level. Nested geometry can + // affect parent Hug/Flex layout, and containers with children can + // reflow descendants, so those use the full layout path. + let Some((parent, _)) = + op_editor_core::walkers::find_parent_and_index(self.editor_state.active_children(), id) + else { + return false; + }; + if parent.is_some() { + return false; + } + let Some(node) = + op_editor_core::walkers::find_node(self.editor_state.active_children(), id) + else { + return false; + }; + node.children().is_none_or(|children| children.is_empty()) + && matches!( + node, + jian_ops_schema::node::PenNode::Rectangle(_) + | jian_ops_schema::node::PenNode::Ellipse(_) + | jian_ops_schema::node::PenNode::Polygon(_) + | jian_ops_schema::node::PenNode::Line(_) + | jian_ops_schema::node::PenNode::Frame(_) + ) + } +} + +fn patch_scene_bounds( + scene: &mut op_editor_ui::layout_scene::LayoutScene, + id: &NodeId, + bounds: Rect, +) -> bool { + let active_page_index = scene.active_page_index; + let Some(page) = scene.pages.get_mut(active_page_index) else { + return false; + }; + patch_node_bounds(&mut page.children, id.as_str(), bounds) +} + +fn patch_node_bounds(nodes: &mut [SceneNode], id: &str, bounds: Rect) -> bool { + for node in nodes { + if node.id == id { + node.bounds = bounds; + node.aggregate_bounds_cache = + SceneNode::compute_aggregate_bounds(bounds, &node.children); + return true; + } + if patch_node_bounds(&mut node.children, id, bounds) { + node.aggregate_bounds_cache = + SceneNode::compute_aggregate_bounds(node.bounds, &node.children); + return true; + } + } + false +} + +fn patch_scene_rotation( + scene: &mut op_editor_ui::layout_scene::LayoutScene, + id: &NodeId, + rotation: f32, +) -> bool { + let active_page_index = scene.active_page_index; + let Some(page) = scene.pages.get_mut(active_page_index) else { + return false; + }; + patch_node_rotation(&mut page.children, id.as_str(), rotation) +} + +fn patch_node_rotation(nodes: &mut [SceneNode], id: &str, rotation: f32) -> bool { + for node in nodes { + if node.id == id { + node.rotation = rotation; + return true; + } + if patch_node_rotation(&mut node.children, id, rotation) { + return true; + } + } + false +} 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 e2fde627f..874ce6968 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 @@ -9,13 +9,27 @@ use super::{NodeDragState, WidgetHostNative}; use jian_ops_schema::node::PenNode; -use op_editor_core::drag_mutators::{ - auto_layout_direction, parent_of, DragDropTarget, FlexDirection, -}; +use op_editor_core::drag_mutators::{auto_layout_direction, DragDropTarget, FlexDirection}; use op_editor_core::editor_ui_state::{CanvasDropIndicator, CanvasOverlayLine, CanvasOverlayRect}; use op_editor_core::{NodeId, PenNodeExt}; use op_editor_ui::widgets::CanvasNodeDragOverlay; use op_editor_ui::{Point2D, Rect}; +use std::collections::{HashMap, HashSet}; + +#[cfg(test)] +thread_local! { + static DROP_INDEX_BUILD_COUNT: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +#[cfg(test)] +pub(in crate::widget_host) fn reset_drop_index_build_count() { + DROP_INDEX_BUILD_COUNT.with(|count| count.set(0)); +} + +#[cfg(test)] +pub(in crate::widget_host) fn drop_index_build_count() -> usize { + DROP_INDEX_BUILD_COUNT.with(std::cell::Cell::get) +} /// Read-phase summary of one dragged node — collected before any /// mutation so no document / scene borrow survives into the mutators. @@ -33,6 +47,40 @@ struct ContainerDropCandidate { insertion: Option, } +#[derive(Debug, Clone, PartialEq, Eq)] +struct CanvasDropIndexKey { + document_generation: u64, + document_revision: u64, + active_page_index: usize, + dragged_id: String, + excluded_ids: Vec, +} + +#[derive(Debug, Clone, Copy)] +struct SceneBounds { + bounds: Rect, + aggregate_bounds: Rect, +} + +#[derive(Debug)] +struct IndexedContainer { + id: NodeId, + bounds: Rect, + flex: Option, + flex_children: Vec, + children: Vec, +} + +#[derive(Debug)] +pub(in crate::widget_host) struct CanvasDropIndex { + key: CanvasDropIndexKey, + current_parent: Option, + current_index: usize, + current_parent_flex: Option, + dragged_scene_path: Vec, + containers: Vec, +} + impl WidgetHostNative { /// Select-tool press over a resolved root-to-deepest hit path. /// A plain click selects the current level's primary node; a @@ -48,6 +96,7 @@ impl WidgetHostNative { text_edit_was_active: bool, viewport_height: f32, ) -> bool { + self.canvas_drop_index = None; let Some(deepest) = hit_path.last().cloned() else { return false; }; @@ -197,24 +246,33 @@ impl WidgetHostNative { self.editor_state.editor_ui.canvas_drop_indicator = None; return; }; - let before_scene = self.layout_scene.clone(); - let current_parent = parent_of(self.editor_state.active_children(), &id); + let (current_parent, current_index) = self + .canvas_drop_index + .as_ref() + .map(|index| (index.current_parent.clone(), index.current_index)) + .unwrap_or((None, 0)); let bounds = plan.dropped_bounds; let mut indicator = None; let mut mutated = false; let mut overlay_bounds = None; + let mut before_scene = None; if let Some(target) = plan.target.clone() { match &target { - DragDropTarget::Container { parent_id, .. } - if current_parent.as_ref() == Some(parent_id) => - { + DragDropTarget::Container { + parent_id, index, .. + } if current_parent.as_ref() == Some(parent_id) => { overlay_bounds = Some(bounds); - mutated |= self.apply_drag_commit(&id, plan); + if current_index != *index { + before_scene = Some(self.layout_scene.clone()); + mutated |= self.apply_drag_commit(&id, plan); + } } DragDropTarget::PageRoot { .. } if current_parent.is_some() => { + before_scene = Some(self.layout_scene.clone()); mutated |= self.apply_drag_commit(&id, plan); } DragDropTarget::Container { .. } if current_parent.is_some() => { + before_scene = Some(self.layout_scene.clone()); mutated |= self.editor_state.move_node_to_drop_target( &id, DragDropTarget::PageRoot { index: 0 }, @@ -231,13 +289,16 @@ impl WidgetHostNative { } } if mutated { + self.canvas_drop_index = None; // Drag history advances the document revision when the gesture // starts, before this live reorder mutates the tree. Invalidate the // revision-keyed scene cache so sibling reflow observes the new // order instead of reusing the pre-mutation scene. self.scene_cache.invalidate(); self.mark_dirty(); - self.start_layout_transition_from_scene_excluding(before_scene, &id); + if let Some(before_scene) = before_scene { + self.start_layout_transition_from_scene_excluding(before_scene, &id); + } } if self.editor_state.editor_ui.canvas_drop_indicator != indicator { self.editor_state.editor_ui.canvas_drop_indicator = indicator; @@ -280,6 +341,7 @@ impl WidgetHostNative { mutated |= self.apply_drag_commit(id, plan); } if mutated { + self.canvas_drop_index = None; // The drag snapshot already consumed this gesture's document // revision, so the final tree mutation must invalidate the scene // cache explicitly. @@ -290,15 +352,13 @@ impl WidgetHostNative { } /// Read phase — gather everything the commit needs as owned data. - fn plan_drag_commit(&self, id: &NodeId, drag: &NodeDragState) -> Option { - let children = self.editor_state.active_children(); - let current_parent = parent_of(children, id); - let current_parent_flex = current_parent - .as_ref() - .and_then(|parent_id| op_editor_core::walkers::find_node(children, parent_id)) - .and_then(auto_layout_direction); + fn plan_drag_commit(&mut self, id: &NodeId, drag: &NodeDragState) -> Option { + self.ensure_canvas_drop_index(id)?; + let index = self.canvas_drop_index.as_ref()?; + let current_parent = index.current_parent.clone(); + let current_parent_flex = index.current_parent_flex; let page = self.layout_scene.active_page()?; - let node_scene = page.find(id.as_str())?; + let node_scene = scene_node_at_path(&page.children, &index.dragged_scene_path)?; let mut nb = node_scene.aggregate_bounds(); if current_parent_flex.is_some() { // Flex children never doc-translate during the drag — the @@ -307,7 +367,7 @@ impl WidgetHostNative { nb.origin.y += drag.total_dy as f32; } let center = Point2D::new(nb.origin.x + nb.size.x / 2.0, nb.origin.y + nb.size.y / 2.0); - let candidate = self.container_drop_candidate(id, center, nb); + let candidate = self.container_drop_candidate(center, nb); let mut indicator = None; let target = if let Some(candidate) = candidate { let same_parent = current_parent.as_ref() == Some(&candidate.parent_id); @@ -361,141 +421,246 @@ impl WidgetHostNative { fn container_drop_candidate( &self, - dragged_id: &NodeId, point: Point2D, dragged_bounds: Rect, ) -> Option { - let children = self.editor_state.active_children(); - let source = op_editor_core::walkers::find_node(children, dragged_id)?; - let page = self.layout_scene.active_page()?; - deepest_container_at(children, source, point, page, &self.option_drag_source_ids).map( - |(parent_id, bounds, flex)| { - let (index, insertion) = if let Some(dir) = flex { - flex_insert_preview(children, page, &parent_id, dragged_id, dragged_bounds, dir) - } else { - (0, None) - }; - ContainerDropCandidate { - parent_id, - bounds, - flex, - index, - insertion, - } - }, - ) + let container = + deepest_indexed_container_at(&self.canvas_drop_index.as_ref()?.containers, point)?; + let (index, insertion) = if let Some(dir) = container.flex { + flex_insert_preview(container, dragged_bounds, dir) + } else { + (0, None) + }; + Some(ContainerDropCandidate { + parent_id: container.id.clone(), + bounds: container.bounds, + flex: container.flex, + index, + insertion, + }) + } + + fn ensure_canvas_drop_index(&mut self, dragged_id: &NodeId) -> Option<()> { + let key = CanvasDropIndexKey { + document_generation: self.editor_state.document_generation(), + document_revision: self.editor_state.document_revision(), + active_page_index: self.layout_scene.active_page_index, + dragged_id: dragged_id.as_str().to_string(), + excluded_ids: self + .option_drag_source_ids + .iter() + .map(|id| id.as_str().to_string()) + .collect(), + }; + if self + .canvas_drop_index + .as_ref() + .is_some_and(|index| index.key == key) + { + return Some(()); + } + self.canvas_drop_index = build_canvas_drop_index( + key, + &self.editor_state, + &self.layout_scene, + dragged_id, + &self.option_drag_source_ids, + ); + self.canvas_drop_index.as_ref().map(|_| ()) } } -fn deepest_container_at( - nodes: &[PenNode], - source: &PenNode, - point: Point2D, - page: &op_editor_ui::layout_scene::ScenePage, +fn build_canvas_drop_index( + key: CanvasDropIndexKey, + state: &op_editor_core::EditorState, + scene: &op_editor_ui::layout_scene::LayoutScene, + dragged_id: &NodeId, excluded_ids: &[NodeId], -) -> Option<(NodeId, Rect, Option)> { - let mut hit = None; +) -> Option { + #[cfg(test)] + DROP_INDEX_BUILD_COUNT.with(|count| count.set(count.get() + 1)); + let nodes = state.active_children(); + let source = op_editor_core::walkers::find_node(nodes, dragged_id)?; + let (current_parent, current_index) = + op_editor_core::walkers::find_parent_and_index(nodes, dragged_id)?; + let current_parent_flex = current_parent + .as_ref() + .and_then(|parent_id| op_editor_core::walkers::find_node(nodes, parent_id)) + .and_then(auto_layout_direction); + let mut scene_bounds = HashMap::new(); + collect_scene_bounds(&scene.active_page()?.children, &mut scene_bounds); + let mut dragged_scene_path = Vec::new(); + if !find_scene_path( + &scene.active_page()?.children, + dragged_id.as_str(), + &mut dragged_scene_path, + ) { + return None; + } + let mut excluded = HashSet::new(); + collect_subtree_ids(source, &mut excluded); + for id in excluded_ids { + if let Some(node) = op_editor_core::walkers::find_node(nodes, id) { + collect_subtree_ids(node, &mut excluded); + } + } + let containers = index_containers(nodes, &scene_bounds, &excluded, dragged_id); + Some(CanvasDropIndex { + key, + current_parent, + current_index, + current_parent_flex, + dragged_scene_path, + containers, + }) +} + +fn find_scene_path( + nodes: &[op_editor_ui::layout_scene::SceneNode], + id: &str, + path: &mut Vec, +) -> bool { + for (index, node) in nodes.iter().enumerate() { + path.push(index); + if node.id == id || find_scene_path(&node.children, id, path) { + return true; + } + path.pop(); + } + false +} + +fn scene_node_at_path<'a>( + nodes: &'a [op_editor_ui::layout_scene::SceneNode], + path: &[usize], +) -> Option<&'a op_editor_ui::layout_scene::SceneNode> { + let (&index, rest) = path.split_first()?; + let node = nodes.get(index)?; + if rest.is_empty() { + Some(node) + } else { + scene_node_at_path(&node.children, rest) + } +} + +fn collect_scene_bounds( + nodes: &[op_editor_ui::layout_scene::SceneNode], + out: &mut HashMap, +) { for node in nodes { - if node.children().is_none() { - continue; + out.insert( + node.id.clone(), + SceneBounds { + bounds: node.bounds, + aggregate_bounds: node.aggregate_bounds(), + }, + ); + collect_scene_bounds(&node.children, out); + } +} + +fn collect_subtree_ids(node: &PenNode, out: &mut HashSet) { + out.insert(node.id_str().to_string()); + if let Some(children) = node.children() { + for child in children { + collect_subtree_ids(child, out); } - let node_id = NodeId::new(node.id_str()); - if excluded_ids.contains(&node_id) { + } +} + +fn index_containers( + nodes: &[PenNode], + scene_bounds: &HashMap, + excluded: &HashSet, + dragged_id: &NodeId, +) -> Vec { + let mut indexed = Vec::new(); + for node in nodes { + let Some(children) = node.children() else { continue; - } - if op_editor_core::walkers::descendant_contains(source, &node_id) { + }; + if excluded.contains(node.id_str()) { continue; } - let Some(scene) = page.find(node.id_str()) else { + let Some(scene) = scene_bounds.get(node.id_str()) else { continue; }; - let bounds = scene.bounds; - if !rect_contains(bounds, point) { + let flex = auto_layout_direction(node); + let flex_children = if flex.is_some() { + children + .iter() + .filter(|child| child.id_str() != dragged_id.as_str()) + .filter_map(|child| { + scene_bounds + .get(child.id_str()) + .map(|scene| scene.aggregate_bounds) + }) + .collect() + } else { + Vec::new() + }; + indexed.push(IndexedContainer { + id: NodeId::new(node.id_str()), + bounds: scene.bounds, + flex, + flex_children, + children: index_containers(children, scene_bounds, excluded, dragged_id), + }); + } + indexed +} + +fn deepest_indexed_container_at( + containers: &[IndexedContainer], + point: Point2D, +) -> Option<&IndexedContainer> { + let mut hit = None; + for container in containers { + if !rect_contains(container.bounds, point) { continue; } - hit = Some((node_id, bounds, auto_layout_direction(node))); - if let Some(children) = node.children() { - if let Some(deeper) = deepest_container_at(children, source, point, page, excluded_ids) - { - hit = Some(deeper); - } + hit = Some(container); + if let Some(deeper) = deepest_indexed_container_at(&container.children, point) { + hit = Some(deeper); } } hit } fn flex_insert_preview( - nodes: &[PenNode], - page: &op_editor_ui::layout_scene::ScenePage, - parent_id: &NodeId, - dragged_id: &NodeId, + parent: &IndexedContainer, dragged_bounds: Rect, dir: FlexDirection, ) -> (usize, Option) { - let Some(parent) = op_editor_core::walkers::find_node(nodes, parent_id) else { - return (0, None); - }; - let Some(parent_scene) = page.find(parent_id.as_str()) else { - return (0, None); - }; - let parent_bounds = parent_scene.bounds; + let parent_bounds = parent.bounds; let vertical = matches!(dir, FlexDirection::Vertical); let drag_mid = if vertical { dragged_bounds.origin.y + dragged_bounds.size.y / 2.0 } else { dragged_bounds.origin.x + dragged_bounds.size.x / 2.0 }; - let mut index = parent - .children() - .map(|children| { - children - .iter() - .filter(|node| node.id_str() != dragged_id.as_str()) - .count() - }) - .unwrap_or(0); - if let Some(children) = parent.children() { - for (i, child) in children - .iter() - .filter(|node| node.id_str() != dragged_id.as_str()) - .enumerate() - { - let Some(scene) = page.find(child.id_str()) else { - continue; - }; - let bounds = scene.aggregate_bounds(); - let mid = if vertical { - bounds.origin.y + bounds.size.y / 2.0 - } else { - bounds.origin.x + bounds.size.x / 2.0 - }; - if drag_mid < mid { - index = i; - break; - } + let mut index = parent.flex_children.len(); + for (i, bounds) in parent.flex_children.iter().copied().enumerate() { + let mid = if vertical { + bounds.origin.y + bounds.size.y / 2.0 + } else { + bounds.origin.x + bounds.size.x / 2.0 + }; + if drag_mid < mid { + index = i; + break; } } - let insertion = flex_insertion_line(parent, page, parent_bounds, dragged_id, index, vertical); + let insertion = flex_insertion_line(parent_bounds, &parent.flex_children, index, vertical); (index, insertion) } fn flex_insertion_line( - parent: &PenNode, - page: &op_editor_ui::layout_scene::ScenePage, parent_bounds: Rect, - dragged_id: &NodeId, + siblings: &[Rect], index: usize, vertical: bool, ) -> Option { - let siblings: Vec = parent - .children()? - .iter() - .filter(|node| node.id_str() != dragged_id.as_str()) - .filter_map(|node| { - page.find(node.id_str()) - .map(|scene| scene.aggregate_bounds()) - }) - .collect(); let inset = 8.0_f32.min(parent_bounds.size.x.max(parent_bounds.size.y) / 4.0); if vertical { let y = if siblings.is_empty() { 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 365b3813f..842ff86ed 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 @@ -234,6 +234,50 @@ fn overlapping_rect_stack(count: usize) -> String { format!(r#"{{"version":"1.0.0","children":[{children}]}}"#) } +fn overlapping_container_stack(count: usize) -> String { + let containers = (0..count) + .map(|i| { + format!( + r#"{{"type":"frame","id":"frame-{i}","name":"Frame {i}","x":650,"y":250,"width":240,"height":240,"children":[]}}"# + ) + }) + .collect::>() + .join(","); + format!( + r#"{{"version":"1.0.0","children":[{{"type":"rectangle","id":"dragged","name":"Dragged","x":700,"y":300,"width":40,"height":40}},{containers}]}}"# + ) +} + +#[test] +fn drop_preview_reuses_container_index_across_pointer_frames() { + let mut host = WidgetHostNative::new(); + seed(&mut host, &overlapping_container_stack(200)); + host.editor_state_mut() + .set_single_selection(NodeId::new("dragged")); + host.node_drag = Some(NodeDragState { + last_screen_x: 0.0, + last_screen_y: 0.0, + press_screen_x: 0.0, + press_screen_y: 0.0, + moved: true, + total_dx: 0.0, + total_dy: 0.0, + overlay_bounds: None, + }); + host.refresh_layout_scene(); + let drag = host.node_drag.expect("active drag"); + super::canvas_select_drag::reset_drop_index_build_count(); + + for _ in 0..2 { + host.apply_live_node_drag_preview(&drag); + } + assert_eq!( + super::canvas_select_drag::drop_index_build_count(), + 1, + "pointer-only preview frames must reuse one container index" + ); +} + #[test] fn canvas_selection_scrolls_layer_panel_to_hidden_selected_row() { let mut host = WidgetHostNative::new(); diff --git a/crates/op-host-native/src/widget_host/input.rs b/crates/op-host-native/src/widget_host/input.rs index a2dab82d4..0d9f421db 100644 --- a/crates/op-host-native/src/widget_host/input.rs +++ b/crates/op-host-native/src/widget_host/input.rs @@ -198,6 +198,12 @@ 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 { + // A transition from the previous drop/reflow paints geometry away + // from the resolved scene. Once direct manipulation starts, the + // cursor must own the node's position exactly. Later same-gesture + // flex reorders may install fresh transitions that exclude the + // dragged node and continue animating sibling avoidance. + self.layout_transition = None; // 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; @@ -1377,13 +1383,15 @@ impl WidgetHostNative { upper_hover_changed = true; } if let Some(drag) = self.rotate_drag { + self.refresh_layout_scene(); let cursor_angle = (y - drag.center_screen_y).atan2(x - drag.center_screen_x); let new_rotation = drag.start_rotation + (cursor_angle - drag.start_cursor_angle); self.editor_state.set_selected_rotation(new_rotation); - self.mark_dirty(); + self.finish_live_rotation_update(new_rotation); return true; } if let Some(drag) = self.handle_drag { + let patch_scene = self.prepare_live_bounds_update(); let zoom = self.editor_state.viewport.zoom.max(0.0001); let dx = (x - drag.start_screen_x) / zoom; let dy = (y - drag.start_screen_y) / zoom; @@ -1402,10 +1410,11 @@ impl WidgetHostNative { new_x, new_y, ); - self.mark_dirty(); + self.finish_live_bounds_update(new_bounds, patch_scene); return true; } if let Some(drag) = self.create_drag { + let patch_scene = self.prepare_live_bounds_update(); let (cx0, cy0) = self.canvas_origin(); let canvas_local = Point2D::new(x - cx0, y - cy0); let cur = self.editor_state.viewport.to_document(canvas_local); @@ -1424,7 +1433,7 @@ impl WidgetHostNative { let new_bounds = Rect::xywh(min_x, min_y, w, h); self.editor_state .set_selected_bounds(rect_to_doc_rect(new_bounds)); - self.mark_dirty(); + self.finish_live_bounds_update(new_bounds, patch_scene); return true; } // Path-anchor / handle drag — TS `movePathControl` semantics @@ -1788,21 +1797,24 @@ impl WidgetHostNative { return true; } if self.rotate_drag.take().is_some() { + self.invalidate_live_scene_for_rebuild(); return true; } if self.handle_drag.take().is_some() { + self.invalidate_live_scene_for_rebuild(); return true; } if self.create_drag.take().is_some() { // Switch back to Select for immediate shape refinement. self.editor_state.tool = op_editor_core::Tool::Select; - self.mark_dirty(); + self.invalidate_live_scene_for_rebuild(); return true; } if self.finish_image_crop_drag() { return true; } if let Some(drag) = self.node_drag.take() { + self.canvas_drop_index = None; // Drag ended — drop the transient smart-guide lines, then // run the drop policy (auto-layout reorder / reparent). self.refresh_layout_scene(); @@ -1937,20 +1949,23 @@ impl WidgetHostNative { return true; } if self.rotate_drag.take().is_some() { + self.invalidate_live_scene_for_rebuild(); return true; } if self.handle_drag.take().is_some() { + self.invalidate_live_scene_for_rebuild(); return true; } if self.create_drag.take().is_some() { self.editor_state.tool = op_editor_core::Tool::Select; - self.mark_dirty(); + self.invalidate_live_scene_for_rebuild(); return true; } if self.finish_image_crop_drag() { return true; } if let Some(drag) = self.node_drag.take() { + self.canvas_drop_index = None; // Drag ended — drop the transient smart-guide lines, then // run the drop policy (auto-layout reorder / reparent). self.refresh_layout_scene(); 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 621fc3723..efc42d811 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,7 +1,7 @@ //! Drag/release tests split from `input_tests.rs` so each test module //! stays under the repository file-size ceiling. -use super::{HandleDragState, NodeDragState, WidgetHostNative}; +use super::{CreateDragState, HandleDragState, NodeDragState, RotateDragState, WidgetHostNative}; use op_editor_core::{NodeId, PenNodeExt}; use op_editor_ui::{widgets::SelectionHandle, Point2D, Rect}; @@ -72,6 +72,145 @@ fn bottom_right_handle_resizes_only_selected_container() { ); } +#[test] +fn consecutive_resize_frames_keep_layout_scene_in_sync() { + let mut host = WidgetHostNative::new(); + seed( + &mut host, + r#"{"version":"1.0.0","children":[{ + "type":"rectangle","id":"shape","name":"shape","x":100,"y":80, + "width":120,"height":80 + }]}"#, + ); + host.editor_state_mut() + .set_single_selection(NodeId::new("shape")); + host.mark_paint_dirty_for_test(); + let _ = host.layout_scene(); + + // Real handle presses advance history/revision once. Cursor frames then + // mutate geometry in place without another revision bump. + host.editor_state_mut().commit_history(); + 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(120.0, 80.0), + }, + start_authored_x: Some(100.0), + start_authored_y: Some(80.0), + }); + + for (cursor_x, expected_width) in [(520.0, 140.0), (540.0, 160.0)] { + assert!(host.apply_cursor_move(cursor_x, 500.0)); + let authored = authored_geometry(&host, "shape"); + assert_eq!(authored.2, Some(expected_width)); + let scene_width = host + .layout_scene() + .active_page() + .and_then(|page| page.find("shape")) + .expect("scene shape") + .bounds + .size + .x; + assert_eq!( + scene_width, expected_width as f32, + "every live resize frame must paint the canonical width" + ); + } +} + +#[test] +fn consecutive_rotation_frames_keep_layout_scene_in_sync() { + let mut host = WidgetHostNative::new(); + seed( + &mut host, + r#"{"version":"1.0.0","children":[{ + "type":"rectangle","id":"shape","name":"shape","x":100,"y":80, + "width":120,"height":80 + }]}"#, + ); + host.editor_state_mut() + .set_single_selection(NodeId::new("shape")); + let _ = host.layout_scene(); + host.editor_state_mut().commit_history(); + host.rotate_drag = Some(RotateDragState { + center_screen_x: 500.0, + center_screen_y: 500.0, + start_cursor_angle: 0.0, + start_rotation: 0.0, + }); + + for (cursor, expected) in [ + ((500.0, 510.0), std::f32::consts::FRAC_PI_2), + ((490.0, 500.0), std::f32::consts::PI), + ] { + assert!(host.apply_cursor_move(cursor.0, cursor.1)); + let scene_rotation = host + .layout_scene() + .active_page() + .and_then(|page| page.find("shape")) + .expect("scene shape") + .rotation; + assert!( + (scene_rotation - expected).abs() < 0.0001, + "every live rotation frame must paint the canonical angle" + ); + } +} + +#[test] +fn consecutive_create_frames_keep_layout_scene_in_sync() { + let mut host = WidgetHostNative::new(); + host.editor_state_mut().tool = op_editor_core::Tool::Rect; + let start = Point2D::new(700.0, 400.0); + let id = host + .create_node_for_active_tool(start) + .expect("rectangle tool creates a node"); + host.editor_state_mut().set_single_selection(id.clone()); + host.create_drag = Some(CreateDragState { + start_doc_x: start.x, + start_doc_y: start.y, + }); + let (canvas_x, canvas_y) = host.canvas_origin(); + + for (doc_cursor, expected_size) in [ + (Point2D::new(740.0, 440.0), Point2D::new(40.0, 40.0)), + (Point2D::new(760.0, 460.0), Point2D::new(60.0, 60.0)), + ] { + assert!(host.apply_cursor_move(canvas_x + doc_cursor.x, canvas_y + doc_cursor.y,)); + let scene_bounds = host + .layout_scene() + .active_page() + .and_then(|page| page.find(id.as_str())) + .expect("created scene shape") + .bounds; + assert_eq!(scene_bounds.size, expected_size); + } +} + +#[test] +fn geometry_drags_enable_canvas_fast_paint_without_enabling_pan_cache() { + let mut host = WidgetHostNative::new(); + assert!(!host.fast_interaction_active()); + assert!(!host.canvas_fast_interaction_active()); + host.handle_drag = Some(HandleDragState { + handle: SelectionHandle::Right, + start_screen_x: 0.0, + start_screen_y: 0.0, + start_bounds: Rect::xywh(0.0, 0.0, 100.0, 100.0), + start_authored_x: Some(0.0), + start_authored_y: Some(0.0), + }); + + assert!(host.canvas_fast_interaction_active()); + assert!( + !host.fast_interaction_active(), + "geometry edits must not make the pan bitmap cache eligible" + ); +} + #[test] fn edge_handle_freezes_only_its_axis_and_preserves_descendants() { for (handle, move_to, expected_width, expected_height) in [ diff --git a/crates/op-host-native/src/widget_host/paint.rs b/crates/op-host-native/src/widget_host/paint.rs index c15bf9afd..696b69237 100644 --- a/crates/op-host-native/src/widget_host/paint.rs +++ b/crates/op-host-native/src/widget_host/paint.rs @@ -233,7 +233,7 @@ impl WidgetHostNative { let canvas_scene = transition_scene.as_ref().unwrap_or(&self.layout_scene); let mut canvas = CanvasViewport::from_editor(&self.editor_state, canvas_scene); canvas.now_ms = self.now_ms; - canvas.fast_interaction = self.fast_interaction_active(); + canvas.fast_interaction = self.canvas_fast_interaction_active(); canvas.set_node_drag_active( self.node_drag.as_ref().is_some_and(|drag| drag.moved), );