From 31dd86d8c0bfb49c22b45eef91a0ec72eaa5b4af Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sun, 9 Aug 2026 21:49:43 +0200 Subject: [PATCH] Coalesce develop renders instead of rendering per input event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every slider, curve-point and crop-handle drag ran a full render straight from its `moved` handler. A render ends in a blocking GPU readback, so that stall sat directly on the input path: touch events arrive far faster than a render completes, the queue backed up, and positions reached the handlers several samples stale — the jumpy dragging. The worse consequence was gestures being lost outright. When events go unconsumed for long enough Android reclaims the stream and hands it to the view underneath, so a drag stopped mid-gesture never received `up`, only `cancel` — which every handler here treats as "abort the drag". `redraw` no longer renders. It marks the canvas dirty and posts one render onto the event loop, coalescing any further requests that arrive while it is pending, and re-checks the flag afterwards so a value that moved during the render is not left on a stale frame. Handlers now return immediately, which is what keeps the gesture consumed. A zero-delay `Timer` rather than `invoke_from_event_loop`: the latter requires `Send` and this state is deliberately `Rc` on the UI thread. All existing `redraw` call sites are unchanged — the signature is the same and coalescing is internal. --- ui/dr-ui/src/lib.rs | 54 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/ui/dr-ui/src/lib.rs b/ui/dr-ui/src/lib.rs index 2dd17eb..1c7f080 100644 --- a/ui/dr-ui/src/lib.rs +++ b/ui/dr-ui/src/lib.rs @@ -479,7 +479,7 @@ pub fn run(paths: Vec) -> Result<()> { // // Called on every slider change, so it must do no more than run the // adjust pass — the demosaic is not repeated. - let redraw: Rc = { + let render_now: Rc = { let session = session.clone(); let viewport = viewport.clone(); Rc::new(move |window: &AppWindow| { @@ -514,6 +514,58 @@ pub fn run(paths: Vec) -> Result<()> { }) }; + // **Rendering is decoupled from input, and this is why.** + // + // A render is a blocking GPU round-trip (see `AdjustPass::read_output`). + // Running one straight from a `moved` handler put that stall *inside* the + // gesture: touch events arrive far faster than a render completes, so the + // input queue backed up, positions arrived stale, and Android — seeing the + // events go unconsumed — reclaimed the gesture and delivered `cancel` + // instead of `up`. That is the dropped-drag bug, and no amount of tuning + // inside the Slint handlers fixes it while the stall is on the input path. + // + // So `redraw` no longer renders. It marks the canvas dirty and posts a + // single render onto the event loop; every further request while one is + // already pending just sets the flag again. A drag emitting forty events + // therefore renders a handful of times instead of forty, and — the part + // that actually fixes the drop — each event handler returns immediately, + // so the gesture is always consumed promptly. + // + // The flag is re-checked *after* the render because parameters may have + // moved again while it ran; that repost is what keeps the image converging + // on the finger rather than settling on a stale frame. + let render_pending = Rc::new(std::cell::Cell::new(false)); + let render_dirty = Rc::new(std::cell::Cell::new(false)); + let redraw: Rc = { + let render_now = render_now.clone(); + let render_pending = render_pending.clone(); + let render_dirty = render_dirty.clone(); + Rc::new(move |window: &AppWindow| { + render_dirty.set(true); + if render_pending.get() { + return; + } + render_pending.set(true); + + let weak = window.as_weak(); + let render_now = render_now.clone(); + let render_pending = render_pending.clone(); + let render_dirty = render_dirty.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); + let Some(window) = weak.upgrade() else { return }; + if !render_dirty.replace(false) { + return; + } + render_now(&window); + }); + }) + }; + let show = { let entries = entries.clone(); let index = index.clone();