diff --git a/crates/ui-egui/src/camera_raw_preview_ui.rs b/crates/ui-egui/src/camera_raw_preview_ui.rs index e0f4938116..f71959d5d5 100644 --- a/crates/ui-egui/src/camera_raw_preview_ui.rs +++ b/crates/ui-egui/src/camera_raw_preview_ui.rs @@ -122,10 +122,8 @@ pub(crate) fn interact(ui: &mut Ui, state: &mut CameraRawPreviewState, viewport: crate::tool_cursor::hand(ui.ctx(), false) } else if !zoom_tool { egui::CursorIcon::Crosshair - } else if modifiers.alt { - egui::CursorIcon::ZoomOut } else { - egui::CursorIcon::ZoomIn + crate::tool_cursor::zoom(ui.ctx(), modifiers.alt) }); } let capture_id = response.id.with("zoom-capture"); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index aefa215635..3912e8f4be 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3759,13 +3759,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: // Dragging is handled with the pan itself, above. Tool::Hand => crate::tool_cursor::hand(ui.ctx(), false), Tool::RotateView => crate::rotate_view::cursor(response.dragged()), - Tool::Zoom => { - if zoom_out(alt) { - egui::CursorIcon::ZoomOut - } else { - egui::CursorIcon::ZoomIn - } - } + Tool::Zoom => crate::tool_cursor::zoom(ui.ctx(), zoom_out(alt)), Tool::Type | Tool::VerticalType | Tool::HorizontalTypeMask | Tool::VerticalTypeMask => egui::CursorIcon::Text, Tool::MagneticLasso => crate::magnetic_lasso_ui::cursor(app, &painter, p, xf.zoom), Tool::RedEye => { diff --git a/crates/ui-egui/src/tool_cursor.rs b/crates/ui-egui/src/tool_cursor.rs index 20ce1b8396..6539ddb860 100644 --- a/crates/ui-egui/src/tool_cursor.rs +++ b/crates/ui-egui/src/tool_cursor.rs @@ -10,6 +10,7 @@ use std::sync::Arc; use egui::{CursorIcon, CustomCursorImage, Painter, Pos2, Stroke, Vec2, vec2}; mod hand; +mod zoom; /// The largest cursor bitmap (physical pixels; points on macOS, see [`native_scale`]); a larger tip /// paints its outline instead, with a small OS crosshair at the hotspot, and that outline trails @@ -20,13 +21,15 @@ mod hand; /// bounded for hostile brush sizes or zooms. const MAX_SIDE: u16 = if cfg!(any(target_os = "windows", target_os = "macos")) { 2048 } else { 255 }; -/// What a bitmap cursor shows. `Hand` is the Hand tool's open hand, or its fist while it drags. -/// `Outline` is a brush tip outline: closed rings of logical-point offsets from the hotspot. +/// What a bitmap cursor shows. `Hand` is the Hand tool's open hand, or its fist while it drags; +/// `Zoom` is the Zoom tool's magnifier, with a minus when it zooms out. `Outline` is a brush tip +/// outline: closed rings of logical-point offsets from the hotspot. #[derive(Clone, Debug, PartialEq)] enum Shape { Circle { radius: f32, centre: bool }, Crosshair { length: f32, gap: f32 }, Hand { closed: bool }, + Zoom { out: bool }, Outline { rings: Arc<[Vec]>, centre: bool }, } @@ -116,6 +119,21 @@ fn hand_with(ctx: &egui::Context, closed: bool, bitmap: bool) -> CursorIcon { if closed { CursorIcon::Grabbing } else { CursorIcon::Grab } } +/// The Zoom tool's pointer: a magnifier with a plus, or a minus when it zooms `out`. winit maps +/// `ZoomIn` and `ZoomOut` to the plain arrow on Windows (#3735), so there it is a bitmap; macOS and +/// the Linux themes already draw magnifiers. +pub(crate) fn zoom(ctx: &egui::Context, out: bool) -> CursorIcon { + zoom_with(ctx, out, cfg!(target_os = "windows") && ctx.viewport_id() == egui::ViewportId::ROOT) +} + +fn zoom_with(ctx: &egui::Context, out: bool, bitmap: bool) -> CursorIcon { + if bitmap && let Some(image) = cached_image(ctx, &Shape::Zoom { out }, native_scale(ctx)) { + ctx.set_cursor_image(Some(image)); + return CursorIcon::None; + } + if out { CursorIcon::ZoomOut } else { CursorIcon::ZoomIn } +} + fn show(painter: &Painter, at: Pos2, shape: Shape) -> CursorIcon { let ctx = painter.ctx(); // Where cursor images can't be uploaded (immediate viewports, the web), paint the shape. @@ -176,7 +194,7 @@ fn paint(painter: &Painter, at: Pos2, shape: &Shape) { } } // Only ever a bitmap: without one the OS draws its own grab cursors. - Shape::Hand { .. } => {} + Shape::Hand { .. } | Shape::Zoom { .. } => {} } } } @@ -224,6 +242,7 @@ fn extent(shape: &Shape) -> Option { if *centre { e.max(3.0) } else { e } } Shape::Hand { .. } => hand::HALF, + Shape::Zoom { .. } => zoom::HALF, }) } @@ -265,15 +284,25 @@ fn coverage(shape: &Shape, dx: f32, dy: f32, scale: f32) -> (f32, f32) { } Shape::Hand { closed } => { let (lx, ly) = (dx / scale, dy / scale); - let edge = hand::shape(*closed, lx, ly) * scale; - let inside = (0.5 - edge).clamp(0.0, 1.0); - let outline = (scale + 0.5 - edge).clamp(0.0, 1.0); - let crease = (0.45 * scale + 0.5 - hand::crease(*closed, lx, ly) * scale).clamp(0.0, 1.0); - (inside * (1.0 - crease), outline.max(inside * crease)) + silhouette(hand::shape(*closed, lx, ly), hand::crease(*closed, lx, ly), 0.45, scale) + } + Shape::Zoom { out } => { + let (lx, ly) = (dx / scale, dy / scale); + silhouette(zoom::shape(lx, ly), zoom::glyph(*out, lx, ly), 0.75, scale) } } } +/// A light filled shape with a 1 px dark outline and dark lines `half_width` logical pixels either +/// side of its creases, from their signed distances in logical pixels. +fn silhouette(edge: f32, crease: f32, half_width: f32, scale: f32) -> (f32, f32) { + let edge = edge * scale; + let inside = (0.5 - edge).clamp(0.0, 1.0); + let outline = (scale + 0.5 - edge).clamp(0.0, 1.0); + let crease = (half_width * scale + 0.5 - crease * scale).clamp(0.0, 1.0); + (inside * (1.0 - crease), outline.max(inside * crease)) +} + /// A 1 px light line with a 1 px dark one on each side, `distance` physical pixels from its centre. fn stroke_coverage(distance: f32, scale: f32) -> (f32, f32) { ((0.5 * scale + 0.5 - distance).clamp(0.0, 1.0), (1.5 * scale + 0.5 - distance).clamp(0.0, 1.0)) diff --git a/crates/ui-egui/src/tool_cursor/hand.rs b/crates/ui-egui/src/tool_cursor/hand.rs index 2ae3ad0af0..063331f669 100644 --- a/crates/ui-egui/src/tool_cursor/hand.rs +++ b/crates/ui-egui/src/tool_cursor/hand.rs @@ -7,7 +7,7 @@ pub(super) const HALF: f32 = 12.5; /// Distance to the segment `a`–`b`, minus `r` (a capsule; a bare line when `r` is 0). -fn capsule(x: f32, y: f32, a: [f32; 2], b: [f32; 2], r: f32) -> f32 { +pub(super) fn capsule(x: f32, y: f32, a: [f32; 2], b: [f32; 2], r: f32) -> f32 { let (px, py) = (x - a[0], y - a[1]); let (dx, dy) = (b[0] - a[0], b[1] - a[1]); let len2 = dx * dx + dy * dy; diff --git a/crates/ui-egui/src/tool_cursor/tests.rs b/crates/ui-egui/src/tool_cursor/tests.rs index 5d287901c0..8a240fa70c 100644 --- a/crates/ui-egui/src/tool_cursor/tests.rs +++ b/crates/ui-egui/src/tool_cursor/tests.rs @@ -156,6 +156,65 @@ fn the_hand_is_a_bitmap_only_where_the_os_has_none() { assert_eq!((open, fist), (CursorIcon::None, CursorIcon::None)); } +#[test] +fn zoom_bitmaps_are_outlined_magnifiers_centred_on_the_lens_at_each_dpi() { + for scale in [1.0, 1.25, 1.5, 2.0, 3.0] { + let plus = rasterize(&Shape::Zoom { out: false }, scale).unwrap(); + let minus = rasterize(&Shape::Zoom { out: true }, scale).unwrap(); + for image in [&plus, &minus] { + let [x, y] = image.hotspot; + assert_eq!(image.size, [2 * x + 1, 2 * y + 1]); + assert_eq!(image.rgba.len(), usize::from(image.size[0]).pow(2) * 4); + assert!(image.size[0] <= (34.0 * scale) as u16, "no bigger than a stock cursor at {scale}: {:?}", image.size); + assert_eq!(alpha(image, 0, 0), 0, "nothing at the corners"); + let handle = (9.0 * scale) as u16; + assert!(alpha(image, x + handle, y + handle) > 230, "the handle reaches down and right"); + assert_eq!(alpha(image, x - handle, y - handle), 0, "and only that way"); + let pixels = image.rgba.as_chunks::<4>().0; + assert!(pixels.iter().any(|p| p[3] > 230 && p[0] > 230), "a white lens"); + assert!(pixels.iter().any(|p| p[3] > 100 && p[0] < 25), "with a black outline, readable on white"); + } + let side = usize::from(plus.size[0]); + let red = |image: &CustomCursorImage, x: u16, y: u16| image.rgba[(usize::from(y) * side + usize::from(x)) * 4]; + let [x, y] = plus.hotspot; + let up = y - (2.5 * scale) as u16; + assert!(red(&plus, x, y) < 60 && red(&minus, x, y) < 60, "both glyphs cross the hotspot"); + assert!(red(&plus, x, up) < 60, "the plus has a vertical arm"); + assert!(red(&minus, x, up) > 200, "the minus does not"); + } +} + +#[test] +fn hostile_scales_get_no_zoom_bitmap() { + for scale in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -1.0, 0.0, f32::MAX, 500.0] { + assert!(rasterize(&Shape::Zoom { out: false }, scale).is_none(), "{scale}"); + assert!(rasterize(&Shape::Zoom { out: true }, scale).is_none(), "{scale}"); + } +} + +/// winit shows the plain arrow for `ZoomIn` and `ZoomOut` on Windows (#3735). +#[test] +fn the_magnifier_is_a_bitmap_only_where_the_os_has_none() { + let ctx = egui::Context::default(); + ctx.add_plugin(CursorLifecycle); + for out in [false, true] { + let native = frame(&ctx, |ui| { + let icon = zoom_with(ui.ctx(), out, false); + ui.ctx().set_cursor_icon(icon); + }); + assert_eq!(native.platform_output.cursor_icon, if out { CursorIcon::ZoomOut } else { CursorIcon::ZoomIn }); + assert!(native.platform_output.cursor_image.is_none(), "macOS and the Linux themes draw their own magnifiers"); + + let bitmap = frame(&ctx, |ui| { + let icon = zoom_with(ui.ctx(), out, true); + ui.ctx().set_cursor_icon(icon); + }); + let image = bitmap.platform_output.cursor_image.expect("Windows has no stock magnifier"); + assert_eq!(image.rgba, rasterize(&Shape::Zoom { out }, 1.0).unwrap().rgba); + assert_eq!(bitmap.platform_output.cursor_icon, CursorIcon::Crosshair, "visible fallback if the OS rejects a bitmap"); + } +} + #[test] fn unchanged_geometry_reuses_the_os_upload_and_dpi_invalidates_it() { let ctx = egui::Context::default(); @@ -227,6 +286,9 @@ fn write_cursor_preview() { (Shape::Hand { closed: true }, 1.0), (Shape::Hand { closed: false }, 1.5), (Shape::Hand { closed: true }, 2.0), + (Shape::Zoom { out: false }, 1.0), + (Shape::Zoom { out: true }, 1.0), + (Shape::Zoom { out: false }, 2.0), ]; let (width, height) = (600_usize, cases.len() * 100); let mut rgba = vec![255; width * height * 4]; @@ -315,6 +377,24 @@ fn brush_hover_uses_an_os_bitmap_that_survives_motion_but_not_tool_or_panel_chan assert_eq!(h.output().platform_output.cursor_icon, CursorIcon::Grab); } + // Likewise the Zoom tool's magnifier, a minus while ⌥ is held (#3735). + h.state_mut().ui.tool = crate::Tool::Zoom; + for out in [false, true] { + let modifiers = if out { Modifiers::ALT } else { Modifiers::NONE }; + h.event(Event::ModifiersChanged(modifiers)); + h.event(Event::PointerMoved(p + vec2(1.0, 0.0))); + h.run_steps(2); + let image = h.output().platform_output.cursor_image.clone(); + if cfg!(target_os = "windows") { + let image = image.expect("the magnifier reaches the OS as a bitmap"); + assert_eq!(image.rgba, rasterize(&Shape::Zoom { out }, h.ctx.pixels_per_point()).unwrap().rgba, "out: {out}"); + } else { + assert!(image.is_none(), "the OS draws the magnifier"); + assert_eq!(h.output().platform_output.cursor_icon, if out { CursorIcon::ZoomOut } else { CursorIcon::ZoomIn }); + } + } + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.state_mut().ui.tool = crate::Tool::Brush; h.state_mut().run("prefs.set", json!({"path": "cursors.painting", "value": "standard"})).unwrap(); h.run_steps(2); diff --git a/crates/ui-egui/src/tool_cursor/zoom.rs b/crates/ui-egui/src/tool_cursor/zoom.rs new file mode 100644 index 0000000000..0a0dcd0000 --- /dev/null +++ b/crates/ui-egui/src/tool_cursor/zoom.rs @@ -0,0 +1,26 @@ +//! The Zoom tool's pointer as signed distance fields (negative inside), in logical pixels from the +//! hotspot with y pointing down: a magnifying glass with a plus, or a minus while ⌥ zooms out, as +//! in Photoshop. The hotspot is the centre of the lens. Only Windows needs it: winit maps `ZoomIn` +//! and `ZoomOut` to the plain arrow there (#3735), while macOS and the Linux themes draw their own. + +use super::hand::capsule; + +/// Half the side of the square the glass fits in, outline excluded. +pub(super) const HALF: f32 = 13.0; + +const LENS: f32 = 6.5; +const HANDLE: ([f32; 2], [f32; 2], f32) = ([5.2, 5.2], [10.8, 10.8], 2.2); +/// Half the length of the plus and minus arms. +const ARM: f32 = 3.4; + +/// The glass's silhouette (lens and handle): negative inside it. +pub(super) fn shape(x: f32, y: f32) -> f32 { + let (a, b, r) = HANDLE; + (x.hypot(y) - LENS).min(capsule(x, y, a, b, r)) +} + +/// The plus (or the minus, `out`) inside the lens: distance to its nearest arm. +pub(super) fn glyph(out: bool, x: f32, y: f32) -> f32 { + let across = capsule(x, y, [-ARM, 0.0], [ARM, 0.0], 0.0); + if out { across } else { across.min(capsule(x, y, [0.0, -ARM], [0.0, ARM], 0.0)) } +}