Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions crates/op-host-native/src/widget_host.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<NodeDragState>,
/// 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<canvas_select_drag::CanvasDropIndex>,
/// 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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<u64> {
Expand Down
Original file line number Diff line number Diff line change
@@ -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");
}
152 changes: 152 additions & 0 deletions crates/op-host-native/src/widget_host/canvas_scene_patch.rs
Original file line number Diff line number Diff line change
@@ -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
}
Loading