From 9b74ba7c1d5e431d22c6cedf155ab795f8f5b68b Mon Sep 17 00:00:00 2001 From: Kayshen-X Date: Mon, 15 Jun 2026 06:20:54 +0800 Subject: [PATCH] perf(canvas): smooth streamed reveal pacing --- crates/op-editor-core/src/agent_indicators.rs | 45 +++++++++++++------ .../src/widgets/canvas_agent_overlay.rs | 6 +-- .../src/widgets/canvas_viewport_paint.rs | 4 +- .../widgets/canvas_viewport_reveal_tests.rs | 38 ++++++++++++++-- .../src/subagent_reveal_tests.rs | 15 ++++--- 5 files changed, 78 insertions(+), 30 deletions(-) diff --git a/crates/op-editor-core/src/agent_indicators.rs b/crates/op-editor-core/src/agent_indicators.rs index 98666fde7..9fcc98ff0 100644 --- a/crates/op-editor-core/src/agent_indicators.rs +++ b/crates/op-editor-core/src/agent_indicators.rs @@ -17,13 +17,13 @@ use std::collections::{HashMap, HashSet}; use std::sync::{LazyLock, Mutex}; /// Duration of the short generated-node entrance animation. -pub const REVEAL_DURATION_MS: u64 = 1_520; +pub const REVEAL_DURATION_MS: u64 = 1_680; /// Delay between the first generated nodes in one applied batch. -pub const REVEAL_STAGGER_MS: u64 = REVEAL_FRAME_MS; +pub const REVEAL_STAGGER_MS: u64 = 48; /// Minimum delay before descendants of a newly revealed container begin /// their own entrances. This leaves the parent opening beat readable /// without making nested content feel stalled. -pub const REVEAL_CHILD_RUNWAY_MS: u64 = REVEAL_FRAME_MS * 6; +pub const REVEAL_CHILD_RUNWAY_MS: u64 = 80; /// Extra delay for nested generated nodes. Visual traversal order /// already places children after parents; keeping this at zero avoids /// depth changes compressing adjacent stream slots into the same frame. @@ -31,13 +31,13 @@ pub const REVEAL_DEPTH_STAGGER_MS: u64 = 0; /// Parent reveals suppress child transforms only during their opening /// beat. Once the parent has begun settling, delayed children animate /// independently so streamed content does not pop in abruptly. -pub const REVEAL_CHILD_SUPPRESS_FRACTION: f32 = 0.08; +pub const REVEAL_CHILD_SUPPRESS_FRACTION: f32 = 0.04; const REVEAL_FULL_STAGGER_SIBLINGS: u64 = 18; const REVEAL_MID_STAGGER_SIBLINGS: u64 = 72; -const REVEAL_COMPRESSED_STAGGER_MS: u64 = REVEAL_FRAME_MS; -const REVEAL_TAIL_STAGGER_MS: u64 = REVEAL_FRAME_MS; +const REVEAL_COMPRESSED_STAGGER_MS: u64 = 32; +const REVEAL_TAIL_STAGGER_MS: u64 = 24; const REVEAL_MAX_NEW_STARTS_PER_SNAPSHOT: usize = 1; -const REVEAL_BURST_RECOVERY_STAGGER_MS: u64 = REVEAL_FRAME_MS; +const REVEAL_BURST_RECOVERY_STAGGER_MS: u64 = REVEAL_STAGGER_MS; const CLOCK_REBASE_THRESHOLD_MS: u64 = 60_000; const REVEAL_FRAME_MS: u64 = 16; @@ -452,7 +452,7 @@ mod tests { "reveal should stay active long enough to read as a smooth entrance" ); assert!( - snapshot_at(2_600).reveals.is_empty(), + snapshot_at(2_800).reveals.is_empty(), "expired reveal should be pruned" ); assert!(!is_active(), "expired reveal should not keep animating"); @@ -564,7 +564,7 @@ mod tests { assert!( snap.reveals .get("n2") - .is_some_and(|started_at| *started_at <= 1_244), + .is_some_and(|started_at| *started_at <= 1_220 + REVEAL_BURST_RECOVERY_STAGGER_MS), "overdue recovery should stay close enough to feel continuous" ); end_if_epoch(epoch); @@ -611,12 +611,12 @@ mod tests { assert!( offsets .windows(2) - .take(24) - .all(|pair| pair[1] - pair[0] == REVEAL_FRAME_MS), - "the first visible nodes should stream at frame cadence, not slower" + .take(REVEAL_FULL_STAGGER_SIBLINGS as usize) + .all(|pair| pair[1] - pair[0] == REVEAL_STAGGER_MS), + "the first visible nodes should stream at readable cadence, not bunch into one frame" ); assert!( - offsets[20] - offsets[0] <= 340, + offsets[20] - offsets[0] <= 1_000, "the first screenful should still avoid a slow reveal queue" ); assert!( @@ -626,8 +626,25 @@ mod tests { "stream items should not share an entrance start frame at 60 fps" ); assert!( - offsets[39] - offsets[0] < 1_200, + offsets[39] - offsets[0] < 1_800, "dense generated batches should stay responsive instead of waiting several seconds" ); } + + #[test] + fn reveal_offsets_keep_first_screen_readable_without_clustering() { + let offsets: Vec = (0..24).map(|i| reveal_offset_ms(0, i)).collect(); + + assert!( + offsets + .windows(2) + .take(16) + .all(|pair| (40..=64).contains(&(pair[1] - pair[0]))), + "the first screenful should stream at a readable cadence instead of bunching into one paint frame" + ); + assert!( + offsets[19] - offsets[0] <= 1_100, + "readable pacing should still keep the first screenful responsive" + ); + } } diff --git a/crates/op-editor-ui/src/widgets/canvas_agent_overlay.rs b/crates/op-editor-ui/src/widgets/canvas_agent_overlay.rs index 9a3d14393..9c9d240a5 100644 --- a/crates/op-editor-ui/src/widgets/canvas_agent_overlay.rs +++ b/crates/op-editor-ui/src/widgets/canvas_agent_overlay.rs @@ -229,8 +229,8 @@ fn paint_reveal_sweep( fn lifted_scaled_rect(rect: Rect, ease: f32) -> Rect { let settle = 1.0 - ease; - let scale = 0.986 + ease * 0.014; - let lift = settle * 5.0; + let scale = 0.970 + ease * 0.030; + let lift = settle * 8.0; let w = rect.size.x * scale; let h = rect.size.y * scale; Rect { @@ -457,7 +457,7 @@ mod tests { &roots, Point2D::new(100.0, 50.0), 2.0, - 2_600, + 2_800, ); assert!(expired_backend.round_fills.is_empty()); assert!(expired_backend.round_strokes.is_empty()); 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 974ee3fb4..adf066ea0 100644 --- a/crates/op-editor-ui/src/widgets/canvas_viewport_paint.rs +++ b/crates/op-editor-ui/src/widgets/canvas_viewport_paint.rs @@ -669,8 +669,8 @@ fn push_reveal_transform(cx: &mut PaintCx<'_>, rect: Rect, phase: RevealPhase) - return false; } let settle = 1.0 - phase.ease; - let lift = 6.5 * settle * (1.0 - phase.t * 0.18); - let scale = 0.982 + 0.018 * phase.ease; + let lift = 9.0 * settle * (1.0 - phase.t * 0.12); + let scale = 0.968 + 0.032 * phase.ease; let pivot = Point2D::new( rect.origin.x + rect.size.x / 2.0, rect.origin.y + rect.size.y / 2.0, diff --git a/crates/op-editor-ui/src/widgets/canvas_viewport_reveal_tests.rs b/crates/op-editor-ui/src/widgets/canvas_viewport_reveal_tests.rs index 3e190aeeb..52d8dc9da 100644 --- a/crates/op-editor-ui/src/widgets/canvas_viewport_reveal_tests.rs +++ b/crates/op-editor-ui/src/widgets/canvas_viewport_reveal_tests.rs @@ -8,6 +8,8 @@ use std::collections::HashMap; struct RevealCaptureBackend { ops: Vec, scales: usize, + scale_values: Vec, + translations: Vec, } impl RenderBackend for RevealCaptureBackend { @@ -26,9 +28,12 @@ impl RenderBackend for RevealCaptureBackend { fn restore(&mut self) { self.ops.push("restore".into()); } - fn translate(&mut self, _: Point2D) {} - fn scale(&mut self, _: Point2D, _: Point2D) { + fn translate(&mut self, delta: Point2D) { + self.translations.push(delta); + } + fn scale(&mut self, factor: Point2D, _: Point2D) { self.scales += 1; + self.scale_values.push(factor); self.ops.push("scale".into()); } fn stroke_line(&mut self, _: Point2D, _: Point2D, _: Color, _: f32) {} @@ -118,6 +123,31 @@ fn active_reveal_wraps_node_paint_in_transform() { ); } +#[test] +fn active_reveal_starts_with_readable_lift_and_scale() { + let mut node = SceneNode::leaf("c", NodeKind::Rect); + node.bounds = Rect::xywh(10.0, 10.0, 50.0, 30.0); + node.fill = Some(Color::RED); + let reveals = HashMap::from([("c".to_string(), 1_000)]); + + let backend = paint_with_reveals(&node, &reveals, 1_000); + + assert!( + backend + .scale_values + .first() + .is_some_and(|scale| scale.x <= 0.972 && scale.y <= 0.972), + "reveal should start with enough scale delta to read as an entrance" + ); + assert!( + backend + .translations + .first() + .is_some_and(|delta| delta.y >= 8.0), + "reveal should start with enough lift to avoid an instant pop" + ); +} + #[test] fn opening_parent_reveal_prevents_nested_child_transform() { let frame = frame_with_child(); @@ -138,9 +168,9 @@ fn opening_parent_reveal_prevents_nested_child_transform() { #[test] fn parent_reveal_suppresses_child_transform_through_opening_beat() { let frame = frame_with_child(); - let reveals = HashMap::from([("f".to_string(), 1_000), ("c".to_string(), 1_080)]); + let reveals = HashMap::from([("f".to_string(), 1_000), ("c".to_string(), 1_048)]); - let backend = paint_with_reveals(&frame, &reveals, 1_120); + let backend = paint_with_reveals(&frame, &reveals, 1_056); assert_eq!( backend.scales, 1, diff --git a/crates/op-orchestrator/src/subagent_reveal_tests.rs b/crates/op-orchestrator/src/subagent_reveal_tests.rs index d817d9e16..338f8b2ed 100644 --- a/crates/op-orchestrator/src/subagent_reveal_tests.rs +++ b/crates/op-orchestrator/src/subagent_reveal_tests.rs @@ -40,15 +40,15 @@ fn reveal_schedule_streams_large_subtrees_without_long_tail() { let section = *snapshot.reveals.get("section").expect("section reveal"); let last_label = *snapshot.reveals.get("label-19").expect("last label reveal"); assert!( - last_label - section >= 300, + last_label - section >= 800, "large nested subtrees should stay visibly streamed instead of arriving in one burst" ); assert!( - last_label - section <= 760, + last_label - section <= 1_100, "large nested subtrees should avoid a slow reveal queue" ); assert!( - snapshot.reveals.values().all(|start| *start < 2_000), + snapshot.reveals.values().all(|start| *start < 2_100), "new content should finish entering within a responsive window" ); op_editor_core::agent_indicators::end_if_epoch(epoch); @@ -103,8 +103,9 @@ fn reveal_schedule_keeps_nested_stream_order_across_sibling_groups() { assert!( [label_0 - row_0, row_1 - label_0, label_1 - row_1] .into_iter() - .all(|gap| gap >= 16 && gap <= op_editor_core::agent_indicators::REVEAL_CHILD_RUNWAY_MS), - "nested stream items should keep frame cadence while giving new containers a runway" + .all(|gap| gap >= 40 + && gap <= op_editor_core::agent_indicators::REVEAL_CHILD_RUNWAY_MS), + "nested stream items should keep readable cadence while giving new containers a runway" ); op_editor_core::agent_indicators::end_if_epoch(epoch); } @@ -161,8 +162,8 @@ fn reveal_schedule_gives_new_container_children_an_entrance_runway() { "children of a new container need their own runway instead of starting inside the parent's first beat" ); assert!( - starts.windows(2).all(|pair| pair[1] - pair[0] >= 16), - "container children should continue one-per-frame after the runway" + starts.windows(2).all(|pair| pair[1] - pair[0] >= 40), + "container children should continue at readable cadence after the runway" ); op_editor_core::agent_indicators::end_if_epoch(epoch); }