Files
community-plugins/keybind-cheatsheet/service.luau
T
cheerfulScumbagandGitHub 2a1efe0468 feat: add keybind cheatsheet plugin (#64)
* feat: add keybind cheatsheet

* docs: refresh keybind cheatsheet preview

* docs: use official generated keybind thumbnail

* feat(keybind-cheatsheet): preload cached snapshots

Move parser and cache ownership into an event-driven data service so the panel opens from a prepared last-known-good snapshot. Document the plugin's inspiration and independent v5 implementation.
2026-07-21 19:50:47 -04:00

1396 lines
44 KiB
Luau

--!nonstrict
-- Cache-first keybind data service for Mango, Hyprland, and Niri.
--
-- The active configuration is parsed once during script startup and only
-- again after a relevant settings change or explicit refresh request. There
-- is no update interval, filesystem watcher, polling loop, or persistent
-- subprocess.
local noctalia = noctalia
if noctalia == nil then
local host = require("./tests/host_mock")
noctalia = host.noctalia
end
local SNAPSHOT_KEY = "keybind-cheatsheet.snapshot"
local REFRESH_REQUEST_KEY = "keybind-cheatsheet.refresh-request"
local SELF_TEST_REQUEST_KEY = "keybind-cheatsheet.self-test-request"
local MAX_PARSE_DEPTH = 32
local MAX_PARSE_FILES = 256
local BINDINGS_CACHE_FILE = "bindings-cache.json"
local CACHE_SCHEMA = 1
local refreshing = false
local refreshQueued = false
local refreshGeneration = 0
local lastGood = nil
local function tr(key, values)
return noctalia.tr(key, values)
end
local function trim(value)
local result = (value or ""):gsub("^%s+", ""):gsub("%s+$", "")
return result
end
local function lower(value)
return string.lower(value or "")
end
local function startsWith(value, prefix)
return value:sub(1, #prefix) == prefix
end
local function configString(key, fallback)
local value = noctalia.getConfig(key)
if type(value) ~= "string" or trim(value) == "" then
return fallback
end
return trim(value)
end
local function pathDirname(path)
local clean = (path or ""):gsub("/+$", "")
local parent = clean:match("^(.*)/[^/]*$")
if parent == nil or parent == "" then
return clean:sub(1, 1) == "/" and "/" or "."
end
return parent
end
local function pathJoin(base, child)
if child == nil or child == "" then
return base
end
if child:sub(1, 1) == "/" then
return child
end
if base == "/" then
return "/" .. child
end
return (base:gsub("/+$", "")) .. "/" .. child
end
local function normalizePath(path)
local absolute = path:sub(1, 1) == "/"
local parts = {}
for part in path:gmatch("[^/]+") do
if part == ".." then
if #parts > 0 and parts[#parts] ~= ".." then
table.remove(parts)
elseif not absolute then
table.insert(parts, part)
end
elseif part ~= "." and part ~= "" then
table.insert(parts, part)
end
end
local result = table.concat(parts, "/")
if absolute then
result = "/" .. result
end
if result == "" then
return absolute and "/" or "."
end
return result
end
local function expandEnvironment(path)
local expanded = path:gsub("%${([%w_]+)}", function(name)
return noctalia.getenv(name) or "${" .. name .. "}"
end)
expanded = expanded:gsub("%$HOME", noctalia.getenv("HOME") or "~")
return noctalia.expandPath(expanded)
end
local function unquote(value)
local result = trim(value)
if #result >= 2 then
local first = result:sub(1, 1)
local last = result:sub(-1)
if (first == "\"" and last == "\"") or (first == "'" and last == "'") then
result = result:sub(2, -2)
end
end
return result
end
local function resolvePath(value, includingFile)
local path = expandEnvironment(unquote(value))
if path:sub(1, 1) ~= "/" then
path = pathJoin(pathDirname(includingFile), path)
end
return normalizePath(path)
end
local function hasGlob(path)
return path:find("*", 1, true) ~= nil
or path:find("?", 1, true) ~= nil
or path:find("[", 1, true) ~= nil
end
local LUA_PATTERN_MAGIC = {
["^"] = true,
["$"] = true,
["("] = true,
[")"] = true,
["%"] = true,
["."] = true,
["+"] = true,
["-"] = true,
["]"] = true,
}
local function globPattern(segment)
local result = { "^" }
local index = 1
while index <= #segment do
local char = segment:sub(index, index)
if char == "*" then
table.insert(result, ".*")
elseif char == "?" then
table.insert(result, ".")
elseif char == "[" then
local closing = segment:find("]", index + 1, true)
if closing ~= nil then
table.insert(result, segment:sub(index, closing))
index = closing
else
table.insert(result, "%[")
end
elseif LUA_PATTERN_MAGIC[char] then
table.insert(result, "%" .. char)
else
table.insert(result, char)
end
index += 1
end
table.insert(result, "$")
return table.concat(result)
end
local function expandGlob(path, context)
if not hasGlob(path) then
-- Read explicit paths directly. A separate existence check can race with
-- atomic Home Manager symlink replacement and suppress a readable file.
return { path }
end
local candidates = { path:sub(1, 1) == "/" and "/" or "." }
for segment in path:gmatch("[^/]+") do
local nextCandidates = {}
if hasGlob(segment) then
local pattern = globPattern(segment)
for _, base in ipairs(candidates) do
local entries = noctalia.listDir(base)
if entries ~= nil then
table.sort(entries)
for _, name in ipairs(entries) do
if name:match(pattern) then
table.insert(nextCandidates, normalizePath(pathJoin(base, name)))
end
end
end
end
else
for _, base in ipairs(candidates) do
table.insert(nextCandidates, normalizePath(pathJoin(base, segment)))
end
end
candidates = nextCandidates
end
local files = {}
for _, candidate in ipairs(candidates) do
local info = noctalia.fileInfo(candidate)
if info ~= nil and not info.isDir then
table.insert(files, candidate)
end
end
table.sort(files)
if context ~= nil and hasGlob(path) then
local snapshot = {}
for _, file in ipairs(files) do table.insert(snapshot, file) end
table.insert(context.globs, { pattern = path, files = snapshot })
end
return files
end
local function extractTrailingDescription(line)
local ending = line:sub(-1)
local startIndex, description
if ending == "\"" then
startIndex, _, description = line:find("#%s*\"([^\"]*)\"%s*$")
elseif ending == "'" then
startIndex, _, description = line:find("#%s*'([^']*)'%s*$")
end
if startIndex == nil then
return line, ""
end
return trim(line:sub(1, startIndex - 1)), trim(description)
end
local function splitCsv(value, maximumParts)
if value:find("\"", 1, true) == nil and value:find("'", 1, true) == nil
and value:find("\\", 1, true) == nil then
local parts = {}
local startIndex = 1
while maximumParts == nil or #parts < maximumParts - 1 do
local comma = value:find(",", startIndex, true)
if comma == nil then break end
table.insert(parts, trim(value:sub(startIndex, comma - 1)))
startIndex = comma + 1
end
table.insert(parts, trim(value:sub(startIndex)))
return parts
end
local parts = {}
local current = {}
local quote = nil
local escaped = false
local index = 1
while index <= #value do
local char = value:sub(index, index)
if escaped then
table.insert(current, char)
escaped = false
elseif char == "\\" and quote ~= nil then
table.insert(current, char)
escaped = true
elseif quote ~= nil then
table.insert(current, char)
if char == quote then
quote = nil
end
elseif char == "\"" or char == "'" then
quote = char
table.insert(current, char)
elseif char == "," and (maximumParts == nil or #parts < maximumParts - 1) then
table.insert(parts, trim(table.concat(current)))
current = {}
else
table.insert(current, char)
end
index += 1
end
table.insert(parts, trim(table.concat(current)))
return parts
end
local MODIFIER_ORDER = { "SUPER", "CTRL", "SHIFT", "ALT", "MOD2", "MOD3", "MOD5" }
local KNOWN_MODIFIERS = { SUPER = true, CTRL = true, SHIFT = true, ALT = true, MOD2 = true, MOD3 = true, MOD5 = true }
local MODIFIER_ALIASES = {
LOGO = "SUPER",
WIN = "SUPER",
MOD4 = "SUPER",
CONTROL = "CTRL",
MOD1 = "ALT",
}
local function expandHyprVariables(value, variables)
local previous = value
for _ = 1, 8 do
local expanded = previous:gsub("%$([%w_]+)", function(name)
return variables[name] or "$" .. name
end)
if expanded == previous then
break
end
previous = expanded
end
return previous
end
local function normalizeModifiers(value, variables)
local raw = expandHyprVariables(value or "", variables or {})
raw = raw:gsub("%+", " "):gsub("|", " "):gsub(",", " ")
local present = {}
local unknown = {}
for token in raw:gmatch("[^%s]+") do
local upper = string.upper(token)
upper = MODIFIER_ALIASES[upper] or upper
if upper ~= "NONE" and upper ~= "" then
if KNOWN_MODIFIERS[upper] then
present[upper] = true
elseif not present[upper] then
present[upper] = true
table.insert(unknown, upper)
end
end
end
local result = {}
for _, name in ipairs(MODIFIER_ORDER) do
if present[name] then
table.insert(result, name)
end
end
for _, name in ipairs(unknown) do
table.insert(result, name)
end
return result
end
local function bindingIdentity(binding)
return table.concat({
binding.compositor or "",
binding.bindingType or "",
table.concat(binding.modifiers or {}, "+"),
binding.key or "",
binding.action or "",
binding.submap or "",
}, "|")
end
local function addBinding(target, binding)
binding.description = trim(binding.description)
binding.category = trim(binding.category)
binding.baseCategory = binding.category
binding.action = trim(binding.action)
binding.key = trim(binding.key)
binding.modifiers = binding.modifiers or {}
binding.id = bindingIdentity(binding)
table.insert(target, binding)
end
local CATEGORY_SMALL_WORDS = {
["and"] = true,
["for"] = true,
of = true,
the = true,
to = true,
}
local function mangoCategoryFromComment(line)
if not startsWith(line, "#") or startsWith(line, "#\"") or startsWith(line, "#'") then
return nil
end
local heading = trim(line:gsub("^#+%s*", ""))
if heading == "" or heading:match("^[%-%=_*]+$") ~= nil then return nil end
local explicit = heading:match("^[Cc]ategory%s*:%s*(.+)$")
if explicit ~= nil then return trim(explicit) end
heading = trim(heading:gsub("^%d+%.%s*", ""))
if #heading > 64 or lower(heading):match("^bind%s*=") ~= nil then return nil end
if heading:find("=", 1, true) ~= nil or heading:find(":", 1, true) ~= nil
or heading:find(" + ", 1, true) ~= nil then return nil end
local wordCount = 0
for token in heading:gmatch("%S+") do
wordCount += 1
local word = token:gsub("^[^%a]+", ""):gsub("[^%a]+$", "")
local lowered = lower(word)
if word ~= "" and not CATEGORY_SMALL_WORDS[lowered]
and word:sub(1, 1) ~= string.upper(word:sub(1, 1)) then
return nil
end
end
if wordCount == 0 or wordCount > 7 then return nil end
return heading
end
local function parseMangoContent(content, sourceFile, context)
local hasBindings = content:find("bind", 1, true) ~= nil
local hasSources = content:find("source", 1, true) ~= nil
if not hasBindings and not hasSources then return {} end
local category = ""
local includes = {}
local lineNumber = 0
for rawLine in (content .. "\n"):gmatch("(.-)\r?\n") do
lineNumber += 1
local line = trim(rawLine)
if line ~= "" then
if startsWith(line, "source") then
local optionalPath = line:match("^source%-optional%s*=%s*(.-)%s*$")
local sourcePath = line:match("^source%s*=%s*(.-)%s*$")
if optionalPath ~= nil then
table.insert(includes, { path = optionalPath, optional = true })
elseif sourcePath ~= nil then
table.insert(includes, { path = expandHyprVariables(sourcePath, context.variables), optional = false })
end
elseif startsWith(line, "#") then
if hasBindings then
local heading = mangoCategoryFromComment(line)
if heading ~= nil then category = heading end
end
elseif hasBindings and line:find("bind", 1, true) ~= nil then
local clean, description = extractTrailingDescription(line)
local directive, body = clean:match("^([%a]*bind)%s*=%s*(.*)$")
if directive ~= nil then
directive = lower(directive)
local maximum = directive == "gesturebind" and 5 or (directive == "switchbind" and 3 or 4)
local fields = splitCsv(body, maximum)
local modifiers = {}
local key = ""
local command = ""
local parameters = ""
if directive == "switchbind" then
key = fields[1] or ""
command = fields[2] or ""
parameters = fields[3] or ""
elseif directive == "gesturebind" then
modifiers = normalizeModifiers(fields[1], {})
key = (fields[3] or "") .. "-finger " .. (fields[2] or "")
command = fields[4] or ""
parameters = fields[5] or ""
else
modifiers = normalizeModifiers(fields[1], {})
key = fields[2] or ""
command = fields[3] or ""
parameters = fields[4] or ""
end
if key ~= "" and command ~= "" then
addBinding(context.bindings, {
compositor = "mango",
bindingType = directive,
modifiers = modifiers,
key = key,
action = trim(command .. " " .. parameters),
description = description,
category = category,
sourceFile = sourceFile,
sourceLine = lineNumber,
})
end
end
end
end
end
return includes
end
local function parseHyprContent(content, sourceFile, context)
local category = ""
local includes = {}
local lineNumber = 0
for rawLine in (content .. "\n"):gmatch("(.-)\r?\n") do
lineNumber += 1
local line = trim(rawLine)
if line ~= "" then
local variable, value = line:match("^%$([%w_]+)%s*=%s*(.-)%s*$")
local sourcePath = line:match("^source%s*=%s*(.-)%s*$")
local numberedCategory = line:match("^#%s*%d+%.%s*(.-)%s*$")
if variable ~= nil then
context.variables[variable] = value
elseif sourcePath ~= nil then
table.insert(includes, { path = expandHyprVariables(sourcePath, context.variables), optional = false })
elseif numberedCategory ~= nil and numberedCategory ~= "" then
category = numberedCategory
else
local clean, description = extractTrailingDescription(line)
local directive, body = clean:match("^(bind[%a]*)%s*=%s*(.*)$")
if directive ~= nil then
local fields = splitCsv(body, 4)
local key = fields[2] or ""
local dispatcher = fields[3] or ""
if key ~= "" and dispatcher ~= "" then
addBinding(context.bindings, {
compositor = "hyprland",
bindingType = lower(directive),
modifiers = normalizeModifiers(fields[1], context.variables),
key = key,
action = trim(dispatcher .. " " .. (fields[4] or "")),
description = description,
category = category,
sourceFile = sourceFile,
sourceLine = lineNumber,
})
end
end
end
end
end
return includes
end
local function niriTokens(content)
local tokens = {}
local index = 1
local line = 1
while index <= #content do
local char = content:sub(index, index)
local nextChar = content:sub(index + 1, index + 1)
if char == "\n" then
line += 1
index += 1
elseif char:match("%s") then
index += 1
elseif char == "/" and nextChar == "/" then
local ending = content:find("\n", index + 2, true) or (#content + 1)
table.insert(tokens, { type = "comment", value = content:sub(index + 2, ending - 1), line = line })
index = ending
elseif char == "/" and nextChar == "*" then
local ending = content:find("*/", index + 2, true) or (#content - 1)
local value = content:sub(index + 2, ending - 1)
table.insert(tokens, { type = "comment", value = value, line = line })
local _, newlines = value:gsub("\n", "")
line += newlines
index = ending + 2
elseif char == "\"" then
local startLine = line
local value = {}
index += 1
local escaped = false
while index <= #content do
local stringChar = content:sub(index, index)
if escaped then
local replacements = { n = "\n", r = "\r", t = "\t" }
table.insert(value, replacements[stringChar] or stringChar)
escaped = false
elseif stringChar == "\\" then
escaped = true
elseif stringChar == "\"" then
index += 1
break
else
if stringChar == "\n" then
line += 1
end
table.insert(value, stringChar)
end
index += 1
end
table.insert(tokens, { type = "string", value = table.concat(value), line = startLine })
elseif char == "{" or char == "}" or char == ";" then
table.insert(tokens, { type = char, value = char, line = line })
index += 1
else
local start = index
while index <= #content do
char = content:sub(index, index)
nextChar = content:sub(index + 1, index + 1)
if char:match("%s") or char == "\"" or char == "{" or char == "}" or char == ";"
or (char == "/" and (nextChar == "/" or nextChar == "*")) then
break
end
index += 1
end
table.insert(tokens, { type = "word", value = content:sub(start, index - 1), line = line })
end
end
return tokens
end
local function niriActionText(tokens)
local values = {}
for _, token in ipairs(tokens) do
if token.type == "string" then
table.insert(values, token.value)
elseif token.type == "word" then
table.insert(values, token.value)
elseif token.type == ";" then
if #values > 0 then
values[#values] = values[#values] .. ";"
end
end
end
return trim(table.concat(values, " "))
end
local function niriCategoryFor(action)
local value = lower(action)
if startsWith(value, "spawn") then return "Applications" end
if startsWith(value, "focus-column-") then return "Column Navigation" end
if startsWith(value, "focus-window-") then return "Window Focus" end
if startsWith(value, "focus-workspace-") then return "Workspace Navigation" end
if startsWith(value, "move-column-") then return "Move Columns" end
if startsWith(value, "move-window-") then return "Move Windows" end
if startsWith(value, "screenshot") then return "Screenshots" end
if startsWith(value, "close-window") or startsWith(value, "fullscreen-window") then return "Window Management" end
if startsWith(value, "power-off-monitors") then return "Power" end
if startsWith(value, "quit") then return "System" end
return "Other"
end
local function niriHeaderDescription(header)
for index, token in ipairs(header) do
if token.type == "word" then
if token.value == "hotkey-overlay-title=" and header[index + 1] ~= nil and header[index + 1].type == "string" then
return header[index + 1].value
end
local quoted = token.value:match('^hotkey%-overlay%-title="(.*)"$')
if quoted ~= nil then
return quoted
end
end
end
return ""
end
local function parseNiriContent(content, sourceFile, context)
local includes = {}
for rawLine in (content .. "\n"):gmatch("(.-)\r?\n") do
local includePath = rawLine:match('^%s*include%s+"([^"]+)"')
if includePath ~= nil then
table.insert(includes, { path = includePath, optional = false })
end
end
local tokens = niriTokens(content)
local index = 1
while index <= #tokens do
if tokens[index].type == "word" and tokens[index].value == "binds"
and tokens[index + 1] ~= nil and tokens[index + 1].type == "{" then
index += 2
local category = ""
while index <= #tokens and tokens[index].type ~= "}" do
local token = tokens[index]
if token.type == "comment" then
local heading = token.value:match('#%s*"([^"]+)"') or token.value:match("#%s*'([^']+)'")
if heading ~= nil then
category = trim(heading)
end
index += 1
elseif token.type == "word" then
local hotkey = token.value
local sourceLine = token.line
local header = {}
index += 1
while index <= #tokens and tokens[index].type ~= "{" and tokens[index].type ~= "}" do
table.insert(header, tokens[index])
index += 1
end
if index <= #tokens and tokens[index].type == "{" then
local depth = 1
local actionTokens = {}
index += 1
while index <= #tokens and depth > 0 do
if tokens[index].type == "{" then
depth += 1
table.insert(actionTokens, tokens[index])
elseif tokens[index].type == "}" then
depth -= 1
if depth > 0 then
table.insert(actionTokens, tokens[index])
end
else
table.insert(actionTokens, tokens[index])
end
index += 1
end
local action = niriActionText(actionTokens)
local keyParts = {}
for part in hotkey:gmatch("[^+]+") do
table.insert(keyParts, part)
end
local key = table.remove(keyParts) or hotkey
local description = niriHeaderDescription(header)
addBinding(context.bindings, {
compositor = "niri",
bindingType = "bind",
modifiers = normalizeModifiers(table.concat(keyParts, " "), {}),
key = key,
action = action,
description = description,
category = category ~= "" and category or niriCategoryFor(action),
sourceFile = sourceFile,
sourceLine = sourceLine,
})
else
index += 1
end
else
index += 1
end
end
end
index += 1
end
return includes
end
local function walkConfig(rootPath, parser)
local context = {
bindings = {},
variables = {},
warnings = {},
visited = {},
fileCount = 0,
rootRead = false,
sources = {},
globs = {},
}
local visit
visit = function(pattern, depth, optional, isRoot)
if depth > MAX_PARSE_DEPTH then
table.insert(context.warnings, "Include depth exceeded at " .. pattern)
return
end
if context.fileCount >= MAX_PARSE_FILES then
table.insert(context.warnings, "File limit reached at " .. pattern)
return
end
local files = expandGlob(pattern, context)
if #files == 0 then
if not optional then
table.insert(context.warnings, "Could not read " .. pattern)
end
return
end
for _, file in ipairs(files) do
file = normalizePath(file)
if not context.visited[file] and context.fileCount < MAX_PARSE_FILES then
context.visited[file] = true
context.fileCount += 1
local content, err = noctalia.readFile(file)
if content == nil then
if not optional then
table.insert(context.warnings, err or ("Could not read " .. file))
end
else
local info = noctalia.fileInfo(file)
if info ~= nil then
table.insert(context.sources, { path = file, size = info.size, mtime = info.mtime })
end
if isRoot then
context.rootRead = true
end
local includes = parser(content, file, context) or {}
for _, include in ipairs(includes) do
local resolved = resolvePath(include.path, file)
visit(resolved, depth + 1, include.optional == true, false)
end
end
end
end
end
local expandedRoot = normalizePath(expandEnvironment(rootPath))
visit(expandedRoot, 0, false, true)
return context
end
local function readConfig(rootPath, parser)
local context = walkConfig(rootPath, parser)
if not context.rootRead then
context = walkConfig(rootPath, parser)
end
if not context.rootRead then
noctalia.log("Keybind cheatsheet could not read " .. expandEnvironment(rootPath)
.. ": " .. table.concat(context.warnings, "; "))
end
return context
end
local function scanHyprLuaContent(content, sourceFile, context)
local category = ""
local includes = {}
for rawLine in (content .. "\n"):gmatch("(.-)\r?\n") do
local heading = rawLine:match("^%s*%-%-%s*%d+%.%s*(.-)%s*$")
if heading ~= nil and heading ~= "" then
category = heading
end
local code = rawLine:gsub("%-%-.*$", "")
local module = code:match('require%s*%(%s*"([^"]+)"')
or code:match("require%s*%(%s*'([^']+)'")
or code:match('require%s+"([^"]+)"')
or code:match("require%s+'([^']+)'")
if module ~= nil then
local modulePath = module:gsub("%.", "/") .. ".lua"
table.insert(includes, { path = pathJoin(context.luaRoot, modulePath), optional = false })
end
local description = code:match('description%s*=%s*"([^"]*)"')
or code:match("description%s*=%s*'([^']*)'")
or code:match('desc%s*=%s*"([^"]*)"')
or code:match("desc%s*=%s*'([^']*)'")
if description ~= nil and description ~= "" then
local kind = code:find("%.%.") ~= nil and "prefix" or "exact"
table.insert(context.rules, { kind = kind, value = description, category = category ~= "" and category or "Other" })
end
end
return includes
end
local function scanHyprLua(rootPath)
local context = {
bindings = {},
variables = {},
warnings = {},
visited = {},
fileCount = 0,
rootRead = false,
rules = {},
luaRoot = pathDirname(normalizePath(expandEnvironment(rootPath))),
sources = {},
globs = {},
}
local visit
visit = function(path, depth, isRoot)
if depth > MAX_PARSE_DEPTH or context.fileCount >= MAX_PARSE_FILES then
table.insert(context.warnings, "Lua include limit reached at " .. path)
return
end
path = normalizePath(path)
if context.visited[path] then
return
end
context.visited[path] = true
context.fileCount += 1
local content, err = noctalia.readFile(path)
if content == nil then
table.insert(context.warnings, err or ("Could not read " .. path))
return
end
local info = noctalia.fileInfo(path)
if info ~= nil then
table.insert(context.sources, { path = path, size = info.size, mtime = info.mtime })
end
if isRoot then context.rootRead = true end
local includes = scanHyprLuaContent(content, path, context)
for _, include in ipairs(includes) do
visit(normalizePath(include.path), depth + 1, false)
end
end
visit(normalizePath(expandEnvironment(rootPath)), 0, true)
return context
end
local function categoryFromHyprRules(description, rules)
for _, rule in ipairs(rules) do
if rule.kind == "exact" and description == rule.value then
return rule.category
end
end
for _, rule in ipairs(rules) do
if rule.kind == "prefix" and startsWith(description, rule.value) then
return rule.category
end
end
return "Other"
end
local function modifiersFromMask(mask)
local result = {}
local bits = {
{ 1, "SHIFT" },
{ 4, "CTRL" },
{ 8, "ALT" },
{ 16, "MOD2" },
{ 32, "MOD3" },
{ 64, "SUPER" },
{ 128, "MOD5" },
}
mask = tonumber(mask) or 0
for _, entry in ipairs(bits) do
if math.floor(mask / entry[1]) % 2 == 1 then
table.insert(result, entry[2])
end
end
return result
end
local function parseHyprJson(raw, rules)
local decoded, err = noctalia.json.decode(raw)
if type(decoded) ~= "table" then
return nil, err or "Invalid hyprctl JSON"
end
local result = {}
for _, entry in ipairs(decoded) do
if type(entry) == "table" then
local description = entry.has_description == false and "" or (entry.description or "")
local key = entry.key or ""
if key == "" and tonumber(entry.keycode) ~= nil and tonumber(entry.keycode) ~= 0 then
key = "code:" .. tostring(entry.keycode)
end
local dispatcher = entry.dispatcher or ""
local argument = entry.arg or ""
if key ~= "" and dispatcher ~= "" then
addBinding(result, {
compositor = "hyprland",
bindingType = entry.mouse == true and "bindm" or "bind",
modifiers = modifiersFromMask(entry.modmask),
key = key,
action = trim(dispatcher .. " " .. argument),
description = description,
category = description ~= "" and categoryFromHyprRules(description, rules) or "",
sourceFile = "hyprctl binds -j",
sourceLine = 0,
submap = entry.submap or "",
})
end
end
end
return result, nil
end
local function detectCompositor()
local forced = configString("compositor", "auto")
if forced ~= "auto" then
return forced
end
if noctalia.getenv("MANGO_INSTANCE_SIGNATURE") ~= nil then return "mango" end
if noctalia.getenv("HYPRLAND_INSTANCE_SIGNATURE") ~= nil then return "hyprland" end
if noctalia.getenv("NIRI_SOCKET") ~= nil then return "niri" end
local desktop = lower((noctalia.getenv("XDG_CURRENT_DESKTOP") or "") .. ":" .. (noctalia.getenv("XDG_SESSION_DESKTOP") or ""))
if desktop:find("mango", 1, true) ~= nil then return "mango" end
if desktop:find("hyprland", 1, true) ~= nil then return "hyprland" end
if desktop:find("niri", 1, true) ~= nil then return "niri" end
if noctalia.fileExists(noctalia.expandPath("~/.config/mango/config.conf")) then return "mango" end
if noctalia.fileExists(noctalia.expandPath("~/.config/hypr/hyprland.lua"))
or noctalia.fileExists(noctalia.expandPath("~/.config/hypr/hyprland.conf")) then return "hyprland" end
if noctalia.fileExists(noctalia.expandPath("~/.config/niri/config.kdl")) then return "niri" end
return nil
end
local function currentRequest()
local compositor = detectCompositor()
if compositor == nil then return nil end
if compositor == "mango" then
return {
compositor = compositor,
parser = "mango",
root = normalizePath(expandEnvironment(configString("mango_config", "~/.config/mango/config.conf"))),
}
end
if compositor == "niri" then
return {
compositor = compositor,
parser = "niri",
root = normalizePath(expandEnvironment(configString("niri_config", "~/.config/niri/config.kdl"))),
}
end
if compositor == "hyprland" then
local mode = configString("hyprland_parser", "auto")
local luaPath = configString("hyprland_lua_config", "~/.config/hypr/hyprland.lua")
local useLua = mode == "lua" or (mode == "auto" and noctalia.fileExists(noctalia.expandPath(luaPath)))
local root = useLua and luaPath or configString("hyprland_config", "~/.config/hypr/hyprland.conf")
return {
compositor = compositor,
parser = useLua and "hypr-lua" or "hypr-conf",
root = normalizePath(expandEnvironment(root)),
}
end
return nil
end
local function requestsMatch(left, right)
return type(left) == "table" and type(right) == "table"
and left.compositor == right.compositor
and left.parser == right.parser
and left.root == right.root
end
local function normalizedCache(decoded)
if type(decoded) ~= "table" or decoded.schema ~= CACHE_SCHEMA or type(decoded.request) ~= "table"
or type(decoded.bindings) ~= "table" or type(decoded.sources) ~= "table"
or type(decoded.globs) ~= "table" then
return nil
end
local cachedBindings = {}
for _, item in ipairs(decoded.bindings) do
if type(item) ~= "table" or type(item.key) ~= "string" or type(item.action) ~= "string"
or type(item.modifiers) ~= "table" then
return nil
end
local modifiers = {}
for _, modifier in ipairs(item.modifiers) do
if type(modifier) ~= "string" then return nil end
table.insert(modifiers, modifier)
end
addBinding(cachedBindings, {
compositor = type(item.compositor) == "string" and item.compositor or decoded.request.compositor,
bindingType = type(item.bindingType) == "string" and item.bindingType or "bind",
modifiers = modifiers,
key = item.key,
action = item.action,
description = type(item.description) == "string" and item.description or "",
category = type(item.category) == "string" and item.category or "",
sourceFile = type(item.sourceFile) == "string" and item.sourceFile or "",
sourceLine = tonumber(item.sourceLine) or 0,
submap = type(item.submap) == "string" and item.submap or "",
})
end
local warnings = {}
for _, warning in ipairs(type(decoded.warnings) == "table" and decoded.warnings or {}) do
if type(warning) == "string" then table.insert(warnings, warning) end
end
return {
schema = CACHE_SCHEMA,
request = {
compositor = decoded.request.compositor,
parser = decoded.request.parser,
root = decoded.request.root,
},
bindings = cachedBindings,
warnings = warnings,
sources = decoded.sources,
globs = decoded.globs,
}
end
local function cachePath()
local directory, err = noctalia.pluginDataDir()
if directory == nil then
noctalia.log("Could not resolve plugin data directory: " .. (err or "unknown error"))
return nil
end
return directory .. "/" .. BINDINGS_CACHE_FILE
end
local function readBindingCache()
local path = cachePath()
if path == nil then return nil end
local raw = noctalia.readFile(path)
if raw == nil or raw == "" then return nil end
local decoded = noctalia.json.decode(raw)
return normalizedCache(decoded)
end
local function cachedBindingValue(binding)
return {
compositor = binding.compositor,
bindingType = binding.bindingType,
modifiers = binding.modifiers,
key = binding.key,
action = binding.action,
description = binding.description,
category = binding.baseCategory or binding.category,
sourceFile = binding.sourceFile,
sourceLine = binding.sourceLine,
submap = binding.submap,
}
end
local function saveBindingCache(cache)
local path = cachePath()
if path == nil then return end
local serializedBindings = {}
for _, binding in ipairs(cache.bindings) do
table.insert(serializedBindings, cachedBindingValue(binding))
end
local encoded, encodeError = noctalia.json.encode({
schema = CACHE_SCHEMA,
request = cache.request,
bindings = serializedBindings,
warnings = cache.warnings,
sources = cache.sources,
globs = cache.globs,
}, false)
if encoded == nil then
noctalia.log("Could not encode keybind cache: " .. (encodeError or "unknown error"))
return
end
local temporary = path .. ".tmp"
local written, writeError = noctalia.writeFile(temporary, encoded)
if not written then
noctalia.log("Could not write keybind cache: " .. (writeError or "unknown error"))
return
end
local renamed, renameError = noctalia.renameFile(temporary, path)
if not renamed then
noctalia.removeFile(temporary)
noctalia.log("Could not commit keybind cache: " .. (renameError or "unknown error"))
end
end
local refresh
local function snapshot(status, request, cache, err, isRefreshing)
return {
schema = CACHE_SCHEMA,
status = status,
compositor = request ~= nil and request.compositor or "",
parser = request ~= nil and request.parser or "",
source = request ~= nil and request.root or "",
request = request,
bindings = cache ~= nil and cache.bindings or {},
warnings = cache ~= nil and cache.warnings or {},
error = err or "",
refreshing = isRefreshing == true,
}
end
local function publish(status, request, cache, err, isRefreshing)
noctalia.state.set(SNAPSHOT_KEY, snapshot(status, request, cache, err, isRefreshing))
end
local function matchingLastGood(request)
if requestsMatch(lastGood ~= nil and lastGood.request or nil, request) then
return lastGood
end
return nil
end
local function finishRefresh(generation, request, result, warnings, err, sourceSnapshot)
if generation ~= refreshGeneration then return end
refreshing = false
if err == nil then
lastGood = {
schema = CACHE_SCHEMA,
request = request,
bindings = result or {},
warnings = warnings or {},
sources = sourceSnapshot ~= nil and sourceSnapshot.sources or {},
globs = sourceSnapshot ~= nil and sourceSnapshot.globs or {},
}
saveBindingCache(lastGood)
publish("ready", request, lastGood, "", false)
else
local previous = matchingLastGood(request)
if previous ~= nil then
publish("ready", request, previous, err, false)
else
publish("error", request, nil, err, false)
end
end
if refreshQueued then
refreshQueued = false
refresh(request)
end
end
local function configReadError(context)
if context.rootRead then return nil end
return tr("missing_config")
end
local function refreshHyprLua(generation, request)
local scan = scanHyprLua(request.root)
if not noctalia.commandExists("hyprctl") then
finishRefresh(generation, request, {}, scan.warnings, tr("hyprctl_missing"), scan)
return
end
local accepted = noctalia.runAsync("hyprctl binds -j", function(result)
if generation ~= refreshGeneration then return end
if result.exitCode ~= 0 or result.timedOut then
local message = trim(result.stderr)
finishRefresh(
generation,
request,
{},
scan.warnings,
message ~= "" and message or tr("hyprctl_failed"),
scan
)
return
end
local parsed, err = parseHyprJson(result.stdout, scan.rules)
finishRefresh(generation, request, parsed or {}, scan.warnings, err, scan)
end, 10000)
if not accepted then
finishRefresh(generation, request, {}, scan.warnings, tr("hyprctl_failed"), scan)
end
end
local function performRefresh(generation, request)
if request == nil then
finishRefresh(generation, { compositor = "", parser = "", root = "" }, {}, {}, tr("unsupported"), nil)
elseif request.parser == "mango" then
local context = readConfig(request.root, parseMangoContent)
finishRefresh(generation, request, context.bindings, context.warnings, configReadError(context), context)
elseif request.parser == "niri" then
local context = readConfig(request.root, parseNiriContent)
finishRefresh(generation, request, context.bindings, context.warnings, configReadError(context), context)
elseif request.parser == "hypr-lua" then
refreshHyprLua(generation, request)
elseif request.parser == "hypr-conf" then
local context = readConfig(request.root, parseHyprContent)
finishRefresh(generation, request, context.bindings, context.warnings, configReadError(context), context)
else
finishRefresh(generation, request, {}, {}, tr("unsupported"), nil)
end
end
refresh = function(request)
if refreshing then
refreshQueued = true
return
end
request = request or currentRequest()
refreshGeneration += 1
local generation = refreshGeneration
refreshing = true
local previous = matchingLastGood(request)
if previous == nil then
publish("loading", request, nil, "", true)
end
performRefresh(generation, request)
end
local function loadCachedSnapshot(request)
local cached = readBindingCache()
if requestsMatch(cached ~= nil and cached.request or nil, request) then
lastGood = cached
publish("ready", request, cached, "", true)
else
lastGood = nil
publish("loading", request, nil, "", true)
end
end
local function bootstrap()
local request = currentRequest()
loadCachedSnapshot(request)
refresh(request)
end
local function lifecycleState()
local current = noctalia.state.get(SNAPSHOT_KEY)
return {
refreshing = refreshing,
refreshQueued = refreshQueued,
bindingCount = type(current) == "table" and type(current.bindings) == "table" and #current.bindings or 0,
cacheLoaded = lastGood ~= nil,
}
end
local function resetForTests()
refreshGeneration += 1
refreshing = false
refreshQueued = false
lastGood = nil
bootstrap()
end
local function containsAll(values, required)
local found = {}
for _, value in ipairs(values) do found[value] = true end
for _, value in ipairs(required or {}) do
if not found[value] then return false, value end
end
return true, nil
end
local function runSelfTest()
local pluginDir = noctalia.pluginDir() or "."
local fixtureRoot = pluginDir .. "/tests/fixtures"
local expectedRaw = noctalia.readFile(pluginDir .. "/tests/expected.json")
local expected = expectedRaw ~= nil and noctalia.json.decode(expectedRaw) or nil
local report = { passed = true, cases = {} }
if type(expected) ~= "table" then
report.passed = false
report.error = "Could not read tests/expected.json"
else
local cases = {
mango = function()
return walkConfig(fixtureRoot .. "/mango/main.conf", parseMangoContent).bindings
end,
hypr_conf = function()
return walkConfig(fixtureRoot .. "/hypr/hyprland.conf", parseHyprContent).bindings
end,
niri = function()
return walkConfig(fixtureRoot .. "/niri/config.kdl", parseNiriContent).bindings
end,
hypr_lua = function()
local scan = scanHyprLua(fixtureRoot .. "/hypr/hyprland.lua")
local raw = noctalia.readFile(fixtureRoot .. "/hypr/binds.json") or "[]"
return parseHyprJson(raw, scan.rules) or {}
end,
}
for name, execute in pairs(cases) do
local parsed = execute()
local expectedCase = expected[name]
local descriptions = {}
local categories = {}
for _, binding in ipairs(parsed) do
table.insert(descriptions, binding.description)
table.insert(
categories,
binding.description == "" and tr("without_description")
or (binding.category ~= "" and binding.category or tr("other"))
)
end
local descriptionsOk, missingDescription = containsAll(descriptions, expectedCase.descriptions)
local categoriesOk, missingCategory = containsAll(categories, expectedCase.categories)
local passed = #parsed == expectedCase.count and descriptionsOk and categoriesOk
report.cases[name] = {
passed = passed,
expectedCount = expectedCase.count,
actualCount = #parsed,
missingDescription = missingDescription,
missingCategory = missingCategory,
}
if not passed then report.passed = false end
end
end
local encoded = noctalia.json.encode(report, true) or "{}"
local dataDir = noctalia.pluginDataDir()
if dataDir ~= nil then noctalia.writeFile(dataDir .. "/selftest.json", encoded) end
noctalia.log("Keybind cheatsheet self-test: " .. encoded)
if report.passed then
noctalia.notify(tr("title"), "Parser self-test passed")
else
noctalia.notifyError(tr("title"), "Parser self-test failed; see the Noctalia log")
end
end
function onConfigChanged()
local request = currentRequest()
local current = noctalia.state.get(SNAPSHOT_KEY)
if requestsMatch(type(current) == "table" and current.request or nil, request) then
return
end
refreshGeneration += 1
refreshing = false
refreshQueued = false
loadCachedSnapshot(request)
refresh(request)
end
function onIpc(event, _payload)
if event == "refresh" then
refresh()
elseif event == "self-test" then
runSelfTest()
end
end
function onExit(_signal)
refreshGeneration += 1
refreshing = false
refreshQueued = false
end
noctalia.state.watch(REFRESH_REQUEST_KEY, function(_request)
refresh()
end)
noctalia.state.watch(SELF_TEST_REQUEST_KEY, function(_request)
runSelfTest()
end)
bootstrap()
return {
parseMangoContent = parseMangoContent,
mangoCategoryFromComment = mangoCategoryFromComment,
parseHyprContent = parseHyprContent,
parseNiriContent = parseNiriContent,
parseHyprJson = parseHyprJson,
scanHyprLua = scanHyprLua,
walkConfig = walkConfig,
configReadError = configReadError,
currentRequest = currentRequest,
requestsMatch = requestsMatch,
normalizedCache = normalizedCache,
refresh = refresh,
bootstrap = bootstrap,
resetForTests = resetForTests,
lifecycleState = lifecycleState,
onConfigChanged = onConfigChanged,
onIpc = onIpc,
onExit = onExit,
runSelfTest = runSelfTest,
snapshotKey = SNAPSHOT_KEY,
refreshRequestKey = REFRESH_REQUEST_KEY,
}