mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-02 06:17:22 -06:00
* Add Shift+scroll snap zoom to Photoshop zoom ladder levels When zooming with the scroll wheel (either via Alt+scroll or via the "Zoom with Scroll Wheel" preference), holding Shift snaps to the next Photoshop zoom ladder level instead of the continuous ×1.1 step. - zoom_with_wheel ON: Shift+scroll snaps to the ladder - zoom_with_wheel OFF: Shift+Alt+scroll snaps to the ladder Co-authored-by: Claude <noreply@anthropic.com> * Handle Shift+wheel arriving as horizontal delta in snap zoom Some systems (notably egui on certain platforms) deliver Shift+scroll as a horizontal delta (x != 0, y = 0) rather than preserving the vertical axis. Use `raw.y + raw.x` so the snap-zoom path catches both orientations, matching the existing Shift+Alt pattern on the line above. Co-authored-by: Claude <noreply@anthropic.com> * Snap zoom: per-viewport fractional accumulation, Camera Raw ladder stepping, and horizontal-delta handling Port the improvements from TheSeanLavery/photocraft#33: - `read()` gains `owner: Id` and `hovered: bool` so each viewport (canvas, navigator, Camera Raw) accumulates its own snap residual without leaking into others. - Fractional scroll input (sub-notch point events) now accumulates across frames with an idle timeout, so precision trackpads and high-resolution mice reach the next ladder rung. - Camera Raw snap zoom steps through the ladder via `step_in_range()` instead of being a no-op. - Shift+wheel arriving as a horizontal delta (x != 0, y == 0) is handled by using `raw.y + raw.x` in the snap path. - `zoom_levels::step_in_range()` extracted for reuse with custom bounds. - 8 new tests (24 wheel_nav total), 4 new Camera Raw snap tests, 1 new zoom_levels test. Co-authored-by: Claude <noreply@anthropic.com> * Fix test destructure after merge with main (#5080) and reformat #5080 added a test that destructures gesture() as a 2-tuple, but this branch makes it return 3 values. Update the destructure and run cargo fmt --all. --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
383 lines
18 KiB
Rust
383 lines
18 KiB
Rust
//! Camera Raw navigation in source-pixel coordinates. No document edits.
|
||
use egui::{Key, Modifiers, PointerButton, Pos2, Rect, Response, Sense, Ui, Vec2, vec2};
|
||
use serde_json::Value;
|
||
|
||
use crate::{state::CameraRawPreviewState, widgets};
|
||
|
||
const MIN_ZOOM: f32 = 0.0001;
|
||
const MAX_ZOOM: f32 = 16.0;
|
||
|
||
impl CameraRawPreviewState {
|
||
pub(crate) fn scale(&self, viewport: Rect, size: Vec2) -> f32 {
|
||
self.zoom
|
||
.filter(|z| z.is_finite() && *z > 0.0)
|
||
.map(|z| z.clamp(MIN_ZOOM, MAX_ZOOM))
|
||
.unwrap_or_else(|| (viewport.width().max(1.0) / size.x.max(f32::EPSILON)).min(viewport.height().max(1.0) / size.y.max(f32::EPSILON)).min(MAX_ZOOM))
|
||
}
|
||
|
||
pub(crate) fn image_rect(&mut self, viewport: Rect, size: Vec2) -> Rect {
|
||
let extent = size.max(Vec2::splat(f32::EPSILON)) * self.scale(viewport, size);
|
||
for (center, (image, visible)) in self.center.iter_mut().zip([(extent.x, viewport.width()), (extent.y, viewport.height())]) {
|
||
let margin = (visible.max(1.0) / image.max(1.0) * 0.5).min(0.5);
|
||
*center = if center.is_finite() { center.clamp(margin, 1.0 - margin) } else { 0.5 };
|
||
}
|
||
Rect::from_min_size(viewport.center() - vec2(self.center[0] * extent.x, self.center[1] * extent.y), extent)
|
||
}
|
||
|
||
pub(crate) fn zoom_at(&mut self, zoom: f32, at: Pos2, viewport: Rect, size: Vec2) {
|
||
if !zoom.is_finite() || !at.x.is_finite() || !at.y.is_finite() {
|
||
return;
|
||
}
|
||
let old = self.image_rect(viewport, size);
|
||
let point = (at - old.min) / old.size();
|
||
self.zoom = Some(zoom.clamp(MIN_ZOOM, MAX_ZOOM));
|
||
let extent = size.max(Vec2::splat(f32::EPSILON)) * self.scale(viewport, size);
|
||
let center = point - (at - viewport.center()) / extent;
|
||
self.center = [center.x, center.y];
|
||
self.image_rect(viewport, size);
|
||
}
|
||
|
||
pub(crate) fn pan(&mut self, delta: Vec2, viewport: Rect, size: Vec2) {
|
||
if !delta.x.is_finite() || !delta.y.is_finite() {
|
||
return;
|
||
}
|
||
let rect = self.image_rect(viewport, size);
|
||
// Leaving fit mode on a hand drag would make later window resizes stop fitting.
|
||
self.center[0] -= delta.x / rect.width();
|
||
self.center[1] -= delta.y / rect.height();
|
||
self.image_rect(viewport, size);
|
||
}
|
||
|
||
pub(crate) fn fit(&mut self) {
|
||
self.zoom = None;
|
||
self.center = [0.5, 0.5];
|
||
}
|
||
}
|
||
|
||
/// Validate with the rest of the Camera Raw request before changing anything.
|
||
pub(crate) fn prepare(current: CameraRawPreviewState, value: &Value) -> Result<CameraRawPreviewState, String> {
|
||
let obj = value.as_object().ok_or("Camera Raw view must be an object")?;
|
||
let mut next = current;
|
||
for (key, value) in obj {
|
||
match key.as_str() {
|
||
"zoom" if value.as_str() == Some("fit") => next.fit(),
|
||
"zoom" => {
|
||
let z = value
|
||
.as_f64()
|
||
.filter(|z| z.is_finite() && (MIN_ZOOM as f64..=MAX_ZOOM as f64).contains(z))
|
||
.ok_or("Camera Raw zoom must be 'fit' or 0.0001–16 (1 = 100%)")?;
|
||
next.zoom = Some(z as f32);
|
||
}
|
||
"center" => {
|
||
let a = value.as_array().filter(|a| a.len() == 2).ok_or("Camera Raw center must be [u,v]")?;
|
||
let coordinate = |i| {
|
||
a.get(i)
|
||
.and_then(Value::as_f64)
|
||
.filter(|v| v.is_finite() && (0.0..=1.0).contains(v))
|
||
.map(|v| v as f32)
|
||
.ok_or("Camera Raw center must be finite coordinates in 0–1")
|
||
};
|
||
next.center = [coordinate(0)?, coordinate(1)?];
|
||
}
|
||
"hand" => next.hand = value.as_bool().ok_or("Camera Raw hand must be a boolean")?,
|
||
_ => return Err(format!("unknown Camera Raw view property {key}")),
|
||
}
|
||
}
|
||
if obj.get("zoom").and_then(Value::as_str) == Some("fit") {
|
||
next.fit();
|
||
}
|
||
Ok(next)
|
||
}
|
||
|
||
#[derive(Clone, Copy)]
|
||
struct ZoomCapture {
|
||
start: Pos2,
|
||
image: Rect,
|
||
box_zoom: bool,
|
||
}
|
||
|
||
/// One capture shared with the colour sampler. Space/middle drag always pans; S takes
|
||
/// precedence over the persistent Zoom/Hand tool, so a sampler click never zooms.
|
||
pub(crate) fn interact(ui: &mut Ui, state: &mut CameraRawPreviewState, viewport: Rect, size: Vec2, sampler: &mut bool) -> (Rect, Response, bool, Option<Rect>) {
|
||
let sampling = *sampler;
|
||
let response = ui.interact(viewport, ui.id().with("cr-sample-preview"), Sense::click_and_drag());
|
||
let modifiers = ui.input(|i| i.modifiers);
|
||
let temporary_zoom = modifiers.command && (state.hand || sampling);
|
||
let hand = !temporary_zoom && (state.hand && !sampling || ui.input(|i| i.key_down(Key::Space)));
|
||
let zoom_tool = temporary_zoom || !hand && !sampling;
|
||
let panning = hand || response.dragged_by(PointerButton::Middle);
|
||
let at = response.hover_pos().unwrap_or(viewport.center());
|
||
let rect = state.image_rect(viewport, size);
|
||
// Reuse the canvas wheel policy: retain the modifiers of the wheel event while egui
|
||
// smooths it, even if Alt is released before the final part of the notch arrives.
|
||
let cr_hovered = response.hovered();
|
||
let wheel = crate::wheel_nav::read(ui.ctx(), false, true, ui.id().with("cr-wheel"), cr_hovered);
|
||
if cr_hovered {
|
||
ui.input_mut(|i| i.smooth_scroll_delta = Vec2::ZERO);
|
||
match wheel {
|
||
Some(crate::wheel_nav::Wheel::Zoom(factor)) => state.zoom_at(state.scale(viewport, size) * factor, at, viewport, size),
|
||
Some(crate::wheel_nav::Wheel::SnapZoom(n)) => {
|
||
let dir = if n > 0 { 1 } else { -1 };
|
||
let mut nz = state.scale(viewport, size);
|
||
for _ in 0..n.unsigned_abs() {
|
||
nz = crate::zoom_levels::step_in_range(nz, dir, MIN_ZOOM, MAX_ZOOM);
|
||
}
|
||
if nz != state.scale(viewport, size) {
|
||
state.zoom_at(nz, at, viewport, size);
|
||
}
|
||
}
|
||
Some(crate::wheel_nav::Wheel::Pan(delta)) => state.pan(delta, viewport, size),
|
||
None => {}
|
||
}
|
||
response.clone().on_hover_cursor(if hand {
|
||
crate::tool_cursor::hand(ui.ctx(), false)
|
||
} else if !zoom_tool {
|
||
egui::CursorIcon::Crosshair
|
||
} else {
|
||
crate::tool_cursor::zoom(ui.ctx(), modifiers.alt)
|
||
});
|
||
}
|
||
let capture_id = response.id.with("zoom-capture");
|
||
if response.drag_started_by(PointerButton::Primary) && zoom_tool {
|
||
let start = ui.input(|i| i.pointer.press_origin()).unwrap_or(at);
|
||
if rect.contains(start) {
|
||
ui.ctx().data_mut(|data| data.insert_temp(capture_id, ZoomCapture { start, image: rect, box_zoom: modifiers.command && !temporary_zoom }));
|
||
}
|
||
}
|
||
let capture = ui.ctx().data(|data| data.get_temp::<ZoomCapture>(capture_id));
|
||
let mut zoom_box = None;
|
||
if panning && (response.dragged_by(PointerButton::Primary) || response.dragged_by(PointerButton::Middle)) {
|
||
state.pan(response.drag_delta(), viewport, size);
|
||
ui.ctx().set_cursor_icon(crate::tool_cursor::hand(ui.ctx(), true));
|
||
} else if response.dragged_by(PointerButton::Primary)
|
||
&& let Some(capture) = capture
|
||
{
|
||
if capture.box_zoom {
|
||
if let Some(end) = response.interact_pointer_pos() {
|
||
let region = Rect::from_two_pos(capture.start, end).intersect(viewport);
|
||
zoom_box = Some(region);
|
||
}
|
||
} else {
|
||
// The anchor is the initial press, not the moving pointer; reversing a scrub
|
||
// follows the same detail instead of drifting across the image.
|
||
state.zoom_at(state.scale(viewport, size) * (response.drag_delta().x * 0.01).clamp(-1.0, 1.0).exp(), capture.start, viewport, size);
|
||
}
|
||
} else if response.double_clicked() && hand {
|
||
state.fit();
|
||
} else if response.clicked() && zoom_tool && rect.contains(at) {
|
||
if modifiers.command && modifiers.shift {
|
||
state.zoom_at(1.0, at, viewport, size);
|
||
} else if modifiers.alt {
|
||
state.zoom_at(state.scale(viewport, size) / 1.25, at, viewport, size);
|
||
} else if state.zoom.is_none() {
|
||
state.zoom_at(1.0, at, viewport, size);
|
||
} else {
|
||
state.fit();
|
||
}
|
||
}
|
||
if response.drag_stopped_by(PointerButton::Primary) {
|
||
if let Some(capture) = capture.filter(|c| c.box_zoom)
|
||
&& let Some(end) = response.interact_pointer_pos()
|
||
{
|
||
let region = Rect::from_two_pos(capture.start, end).intersect(viewport);
|
||
if region.width() >= 4.0 && region.height() >= 4.0 {
|
||
let point = (region.center() - capture.image.min) / capture.image.size();
|
||
state.zoom = Some(
|
||
(capture.image.width() / size.x * (viewport.width() / region.width()).min(viewport.height() / region.height())).clamp(MIN_ZOOM, MAX_ZOOM),
|
||
);
|
||
state.center = [point.x, point.y];
|
||
}
|
||
}
|
||
ui.ctx().data_mut(|data| data.remove::<ZoomCapture>(capture_id));
|
||
}
|
||
response.context_menu(|ui| {
|
||
if ui.button(tl!("Fit in View")).clicked() {
|
||
state.fit();
|
||
ui.close();
|
||
}
|
||
if ui.button(tl!("100%")).clicked() {
|
||
state.zoom_at(1.0, at, viewport, size);
|
||
ui.close();
|
||
}
|
||
ui.menu_button(tl!("Zoom Tool"), |ui| {
|
||
for percent in [25.0, 50.0, 100.0, 200.0, 400.0, 800.0, 1600.0] {
|
||
if ui.button(format!("{percent:.0}%")).clicked() {
|
||
state.zoom_at(percent / 100.0, at, viewport, size);
|
||
ui.close();
|
||
}
|
||
}
|
||
});
|
||
});
|
||
if !ui.ctx().text_edit_focused() {
|
||
ui.input_mut(|i| {
|
||
if i.consume_key(Modifiers::COMMAND | Modifiers::ALT, Key::Num0) {
|
||
state.zoom_at(1.0, viewport.center(), viewport, size);
|
||
} else if i.consume_key(Modifiers::COMMAND, Key::Num0) {
|
||
state.fit();
|
||
}
|
||
if i.consume_key(Modifiers::COMMAND, Key::Plus) || i.consume_key(Modifiers::COMMAND, Key::Equals) {
|
||
state.zoom_at(state.scale(viewport, size) * 1.25, viewport.center(), viewport, size);
|
||
}
|
||
if i.consume_key(Modifiers::COMMAND, Key::Minus) {
|
||
state.zoom_at(state.scale(viewport, size) / 1.25, viewport.center(), viewport, size);
|
||
}
|
||
if i.consume_key(Modifiers::NONE, Key::H) {
|
||
state.hand = true;
|
||
*sampler = false;
|
||
}
|
||
if i.consume_key(Modifiers::NONE, Key::Z) {
|
||
state.hand = false;
|
||
*sampler = false;
|
||
}
|
||
});
|
||
}
|
||
(state.image_rect(viewport, size), response, panning || temporary_zoom, zoom_box)
|
||
}
|
||
|
||
pub(crate) fn toolbar(ui: &mut Ui, state: &mut CameraRawPreviewState, viewport: Rect, size: Vec2, sampler: &mut bool) {
|
||
let zoom_tool = crate::icons::button(ui, "zoom-in", 28.0, !state.hand && !*sampler, tl!("Zoom Tool"));
|
||
zoom_tool.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Zoom Tool")));
|
||
if zoom_tool.double_clicked() {
|
||
state.fit();
|
||
}
|
||
if zoom_tool.clicked() {
|
||
state.hand = false;
|
||
*sampler = false;
|
||
}
|
||
let hand_tool = crate::icons::button(ui, "hand", 28.0, state.hand && !*sampler, tl!("Hand Tool"));
|
||
hand_tool.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Hand Tool")));
|
||
if hand_tool.double_clicked() {
|
||
state.fit();
|
||
}
|
||
if hand_tool.clicked() {
|
||
state.hand = true;
|
||
*sampler = false;
|
||
}
|
||
if widgets::secondary_button(ui, tl!("Fit in View"), 84.0).clicked() {
|
||
state.fit();
|
||
}
|
||
if widgets::secondary_button(ui, tl!("100%"), 52.0).clicked() {
|
||
state.zoom_at(1.0, viewport.center(), viewport, size);
|
||
}
|
||
let mut zoom = state.zoom;
|
||
let mut options = vec![(None, tl!("Fit in View"))];
|
||
let labels = ["25%", "50%", "100%", "200%", "400%", "800%", "1600%"];
|
||
for (z, label) in [0.25, 0.5, 1.0, 2.0, 4.0, 8.0, 16.0].into_iter().zip(labels) {
|
||
options.push((Some(z), label));
|
||
}
|
||
let current_label = format!("{:.1}%", state.scale(viewport, size) * 100.0);
|
||
if !options.iter().any(|(z, _)| *z == zoom) {
|
||
options.push((zoom, ¤t_label));
|
||
}
|
||
if widgets::dropdown(ui, "cr-preview-zoom", &mut zoom, &options, 80.0) {
|
||
match zoom {
|
||
Some(z) => state.zoom_at(z, viewport.center(), viewport, size),
|
||
None => state.fit(),
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use egui::pos2;
|
||
#[test]
|
||
fn zoom_preserves_the_point_under_the_pointer_and_pan_clamps() {
|
||
let viewport = Rect::from_min_size(pos2(10.0, 20.0), vec2(800.0, 600.0));
|
||
let size = vec2(1600.0, 1200.0);
|
||
let mut view = CameraRawPreviewState::default();
|
||
let at = pos2(310.0, 270.0);
|
||
let initial = view.image_rect(viewport, size);
|
||
let point = (at - initial.min) / initial.size();
|
||
view.zoom_at(1.0, at, viewport, size);
|
||
let enlarged = view.image_rect(viewport, size);
|
||
assert!(((at - enlarged.min) / enlarged.size() - point).length() < 1e-5);
|
||
view.pan(vec2(1e9, -1e9), viewport, size);
|
||
let panned = view.image_rect(viewport, size);
|
||
assert!(panned.contains_rect(viewport));
|
||
view.fit();
|
||
assert_eq!(view.image_rect(viewport, size), initial);
|
||
}
|
||
#[test]
|
||
fn shift_alt_wheel_snaps_zoom_in_camera_raw() {
|
||
let viewport = Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0));
|
||
let size = vec2(1600.0, 1200.0);
|
||
let at = pos2(400.0, 300.0);
|
||
let mut state = CameraRawPreviewState { zoom: Some(1.0), center: [0.5, 0.5], hand: false };
|
||
let scale_before = state.scale(viewport, size);
|
||
let n = 1i32;
|
||
let dir = if n > 0 { 1 } else { -1 };
|
||
let mut nz = state.scale(viewport, size);
|
||
for _ in 0..n.unsigned_abs() {
|
||
nz = crate::zoom_levels::step_in_range(nz, dir, MIN_ZOOM, MAX_ZOOM);
|
||
}
|
||
assert_ne!(nz, scale_before, "zoom-in steps to a new level");
|
||
state.zoom_at(nz, at, viewport, size);
|
||
assert_eq!(state.scale(viewport, size), nz, "zoom applied");
|
||
assert!(nz > scale_before, "zoomed in");
|
||
let scale_after_in = state.scale(viewport, size);
|
||
let mut nz = state.scale(viewport, size);
|
||
nz = crate::zoom_levels::step_in_range(nz, -1, MIN_ZOOM, MAX_ZOOM);
|
||
state.zoom_at(nz, at, viewport, size);
|
||
assert!(state.scale(viewport, size) < scale_after_in, "zoomed out");
|
||
}
|
||
|
||
#[test]
|
||
fn shift_alt_snap_at_limits_does_not_move() {
|
||
let viewport = Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0));
|
||
let size = vec2(1600.0, 1200.0);
|
||
let at = viewport.center();
|
||
let state = CameraRawPreviewState { zoom: Some(MAX_ZOOM), center: [0.5, 0.5], hand: false };
|
||
let nz = crate::zoom_levels::step_in_range(state.scale(viewport, size), 1, MIN_ZOOM, MAX_ZOOM);
|
||
assert_eq!(nz, state.scale(viewport, size), "at max, step up stays put");
|
||
let state_lo = CameraRawPreviewState { zoom: Some(MIN_ZOOM), center: [0.5, 0.5], hand: false };
|
||
let nz = crate::zoom_levels::step_in_range(state_lo.scale(viewport, size), -1, MIN_ZOOM, MAX_ZOOM);
|
||
assert_eq!(nz, state_lo.scale(viewport, size), "at min, step down stays put");
|
||
let _ = at;
|
||
}
|
||
|
||
#[test]
|
||
fn snap_zoom_from_fit_enters_explicit_zoom() {
|
||
let viewport = Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0));
|
||
let size = vec2(1600.0, 1200.0);
|
||
let at = viewport.center();
|
||
let mut state = CameraRawPreviewState::default();
|
||
assert!(state.zoom.is_none(), "starts in fit mode");
|
||
let fit_scale = state.scale(viewport, size);
|
||
let nz = crate::zoom_levels::step_in_range(fit_scale, 1, MIN_ZOOM, MAX_ZOOM);
|
||
if nz != fit_scale {
|
||
state.zoom_at(nz, at, viewport, size);
|
||
}
|
||
assert!(state.zoom.is_some(), "snap zoom leaves fit mode");
|
||
}
|
||
|
||
#[test]
|
||
fn snap_zoom_preserves_point_under_pointer() {
|
||
let viewport = Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0));
|
||
let size = vec2(1600.0, 1200.0);
|
||
let at = pos2(300.0, 200.0);
|
||
let mut state = CameraRawPreviewState { zoom: Some(1.0), center: [0.5, 0.5], hand: false };
|
||
let rect_before = state.image_rect(viewport, size);
|
||
let point_before = (at - rect_before.min) / rect_before.size();
|
||
let nz = crate::zoom_levels::step_in_range(1.0, 1, MIN_ZOOM, MAX_ZOOM);
|
||
state.zoom_at(nz, at, viewport, size);
|
||
let rect_after = state.image_rect(viewport, size);
|
||
let point_after = (at - rect_after.min) / rect_after.size();
|
||
assert!((point_before - point_after).length() < 0.01, "point preserved: {point_before:?} vs {point_after:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn tiny_images_nonfinite_state_and_resize_stay_centered_and_finite() {
|
||
let viewport = Rect::from_min_size(Pos2::ZERO, vec2(600.0, 400.0));
|
||
let size = vec2(40.0, 20.0);
|
||
let mut view = CameraRawPreviewState { zoom: Some(f32::NAN), center: [f32::INFINITY, f32::NAN], hand: true };
|
||
assert!(view.image_rect(viewport, size).is_finite());
|
||
view.zoom_at(1.0, pos2(100.0, 100.0), viewport, size);
|
||
assert_eq!(view.image_rect(viewport, size).center(), viewport.center());
|
||
let before = view;
|
||
view.zoom_at(f32::INFINITY, viewport.center(), viewport, size);
|
||
view.pan(vec2(f32::NAN, 1.0), viewport, size);
|
||
assert_eq!(view, before);
|
||
}
|
||
}
|