mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-29 12:37:51 -06:00
Loupe: animated click-zoom with selectable 2:1 / 3:1 / 5:1 ratio
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
7bf24ff856
commit
7b8df44947
@@ -1089,6 +1089,25 @@ mod tests {
|
||||
h.settle(SETTLE);
|
||||
}
|
||||
|
||||
/// A click on the image zooms to the chosen click-zoom ratio, animating the loupe, and a second click returns to Fit.
|
||||
#[test]
|
||||
fn click_zoom_ratio_animates() {
|
||||
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);
|
||||
h.app.ui.click_zoom = 300;
|
||||
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, crate::state::Zoom::Percent(300));
|
||||
assert!(h.app.ui.zoom_anim, "animation started");
|
||||
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, crate::state::Zoom::Fit);
|
||||
}
|
||||
|
||||
/// The Navigator appears when zoomed in; clicking it pans to that point.
|
||||
#[test]
|
||||
fn navigator_pans_the_zoomed_loupe() {
|
||||
|
||||
@@ -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 n in [2u32, 3, 5] {
|
||||
if ui.selectable_label(app.ui.click_zoom == n * 100, format!("{n}:1")).clicked() {
|
||||
app.ui.click_zoom = n * 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,7 +190,8 @@ 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();
|
||||
@@ -580,7 +594,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 +602,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 {
|
||||
ui.ctx().set_cursor_icon(if resp.dragged() { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab });
|
||||
|
||||
@@ -189,6 +189,11 @@ 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, in percent (200 = 2:1).
|
||||
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 +475,8 @@ impl Default for UiState {
|
||||
filmstrip: true,
|
||||
zoom: Zoom::Fit,
|
||||
pan: (0.5, 0.5),
|
||||
click_zoom: 200,
|
||||
zoom_anim: false,
|
||||
before_after: BeforeAfter::Off,
|
||||
thumb_size: 220.0,
|
||||
open_sections: vec!["light".into()],
|
||||
|
||||
Reference in New Issue
Block a user