Keep a drag in draft until it stops, then render sharp once

During any drag longer than 120 ms the canvas rendered a full-resolution
frame every 128 ms under the finger. The settle timer was armed by the
first draft of a burst and not re-armed by later ones, so it counted from
the start of the gesture rather than from its last movement, fired
mid-drag, and the next coalesced event armed it again. Each of those
frames is the most expensive one the canvas draws, landing where the
frame budget is tightest.

The draft/sharp decision now lives in `refine::Refine`, apart from the
timers that carry it out. Every draft frame arms a settle timer carrying
a generation token and only the newest token is honoured, so the sharp
frame lands SETTLE_DELAY after the last movement. A request arriving
while a settle is still owed also counts as part of the gesture, so a
slow stretch of a drag (one event per frame, nothing to coalesce) no
longer renders sharp between drafts. A timer that fires with a render
already posted defers to it.

The tests drive the state machine through simulated timelines; the
long-drag case reproduced the four mid-drag sharp frames before the fix.
This commit is contained in:
2026-09-24 21:52:23 -04:00
parent c3d1f83b96
commit 7d0870c3fb
3 changed files with 331 additions and 112 deletions
+32 -64
View File
@@ -58,6 +58,7 @@ mod place;
mod preset_store;
mod presets;
mod recovery_ui;
mod refine;
mod remote;
pub mod repairs;
mod segmentation;
@@ -2428,77 +2429,44 @@ fn build_render_now(
/// on the finger rather than settling on a stale frame.
fn build_redraw(render_now: &Render) -> Rc<dyn Fn(&AppWindow)> {
let render_now = render_now.clone();
let render_pending = Rc::new(std::cell::Cell::new(false));
let render_dirty = Rc::new(std::cell::Cell::new(false));
// Set while a settle render is already queued, so a burst of draft frames
// schedules exactly one of them rather than one apiece.
let settle_pending = Rc::new(std::cell::Cell::new(false));
// Whether a request had to be coalesced into one already queued — see the
// gesture note below, where this stands in for a drag boundary.
let was_coalesced = Rc::new(std::cell::Cell::new(false));
{
// Which frame to draw and when to settle, decided apart from the timers
// that carry it out — see `refine` for the rules and their tests.
let refine = Rc::new(RefCell::new(refine::Refine::default()));
Rc::new(move |window: &AppWindow| {
if !refine.borrow_mut().request() {
return;
}
let weak = window.as_weak();
let render_now = render_now.clone();
let render_pending = render_pending.clone();
let render_dirty = render_dirty.clone();
let settle_pending = settle_pending.clone();
let was_coalesced = was_coalesced.clone();
Rc::new(move |window: &AppWindow| {
render_dirty.set(true);
if render_pending.get() {
was_coalesced.set(true);
let refine = refine.clone();
// A zero-delay `Timer` rather than `invoke_from_event_loop`: the
// latter demands `Send`, and every piece of state here is `Rc` on
// the UI thread by design. The delay being zero is the point — this
// is "after the queued input has drained", not a throttle.
slint::Timer::single_shot(std::time::Duration::ZERO, move || {
let Some(window) = weak.upgrade() else { return };
let Some((frame, settle)) = refine.borrow_mut().tick() else {
return;
}
render_pending.set(true);
};
render_now(&window, frame == refine::Frame::Draft);
let Some(token) = settle else { return };
// TRACES: FR-DSP-4
// One timer per draft frame, and only the newest is honoured, so
// the sharp frame waits for the gesture to stop rather than for
// the first draft to age. A superseded timer wakes, finds its
// token stale, and does nothing — cheaper than holding a `Timer`
// to restart, and it keeps the rule where the tests can see it.
let weak = window.as_weak();
let render_now = render_now.clone();
let render_pending = render_pending.clone();
let render_dirty = render_dirty.clone();
let settle_pending = settle_pending.clone();
let was_coalesced = was_coalesced.clone();
// A zero-delay `Timer` rather than `invoke_from_event_loop`: the
// latter demands `Send`, and every piece of state here is `Rc` on
// the UI thread by design. The delay being zero is the point — this
// is "after the queued input has drained", not a throttle.
slint::Timer::single_shot(std::time::Duration::ZERO, move || {
render_pending.set(false);
slint::Timer::single_shot(SETTLE_DELAY, move || {
let Some(window) = weak.upgrade() else { return };
if !render_dirty.replace(false) {
return;
}
// **What counts as "still dragging".**
//
// No control reports a gesture boundary, and threading one out
// of every slider, curve point and crop handle would be a lot
// of surface for a rendering concern. `was_coalesced` answers
// it instead: it is set only when a request arrived while a
// render was already queued, which can only mean a control
// moved again — that is a drag. One-off changes — a click, a
// reset, a resize — never coalesce, so they render sharp the
// first time and never draw a draft frame at all.
let dragging = was_coalesced.replace(false);
render_now(&window, dragging);
if !dragging {
return;
}
if settle_pending.replace(true) {
return;
}
let weak = window.as_weak();
let render_now = render_now.clone();
let settle_pending = settle_pending.clone();
// Long enough that an ordinary drag never reaches it, short
// enough that the sharp frame feels immediate on release.
slint::Timer::single_shot(SETTLE_DELAY, move || {
settle_pending.set(false);
let Some(window) = weak.upgrade() else { return };
if refine.borrow_mut().settle(token).is_some() {
render_now(&window, false);
});
}
});
})
}
});
})
}
/// Build `show`: load the entry at `index` and put it on screen.