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:
Khawar Zamman Wani
2026-10-14 23:50:19 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 Brandon Thomas
parent 96c683b25d
commit 17c85e4175
3 changed files with 53 additions and 2 deletions
+1
View File
@@ -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;
+18 -2
View File
@@ -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
+34
View File
@@ -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"]