From f5170dd07066a9703ab57f059d14a9cc9218fad8 Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Sat, 10 Oct 2026 17:17:28 -0500 Subject: [PATCH 1/2] Recover the startup cursor across Hyprland reloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hyprland discards Lua globals and timers during reload, so the pending startup reveal must survive outside Lua state. Preserve the normal cursor theme for this compositor session, recover orphaned blank settings, and repair activation environments before future applications inherit them. 🤖 Generated by GPT-6.1-Sol High in Codex through T3 Code. Reviewed by GPT-6.1-Sol XHigh in Codex through T3 Code. Co-Authored-By: GPT-6.1-Sol XHigh --- default/hypr/startup-cursor.lua | 68 +++++++++++++++--- .../hyprland-startup-cursor-cold-test.sh | 6 ++ test/shell.d/hyprland-startup-cursor-test.sh | 72 ++++++++++++++++++- 3 files changed, 135 insertions(+), 11 deletions(-) diff --git a/default/hypr/startup-cursor.lua b/default/hypr/startup-cursor.lua index f80e0e1c..b30ede4f 100644 --- a/default/hypr/startup-cursor.lua +++ b/default/hypr/startup-cursor.lua @@ -1,9 +1,37 @@ +local function cursor_theme(value) + if value and value ~= "" and value ~= "omarchy-startup" then return value end +end + +local function normal_theme(interrupted) + local theme = cursor_theme(os.getenv("XCURSOR_THEME")) + if not theme and (interrupted or os.getenv("XCURSOR_THEME") == "omarchy-startup") then + theme = cursor_theme(os.getenv("OMARCHY_STARTUP_CURSOR_THEME")) + end + 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 +39,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 +68,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..447f84c5 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,59 @@ 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") +io.popen = popen LUA pass "the first compositor frame starts blank and the reveal restores user cursor settings" From 0bcb6a2559f85851fc46b7196ce0c5299af47b54 Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Sat, 10 Oct 2026 20:55:29 -0500 Subject: [PATCH 2/2] Keep the default cursor alias on ordinary boots Omarchy never sets XCURSOR_THEME, so asking GSettings on every cold boot replaced the "default" alias with whatever GSettings held, which is the schema default Adwaita when nothing was ever set. That overrode a theme chosen through ~/.icons/default and exported the substitute to every application. The saved theme and GSettings are now consulted only when recovering from the blank startup theme or an interrupted reveal. Co-Authored-By: Codex XHigh --- default/hypr/startup-cursor.lua | 9 +++++---- test/shell.d/hyprland-startup-cursor-test.sh | 4 ++++ 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/default/hypr/startup-cursor.lua b/default/hypr/startup-cursor.lua index b30ede4f..4744a724 100644 --- a/default/hypr/startup-cursor.lua +++ b/default/hypr/startup-cursor.lua @@ -3,10 +3,11 @@ local function cursor_theme(value) end local function normal_theme(interrupted) - local theme = cursor_theme(os.getenv("XCURSOR_THEME")) - if not theme and (interrupted or os.getenv("XCURSOR_THEME") == "omarchy-startup") then - theme = cursor_theme(os.getenv("OMARCHY_STARTUP_CURSOR_THEME")) - end + 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 diff --git a/test/shell.d/hyprland-startup-cursor-test.sh b/test/shell.d/hyprland-startup-cursor-test.sh index 447f84c5..b83be465 100755 --- a/test/shell.d/hyprland-startup-cursor-test.sh +++ b/test/shell.d/hyprland-startup-cursor-test.sh @@ -127,6 +127,10 @@ 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"