fix(ui): show a magnifier cursor for the Zoom tool on Windows (#3735) (#3783)

winit maps CursorIcon::ZoomIn/ZoomOut to the plain arrow on Windows, so
the Zoom tool showed no cursor feedback there. Draw the magnifier (plus,
or minus while Alt zooms out) as an OS cursor bitmap, like the Hand
tool's #2196 fix; macOS and Linux keep their stock zoom cursors. Camera
Raw's preview zoom uses it too.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
JGF
2026-10-13 12:29:13 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent ef2905ea8e
commit 033ec991c2
6 changed files with 146 additions and 19 deletions
+1 -3
View File
@@ -122,10 +122,8 @@ pub(crate) fn interact(ui: &mut Ui, state: &mut CameraRawPreviewState, viewport:
crate::tool_cursor::hand(ui.ctx(), false) crate::tool_cursor::hand(ui.ctx(), false)
} else if !zoom_tool { } else if !zoom_tool {
egui::CursorIcon::Crosshair egui::CursorIcon::Crosshair
} else if modifiers.alt {
egui::CursorIcon::ZoomOut
} else { } else {
egui::CursorIcon::ZoomIn crate::tool_cursor::zoom(ui.ctx(), modifiers.alt)
}); });
} }
let capture_id = response.id.with("zoom-capture"); let capture_id = response.id.with("zoom-capture");
+1 -7
View File
@@ -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. // Dragging is handled with the pan itself, above.
Tool::Hand => crate::tool_cursor::hand(ui.ctx(), false), Tool::Hand => crate::tool_cursor::hand(ui.ctx(), false),
Tool::RotateView => crate::rotate_view::cursor(response.dragged()), Tool::RotateView => crate::rotate_view::cursor(response.dragged()),
Tool::Zoom => { Tool::Zoom => crate::tool_cursor::zoom(ui.ctx(), zoom_out(alt)),
if zoom_out(alt) {
egui::CursorIcon::ZoomOut
} else {
egui::CursorIcon::ZoomIn
}
}
Tool::Type | Tool::VerticalType | Tool::HorizontalTypeMask | Tool::VerticalTypeMask => egui::CursorIcon::Text, Tool::Type | Tool::VerticalType | Tool::HorizontalTypeMask | Tool::VerticalTypeMask => egui::CursorIcon::Text,
Tool::MagneticLasso => crate::magnetic_lasso_ui::cursor(app, &painter, p, xf.zoom), Tool::MagneticLasso => crate::magnetic_lasso_ui::cursor(app, &painter, p, xf.zoom),
Tool::RedEye => { Tool::RedEye => {
+37 -8
View File
@@ -10,6 +10,7 @@ use std::sync::Arc;
use egui::{CursorIcon, CustomCursorImage, Painter, Pos2, Stroke, Vec2, vec2}; use egui::{CursorIcon, CustomCursorImage, Painter, Pos2, Stroke, Vec2, vec2};
mod hand; mod hand;
mod zoom;
/// The largest cursor bitmap (physical pixels; points on macOS, see [`native_scale`]); a larger tip /// 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 /// 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. /// bounded for hostile brush sizes or zooms.
const MAX_SIDE: u16 = if cfg!(any(target_os = "windows", target_os = "macos")) { 2048 } else { 255 }; 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. /// 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. /// `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)] #[derive(Clone, Debug, PartialEq)]
enum Shape { enum Shape {
Circle { radius: f32, centre: bool }, Circle { radius: f32, centre: bool },
Crosshair { length: f32, gap: f32 }, Crosshair { length: f32, gap: f32 },
Hand { closed: bool }, Hand { closed: bool },
Zoom { out: bool },
Outline { rings: Arc<[Vec<Vec2>]>, centre: bool }, Outline { rings: Arc<[Vec<Vec2>]>, centre: bool },
} }
@@ -116,6 +119,21 @@ fn hand_with(ctx: &egui::Context, closed: bool, bitmap: bool) -> CursorIcon {
if closed { CursorIcon::Grabbing } else { CursorIcon::Grab } 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 { fn show(painter: &Painter, at: Pos2, shape: Shape) -> CursorIcon {
let ctx = painter.ctx(); let ctx = painter.ctx();
// Where cursor images can't be uploaded (immediate viewports, the web), paint the shape. // 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. // 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<f32> {
if *centre { e.max(3.0) } else { e } if *centre { e.max(3.0) } else { e }
} }
Shape::Hand { .. } => hand::HALF, 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 } => { Shape::Hand { closed } => {
let (lx, ly) = (dx / scale, dy / scale); let (lx, ly) = (dx / scale, dy / scale);
let edge = hand::shape(*closed, lx, ly) * scale; silhouette(hand::shape(*closed, lx, ly), hand::crease(*closed, lx, ly), 0.45, scale)
let inside = (0.5 - edge).clamp(0.0, 1.0); }
let outline = (scale + 0.5 - edge).clamp(0.0, 1.0); Shape::Zoom { out } => {
let crease = (0.45 * scale + 0.5 - hand::crease(*closed, lx, ly) * scale).clamp(0.0, 1.0); let (lx, ly) = (dx / scale, dy / scale);
(inside * (1.0 - crease), outline.max(inside * crease)) 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. /// 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) { 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)) ((0.5 * scale + 0.5 - distance).clamp(0.0, 1.0), (1.5 * scale + 0.5 - distance).clamp(0.0, 1.0))
+1 -1
View File
@@ -7,7 +7,7 @@
pub(super) const HALF: f32 = 12.5; pub(super) const HALF: f32 = 12.5;
/// Distance to the segment `a`–`b`, minus `r` (a capsule; a bare line when `r` is 0). /// 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 (px, py) = (x - a[0], y - a[1]);
let (dx, dy) = (b[0] - a[0], b[1] - a[1]); let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
let len2 = dx * dx + dy * dy; let len2 = dx * dx + dy * dy;
+80
View File
@@ -156,6 +156,65 @@ fn the_hand_is_a_bitmap_only_where_the_os_has_none() {
assert_eq!((open, fist), (CursorIcon::None, CursorIcon::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] #[test]
fn unchanged_geometry_reuses_the_os_upload_and_dpi_invalidates_it() { fn unchanged_geometry_reuses_the_os_upload_and_dpi_invalidates_it() {
let ctx = egui::Context::default(); let ctx = egui::Context::default();
@@ -227,6 +286,9 @@ fn write_cursor_preview() {
(Shape::Hand { closed: true }, 1.0), (Shape::Hand { closed: true }, 1.0),
(Shape::Hand { closed: false }, 1.5), (Shape::Hand { closed: false }, 1.5),
(Shape::Hand { closed: true }, 2.0), (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 (width, height) = (600_usize, cases.len() * 100);
let mut rgba = vec![255; width * height * 4]; 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); 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().ui.tool = crate::Tool::Brush;
h.state_mut().run("prefs.set", json!({"path": "cursors.painting", "value": "standard"})).unwrap(); h.state_mut().run("prefs.set", json!({"path": "cursors.painting", "value": "standard"})).unwrap();
h.run_steps(2); h.run_steps(2);
+26
View File
@@ -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)) }
}