From eb30a01f2ddd84d7e0af93313fb9179ecf5cee17 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Sat, 12 Sep 2026 21:37:47 -0400 Subject: [PATCH] iris: drive animation from painter frame time --- core/src/ui/mod.rs | 38 +++++++++------------------- core/src/ui/painter.rs | 17 ++++++++++++- core/src/ui/render_state.rs | 26 +++++++++++++++++++ core/src/widget/mod.rs | 5 ---- src/android/view.rs | 7 +++--- src/desktop/mod.rs | 6 ++--- src/harness.rs | 7 +++--- src/layout_tests.rs | 43 ++++++++++++++++++++++++++++++++ src/rsc/sense.rs | 18 ++++++------- src/rsc/sense_tests.rs | 10 +++----- src/widget/layout/lazy_span.rs | 36 ++++++++++---------------- src/widget/layout/scroll_area.rs | 20 +++++---------- src/widget/layout/scrollable.rs | 25 ++++++------------- src/widget/text/selection.rs | 6 ++--- 14 files changed, 148 insertions(+), 116 deletions(-) diff --git a/core/src/ui/mod.rs b/core/src/ui/mod.rs index 2c5395a..433ca33 100644 --- a/core/src/ui/mod.rs +++ b/core/src/ui/mod.rs @@ -26,7 +26,6 @@ pub struct UiData { pub text: Rc>, pub masks: TrackedArena, pub move_offsets: TrackedArena, - animating: Vec, } #[derive(Clone)] @@ -169,40 +168,27 @@ impl DerefMut for Ui { } } -impl UiData { - /// Ask for `id`'s [`crate::Widget::tick`] to run every frame until it - /// says it is done. Idempotent -- registering an already-animating - /// widget is the ordinary case (a second fling before the first - /// settled) and must not tick it twice per frame. - pub fn animate(&mut self, id: WidgetId) { - if !self.animating.contains(&id) { - self.animating.push(id); - } - } - - pub fn tick_animations(&mut self, now: std::time::Instant) -> bool { - let mut registered = std::mem::take(&mut self.animating); - registered.retain(|&id| match self.widgets.get_dyn_mut(id) { - Some(widget) => widget.tick(now), - None => false, - }); - for id in registered { - self.animate(id); - } - !self.animating.is_empty() - } -} - pub trait UiRsc { fn ui(&self) -> &Ui; fn ui_mut(&mut self) -> &mut Ui; fn draw<'a>(&mut self, root: impl Into>) + where + Self: Sized, + { + self.draw_at(root, std::time::Instant::now()); + } + + fn draw_at<'a>( + &mut self, + root: impl Into>, + frame_time: std::time::Instant, + ) -> bool where Self: Sized, { let render_state = self.ui().render_state.clone(); - render_state.get_mut().update(root, self); + render_state.get_mut().update_at(root, self, frame_time) } #[allow(unused_variables)] diff --git a/core/src/ui/painter.rs b/core/src/ui/painter.rs index 9a1dc06..012ad20 100644 --- a/core/src/ui/painter.rs +++ b/core/src/ui/painter.rs @@ -9,7 +9,7 @@ use crate::{ ui::render_state::Retained, util::Vec2, }; -use std::{cell::RefCell, rc::Rc}; +use std::{cell::RefCell, rc::Rc, time::Instant}; pub struct Painter<'a> { pub(super) render_state: &'a mut UiRenderState, @@ -52,6 +52,21 @@ impl DrawResult<'_, '_> { } impl<'a> Painter<'a> { + /// The presentation time of this frame, supplied by the platform. On a + /// backend with a display clock this is the vsync timestamp, not the time + /// at which this widget happened to be drawn. + pub fn frame_time(&self) -> Instant { + self.render_state.frame_time + } + + /// Redraw this widget on the following frame and ask the platform to + /// produce that frame. The invalidation is staged until the current + /// retained redraw has finished, so it cannot be consumed again in this + /// frame. + pub fn request_next_frame(&mut self) { + self.render_state.next_frame.insert(self.id); + } + pub fn set_size(&mut self, size: Size) { assert!( self.size.replace(size).is_none(), diff --git a/core/src/ui/render_state.rs b/core/src/ui/render_state.rs index 3a20068..7495d43 100644 --- a/core/src/ui/render_state.rs +++ b/core/src/ui/render_state.rs @@ -45,6 +45,11 @@ pub struct UiRenderState { /// guard could never fire and the set grew by one entry per widget /// ever drawn and was never emptied. draw_started: HashSet, + /// Widgets which asked from inside `draw` to be drawn on the following + /// frame. Kept separate from `Widgets::needs_redraw` until `update_at` + /// finishes because `redraw_updates` drains that set to completion. + pub(super) next_frame: HashSet, + pub(super) frame_time: Instant, /// `Widget::draw` calls and `Primitives::region_mut` rewrites since the /// last `take_counters`. LAYOUT.md section 8's pass conditions are @@ -111,6 +116,8 @@ impl UiRenderState { old_root: None, resized: false, draw_started: Default::default(), + next_frame: Default::default(), + frame_time: Instant::now(), draw_count: 0, region_mut_count: 0, mov_count: 0, @@ -225,6 +232,17 @@ impl UiRenderState { } pub fn update<'a>(&mut self, root: impl Into>, rsc: &mut dyn UiRsc) { + self.update_at(root, rsc, Instant::now()); + } + + /// Draw a frame dated on the platform's presentation clock. Returns + /// whether a widget requested another frame. + pub fn update_at<'a>( + &mut self, + root: impl Into>, + rsc: &mut dyn UiRsc, + frame_time: Instant, + ) -> bool { // safety mechanism for memory leaks; might wanna return a result instead so user can // decide whether to panic or not if !rsc.widgets().waiting.is_empty() { @@ -241,6 +259,7 @@ impl UiRenderState { weak widgets: {all:#?}" ); } + self.frame_time = frame_time; let root = root.into(); debug_assert!( self.draw_started.is_empty(), @@ -270,6 +289,13 @@ impl UiRenderState { self.apply_free(); #[cfg(debug_assertions)] debug_assert!(self.primitive_counts_agree(), "{}", self.orphan_report(rsc),); + let next: Vec<_> = std::mem::take(&mut self.next_frame) + .into_iter() + .filter(|id| self.active.contains_key(id)) + .collect(); + let requested = !next.is_empty(); + rsc.widgets_mut().needs_redraw.extend(next); + requested } pub fn epoch(&self) -> Instant { diff --git a/core/src/widget/mod.rs b/core/src/widget/mod.rs index 425a175..54781af 100644 --- a/core/src/widget/mod.rs +++ b/core/src/widget/mod.rs @@ -47,11 +47,6 @@ pub trait Widget: Any { fn child_order(&self) -> ChildOrder { ChildOrder::Draw } - - #[allow(unused_variables)] - fn tick(&mut self, now: std::time::Instant) -> bool { - false - } } impl Widget for () { diff --git a/src/android/view.rs b/src/android/view.rs index 2628f1b..8686b0e 100644 --- a/src/android/view.rs +++ b/src/android/view.rs @@ -356,13 +356,14 @@ impl IrisViewPeer { ); } let frame_start = Instant::now(); - let animating = self.rsc.ui_mut().tick_animations(now); + let ui_state = self.state.android_state_mut(); + let animating = self.rsc.draw_at(&ui_state.root, now); + // Ask before renderer update and swapchain acquisition: the latter + // may block until near the next presentation boundary. if animating { ctx.view.post_frame_callback(&mut ctx.env); } let ui_state = self.state.android_state_mut(); - self.rsc.draw(&ui_state.root); - let ui_state = self.state.android_state_mut(); let Some(renderer) = &mut ui_state.renderer else { return; }; diff --git a/src/desktop/mod.rs b/src/desktop/mod.rs index 10a982b..953dfd3 100644 --- a/src/desktop/mod.rs +++ b/src/desktop/mod.rs @@ -255,14 +255,14 @@ impl AppState for DesktopApp { match &event { WindowEvent::CloseRequested => event_loop.exit(), WindowEvent::RedrawRequested => { - // Advance animations before drawing and keep requesting frames while active. let frame_start = std::time::Instant::now(); - let animating = rsc.ui_mut().tick_animations(frame_start); let ui_state = state.desktop_state_mut(); + let animating = rsc.draw_at(&ui_state.root, frame_start); + // Ask before renderer update and swapchain acquisition: the latter + // may block until near the next presentation boundary. if animating { ui_state.window.request_redraw(); } - rsc.draw(&ui_state.root); ui_state.renderer.update(&mut rsc.ui); let mut parts = ui_state.renderer.draw(); parts.total = frame_start.elapsed(); diff --git a/src/harness.rs b/src/harness.rs index 0e54b3b..a57d11c 100644 --- a/src/harness.rs +++ b/src/harness.rs @@ -198,8 +198,8 @@ impl Harness { } /// The `Instant` this harness means by `t_ms`. Public because a - /// caller driving `ScrollController::tick` or `DragGesture` by hand needs - /// to date those calls on the same clock the touch samples use. + /// caller driving a `DragGesture` by hand needs to date those calls on the + /// same clock the touch samples and frame painter use. pub fn at(&self, t_ms: u64) -> Instant { self.base + Duration::from_millis(t_ms) } @@ -218,8 +218,7 @@ impl Harness { } let now = self.at(t_ms); let at = Instant::now(); - let animating = self.rsc.ui.tick_animations(now); - self.rsc.draw(&self.state.root); + let animating = self.rsc.draw_at(&self.state.root, now); // No GPU here, so there is nothing to acquire and nothing to // submit: the frame is all `build`, which is honest rather than // zero-filled (`FrameParts::whole`). `layout`/`redraw`/ diff --git a/src/layout_tests.rs b/src/layout_tests.rs index fc58399..6d9f4e8 100644 --- a/src/layout_tests.rs +++ b/src/layout_tests.rs @@ -69,6 +69,23 @@ struct CountedLeaf { draws: Rc>, } +struct FrameRequester { + draws: Rc>, + seen_time: Rc>>, + request: bool, +} + +impl Widget for FrameRequester { + fn draw(&mut self, painter: &mut Painter) { + self.draws.set(self.draws.get() + 1); + self.seen_time.set(Some(painter.frame_time())); + if self.request { + painter.request_next_frame(); + } + painter.set_size(Size::ZERO); + } +} + impl Widget for CountedLeaf { fn draw(&mut self, painter: &mut Painter) { self.draws.set(self.draws.get() + 1); @@ -93,6 +110,32 @@ impl Widget for TracedParent { } } +#[test] +fn a_next_frame_request_is_staged_after_the_current_redraw() { + let mut rsc = TestRsc { ui: Ui::default() }; + let draws = Rc::new(Cell::new(0)); + let seen_time = Rc::new(Cell::new(None)); + let widget = rsc.ui.widgets.add_strong(FrameRequester { + draws: draws.clone(), + seen_time: seen_time.clone(), + request: true, + }); + let weak = widget.weak(); + let root = widget.any(); + let mut render = UiRenderState::new(); + let frame_time = std::time::Instant::now(); + + assert!(render.update_at(&root, &mut rsc, frame_time)); + assert_eq!(draws.get(), 1, "the request was consumed in the same frame"); + assert_eq!(seen_time.get(), Some(frame_time)); + assert!(rsc.ui.widgets.needs_redraw.contains(&weak.id())); + + rsc.ui.widgets.get_mut(&weak).unwrap().request = false; + assert!(!render.update_at(&root, &mut rsc, frame_time)); + assert_eq!(draws.get(), 2); + assert!(!rsc.ui.widgets.needs_redraw.contains(&weak.id())); +} + #[test] fn a_widget_retains_its_entry_layer_not_its_child_cursor() { let mut rsc = TestRsc { ui: Ui::default() }; diff --git a/src/rsc/sense.rs b/src/rsc/sense.rs index a8bec3e..3a80307 100644 --- a/src/rsc/sense.rs +++ b/src/rsc/sense.rs @@ -1344,8 +1344,8 @@ impl FlingCalculator { } /// The signed distance covered by `elapsed` into a fling of this - /// `velocity` -- what a per-frame ticker (`ScrollController::tick`) calls to - /// find how far to have scrolled by now. Clamped to the full + /// `velocity` -- what a scrolling widget uses to find how far it should + /// have moved by this frame. Clamped to the full /// `distance()` once `elapsed` reaches `duration()`, so a caller need /// not special-case "past the end." pub fn position_at(&self, velocity: f32, elapsed: Duration) -> f32 { @@ -1373,7 +1373,7 @@ impl FlingCalculator { /// go, are the caller's -- a `LazySpan` scrolls its anchor one way and a /// `ScrollArea` moves its `amt` the other, and a `Flinger` that tried to know /// which would have to be told, which is the same thing as not knowing. -/// So a caller applies [`Self::tick`]'s delta in its own convention and +/// So a caller applies [`Self::advance`]'s delta in its own convention and /// calls [`Self::stop`] when it runs out of content. pub struct Flinger { fling: Option, @@ -1382,7 +1382,7 @@ pub struct Flinger { struct InFlight { calc: FlingCalculator, velocity: f32, - /// When the curve begins -- **the first [`Flinger::tick`], not the + /// When the curve begins -- **the first [`Flinger::advance`], not the /// release**. Set there so the only clock this reads is the one its /// driver hands it: a caller running frames on an explicit clock /// (`iris::harness`, `bench_client.rs`'s scripted phases) would @@ -1406,15 +1406,13 @@ impl Flinger { } /// Start a fling at `velocity_px_per_s`, in whatever pixel space the - /// caller applies [`Self::tick`]'s delta in. `density` is physical + /// caller applies [`Self::advance`]'s delta in. `density` is physical /// pixels per dp, from the painter -- it does **not** cancel out of /// the spline (see [`FlingCalculator`]), and a hardcoded 1.0 against a /// 2.55-density screen made a one-second coast run for 45. /// - /// Answers whether a fling actually started, which is a caller's cue - /// to register for frames (`UiData::animate`): registering a widget - /// that is not animating only asks the next frame to find that out. - /// Cancels any fling already in progress. + /// Cancels any fling already in progress. A widget which owns one advances + /// it during `draw` and asks its painter for the following frame. /// /// Compose's two thresholds at a release, and **only** those two. The /// maximum is `ViewConfiguration.getScaledMaximumFlingVelocity()` @@ -1476,7 +1474,7 @@ impl Flinger { /// itself on the spline's own schedule, after which /// [`Self::is_flinging`] is false and the caller stops asking for /// frames. - pub fn tick(&mut self, now: Instant) -> f32 { + pub fn advance(&mut self, now: Instant) -> f32 { let Some(f) = &mut self.fling else { return 0.0; }; diff --git a/src/rsc/sense_tests.rs b/src/rsc/sense_tests.rs index 21f7750..67a92c7 100644 --- a/src/rsc/sense_tests.rs +++ b/src/rsc/sense_tests.rs @@ -616,8 +616,9 @@ fn a_press_does_not_reach_a_widget_the_pointer_has_just_left() { let mut c = cursor_at((50.0, y).into()); c.buttons.left = button; c.time = base + std::time::Duration::from_millis(at_ms); + let frame_time = c.time; render.run_sensors(rsc, state, c, win); - render.update(&root, rsc); + render.update_at(&root, rsc, frame_time); }; sample( &mut render, @@ -645,8 +646,6 @@ fn a_press_does_not_reach_a_widget_the_pointer_has_just_left() { "the flick must leave the top area coasting -- the press below is \ only dangerous while something is still moving", ); - let flung_to = rsc.ui.widgets.get(&top_w).unwrap().amt(); - t += 8; sample( &mut render, @@ -672,9 +671,8 @@ fn a_press_does_not_reach_a_widget_the_pointer_has_just_left() { "the area actually under the finger should have panned by the 40px \ past the slop, got {moved}" ); - assert_eq!( - rsc.ui.widgets.get(&top_w).unwrap().amt(), - flung_to, + assert!( + rsc.ui.widgets.get(&top_w).unwrap().is_scrolling(), "the area the pointer had left must not have seen the press at all -- \ a catch would have stopped its fling on the touch-down" ); diff --git a/src/widget/layout/lazy_span.rs b/src/widget/layout/lazy_span.rs index 37a6e54..9e9d2eb 100644 --- a/src/widget/layout/lazy_span.rs +++ b/src/widget/layout/lazy_span.rs @@ -1,7 +1,6 @@ use crate::prelude::*; use iris_core::util::HashMap; use std::collections::VecDeque; -use std::time::Instant; pub type RowKey = u64; @@ -745,15 +744,13 @@ impl Widget for LazySpan { ChildOrder::Axis(self.dir.axis) } - /// A lazy span animates exactly one thing, its fling -- and it drives - /// its own rather than being handed deltas by a `ScrollArea` around - /// it, since which rows exist at all is a function of where it is - /// scrolled to and a moved lump would never update them. - fn tick(&mut self, now: Instant) -> bool { - self.tick_fling(now) - } - fn draw(&mut self, painter: &mut Painter) { + // A lazy span drives its own fling because which rows exist is a + // function of where it is scrolled to; it cannot be moved as one + // retained lump by a `ScrollArea` around it. + if self.ctl.advance(painter.frame_time()) { + painter.request_next_frame(); + } let axis = self.dir.axis; let output_len = painter.output_size().axis(axis); self.viewport_len = painter.region().axis(axis).len().to_abs(output_len); @@ -1529,14 +1526,11 @@ mod tests { fn fling_frame( rsc: &mut TestRsc, - scroll: &WeakWidget, root: &StrongWidget, render: &mut UiRenderState, now: Instant, ) -> bool { - let still = rsc.ui.widgets.get_mut(scroll).unwrap().tick(now); - render.update(root, rsc); - still + render.update_at(root, rsc, now) } #[test] @@ -1552,7 +1546,7 @@ mod tests { let mut still = true; for step in 0..600 { let now = start + std::time::Duration::from_millis(step * 16); - still = fling_frame(&mut rsc, &scroll, &root, &mut render, now); + still = fling_frame(&mut rsc, &root, &mut render, now); if !still { break; } @@ -1566,24 +1560,20 @@ mod tests { } #[test] - fn a_registered_fling_is_driven_by_tick_animations_and_then_unregisters() { + fn a_fling_requests_frames_until_it_settles() { let mut rsc = TestRsc { ui: Ui::default() }; let (scroll, root, mut render) = build_flingable_list(&mut rsc); let list_weak = scroll; let before = scroll_position(rsc.ui.widgets.get(&list_weak).unwrap()); - if rsc.ui.widgets.get_mut(&scroll).unwrap().fling(8000.0) { - let id = scroll.id(); - rsc.ui.animate(id); - } + assert!(rsc.ui.widgets.get_mut(&scroll).unwrap().fling(8000.0)); let start = Instant::now(); let mut steps = 0; let mut animating = true; while animating && steps < 600 { let now = start + std::time::Duration::from_millis(steps * 16); - animating = rsc.ui.tick_animations(now); - render.update(&root, &mut rsc); + animating = render.update_at(&root, &mut rsc, now); steps += 1; } assert!(!animating, "the driver never stopped within 600 frames"); @@ -1595,7 +1585,7 @@ mod tests { ); let now = start + std::time::Duration::from_millis(steps * 16); - assert!(!rsc.ui.tick_animations(now)); + assert!(!render.update_at(&root, &mut rsc, now)); } #[test] @@ -1650,7 +1640,7 @@ mod tests { let start = Instant::now(); for step in 0..2000 { let now = start + std::time::Duration::from_millis(step * 16); - if !fling_frame(&mut rsc, &scroll, &root, &mut render, now) { + if !fling_frame(&mut rsc, &root, &mut render, now) { break; } } diff --git a/src/widget/layout/scroll_area.rs b/src/widget/layout/scroll_area.rs index 9318657..1c7d0dd 100644 --- a/src/widget/layout/scroll_area.rs +++ b/src/widget/layout/scroll_area.rs @@ -1,5 +1,4 @@ use crate::prelude::*; -use std::time::Instant; pub struct ScrollArea { inner: StrongWidget, @@ -19,11 +18,10 @@ impl Scrollable for ScrollArea { } impl Widget for ScrollArea { - fn tick(&mut self, now: Instant) -> bool { - self.tick_fling(now) - } - fn draw(&mut self, painter: &mut Painter) { + if self.ctl.advance(painter.frame_time()) { + painter.request_next_frame(); + } let axis = self.ctl.axis(); let container_len = painter.px_size().axis(axis); self.container_len = container_len; @@ -87,7 +85,7 @@ mod tests { use super::*; use crate::layout_tests::TestRsc; use crate::sense::{CursorButton, DRAG_SLOP, PointerRequests}; - use std::time::Duration; + use std::time::{Duration, Instant}; fn area() -> (Fixture, WidgetId) { area_on(Axis::Y) @@ -146,9 +144,7 @@ mod tests { } fn fling_frame(&mut self, now: Instant) -> bool { - let still = self.get().tick(now); - self.draw(); - still + self.render.update_at(&self.root, &mut self.rsc, now) } } @@ -158,11 +154,7 @@ mod tests { fn drag(f: &mut Fixture, id: WidgetId, sense: CursorSense, pos: Vec2, t: Instant) { let pointer = PointerRequests::default(); - let flung = f.get().drag(&pointer, id, sense, pos, t); - if flung { - let id = f.area.id(); - f.rsc.ui.animate(id); - } + f.get().drag(&pointer, id, sense, pos, t); f.draw(); } diff --git a/src/widget/layout/scrollable.rs b/src/widget/layout/scrollable.rs index d7331c3..dddf7db 100644 --- a/src/widget/layout/scrollable.rs +++ b/src/widget/layout/scrollable.rs @@ -159,9 +159,9 @@ impl ScrollController { } /// Start a fling at `velocity`, in [`Self::scroll`]'s direction - /// convention. Answers whether one actually started, which is the - /// caller's cue to register the widget for frames (`UiData::animate`). - /// Cancels any fling already in progress. + /// convention, cancelling any fling already in progress. Answers whether + /// one actually started. The owning widget advances it from `draw` and + /// requests another frame while it remains active. pub fn fling(&mut self, velocity: f32) -> bool { self.fling.start(velocity, self.density) } @@ -189,8 +189,8 @@ impl ScrollController { self.fling.velocity() } - pub fn tick(&mut self, now: Instant) -> bool { - let delta = self.fling.tick(now); + pub(crate) fn advance(&mut self, now: Instant) -> bool { + let delta = self.fling.advance(now); self.scroll(delta); self.fling.is_flinging() } @@ -210,9 +210,8 @@ impl ScrollController { /// this takes the gesture over. Android's own `EditText` behaves the /// same way -- a vertical drag scrolls, and only a long press selects. /// - /// Answers whether this frame *started a fling*, which is the caller's - /// cue to register the widget for frames (`UiData::animate`) -- see - /// [`Self::fling`]. + /// Answers whether this frame started a fling, for callers that need to + /// distinguish a release which began coasting from one which did not. pub fn drag( &mut self, pointer: &PointerRequests, @@ -306,10 +305,6 @@ pub trait Scrollable { fn set_pinned_to_end(&mut self, pinned: bool) { self.controller_mut().set_pinned_to_end(pinned); } - - fn tick_fling(&mut self, now: Instant) -> bool { - self.controller_mut().tick(now) - } } pub fn scroll_senses(w: WL, axis: Axis) -> impl WidgetIdFn @@ -325,11 +320,7 @@ where .on(CursorSense::drag(axis), |ctx, rsc: &mut Rsc| { let id = ctx.widget.id(); let (sense, pos) = (ctx.data.sense, ctx.data.cursor.pos); - let flung = ctx - .widget(rsc) + ctx.widget(rsc) .drag(ctx.data.pointer, id, sense, pos, ctx.data.cursor.time); - if flung { - rsc.ui_mut().animate(id); - } }) } diff --git a/src/widget/text/selection.rs b/src/widget/text/selection.rs index c3e5c28..c22664e 100644 --- a/src/widget/text/selection.rs +++ b/src/widget/text/selection.rs @@ -432,10 +432,8 @@ impl SelectionController { SelectionInput::Handled } GestureOutcome::Released(Some(velocity)) => { - if let Some(scroll) = self.scroll - && scroll(rsc).fling(velocity) - { - rsc.ui_mut().animate(scroll.id()); + if let Some(scroll) = self.scroll { + scroll(rsc).fling(velocity); } SelectionInput::Handled }