Repository navigation
Warn when a bind's id lands where nothing reads it #13923
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: quattro
Are you sure you want to change the base?
Changes from all commits
9b06086
125284e
1c47726
64ca9f0
c9d7232
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -87,7 +87,7 @@ local function shell_dispatcher(kind, target, command) | |
| return command | ||
| end | ||
|
|
||
| local function command_from(value, description) | ||
| local function command_from(value, id) | ||
| if type(value) ~= "table" then | ||
| return value | ||
| end | ||
|
|
@@ -112,7 +112,7 @@ local function command_from(value, description) | |
| return o.launch(value.launch) | ||
| elseif value.webapp then | ||
| if value.focus then | ||
| return o.launch_webapp_sole(description, value.webapp) | ||
| return o.launch_webapp_sole(id, value.webapp) | ||
| else | ||
| return o.launch_webapp(value.webapp) | ||
| end | ||
|
|
@@ -135,25 +135,69 @@ function o.preinstalled_bindings_enabled() | |
| return not file_exists((os.getenv("HOME") or "") .. "/.local/state/omarchy/preinstalls-removed") | ||
| end | ||
|
|
||
| function o.bind(keys, description, dispatcher, options) | ||
| local opts = options or {} | ||
| -- A bind written wrong says so on stderr. Not stdout: the keybindings menu | ||
| -- loads the whole config under a stub and reads bind records off what hl.bind | ||
| -- prints there, so a warning on that stream would arrive as a row. | ||
| local function warn(message) | ||
| io.stderr:write("omarchy: " .. message .. "\n") | ||
| end | ||
|
|
||
| function o.bind(keys, description, dispatcher, options, ...) | ||
| -- Lua discards an argument a function does not declare, so a bind written with | ||
| -- a fifth one still binds and still works, having quietly lost whatever was in | ||
| -- it. An id passed that way is the case that bites: the bind falls back to its | ||
| -- description, and the only symptom is a row somewhere else in the menu. | ||
| local extra = select("#", ...) | ||
|
|
||
| if extra > 0 then | ||
| warn("the bind for " .. tostring(keys) .. " passes " .. (4 + extra) .. | ||
| " arguments and Lua drops everything past the fourth. An id belongs in the " .. | ||
| "options table: o.bind(keys, description, dispatcher, { id = \"...\" }).") | ||
| end | ||
|
|
||
| -- Copy what the caller handed over rather than writing into it. The id below | ||
| -- fills in only when none is set yet, so a config reusing one options table | ||
| -- across several binds would give every later bind the first one's id. | ||
| local opts = {} | ||
|
|
||
| for key, value in pairs(options or {}) do | ||
| opts[key] = value | ||
| end | ||
|
Comment on lines
+161
to
+165
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| if description then | ||
| opts.description = description | ||
| end | ||
|
|
||
| dispatcher = command_from(dispatcher, description) | ||
| -- A description is what a bind is called; an id is what it is. Anything that | ||
| -- has to recognize a bind again later (a cache key, a window match) keys on | ||
| -- the id, so a renamed description does not drag behaviour along with it. | ||
| -- The description is the only name most binds need, so it is the default. | ||
| opts.id = opts.id or description | ||
|
|
||
| local command = command_from(dispatcher, opts.id) | ||
|
|
||
| -- command_from hands back the table it was given when it recognises none of | ||
| -- its keys, so anything else means the table was a dispatcher it understood. | ||
| -- An id there is read by nothing, and the bind falls back to its description: | ||
| -- the same silent loss as a fifth argument, reached from the other direction. | ||
| -- Scoped to the tables command_from recognises on purpose, since a table it | ||
| -- passes straight through could be anything Hyprland's own API hands back. | ||
| if type(dispatcher) == "table" and dispatcher.id ~= nil and command ~= dispatcher then | ||
| warn("the bind for " .. tostring(keys) .. " sets id = \"" .. tostring(dispatcher.id) .. | ||
| "\" inside its dispatcher table, where nothing reads it. An id belongs in the options " .. | ||
| "table: o.bind(keys, description, dispatcher, { id = \"...\" }).") | ||
| end | ||
|
|
||
| if type(dispatcher) == "string" then | ||
| dispatcher = hl.dsp.exec_cmd(dispatcher) | ||
| if type(command) == "string" then | ||
| command = hl.dsp.exec_cmd(command) | ||
| end | ||
|
|
||
| hl.bind(keys, dispatcher, opts) | ||
| hl.bind(keys, command, opts) | ||
| end | ||
|
|
||
| function o.rebind(keys, description, dispatcher, options) | ||
| function o.rebind(keys, description, dispatcher, options, ...) | ||
| hl.unbind(keys) | ||
| o.bind(keys, description, dispatcher, options) | ||
| o.bind(keys, description, dispatcher, options, ...) | ||
| end | ||
|
|
||
| function o.launch(command) | ||
|
|
@@ -202,8 +246,10 @@ function o.launch_sole(match, command) | |
| return "omarchy-launch-or-focus " .. shell_quote(match) .. " " .. shell_quote(o.launch(command)) | ||
| end | ||
|
|
||
| function o.bind_toggle(keys, description, toggle, options) | ||
| o.bind(keys, description, "omarchy-toggle-" .. toggle, options) | ||
| -- Hands on whatever it was given past its fourth argument, so a toggle written | ||
| -- with a fifth one reaches the warning in o.bind rather than losing it here. | ||
| function o.bind_toggle(keys, description, toggle, options, ...) | ||
| o.bind(keys, description, "omarchy-toggle-" .. toggle, options, ...) | ||
| end | ||
|
|
||
| function o.notify(message) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,127 @@ | ||
| #!/bin/bash | ||
|
|
||
| source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh" | ||
|
|
||
| require_command lua | ||
|
|
||
| tmpdir=$(mktemp -d) && [[ -n $tmpdir && -d $tmpdir ]] || | ||
| fail "the test gets a temporary directory to load the Lua config in" | ||
| trap 'rm -rf "$tmpdir"' EXIT | ||
|
|
||
| # What o.bind hands to hl.bind, one declaration at a time. The dispatcher is | ||
| # printed as the command string Hyprland would run, since that is where a web | ||
| # app bind carries the name it matches a window by. | ||
| bound() { | ||
| cat >"$tmpdir/bind.lua" <<LUA | ||
| hl = { | ||
| dsp = { exec_cmd = function(command) return command end }, | ||
| bind = function(keys, dispatcher, opts) | ||
| print("description: " .. tostring(opts.description)) | ||
| print("id: " .. tostring(opts.id)) | ||
| print("dispatcher: " .. tostring(dispatcher)) | ||
| end, | ||
| } | ||
|
|
||
| dofile("$ROOT/default/hypr/helpers.lua") | ||
|
|
||
| $1 | ||
| LUA | ||
|
|
||
| lua "$tmpdir/bind.lua" | ||
| } | ||
|
|
||
| declared=$(bound 'o.bind("SUPER + K", "Calendar", "true")') | ||
| grep -qx 'id: Calendar' <<<"$declared" || | ||
| fail "a bind that names no id of its own answers to its description" "$declared" | ||
| pass "a bind that names no id of its own answers to its description" | ||
|
|
||
| declared=$(bound 'o.bind("SUPER + K", "Calendar", "true", { id = "calendar" })') | ||
| grep -qx 'id: calendar' <<<"$declared" || | ||
| fail "a declared id is the one the bind keeps" "$declared" | ||
| grep -qx 'description: Calendar' <<<"$declared" || | ||
| fail "a declared id leaves the description alone" "$declared" | ||
| pass "a declared id is the one the bind keeps" | ||
|
|
||
| # Nothing stops a config from handing one options table to several binds, and the | ||
| # id fills in only when none is set yet. Write that fallback into the table the | ||
| # caller passed and the first description becomes the id of every bind after it, | ||
| # which is the one way an id can be wrong without anyone declaring it. | ||
| declared=$(bound ' | ||
| local shared = {} | ||
| o.bind("SUPER + K", "Calendar", "true", shared) | ||
| o.bind("SUPER + M", "Mail", "true", shared) | ||
| ') | ||
| grep -qx 'id: Mail' <<<"$declared" || | ||
| fail "a reused options table does not keep the first bind's id" "$declared" | ||
| pass "a reused options table does not keep the first bind's id" | ||
|
|
||
| # The whole point of the id: this command's first argument is matched against an | ||
| # open window's class or title, so it has to hold still while the label moves. | ||
| declared=$(bound 'o.bind("SUPER + K", "Photos", { webapp = "https://photos.test/", focus = true }, { id = "Google Photos" })') | ||
| grep -qF "omarchy-launch-or-focus-webapp 'Google Photos' 'https://photos.test/'" <<<"$declared" || | ||
| fail "a web app bind matches its window by id" "$declared" | ||
| ! grep -qF "'Photos'" <<<"$declared" || | ||
| fail "a web app bind does not match its window by description" "$declared" | ||
| pass "a web app bind matches its window by id" | ||
|
|
||
| declared=$(bound 'o.bind_toggle("SUPER + K", "Nightlight", "nightlight", { id = "nightlight" })') | ||
| grep -qx 'id: nightlight' <<<"$declared" || | ||
| fail "a toggle bind carries a declared id too" "$declared" | ||
| pass "a toggle bind carries a declared id too" | ||
|
|
||
| # The menu keeps its own scan of the Lua config, because Hyprland reports every | ||
| # Lua bind as dispatcher __lua and reports no id at all. Read that scan out of | ||
| # the menu rather than keeping a second copy of it here. | ||
| sed -n "/^ lua <<'LUA'\$/,/^LUA\$/p" "$ROOT/bin/omarchy-menu-keybindings" | | ||
| sed '1d;$d' >"$tmpdir/scan.lua" | ||
| [[ -s $tmpdir/scan.lua ]] || | ||
| fail "the menu's Lua bind scan can be read out of the script" | ||
|
|
||
| home="$tmpdir/home" | ||
| mkdir -p "$home/.config/hypr" | ||
|
|
||
| # The scan reads ~/.config/hypr/hyprland.lua. Pull in the one binding file this | ||
| # is about rather than the whole config: the rest of it is not what is under | ||
| # test. The two binds below are what a personal config adds, an id that is | ||
| # nothing like its label and a bind written straight against hl.bind. | ||
| cat >"$home/.config/hypr/hyprland.lua" <<LUA | ||
| dofile("$ROOT/default/hypr/bootstrap.lua") | ||
| require("default.hypr.helpers") | ||
| require("default.hypr.bindings.applications") | ||
|
|
||
| o.bind("SUPER + ALT + Z", "Renamed later", { webapp = "https://example.test/", focus = true }, { id = "Stable Id" }) | ||
| hl.bind("SUPER + ALT + Y", hl.dsp.exec_cmd("true"), { description = "Written against hl" }) | ||
| LUA | ||
|
|
||
| scanned=$(env -i PATH="$PATH" HOME="$home" OMARCHY_PATH="$ROOT" lua "$tmpdir/scan.lua") | ||
| [[ -n $scanned ]] || fail "the scan reports the binds in the Lua config" | ||
|
|
||
| # modmask, description, id, key, dispatcher kind, dispatcher arg | ||
| [[ -z $(awk -F '\t' '$3 == "" { print }' <<<"$scanned") ]] || | ||
| fail "every bind the scan reports answers to an id" "$scanned" | ||
| pass "every bind the scan reports answers to an id" | ||
|
|
||
| awk -F '\t' '$2 == "Terminal" && $3 == "Terminal" { found = 1 } END { exit !found }' <<<"$scanned" || | ||
| fail "the scan reports the description as the id when no other was declared" "$scanned" | ||
| awk -F '\t' '$2 == "Written against hl" && $3 == $2 { found = 1 } END { exit !found }' <<<"$scanned" || | ||
| fail "a bind written against hl.bind still reports an id" "$scanned" | ||
| pass "the scan reports the description as the id when no other was declared" | ||
|
|
||
| awk -F '\t' '$2 == "Renamed later" && $3 == "Stable Id" { found = 1 } END { exit !found }' <<<"$scanned" || | ||
| fail "the scan reports the id a bind declared, not its label" "$scanned" | ||
| awk -F '\t' '$3 == "Stable Id" && $6 ~ /or-focus-webapp .Stable Id./ { found = 1 } END { exit !found }' <<<"$scanned" || | ||
| fail "the scan reports the command the id built" "$scanned" | ||
| pass "the scan reports the id a bind declared, not its label" | ||
|
|
||
| awk -F '\t' '$3 == "WhatsApp" && $6 ~ /or-focus-webapp .WhatsApp./ { found = 1 } END { exit !found }' <<<"$scanned" || | ||
| fail "a web app Omarchy ships focuses the window its id names" "$scanned" | ||
| pass "a web app Omarchy ships focuses the window its id names" | ||
|
|
||
| # A web app bind that focuses an open window spends its id on a pattern the web | ||
| # app decides, so it cannot inherit one from a description somebody may later | ||
| # translate. Anything added alongside the four has to say so out loud. | ||
| while IFS= read -r binding; do | ||
| [[ $binding == *"id = "* ]] || | ||
| fail "every web app bind that focuses an open window names its own id" "$binding" | ||
| done < <(grep -rh 'webapp = ' "$ROOT/default/hypr/bindings" | grep -F 'focus = true') | ||
|
Comment on lines
+122
to
+126
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| pass "every web app bind that focuses an open window names its own id" | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If a user-defined ID contains a tab or newline, this line writes it directly into the tab-delimited record. The cache then shifts or splits the fields and cannot recover the bind’s key and dispatcher, leaving the shortcut unusable from the menu. Escape or encode IDs before emitting records.