LR-VIEW-ZOOM: a pinch holds the loupe's fit size, so it no longer renders at every zoom step

This commit is contained in:
Nathaniel Ledford
2026-10-21 19:50:28 -04:00
parent bfb23db2ca
commit 0e08318bad
4 changed files with 57 additions and 14 deletions
+6 -11
View File
@@ -575,6 +575,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
} }
// a pinch changes the zoom every frame: keep the sizes of before it until it is quiet // a pinch changes the zoom every frame: keep the sizes of before it until it is quiet
let plan = app.size_hold.apply(id, now, navigating, plan); let plan = app.size_hold.apply(id, now, navigating, plan);
// the drawn size in pixels, clamped first: a NaN or negative float must not become a size
let drawn_px = |points: f32| (points * ppp).round().clamp(0.0, 65_536.0) as usize;
let fit_px = app.size_hold.display_fit(now, navigating, [drawn_px(target_rect.width()), drawn_px(target_rect.height())]);
if app.size_hold.holding(now) { if app.size_hold.holding(now) {
ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(crate::region::HOLD_SECS)); ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(crate::region::HOLD_SECS));
} }
@@ -603,11 +606,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
// at rest the render is shrunk to exactly the size it is drawn at with a Lanczos filter on // at rest the render is shrunk to exactly the size it is drawn at with a Lanczos filter on
// the worker, so the window draws it texel for texel instead of shrinking it with 2-tap // the worker, so the window draws it texel for texel instead of shrinking it with 2-tap
// sampling (the render is up to a 64 px step larger, or the preview level) // sampling (the render is up to a 64 px step larger, or the preview level)
let job = if interacting { let job = if interacting { job } else { job.with_display_fit(fit_px[0], fit_px[1]) };
job
} else {
job.with_display_fit((target_rect.width() * ppp).round() as usize, (target_rect.height() * ppp).round() as usize)
};
let job = job.with_overlay(overlay).with_proof(app.ui.soft_proof.then_some(app.ui.proof)); let job = job.with_overlay(overlay).with_proof(app.ui.soft_proof.then_some(app.ui.proof));
let job = job.with_hidden_grading(app.ui.grading_hidden_for(id)); let job = job.with_hidden_grading(app.ui.grading_hidden_for(id));
app.renderer.request(Slot::Main, job, 100); app.renderer.request(Slot::Main, job, 100);
@@ -626,7 +625,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let hover_key = match app.hover_preview.clone() { let hover_key = match app.hover_preview.clone() {
Some(h) if !interacting => app.session.preview_job(id, sw.max(8), sh.max(8), !crop_tool, &h.settings).map(|job| { Some(h) if !interacting => app.session.preview_job(id, sw.max(8), sh.max(8), !crop_tool, &h.settings).map(|job| {
// (the same display fit as the After view, drawn texel for texel like it) // (the same display fit as the After view, drawn texel for texel like it)
let job = job.with_display_fit((target_rect.width() * ppp).round() as usize, (target_rect.height() * ppp).round() as usize); let job = job.with_display_fit(fit_px[0], fit_px[1]);
let key = job.key; let key = job.key;
app.renderer.request(Slot::Hover, job, 105); app.renderer.request(Slot::Hover, job, 105);
(key, h.label) (key, h.label)
@@ -666,11 +665,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
&& let Some(job) = app.session.render_job(id, sw.max(8), sh.max(8), true, !crop_tool) && let Some(job) = app.session.render_job(id, sw.max(8), sh.max(8), true, !crop_tool)
{ {
// (the same display fit as the After view, so both panes are the same texture size) // (the same display fit as the After view, so both panes are the same texture size)
let job = if interacting { let job = if interacting { job } else { job.with_display_fit(fit_px[0], fit_px[1]) };
job
} else {
job.with_display_fit((target_rect.width() * ppp).round() as usize, (target_rect.height() * ppp).round() as usize)
};
app.renderer.request(Slot::Before, job, 90); app.renderer.request(Slot::Before, job, 90);
} }
// zoomed past what the whole-frame render holds: also render the part on screen, at no more // zoomed past what the whole-frame render holds: also render the part on screen, at no more
+42 -1
View File
@@ -331,6 +331,7 @@ pub const HOLD_SECS: f64 = 0.25;
pub struct SizeHold { pub struct SizeHold {
photo: Option<lightcraft_catalog::PhotoId>, photo: Option<lightcraft_catalog::PhotoId>,
plan: Option<LoupePlan>, plan: Option<LoupePlan>,
fit: Option<[usize; 2]>,
until: f64, until: f64,
} }
@@ -339,7 +340,7 @@ impl SizeHold {
/// one is running (`gesturing`) and for [`HOLD_SECS`] after. `now` is in seconds. /// one is running (`gesturing`) and for [`HOLD_SECS`] after. `now` is in seconds.
pub fn apply(&mut self, photo: lightcraft_catalog::PhotoId, now: f64, gesturing: bool, fresh: LoupePlan) -> LoupePlan { pub fn apply(&mut self, photo: lightcraft_catalog::PhotoId, now: f64, gesturing: bool, fresh: LoupePlan) -> LoupePlan {
if self.photo != Some(photo) { if self.photo != Some(photo) {
*self = SizeHold { photo: Some(photo), plan: Some(fresh), until: f64::NEG_INFINITY }; *self = SizeHold { photo: Some(photo), plan: Some(fresh), fit: None, until: f64::NEG_INFINITY };
return fresh; return fresh;
} }
if gesturing { if gesturing {
@@ -354,6 +355,18 @@ impl SizeHold {
} }
} }
/// The size the whole frame is shrunk to at rest (the drawn size): the one from before the
/// gesture while sizes are held (call after [`Self::apply`] in the same frame), else `fresh`.
pub fn display_fit(&mut self, now: f64, gesturing: bool, fresh: [usize; 2]) -> [usize; 2] {
match self.fit {
Some(held) if gesturing || now < self.until => held,
_ => {
self.fit = Some(fresh);
fresh
}
}
}
/// Whether sizes are being held at `now` (the caller asks for a repaint when it ends). /// Whether sizes are being held at `now` (the caller asks for a repaint when it ends).
pub fn holding(&self, now: f64) -> bool { pub fn holding(&self, now: f64) -> bool {
now < self.until now < self.until
@@ -889,6 +902,34 @@ mod tests {
assert_eq!(hold.apply(p, 1.4, false, zoomed), zoomed); assert_eq!(hold.apply(p, 1.4, false, zoomed), zoomed);
} }
// Given a pinch, the display-fit size holds like the render sizes and is retaken after it
#[test]
fn a_pinch_holds_the_display_fit_size() {
use lightcraft_catalog::PhotoId;
let (fit, zoomed) = (plan(&auto(), sizes(1400.0, 1400.0, 6000)), plan(&auto(), sizes(24000.0, 1400.0, 6000)));
let mut hold = SizeHold::default();
let p = PhotoId(1);
hold.apply(p, 0.0, false, fit);
assert_eq!(hold.display_fit(0.0, false, [1000, 800]), [1000, 800]);
for k in 1..60 {
let now = k as f64 / 60.0;
hold.apply(p, now, true, zoomed);
assert_eq!(hold.display_fit(now, true, [1000 + k, 800 + k]), [1000, 800], "frame {k}");
}
// stopped at t = 59/60: still held within the hold time…
hold.apply(p, 1.1, false, zoomed);
assert_eq!(hold.display_fit(1.1, false, [2560, 2048]), [1000, 800]);
// …then the size it ended at is taken, and kept while nothing moves
hold.apply(p, 1.3, false, zoomed);
assert_eq!(hold.display_fit(1.3, false, [2560, 2048]), [2560, 2048]);
assert_eq!(hold.display_fit(1.4, false, [2561, 2049]), [2561, 2049]);
// another photo forgets the held size
hold.apply(p, 1.5, true, zoomed);
hold.display_fit(1.5, true, [2561, 2049]);
hold.apply(PhotoId(2), 1.6, true, zoomed);
assert_eq!(hold.display_fit(1.6, true, [640, 480]), [640, 480]);
}
// Given another photo, the held sizes are forgotten // Given another photo, the held sizes are forgotten
#[test] #[test]
fn another_photo_is_planned_afresh() { fn another_photo_is_planned_afresh() {
File diff suppressed because one or more lines are too long
+6
View File
@@ -1,5 +1,11 @@
# What's new in LightCraft # What's new in LightCraft
## 2026-10-21
### A pinch no longer re-renders the photo at every zoom step
- Pinching the loupe now keeps the size the fit view is rendered at until the gesture is quiet, as it already did for
the render sizes and windows. Before, each frame of a pinch asked for a new full-frame render at a new size.
## 2026-10-19 ## 2026-10-19
### Hidden Local locations come back one at a time ### Hidden Local locations come back one at a time