Merge pull request #14935 from omacom/startup-cursor-recovery
Recover the startup cursor across Hyprland reloads
This commit is contained in:
3 files changed
+140
-11
No files matched your search
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
Reference in new issue
Block a user