mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-24 09:57:08 -06:00
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:
@@ -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");
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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<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 }
|
||||
}
|
||||
|
||||
/// 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<f32> {
|
||||
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))
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)) }
|
||||
}
|
||||
Reference in New Issue
Block a user