Merge pull request #5 from GlemNL/feat/click-zoom-ratio
Loupe: animated click-zoom with selectable 2:1 / 3:1 / 5:1 ratio
This commit is contained in:
commit
fc66026ef0
|
|
@ -1118,6 +1118,48 @@ mod tests {
|
|||
h.settle(SETTLE);
|
||||
}
|
||||
|
||||
/// A click on the image zooms to the chosen click-zoom ratio (1:1 by default), animating the
|
||||
/// loupe while rendering once at the final size; Z uses the same ratio; a second click returns to Fit.
|
||||
#[test]
|
||||
fn click_zoom_ratio_animates() {
|
||||
use crate::render::Slot;
|
||||
use crate::state::Zoom;
|
||||
let mut h = demo([1000.0, 700.0]);
|
||||
let t = Duration::from_secs(10);
|
||||
h.request("ui.set", json!({"view": "detail", "right": "none"}), t);
|
||||
h.settle(SETTLE);
|
||||
assert_eq!(h.app.ui.click_zoom, 100, "1:1 by default");
|
||||
let r = h.request("engine.execute", json!({"command": "view.clickZoom", "params": {"ratio": 3}}), t);
|
||||
assert_eq!(r["result"]["ratio"], 3, "{r}");
|
||||
let r = h.request("engine.execute", json!({"command": "view.clickZoom", "params": {"ratio": 5}}), t);
|
||||
assert_ne!(r["ok"], true, "{r}");
|
||||
let fit = h.app.image_rect.unwrap();
|
||||
h.request("ui.clickWidget", json!({"id": "canvas:image", "fx": 0.5, "fy": 0.5}), t);
|
||||
assert_eq!(h.app.ui.zoom, Zoom::Percent(300));
|
||||
assert!(h.app.ui.zoom_anim, "animation started");
|
||||
// every animation frame asks for the same (final-size) render
|
||||
let mut keys = std::collections::HashSet::new();
|
||||
for _ in 0..40 {
|
||||
h.step();
|
||||
keys.extend(h.app.renderer.wanted(Slot::Main));
|
||||
if !h.app.ui.zoom_anim {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert_eq!(keys.len(), 1, "one render for the whole animation: {keys:?}");
|
||||
h.settle(SETTLE);
|
||||
assert!(!h.app.ui.zoom_anim, "animation finished");
|
||||
assert!(h.app.image_rect.unwrap().width() > fit.width());
|
||||
h.request("ui.clickWidget", json!({"id": "canvas:image", "fx": 0.5, "fy": 0.5}), t);
|
||||
assert_eq!(h.app.ui.zoom, Zoom::Fit);
|
||||
// Z zooms to the same ratio as a click
|
||||
h.request("ui.key", json!({"key": "z"}), t);
|
||||
assert_eq!(h.app.ui.zoom, Zoom::Percent(300));
|
||||
h.request("ui.key", json!({"key": "z"}), t);
|
||||
assert_eq!(h.app.ui.zoom, Zoom::Fit);
|
||||
h.settle(SETTLE);
|
||||
}
|
||||
|
||||
/// The Navigator appears when zoomed in; clicking it pans to that point.
|
||||
#[test]
|
||||
fn navigator_pans_the_zoomed_loupe() {
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[
|
|||
("view.zoomFit", "Zoom to Fit", Some("Cmd+0"), "View"),
|
||||
("view.zoom100", "Zoom 100%", Some("Cmd+Alt+0"), "View"),
|
||||
("view.zoomToggle", "Toggle Zoom", Some("Z"), "View"),
|
||||
// the ratio a click (and Z / Space) zooms to
|
||||
("view.clickZoom", "Click Zoom Ratio", None, ""),
|
||||
("view.zoomIn", "Zoom In", Some("Cmd+="), "View"),
|
||||
("view.zoomOut", "Zoom Out", Some("Cmd+-"), "View"),
|
||||
("view.clipping", "Show Clipping", Some("J"), "View"),
|
||||
|
|
@ -403,9 +405,22 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
|||
Ok(Value::Null)
|
||||
}
|
||||
"view.zoomToggle" => {
|
||||
app.ui.zoom = if app.ui.zoom == Zoom::Fit { Zoom::Percent(100) } else { Zoom::Fit };
|
||||
// the same ratio a click on the photo zooms to
|
||||
app.ui.zoom = if app.ui.zoom == Zoom::Fit { Zoom::Percent(app.ui.click_zoom) } else { Zoom::Fit };
|
||||
app.ui.zoom_anim = true;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"view.clickZoom" => {
|
||||
// {ratio?: 1|2|3|4|8} → {ratio}
|
||||
if let Some(r) = p.get("ratio").and_then(Value::as_f64) {
|
||||
let pct = (r * 100.0).round() as u32;
|
||||
if !crate::state::CLICK_ZOOMS.contains(&pct) {
|
||||
return Some(Err(format!("view.clickZoom: ratio {r} (1, 2, 3, 4 or 8)")));
|
||||
}
|
||||
app.ui.click_zoom = pct;
|
||||
}
|
||||
Ok(json!({"ratio": app.ui.click_zoom / 100}))
|
||||
}
|
||||
"view.zoomIn" | "view.zoomOut" => {
|
||||
let steps = [25u32, 50, 100, 200, 400, 800];
|
||||
let cur = match app.ui.zoom {
|
||||
|
|
|
|||
|
|
@ -163,6 +163,16 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
|||
}
|
||||
}
|
||||
});
|
||||
child.add_space(6.0);
|
||||
let cz =
|
||||
crate::widgets::dropdown(&mut child, "clickZoom", &format!("Click {}:1", app.ui.click_zoom / 100), t.font(13.0), t.text_label);
|
||||
egui::Popup::menu(&cz).show(|ui| {
|
||||
for pct in crate::state::CLICK_ZOOMS {
|
||||
if ui.selectable_label(app.ui.click_zoom == pct, format!("{}:1", pct / 100)).clicked() {
|
||||
let _ = app.run("view.clickZoom", json!({"ratio": pct / 100}));
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// thumbnail size slider
|
||||
let (r, resp) = child.allocate_exact_size(vec2(110.0, 20.0), Sense::click_and_drag());
|
||||
|
|
|
|||
|
|
@ -124,6 +124,19 @@ pub(crate) fn fit_rect(area: Rect, aspect: f32, zoom: Zoom, img_px: [usize; 2],
|
|||
Rect::from_center_size(c, vec2(w, h))
|
||||
}
|
||||
|
||||
/// Ease the loupe rect toward `target` while a click-zoom animation runs; otherwise follow it exactly.
|
||||
fn animated_rect(ctx: &egui::Context, anim: &mut bool, target: Rect) -> Rect {
|
||||
let t = if *anim { 0.22 } else { 0.0 };
|
||||
let id = egui::Id::new("loupe_anim");
|
||||
let v = |k: &str, x: f32| ctx.animate_value_with_time(id.with(k), x, t);
|
||||
let (c, s) = (target.center(), target.size());
|
||||
let r = Rect::from_center_size(pos2(v("cx", c.x), v("cy", c.y)), vec2(v("w", s.x), v("h", s.y)));
|
||||
if *anim && (r.center() - c).abs().max_elem() < 0.5 && (r.size() - s).abs().max_elem() < 0.5 {
|
||||
*anim = false;
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let full = ui.max_rect();
|
||||
|
|
@ -177,12 +190,14 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
|||
_ => vec![area],
|
||||
};
|
||||
let main_area = *areas.last().unwrap_or(&area);
|
||||
let img_rect = fit_rect(main_area, aspect, app.ui.zoom, native, ppp, app.ui.pan);
|
||||
let target_rect = fit_rect(main_area, aspect, app.ui.zoom, native, ppp, app.ui.pan);
|
||||
let img_rect = animated_rect(ui.ctx(), &mut app.ui.zoom_anim, target_rect);
|
||||
app.image_rect = Some(img_rect);
|
||||
// request renders: the loupe at display resolution (drafts during drags)
|
||||
let interacting = app.session.interaction.is_some();
|
||||
let scale = if interacting { 0.6 } else { 1.0 };
|
||||
let want = (img_rect.width().max(img_rect.height()) * ppp * scale).min(max_edge) as usize;
|
||||
// render at the final size: a click-zoom animation only changes how the result is drawn
|
||||
let want = (target_rect.width().max(target_rect.height()) * ppp * scale).min(max_edge) as usize;
|
||||
let (rw, rh) = if aspect >= 1.0 { (want, (want as f32 / aspect) as usize) } else { ((want as f32 * aspect) as usize, want) };
|
||||
if let Some(job) = app.session.loupe_job(id, rw.max(8), rh.max(8), !crop_tool) {
|
||||
let job = if interacting { job.draft() } else { job };
|
||||
|
|
@ -580,7 +595,7 @@ fn general_interaction(
|
|||
}
|
||||
return;
|
||||
}
|
||||
// click toggles Fit ↔ 100 % at the clicked point; drag pans when zoomed
|
||||
// click toggles Fit ↔ the click-zoom ratio (2:1/3:1/5:1) at the clicked point; drag pans when zoomed
|
||||
let zoomed = img.width() > canvas.width() + 1.0 || img.height() > canvas.height() + 1.0;
|
||||
if resp.double_clicked() || (resp.clicked() && !zoomed) {
|
||||
if let Some(q) = resp.interact_pointer_pos() {
|
||||
|
|
@ -588,9 +603,11 @@ fn general_interaction(
|
|||
let v = ((q.y - img.top()) / img.height()).clamp(0.0, 1.0);
|
||||
app.ui.pan = (u, v);
|
||||
}
|
||||
app.ui.zoom = if matches!(app.ui.zoom, Zoom::Fit) { Zoom::Percent(100) } else { Zoom::Fit };
|
||||
app.ui.zoom = if matches!(app.ui.zoom, Zoom::Fit) { Zoom::Percent(app.ui.click_zoom) } else { Zoom::Fit };
|
||||
app.ui.zoom_anim = true;
|
||||
} else if resp.clicked() && zoomed {
|
||||
app.ui.zoom = Zoom::Fit;
|
||||
app.ui.zoom_anim = true;
|
||||
}
|
||||
if zoomed {
|
||||
// only over the photo: a panel drawn earlier (sliders) must keep its own cursor
|
||||
|
|
|
|||
|
|
@ -161,6 +161,11 @@ impl Renderer {
|
|||
}
|
||||
|
||||
/// Is the slot's current texture (or pending request) already for `key`?
|
||||
/// Key of what `slot` shows or is rendering next (the pending request wins).
|
||||
pub fn wanted(&self, slot: Slot) -> Option<u64> {
|
||||
self.pending.get(&slot).map(|p| p.0).or_else(|| self.textures.get(&slot).map(|t| t.key))
|
||||
}
|
||||
|
||||
pub fn is_current(&self, slot: Slot, key: u64) -> bool {
|
||||
self.textures.get(&slot).is_some_and(|t| t.key == key) || self.pending.get(&slot).is_some_and(|p| p.0 == key)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -172,6 +172,9 @@ impl Default for AppSettings {
|
|||
/// Preview sizes offered in Settings → Performance.
|
||||
pub const PREVIEW_EDGES: [u32; 4] = [1600, 2560, 3840, 5120];
|
||||
|
||||
/// Click-zoom ratios offered (percent): 1:1, 2:1, 3:1, 4:1, 8:1.
|
||||
pub const CLICK_ZOOMS: [u32; 5] = [100, 200, 300, 400, 800];
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct UiState {
|
||||
|
|
@ -189,6 +192,12 @@ pub struct UiState {
|
|||
pub zoom: Zoom,
|
||||
/// Pan offset of the loupe when zoomed (image-normalized centre).
|
||||
pub pan: (f32, f32),
|
||||
/// Zoom applied by a click on the image (and Z / Space), in percent (100 = 1:1, 200 = 2:1);
|
||||
/// one of [`CLICK_ZOOMS`].
|
||||
pub click_zoom: u32,
|
||||
/// Animate the loupe rect toward its target (set by a click-zoom).
|
||||
#[serde(skip)]
|
||||
pub zoom_anim: bool,
|
||||
pub before_after: BeforeAfter,
|
||||
pub thumb_size: f32,
|
||||
/// Open Edit sections by id.
|
||||
|
|
@ -470,6 +479,8 @@ impl Default for UiState {
|
|||
filmstrip: true,
|
||||
zoom: Zoom::Fit,
|
||||
pan: (0.5, 0.5),
|
||||
click_zoom: 100,
|
||||
zoom_anim: false,
|
||||
before_after: BeforeAfter::Off,
|
||||
thumb_size: 220.0,
|
||||
open_sections: vec!["light".into()],
|
||||
|
|
|
|||
|
|
@ -155,7 +155,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
|||
| LR-VIEW-EDIT | Edit view | P0 | ✅ | `cmd:panel.edit` | |
|
||||
| LR-VIEW-FULLSCREEN | Full-screen preview | P1 | ✅ | `cmd:view.fullScreenPreview`, `cmd:view.enterFullScreen` | photo on black, arrows step, Esc exits; ⇧⌘F window full screen |
|
||||
| LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip` | |
|
||||
| LR-VIEW-ZOOM | Zoom & pan | P0 | ✅ | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle` | steps 25–800 % (not 6–1600 %); Fill only in the bottom bar |
|
||||
| LR-VIEW-ZOOM | Zoom & pan | P0 | ✅ | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom` | steps 25–800 % (not 6–1600 %); Fill only in the bottom bar; a click on the photo (and Z / Space) eases to the click-zoom ratio (1:1 default, 2:1, 3:1, 4:1, 8:1 in the bottom bar) |
|
||||
| LR-VIEW-NAVIGATOR | Navigator mini map | P1 | ✅ | `cmd:view.navigator`, `crates/ui-egui/src/panels/detail.rs` | shown while zoomed (bottom right); click/drag pans |
|
||||
| LR-VIEW-BEFOREAFTER | Before / after | P0 | ✅ | `cmd:view.showOriginal`, `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom`, `cmd:beforeAfter.setBefore`, `cmd:beforeAfter.copyAfterToBefore`, `cmd:beforeAfter.copyBeforeToAfter`, `cmd:beforeAfter.swap`, `cmd:beforeAfter.resetBefore` | all four layouts; before = the import state (defaults + import preset) or a chosen history step / version / the current settings; copy and swap (View → Before/After Settings, History row menu). The chosen before lasts for the session |
|
||||
| LR-VIEW-COMPARE | Compare two photos | P1 | ✅ | `cmd:view.compare`, `cmd:compare.swap`, `cmd:compare.makeSelect`, `crates/ui-egui/src/panels/compare.rs` | select / candidate, synced zoom + pan, arrows move the candidate; no zoom-link toggle |
|
||||
|
|
|
|||
Loading…
Reference in New Issue