Cursor: keep the system pointer where no cursor theme loads on Wayland (#2450)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76
2026-10-17 12:50:38 +07:00
committed by bflatastic
co-authored by Claude Opus 5.5
parent 743930ac11
commit 37dfdd5da5
5 changed files with 116 additions and 1 deletions
+86
View File
@@ -7,11 +7,15 @@
//! fails in a Flatpak sandbox (`XCURSOR_PATH` unset, no cursor theme in the runtime) and where the
//! `default` theme is a dangling symlink; the desktop's own setting names a theme that loads.
//!
//! Where no cursor theme can be found at all (KDE or sway without gsettings), the env fix does
//! nothing: [`theme_missing`] tells the canvas to keep the system pointer visible beside the tool glyph.
//!
//! This reads the desktop's cursor theme and size and sets what the user left unset, once, before
//! any thread starts and before the window opens.
#![cfg_attr(not(target_os = "linux"), allow(dead_code))]
use std::ffi::OsString;
use std::path::{Path, PathBuf};
/// Where a Flatpak sandbox mounts the host's cursor themes (user and system).
const FLATPAK_HOST_ICONS: [&str; 2] = ["/run/host/user-share/icons", "/run/host/share/icons"];
@@ -48,6 +52,67 @@ pub fn plan(var: impl Fn(&str) -> Option<OsString>, host_icons: &[&str], desktop
out
}
/// Xcursor's default search path when `XCURSOR_PATH` is unset (relative entries are in `$HOME`).
const DEFAULT_ICON_DIRS: [&str; 5] = ["~/.local/share/icons", "~/.icons", "/usr/share/icons", "/usr/share/pixmaps", "/usr/X11R6/lib/X11/icons"];
/// The themes `index.theme` lists after `Inherits=`.
fn inherits(index: &str) -> Vec<String> {
index
.lines()
.find_map(|l| l.trim().strip_prefix("Inherits="))
.map(|v| v.split([',', ';']).map(|t| t.trim().to_owned()).filter(|t| !t.is_empty()).collect())
.unwrap_or_default()
}
/// Can Xcursor load a pointer for the environment's theme (`XCURSOR_THEME`, else `default`)? It does
/// when the theme, or one it inherits from, has a `default` or `left_ptr` cursor file in a folder of
/// `XCURSOR_PATH` (else Xcursor's default folders). `read` returns a file's text, `exists` tells
/// whether a file is there.
pub fn theme_usable(
var: impl Fn(&str) -> Option<OsString>,
home: Option<&Path>,
read: impl Fn(&Path) -> Option<String>,
exists: impl Fn(&Path) -> bool,
) -> bool {
let dirs: Vec<PathBuf> = match var("XCURSOR_PATH").filter(|v| !v.is_empty()) {
Some(v) => std::env::split_paths(&v).collect(),
None => DEFAULT_ICON_DIRS
.iter()
.filter_map(|d| match d.strip_prefix("~/") {
Some(rest) => home.map(|h| h.join(rest)),
None => Some(PathBuf::from(d)),
})
.collect(),
};
let first = var("XCURSOR_THEME").and_then(|v| v.into_string().ok()).filter(|t| !t.is_empty()).unwrap_or_else(|| "default".into());
let mut todo = vec![first];
let mut seen: Vec<String> = Vec::new();
while let Some(theme) = todo.pop() {
// Bounded: themes inherit in short chains, and a cycle must end.
if seen.contains(&theme) || seen.len() >= 16 {
continue;
}
for dir in &dirs {
let base = dir.join(&theme);
if ["default", "left_ptr"].iter().any(|c| exists(&base.join("cursors").join(c))) {
return true;
}
if let Some(index) = read(&base.join("index.theme")) {
todo.extend(inherits(&index));
}
}
seen.push(theme);
}
false
}
/// On this machine, after [`apply`]: is there no cursor theme Xcursor can load? Then winit never
/// sets a pointer, and the canvas must not hide the system one (#2450).
pub fn theme_missing() -> bool {
let home = std::env::var_os("HOME").filter(|h| !h.is_empty()).map(PathBuf::from);
!theme_usable(|n| std::env::var_os(n), home.as_deref(), |p| std::fs::read_to_string(p).ok(), |p| p.exists())
}
/// The value of a `gsettings get` line: `'Adwaita'` becomes `Adwaita`, `24` stays `24`.
fn gsettings_value(output: &str) -> &str {
output.trim().trim_matches('\'')
@@ -113,6 +178,27 @@ mod tests {
assert!(plan(env(&[("WAYLAND_DISPLAY", "wayland-0")]), &[], odd).is_empty());
}
#[test]
fn a_theme_is_usable_directly_or_through_inherits_and_missing_otherwise() {
let files = ["/i/Bibata/cursors/left_ptr", "/i/Breeze/index.theme", "/i/Loop/index.theme"];
let exists = |p: &Path| p.to_str().is_some_and(|p| files.contains(&p));
let read = |p: &Path| match p.to_str()? {
"/i/Breeze/index.theme" => Some("[Icon Theme]\nInherits=Other, Bibata\n".to_owned()),
"/i/Loop/index.theme" => Some("Inherits=Loop\n".to_owned()),
_ => None,
};
let with = |theme: &'static str| {
move |n: &str| (n == "XCURSOR_PATH").then(|| OsString::from("/i")).or((n == "XCURSOR_THEME").then(|| OsString::from(theme)))
};
assert!(theme_usable(with("Bibata"), None, read, exists));
assert!(theme_usable(with("Breeze"), None, read, exists));
// No such theme, a theme without cursors, an inheritance loop: nothing to load.
assert!(!theme_usable(with("Nope"), None, read, exists));
assert!(!theme_usable(with("Loop"), None, read, exists));
// Unset: Xcursor's default folders and the `default` theme.
assert!(!theme_usable(|_| None, Some(Path::new("/home/u")), read, exists));
}
#[test]
fn gsettings_output_is_unquoted() {
assert_eq!(gsettings_value("'Adwaita'\n"), "Adwaita");
+1
View File
@@ -526,6 +526,7 @@ fn services(parent: Parent, ui: egui::Context) -> Services {
start_pick: cfg!(unix).then(|| Box::new(move |request: PickRequest| start_pick(request, &p5, &ui)) as vectorcraft_ui_egui::picks::StartPick),
dialogs_are_sheets: cfg!(target_os = "macos"),
wayland,
no_cursor_theme: wayland && cursor_theme::theme_missing(),
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
write: Some(Box::new(write_file)),
// Background Save and Export write from a worker thread.
+2 -1
View File
@@ -565,7 +565,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
&& crate::cursors::paint_glyphs(crate::cursors::HIDES_ONLY_WHEN_KEY, ui.input(|i| i.focused))
&& crate::cursors::paint(&painter, c, hp, 1.0 / ui_scale) =>
{
egui::CursorIcon::None
// No cursor theme (Wayland): the glyph is painted, the system pointer stays (#2450).
if crate::cursors::hide_system_pointer(app.services.no_cursor_theme) { egui::CursorIcon::None } else { cursor_icon(c) }
}
_ => cursor_icon(c),
}
+23
View File
@@ -581,6 +581,13 @@ pub fn os_cursors(build: bool, wayland: bool) -> bool {
build && !wayland
}
/// Does the canvas hide the system pointer to show a painted tool glyph alone? Not when there is no
/// cursor theme on Wayland ([`crate::Services::no_cursor_theme`]): winit sets no pointer then, and a
/// hidden one would not come back over the panels (#2450). The system pointer stays beside the glyph.
pub fn hide_system_pointer(no_cursor_theme: bool) -> bool {
!no_cursor_theme
}
/// Does the window hide the pointer only while it is the key window? macOS does: winit hides it with
/// a cursor rect holding a transparent cursor, and macOS applies the rect only in the key window.
/// Over a window behind another app or under a sheet, the system pointer shows (#2020).
@@ -927,6 +934,22 @@ mod tests {
assert_eq!(out.platform_output.cursor_icon, egui::CursorIcon::None);
}
/// Without a cursor theme the canvas keeps the system pointer visible (#2450).
#[test]
fn no_cursor_theme_keeps_the_system_pointer() {
assert!(!hide_system_pointer(true));
assert!(hide_system_pointer(false));
let mut app = VectorcraftApp::new(Session::new(), crate::Services { wayland: true, no_cursor_theme: true, ..Default::default() });
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
let ctx = egui::Context::default();
for _ in 0..3 {
frame(&mut app, &ctx, vec![]);
}
let over = app.canvas_rect.unwrap().center();
let out = frame(&mut app, &ctx, vec![egui::Event::PointerMoved(over)]);
assert_ne!(out.platform_output.cursor_icon, egui::CursorIcon::None);
}
/// On macOS a window without the focus shows the system pointer over the canvas, and the glyph
/// is not painted next to it (#2020). With the focus back, the canvas sets `CursorIcon::None` again
/// on the next frame, without a pointer move.
+4
View File
@@ -275,6 +275,10 @@ pub struct Services {
/// The window is on Wayland: the tool cursors are painted into it instead of handed to the
/// compositor as bitmaps ([`cursors::os_cursors`]).
pub wayland: bool,
/// On Wayland, no cursor theme can be loaded, so winit sets no pointer and the compositor's
/// default one would never come back once the canvas hid it: the canvas keeps the system
/// pointer visible next to the tool glyph ([`cursors::hide_system_pointer`], #2450).
pub no_cursor_theme: bool,
}
/// Cached canvas raster.