mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-27 11:17:01 -06:00
Merge pull request #2502 from vangel76/fix/2450-wayland-cursor-theme
Wayland: pointer returns over panels and menus after a tool cursor (#2450)
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
//! Linux Wayland: tell winit where the cursor theme is (#2450).
|
||||
//!
|
||||
//! Without the cursor-shape protocol (GNOME 46 and others) winit loads the pointer from an Xcursor
|
||||
//! theme itself. When that lookup finds nothing, no pointer is ever set and the compositor keeps
|
||||
//! showing its default one, until the canvas hides the system pointer to paint a tool glyph
|
||||
//! (`CursorIcon::None`). Then the arrow never comes back over the panels and menus. The lookup
|
||||
//! 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"];
|
||||
|
||||
/// The desktop's cursor settings (GNOME's `org.gnome.desktop.interface`).
|
||||
#[derive(Debug, Default, PartialEq)]
|
||||
pub struct DesktopCursor {
|
||||
pub theme: Option<String>,
|
||||
pub size: Option<u32>,
|
||||
}
|
||||
|
||||
/// The variables to set, given the environment, the host icon folders that exist and the
|
||||
/// desktop's cursor settings. Empty unless this is a Wayland session; variables the user set
|
||||
/// stay as they are.
|
||||
pub fn plan(var: impl Fn(&str) -> Option<OsString>, host_icons: &[&str], desktop: DesktopCursor) -> Vec<(&'static str, OsString)> {
|
||||
let set = |name: &str| var(name).is_some_and(|v| !v.is_empty());
|
||||
if !(set("WAYLAND_DISPLAY") || set("WAYLAND_SOCKET")) {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
if !set("XCURSOR_PATH") && !host_icons.is_empty() {
|
||||
out.push(("XCURSOR_PATH", OsString::from(host_icons.join(":"))));
|
||||
}
|
||||
if !set("XCURSOR_THEME")
|
||||
&& let Some(theme) = desktop.theme.filter(|t| !t.is_empty())
|
||||
{
|
||||
out.push(("XCURSOR_THEME", OsString::from(theme)));
|
||||
}
|
||||
if !set("XCURSOR_SIZE")
|
||||
&& let Some(size) = desktop.size.filter(|s| (1..=512).contains(s))
|
||||
{
|
||||
out.push(("XCURSOR_SIZE", OsString::from(size.to_string())));
|
||||
}
|
||||
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('\'')
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn desktop_cursor() -> DesktopCursor {
|
||||
let get = |key: &str| {
|
||||
let out = std::process::Command::new("gsettings").args(["get", "org.gnome.desktop.interface", key]).output().ok()?;
|
||||
out.status.success().then(|| String::from_utf8_lossy(&out.stdout).into_owned())
|
||||
};
|
||||
DesktopCursor {
|
||||
theme: get("cursor-theme").map(|v| gsettings_value(&v).to_owned()),
|
||||
size: get("cursor-size").and_then(|v| gsettings_value(&v).parse().ok()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets `XCURSOR_PATH`, `XCURSOR_THEME` and `XCURSOR_SIZE` where a Wayland session leaves them
|
||||
/// unset. Call first thing in `main`, while the process is single-threaded.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[allow(unsafe_code)]
|
||||
pub fn apply() {
|
||||
let host: Vec<&str> = FLATPAK_HOST_ICONS.iter().copied().filter(|p| std::path::Path::new(p).is_dir()).collect();
|
||||
for (name, value) in plan(|n| std::env::var_os(n), &host, desktop_cursor()) {
|
||||
log::debug!("cursor theme: {name}={value:?}");
|
||||
// SAFETY: called at the top of `main`, before the app starts any thread.
|
||||
unsafe { std::env::set_var(name, value) };
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn env<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<OsString> + 'a {
|
||||
move |name| pairs.iter().find(|(k, _)| *k == name).map(|(_, v)| OsString::from(v))
|
||||
}
|
||||
|
||||
fn adwaita() -> DesktopCursor {
|
||||
DesktopCursor { theme: Some("Adwaita".into()), size: Some(24) }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wayland_session_gets_the_desktop_theme_and_the_host_icons() {
|
||||
let got = plan(env(&[("WAYLAND_DISPLAY", "wayland-0")]), &["/run/host/share/icons"], adwaita());
|
||||
assert_eq!(
|
||||
got,
|
||||
vec![
|
||||
("XCURSOR_PATH", OsString::from("/run/host/share/icons")),
|
||||
("XCURSOR_THEME", OsString::from("Adwaita")),
|
||||
("XCURSOR_SIZE", OsString::from("24")),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_the_user_made_and_other_sessions_stay_untouched() {
|
||||
let set = [("WAYLAND_DISPLAY", "wayland-0"), ("XCURSOR_PATH", "/x"), ("XCURSOR_THEME", "Bibata"), ("XCURSOR_SIZE", "32")];
|
||||
assert!(plan(env(&set), &["/run/host/share/icons"], adwaita()).is_empty());
|
||||
assert!(plan(env(&[("DISPLAY", ":0")]), &["/run/host/share/icons"], adwaita()).is_empty());
|
||||
// A theme-less desktop and an absurd size set nothing.
|
||||
let odd = DesktopCursor { theme: Some(String::new()), size: Some(0) };
|
||||
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"];
|
||||
// Compared as paths, so the joins match on Windows too, where they use `\`.
|
||||
let exists = |p: &Path| files.iter().any(|f| Path::new(f) == p);
|
||||
let read = |p: &Path| {
|
||||
if p == Path::new("/i/Breeze/index.theme") {
|
||||
Some("[Icon Theme]\nInherits=Other, Bibata\n".to_owned())
|
||||
} else if p == Path::new("/i/Loop/index.theme") {
|
||||
Some("Inherits=Loop\n".to_owned())
|
||||
} else {
|
||||
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");
|
||||
assert_eq!(gsettings_value("24\n"), "24");
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ mod clipboard;
|
||||
#[cfg(feature = "wgpu")]
|
||||
mod compositor_gpu;
|
||||
mod control_server;
|
||||
mod cursor_theme;
|
||||
#[cfg(feature = "wgpu")]
|
||||
mod gpu;
|
||||
mod logging;
|
||||
@@ -525,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.
|
||||
@@ -654,6 +656,9 @@ Without a window (converting, scripting, the MCP server), use vectorcraft-cli:
|
||||
";
|
||||
|
||||
fn main() -> std::process::ExitCode {
|
||||
// Before any thread starts: the pointer must find a cursor theme on Wayland (#2450).
|
||||
#[cfg(target_os = "linux")]
|
||||
cursor_theme::apply();
|
||||
// Validate roots before logging, font scans, preferences or the window: a missing value
|
||||
// must not clear an environment root and start the control channel without confinement.
|
||||
let mut control = automation_args::ControlArg::default();
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -24,6 +24,10 @@ finish-args:
|
||||
- --socket=fallback-x11
|
||||
# GPU access for the wgpu renderer (Vulkan, or GL as a fallback).
|
||||
- --device=dri
|
||||
# Cursor theme: without the cursor-shape protocol (e.g. GNOME 46) winit loads the theme itself.
|
||||
# The sandbox sets no XCURSOR_PATH and ships no cursor themes, so the pointer would turn invisible
|
||||
# over the window. The host's themes are mounted under /run/host (#2450).
|
||||
- --env=XCURSOR_PATH=/run/host/user-share/icons:/run/host/share/icons
|
||||
# Open/Save dialogs use the file-chooser portal (rfd), and drag-and-drop uses the document
|
||||
# portal, so broad filesystem access isn't needed. Pictures is granted for opening files by
|
||||
# path from the command line.
|
||||
|
||||
@@ -22,6 +22,10 @@ finish-args:
|
||||
- --socket=wayland
|
||||
- --socket=fallback-x11
|
||||
- --device=dri
|
||||
# Cursor theme: without the cursor-shape protocol (e.g. GNOME 46) winit loads the theme itself.
|
||||
# The sandbox sets no XCURSOR_PATH and ships no cursor themes, so the pointer would turn invisible
|
||||
# over the window. The host's themes are mounted under /run/host (#2450).
|
||||
- --env=XCURSOR_PATH=/run/host/user-share/icons:/run/host/share/icons
|
||||
# Open/Save dialogs use the file-chooser portal (rfd), and drag-and-drop uses the document
|
||||
# portal, so broad filesystem access isn't needed. Pictures is granted for opening files by
|
||||
# path from the command line.
|
||||
|
||||
Reference in New Issue
Block a user