Render draft frames while a gesture is still moving

The adjust pass and the readback both scale with pixel count, so a drag
paid full viewport cost on every frame it managed to produce. Halving
each edge while the gesture is moving is roughly a quarter of the work.

Detecting the gesture needed no new plumbing. No control reports a drag
boundary, and threading one out of every slider, curve point and crop
handle would be a lot of surface for what is a rendering concern. The
coalescing flag already carries the answer: a request that arrives while
a render is queued can only come from a control that moved again. A
click, a reset or a resize never coalesces, so those still render sharp
the first time and never show a draft frame.

A settle render restores full resolution 120 ms after the last change —
above the interval between events within a drag, so an ordinary gesture
never trips it mid-motion, and well under the point where waiting for
the sharp frame would be noticeable. Only one settle is ever queued.

The softness is therefore visible only while the image is moving too
fast to study.

dr-ui tests pass.
This commit is contained in:
2026-08-09 21:56:44 +02:00
parent 250ff1e327
commit 75ce3846c3
+65 -4
View File
@@ -53,6 +53,13 @@ const MAX_DISPLAY_DIM: u32 = 2048;
/// compact layout exactly as a tablet in portrait would.
const EXPANDED_MIN_WIDTH: f32 = 820.0;
/// How long after the last change a draft frame is replaced by a sharp one.
///
/// Above the interval between events in a drag, so an ordinary gesture never
/// reaches it and never renders full resolution mid-motion; well below the
/// point where a photographer would notice waiting for the sharp frame.
const SETTLE_DELAY: std::time::Duration = std::time::Duration::from_millis(120);
/// Everything loaded for the currently displayed image.
struct Loaded {
/// A develop session. `None` only where the file could not be opened for
@@ -479,13 +486,27 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
//
// Called on every slider change, so it must do no more than run the
// adjust pass — the demosaic is not repeated.
let render_now: Rc<dyn Fn(&AppWindow)> = {
let render_now: Rc<dyn Fn(&AppWindow, bool)> = {
let session = session.clone();
let viewport = viewport.clone();
Rc::new(move |window: &AppWindow| {
Rc::new(move |window: &AppWindow, draft: bool| {
let mut slot = session.borrow_mut();
let Some(s) = slot.as_mut() else { return };
let (w, h) = *viewport.borrow();
let (mut w, mut h) = *viewport.borrow();
// **Half resolution while the gesture is still moving.**
//
// The adjust pass and the readback both scale with pixel count, so
// halving each edge is roughly a quarter of the work — the
// difference between keeping up with a drag and lagging behind it.
// A draft frame is visible for one gesture and is replaced by a
// full-resolution one the moment motion stops, so the cost is a
// little softness exactly while the image is moving too fast to
// study anyway.
if draft {
w = (w / 2).max(1);
h = (h / 2).max(1);
}
// Crop mode shows the whole frame, or the area being cropped away
// would not be on screen for the handles to drag across. The
@@ -536,13 +557,22 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// 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));
// 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));
let redraw: Rc<dyn Fn(&AppWindow)> = {
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);
return;
}
render_pending.set(true);
@@ -551,6 +581,8 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
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
@@ -561,7 +593,36 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
if !render_dirty.replace(false) {
return;
}
render_now(&window);
// **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 };
render_now(&window, false);
});
});
})
};