diff --git a/default/hypr/startup-cursor.lua b/default/hypr/startup-cursor.lua index f80e0e1c..4744a724 100644 --- a/default/hypr/startup-cursor.lua +++ b/default/hypr/startup-cursor.lua @@ -1,9 +1,38 @@ +local function cursor_theme(value) + if value and value ~= "" and value ~= "omarchy-startup" then return value end +end + +local function normal_theme(interrupted) + local current = os.getenv("XCURSOR_THEME") + local theme = cursor_theme(current) + -- Only a lost preference needs recovering; an unset theme still means the default alias. + if theme or not (interrupted or current == "omarchy-startup") then return theme or "default" end + theme = cursor_theme(os.getenv("OMARCHY_STARTUP_CURSOR_THEME")) + if theme then return theme end + local pipe = io.popen("gsettings get org.gnome.desktop.interface cursor-theme 2>/dev/null", "r") + if pipe then + theme = cursor_theme((pipe:read("*a") or ""):match("^'(.-)'%s*$")) + pipe:close() + end + return theme or "default" +end + +local function normal_path() + local kept = {} + for entry in (os.getenv("XCURSOR_PATH") or "~/.local/share/icons:~/.icons:/usr/share/icons:/usr/share/pixmaps"):gmatch("[^:]+") do + if not entry:match("/default/hypr/cursors$") then table.insert(kept, entry) end + end + return table.concat(kept, ":") +end + -- Hyprland polls cursor.invisible, so its first frames need a blank cursor too. function omarchy_startup_cursor_restore(loaded) if not omarchy_startup_cursor_pending then return end local cursor = omarchy_startup_cursor if loaded then omarchy_startup_cursor_pending = false + hl.env("OMARCHY_STARTUP_CURSOR_SESSION", "") + hl.env("OMARCHY_STARTUP_CURSOR_THEME", "") hl.config({ cursor = cursor.config }) if cursor.config.enable_hyprcursor and cursor.hyprcursor then hl.exec_cmd("hyprctl setcursor " .. o.shell_quote(cursor.hyprcursor) .. " " .. cursor.size) @@ -11,15 +40,25 @@ function omarchy_startup_cursor_restore(loaded) elseif not cursor.restoring then -- Reload the normal Xcursor fallback before enabling Hyprcursor or GSettings. cursor.restoring = true + hl.env("XCURSOR_PATH", cursor.path) + hl.env("XCURSOR_THEME", cursor.xcursor) + hl.exec_cmd("dbus-update-activation-environment --systemd XCURSOR_PATH XCURSOR_THEME") + local complete = "if omarchy_startup_cursor and omarchy_startup_cursor.xcursor == " .. string.format("%q", cursor.xcursor) + .. " and omarchy_startup_cursor.size == " .. cursor.size .. " then omarchy_startup_cursor_restore(true) end" hl.exec_cmd("hyprctl setcursor " .. o.shell_quote(cursor.xcursor) .. " " .. cursor.size - .. " && hyprctl eval 'omarchy_startup_cursor_restore(true)'") + .. " && hyprctl eval " .. o.shell_quote(complete)) end end hl.on("config.reloaded", function() + local recover = false if omarchy_startup_cursor_pending == nil then - -- Config updates in an existing compositor must not hide its pointer. - omarchy_startup_cursor_pending = #hl.get_monitors() == 0 + -- An ordinary reload must not hide the pointer, but an interrupted reveal survives. + local cold = #hl.get_monitors() == 0 + local session = os.getenv("HYPRLAND_INSTANCE_SIGNATURE") + local interrupted = session and os.getenv("OMARCHY_STARTUP_CURSOR_SESSION") == session + recover = not cold and not interrupted and os.getenv("XCURSOR_THEME") == "omarchy-startup" + omarchy_startup_cursor_pending = cold or interrupted or recover if omarchy_startup_cursor_pending then local hyprcursor = hl.get_config("cursor.enable_hyprcursor") omarchy_startup_cursor = { @@ -30,17 +69,31 @@ hl.on("config.reloaded", function() }, hyprcursor = os.getenv("HYPRCURSOR_THEME"), size = tonumber((hyprcursor and os.getenv("HYPRCURSOR_SIZE")) or os.getenv("XCURSOR_SIZE")) or 24, - path = os.getenv("XCURSOR_PATH") or "~/.local/share/icons:~/.icons:/usr/share/icons:/usr/share/pixmaps", - xcursor = os.getenv("XCURSOR_THEME") or "default", + path = normal_path(), + xcursor = normal_theme(interrupted), } if omarchy_startup_cursor.size <= 0 then omarchy_startup_cursor.size = 24 end - hl.env("XCURSOR_PATH", os.getenv("OMARCHY_PATH") .. "/default/hypr/cursors:" .. omarchy_startup_cursor.path) - hl.env("XCURSOR_THEME", "omarchy-startup") + -- Compositor environment survives Lua reloads; a session id prevents stale inheritance. + hl.env("OMARCHY_STARTUP_CURSOR_THEME", omarchy_startup_cursor.xcursor) + hl.env("OMARCHY_STARTUP_CURSOR_SESSION", os.getenv("HYPRLAND_INSTANCE_SIGNATURE")) + if cold then + hl.env("XCURSOR_PATH", os.getenv("OMARCHY_PATH") .. "/default/hypr/cursors:" .. omarchy_startup_cursor.path) + hl.env("XCURSOR_THEME", "omarchy-startup") + else + hl.env("XCURSOR_PATH", omarchy_startup_cursor.path) + hl.env("XCURSOR_THEME", omarchy_startup_cursor.xcursor) + end end end if omarchy_startup_cursor_pending then -- Keep the temporary cursor private to the compositor, including GSettings. hl.config({ cursor = { invisible = true, enable_hyprcursor = false, sync_gsettings_theme = false } }) - hl.timer(function() omarchy_startup_cursor_restore() end, { timeout = 15000, type = "oneshot" }) + hl.timer(function() + if omarchy_startup_cursor_pending then + omarchy_startup_cursor.restoring = false + omarchy_startup_cursor_restore() + end + end, { timeout = 15000, type = "oneshot" }) + if recover then omarchy_startup_cursor_restore() end end end) diff --git a/test/shell.d/hyprland-startup-cursor-cold-test.sh b/test/shell.d/hyprland-startup-cursor-cold-test.sh index dca0a2f1..6491907c 100755 --- a/test/shell.d/hyprland-startup-cursor-cold-test.sh +++ b/test/shell.d/hyprland-startup-cursor-cold-test.sh @@ -67,6 +67,9 @@ with tempfile.TemporaryDirectory() as directory: time.sleep(0.01) wait_phase("holding") + subprocess.run(["hyprctl", "reload"], env=env, check=True, capture_output=True, timeout=5) + subprocess.run(["hyprctl", "eval", "assert(omarchy_startup_cursor_pending, 'reload lost the pending cursor reveal')"], + env=env, check=True, capture_output=True, timeout=5) (stage / "release").write_text("reveal") wait_phase("revealed") # Cursor visibility is polled by the compositor independently of the fade. @@ -78,6 +81,9 @@ with tempfile.TemporaryDirectory() as directory: if float(green) > 20: break assert time.monotonic() < deadline, "the normal cursor did not return with the desktop" + except Exception: + print((stage / "compositor.log").read_text()) + raise finally: os.killpg(compositor.pid, signal.SIGTERM) compositor.wait(timeout=10) diff --git a/test/shell.d/hyprland-startup-cursor-test.sh b/test/shell.d/hyprland-startup-cursor-test.sh index 248afd48..b83be465 100755 --- a/test/shell.d/hyprland-startup-cursor-test.sh +++ b/test/shell.d/hyprland-startup-cursor-test.sh @@ -4,11 +4,11 @@ set -euo pipefail source "$(dirname "$0")/base-test.sh" require_command lua -lua <<'LUA' +lua - <<'LUA' package.path = os.getenv("ROOT") .. "/?.lua;" .. package.path -local events, recovery, command = {}, nil, nil +local events, recovery, command, imported = {}, nil, nil, false local config = { invisible = false, enable_hyprcursor = true, sync_gsettings_theme = true } -local env = { XCURSOR_THEME = "my-xcursor", HYPRCURSOR_THEME = "my-hyprcursor", XCURSOR_PATH = "/my/icons" } +local env = { XCURSOR_THEME = "my-xcursor", HYPRCURSOR_THEME = "my-hyprcursor", XCURSOR_PATH = "/my/icons", HYPRLAND_INSTANCE_SIGNATURE = "test-session", OMARCHY_PATH = os.getenv("ROOT") } local getenv = os.getenv os.getenv = function(name) return env[name] or getenv(name) end local monitors = {} @@ -25,6 +25,10 @@ hl = { recovery = callback end, exec_cmd = function(value) + if value == "dbus-update-activation-environment --systemd XCURSOR_PATH XCURSOR_THEME" then + assert(env.XCURSOR_THEME ~= "omarchy-startup" and not env.XCURSOR_PATH:match("/default/hypr/cursors")) + imported = true + end if value == "omarchy-launch-shell" then assert(config.invisible and env.XCURSOR_THEME == "my-xcursor", "hide the compositor cursor while restoring application settings before launch") end @@ -44,7 +48,15 @@ assert(env.XCURSOR_THEME == "my-xcursor" and env.XCURSOR_PATH == "/my/icons", "a assert(config.invisible, "starting applications must not reveal the cursor") local previous_recovery = recovery +-- Hyprland reloads the configuration in a fresh Lua state and discards timers. +omarchy_startup_cursor_pending = nil +omarchy_startup_cursor = nil +for name in pairs(config) do config[name] = true end +config.invisible = false +monitors = { {} } +dofile(os.getenv("ROOT") .. "/default/hypr/startup-cursor.lua") events["config.reloaded"]() +assert(omarchy_startup_cursor_pending and omarchy_startup_cursor.xcursor == "my-xcursor", "a fresh Lua state must retain the pending reveal and the user's cursor") assert(recovery ~= previous_recovery and config.invisible, "a config reload must retain startup hiding and rearm recovery") recovery() assert(command:match("setcursor 'my%-xcursor'"), "restore the Xcursor fallback before revealing the pointer") @@ -62,5 +74,63 @@ omarchy_startup_cursor_pending = nil monitors = { {} } events["config.reloaded"]() assert(not omarchy_startup_cursor_pending and not config.invisible, "installing the fix in a running compositor must leave its cursor alone") + +local popen = io.popen +local gsettings = "'custom-cursor'\n" +io.popen = function(value) + assert(value:match("gsettings get org.gnome.desktop.interface cursor%-theme")) + return { read = function() return gsettings end, close = function() return true end } +end +local function orphan(theme) + imported = false + omarchy_startup_cursor_pending, omarchy_startup_cursor = nil, nil + env.XCURSOR_THEME = "omarchy-startup" + env.OMARCHY_STARTUP_CURSOR_THEME = theme + env.OMARCHY_STARTUP_CURSOR_SESSION = "old-session" + env.XCURSOR_PATH = "/old/worktree/default/hypr/cursors:/my/icons" + env.OMARCHY_STARTUP_CURSOR_PATH = nil + config.invisible, config.enable_hyprcursor, config.sync_gsettings_theme = false, false, true + dofile(os.getenv("ROOT") .. "/default/hypr/startup-cursor.lua") + events["config.reloaded"]() + assert(omarchy_startup_cursor_pending, "an orphan blank theme must be recovered with live monitors") + assert(env.XCURSOR_THEME ~= "omarchy-startup" and env.XCURSOR_PATH == "/my/icons", "recovery must remove the blank theme and old worktree search path") + return omarchy_startup_cursor.xcursor +end +assert(orphan("saved-custom") == "saved-custom", "saved settings take precedence over GSettings") +omarchy_startup_cursor_restore() +assert(command:match("setcursor 'saved%-custom'") and imported, "orphan recovery must also repair activation environments") +local stalled = command +command = nil +recovery() +assert(command == stalled, "the timeout must retry an unfinished restore") +local completion = assert(command:match("hyprctl eval '(.*)'$")) +omarchy_startup_cursor.xcursor = "changed-during-restore" +assert(load(completion))() +assert(omarchy_startup_cursor_pending, "a stale completion must not reveal a different cursor after reload") +omarchy_startup_cursor.xcursor = "saved-custom" +local old_size = omarchy_startup_cursor.size +omarchy_startup_cursor.size = old_size + 8 +assert(load(completion))() +assert(omarchy_startup_cursor_pending, "a stale completion must not acknowledge a different cursor size") +omarchy_startup_cursor.size = old_size +omarchy_startup_cursor_restore(true) +assert(not config.invisible and config.sync_gsettings_theme) +assert(orphan(nil) == "custom-cursor", "recover a nonblank user GSettings theme when no snapshot survives") +omarchy_startup_cursor_restore(true) +gsettings = "'omarchy-startup'\n" +assert(orphan(nil) == "default", "a destroyed user preference must fall back to the default alias, never the blank theme") +omarchy_startup_cursor_restore(true) +omarchy_startup_cursor_pending, omarchy_startup_cursor = nil, nil +monitors = {} +env.XCURSOR_THEME, env.XCURSOR_PATH = "next-theme", "/next/icons" +env.OMARCHY_STARTUP_CURSOR_PATH = "/stale/icons" +env.OMARCHY_STARTUP_CURSOR_SESSION = "old-session" +events["config.reloaded"]() +assert(omarchy_startup_cursor.xcursor == "next-theme" and omarchy_startup_cursor.path == "/next/icons", "a new session must honor changed cursor settings instead of inherited snapshots") +omarchy_startup_cursor_pending, omarchy_startup_cursor = nil, nil +env.XCURSOR_THEME, env.OMARCHY_STARTUP_CURSOR_SESSION, gsettings = "", "old-session", "'custom-cursor'\n" +events["config.reloaded"]() +assert(omarchy_startup_cursor.xcursor == "default", "an ordinary boot without a cursor theme keeps the default alias instead of adopting GSettings") +io.popen = popen LUA pass "the first compositor frame starts blank and the reveal restores user cursor settings"