Merge pull request #14935 from omacom/startup-cursor-recovery

Recover the startup cursor across Hyprland reloads
This commit is contained in:
Spencer Bull authored and GitHub committed 2026-10-10 20:59:21 -05:00
commit 9830142834
3 files changed
+140 -11

No files matched your search

+61 -8
View File
@@ -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. -- Hyprland polls cursor.invisible, so its first frames need a blank cursor too.
function omarchy_startup_cursor_restore(loaded) function omarchy_startup_cursor_restore(loaded)
if not omarchy_startup_cursor_pending then return end if not omarchy_startup_cursor_pending then return end
local cursor = omarchy_startup_cursor local cursor = omarchy_startup_cursor
if loaded then if loaded then
omarchy_startup_cursor_pending = false omarchy_startup_cursor_pending = false
hl.env("OMARCHY_STARTUP_CURSOR_SESSION", "")
hl.env("OMARCHY_STARTUP_CURSOR_THEME", "")
hl.config({ cursor = cursor.config }) hl.config({ cursor = cursor.config })
if cursor.config.enable_hyprcursor and cursor.hyprcursor then if cursor.config.enable_hyprcursor and cursor.hyprcursor then
hl.exec_cmd("hyprctl setcursor " .. o.shell_quote(cursor.hyprcursor) .. " " .. cursor.size) 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 elseif not cursor.restoring then
-- Reload the normal Xcursor fallback before enabling Hyprcursor or GSettings. -- Reload the normal Xcursor fallback before enabling Hyprcursor or GSettings.
cursor.restoring = true 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 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
end end
hl.on("config.reloaded", function() hl.on("config.reloaded", function()
local recover = false
if omarchy_startup_cursor_pending == nil then if omarchy_startup_cursor_pending == nil then
-- Config updates in an existing compositor must not hide its pointer. -- An ordinary reload must not hide the pointer, but an interrupted reveal survives.
omarchy_startup_cursor_pending = #hl.get_monitors() == 0 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 if omarchy_startup_cursor_pending then
local hyprcursor = hl.get_config("cursor.enable_hyprcursor") local hyprcursor = hl.get_config("cursor.enable_hyprcursor")
omarchy_startup_cursor = { omarchy_startup_cursor = {
@@ -30,17 +69,31 @@ hl.on("config.reloaded", function()
}, },
hyprcursor = os.getenv("HYPRCURSOR_THEME"), hyprcursor = os.getenv("HYPRCURSOR_THEME"),
size = tonumber((hyprcursor and os.getenv("HYPRCURSOR_SIZE")) or os.getenv("XCURSOR_SIZE")) or 24, 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", path = normal_path(),
xcursor = os.getenv("XCURSOR_THEME") or "default", xcursor = normal_theme(interrupted),
} }
if omarchy_startup_cursor.size <= 0 then omarchy_startup_cursor.size = 24 end 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) -- Compositor environment survives Lua reloads; a session id prevents stale inheritance.
hl.env("XCURSOR_THEME", "omarchy-startup") 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
end end
if omarchy_startup_cursor_pending then if omarchy_startup_cursor_pending then
-- Keep the temporary cursor private to the compositor, including GSettings. -- Keep the temporary cursor private to the compositor, including GSettings.
hl.config({ cursor = { invisible = true, enable_hyprcursor = false, sync_gsettings_theme = false } }) 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
end) end)
@@ -67,6 +67,9 @@ with tempfile.TemporaryDirectory() as directory:
time.sleep(0.01) time.sleep(0.01)
wait_phase("holding") 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") (stage / "release").write_text("reveal")
wait_phase("revealed") wait_phase("revealed")
# Cursor visibility is polled by the compositor independently of the fade. # Cursor visibility is polled by the compositor independently of the fade.
@@ -78,6 +81,9 @@ with tempfile.TemporaryDirectory() as directory:
if float(green) > 20: if float(green) > 20:
break break
assert time.monotonic() < deadline, "the normal cursor did not return with the desktop" assert time.monotonic() < deadline, "the normal cursor did not return with the desktop"
except Exception:
print((stage / "compositor.log").read_text())
raise
finally: finally:
os.killpg(compositor.pid, signal.SIGTERM) os.killpg(compositor.pid, signal.SIGTERM)
compositor.wait(timeout=10) compositor.wait(timeout=10)
+73 -3
View File
@@ -4,11 +4,11 @@ set -euo pipefail
source "$(dirname "$0")/base-test.sh" source "$(dirname "$0")/base-test.sh"
require_command lua require_command lua
lua <<'LUA' lua - <<'LUA'
package.path = os.getenv("ROOT") .. "/?.lua;" .. package.path 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 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 local getenv = os.getenv
os.getenv = function(name) return env[name] or getenv(name) end os.getenv = function(name) return env[name] or getenv(name) end
local monitors = {} local monitors = {}
@@ -25,6 +25,10 @@ hl = {
recovery = callback recovery = callback
end, end,
exec_cmd = function(value) 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 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") assert(config.invisible and env.XCURSOR_THEME == "my-xcursor", "hide the compositor cursor while restoring application settings before launch")
end 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") assert(config.invisible, "starting applications must not reveal the cursor")
local previous_recovery = recovery 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"]() 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") assert(recovery ~= previous_recovery and config.invisible, "a config reload must retain startup hiding and rearm recovery")
recovery() recovery()
assert(command:match("setcursor 'my%-xcursor'"), "restore the Xcursor fallback before revealing the pointer") assert(command:match("setcursor 'my%-xcursor'"), "restore the Xcursor fallback before revealing the pointer")
@@ -62,5 +74,63 @@ omarchy_startup_cursor_pending = nil
monitors = { {} } monitors = { {} }
events["config.reloaded"]() 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") 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 LUA
pass "the first compositor frame starts blank and the reveal restores user cursor settings" pass "the first compositor frame starts blank and the reveal restores user cursor settings"