mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-24 09:57:08 -06:00
fix(ui): brush outline matches the stroke on Wayland at fractional scales (#5796)
* fix(ui): brush outline matches the stroke on Wayland at fractional scales On Hyprland at 1.25 the brush outline was 1.25x too large and sat right of and below where the brush painted, by more the larger the brush. The engine places dabs exactly; the OS cursor bitmap was wrong. winit 0.30 sizes custom cursor bitmaps on Wayland with a wp_viewport destination whenever the scale isn't 1, and Hyprland (like Mutter before GNOME 48) ignores viewports on cursor surfaces: it shows the bitmap one pixel per logical pixel with the hotspot winit divided by the scale. A client can't tell which compositors do this, so on Wayland at any scale but 1 the brush outline and crosshairs are painted in-app, as on the web. Other platforms and Wayland at scale 1 keep the low-latency OS bitmap. Measured on Hyprland 0.56.2 at 1.25 (100 % zoom, 100 px brush): dab centre (288.5, 560.5), diameter 97 px; outline before (303, 574.5), ~130 px; after (288.5, 562), 107 px including its border. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Merge main; cargo fmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
Brandon Thomas
parent
96c683b25d
commit
17c85e4175
@@ -1771,6 +1771,7 @@ impl eframe::App for PhotocraftApp {
|
||||
// Caps Lock state read from the OS once per frame (see `Services::caps_lock`): the canvas
|
||||
// cursor block needs it before the frame renders. `None` (Wayland/web) keeps `false`.
|
||||
self.caps_lock = self.services.caps_lock.as_mut().is_some_and(|f| f());
|
||||
tool_cursor::set_wayland(ctx, self.services.is_wayland);
|
||||
if !self.styled {
|
||||
Self::setup_context(ctx, self.ui.theme);
|
||||
self.styled = true;
|
||||
|
||||
@@ -93,9 +93,25 @@ fn close_ring() -> Vec<Vec2> {
|
||||
}
|
||||
|
||||
/// Can this pass show an OS cursor bitmap? eframe's immediate viewports and its web integration
|
||||
/// don't upload cursor images; every desktop root viewport does (winit custom cursors).
|
||||
/// don't upload cursor images; every desktop root viewport does (winit custom cursors). Wayland at
|
||||
/// any scale but 1 doesn't either: winit sizes the bitmap there with a `wp_viewport` destination
|
||||
/// that some compositors ignore on cursor surfaces (Hyprland 0.56; Mutter before GNOME 48). They
|
||||
/// show it one bitmap pixel per logical pixel, so at 1.25 a brush outline was 1.25× too large and
|
||||
/// its centre sat 0.2 × its half-width right of and below the pointer, which is where the brush
|
||||
/// painted. Nothing tells the app which compositor does what, so the outline is painted instead.
|
||||
fn native(ctx: &egui::Context) -> bool {
|
||||
cfg!(not(target_arch = "wasm32")) && ctx.viewport_id() == egui::ViewportId::ROOT
|
||||
cfg!(not(target_arch = "wasm32")) && ctx.viewport_id() == egui::ViewportId::ROOT && !scaled_wayland(ctx)
|
||||
}
|
||||
|
||||
/// Mark this window as a Wayland surface (or not), for [`native`]; the shell calls it every pass.
|
||||
pub(crate) fn set_wayland(ctx: &egui::Context, wayland: bool) {
|
||||
ctx.data_mut(|d| d.insert_temp(egui::Id::new("photocraft-cursor-wayland"), wayland));
|
||||
}
|
||||
|
||||
/// A Wayland window whose scale is not 1, where winit's cursor bitmaps are unreliable.
|
||||
fn scaled_wayland(ctx: &egui::Context) -> bool {
|
||||
let wayland = cfg!(target_os = "linux") && ctx.data(|d| d.get_temp::<bool>(egui::Id::new("photocraft-cursor-wayland"))).unwrap_or(false);
|
||||
wayland && ctx.native_pixels_per_point().unwrap_or_else(|| ctx.pixels_per_point() / ctx.zoom_factor()) != 1.0
|
||||
}
|
||||
|
||||
/// Bitmap pixels per egui point. winit takes an `NSCursor` image's dimensions as logical points on
|
||||
|
||||
@@ -272,6 +272,40 @@ fn ordinary_brush_cursor_is_not_part_of_the_canvas_draw_list() {
|
||||
assert!(output.shapes.iter().any(|s| matches!(s.shape, egui::Shape::Circle(_))), "the full tip is not silently shrunk");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(target_os = "linux")]
|
||||
fn scaled_wayland_paints_the_brush_outline_instead_of_an_os_bitmap() {
|
||||
// Hyprland ignores the viewport winit sizes cursor bitmaps with: at 1.25 the outline was
|
||||
// 1.25× too large and its centre 0.2 × its half-width right of and below where the brush
|
||||
// painted. Off scale 1 on Wayland the outline is painted at the pointer, at its true size.
|
||||
let run = |ctx: &egui::Context, native_ppp: f32| {
|
||||
let mut input = egui::RawInput::default();
|
||||
input.viewports.entry(egui::ViewportId::ROOT).or_default().native_pixels_per_point = Some(native_ppp);
|
||||
let mut output = ctx.run_ui(input, |ui| {
|
||||
let icon = circle(ui.painter(), pos2(100.0, 100.0), 20.0, false);
|
||||
ui.ctx().set_cursor_icon(icon);
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
let circles: Vec<_> =
|
||||
output.shapes.iter().filter_map(|s| if let egui::Shape::Circle(c) = &s.shape { Some((c.center, c.radius)) } else { None }).collect();
|
||||
(output.platform_output.cursor_image.is_some(), output.platform_output.cursor_icon, circles)
|
||||
};
|
||||
let ctx = egui::Context::default();
|
||||
ctx.add_plugin(CursorLifecycle);
|
||||
set_wayland(&ctx, true);
|
||||
for ppp in [1.25, 1.5, 2.0] {
|
||||
let (bitmap, icon, circles) = run(&ctx, ppp);
|
||||
assert!(!bitmap, "{ppp}: no OS bitmap");
|
||||
assert_eq!(icon, CursorIcon::None, "{ppp}: the painted outline is the pointer");
|
||||
assert!(!circles.is_empty() && circles.iter().all(|&(c, r)| c == pos2(100.0, 100.0) && r == 20.0), "{ppp}: {circles:?}");
|
||||
}
|
||||
let (bitmap, _, circles) = run(&ctx, 1.0);
|
||||
assert!(bitmap && circles.is_empty(), "Wayland at scale 1 keeps the low-latency bitmap");
|
||||
set_wayland(&ctx, false);
|
||||
let (bitmap, _, circles) = run(&ctx, 1.25);
|
||||
assert!(bitmap && circles.is_empty(), "X11 at 1.25 keeps the bitmap");
|
||||
}
|
||||
|
||||
/// Run explicitly for visual QA: native cursors are absent from GPU screenshots.
|
||||
#[test]
|
||||
#[ignore = "writes target/tool-cursor-preview.png for visual inspection"]
|
||||
|
||||
Reference in New Issue
Block a user