--!nonstrict -- Keymap data service. -- -- The live Hyprland bind registry is authoritative. Lua sources are scanned only -- to recover user-defined category order and map descriptions back to categories. local SNAPSHOT_KEY = "keymap.snapshot" local REFRESH_REQUEST_KEY = "keymap.refresh_request" local MAX_LUA_FILES = 64 local MAX_SOURCE_BYTES = 512 * 1024 local MAX_HIDDEN_BYTES = 2 * 1024 * 1024 local HYPRCTL_TIMEOUT_MS = 5000 local EXACT_SOURCE_FINGERPRINT = "exact-v1" local refreshing = false local refreshQueued = false local refreshGeneration = 0 local snapshotCompositor = "Hyprland" local function config(key, fallback) local value = noctalia.getConfig(key) if value == nil then return fallback end return value end local function environment(name) local value = noctalia.getenv(name) return type(value) == "string" and value or "" end local function configuredCompositor() local selected = tostring(config("compositor", "auto")):lower() if selected ~= "auto" then return selected end if environment("NIRI_SOCKET") ~= "" then return "niri" end if environment("HYPRLAND_INSTANCE_SIGNATURE") ~= "" then return "hyprland" end if environment("MANGO_INSTANCE_SIGNATURE") ~= "" then return "mangowc" end local desktop = (environment("XDG_CURRENT_DESKTOP") .. ":" .. environment("XDG_SESSION_DESKTOP") .. ":" .. environment("DESKTOP_SESSION")):lower() if desktop:find("niri", 1, true) ~= nil then return "niri" end if desktop:find("hyprland", 1, true) ~= nil then return "hyprland" end if desktop:find("mangowc", 1, true) ~= nil or desktop:find("mango", 1, true) ~= nil then return "mangowc" end return nil end local function trim(value) if type(value) ~= "string" then return "" end return value:match("^%s*(.-)%s*$") or "" end local function appendUnique(items, seen, value) if value == "" or seen[value] then return end seen[value] = true items[#items + 1] = value end local function dirname(path) local directory = path:match("^(.*)/[^/]*$") if directory == nil or directory == "" then return "." end return directory 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 parts[#parts + 1] = part end elseif part ~= "." and part ~= "" then parts[#parts + 1] = part end end local normalized = table.concat(parts, "/") if absolute then return "/" .. normalized end return normalized ~= "" and normalized or "." end local function expandedConfigPath() local configured = config("hyprland_config", "~/.config/hypr/hyprland.lua") local expanded = noctalia.expandPath(configured) if type(expanded) ~= "string" or expanded == "" then expanded = configured end expanded = normalizePath(expanded) if noctalia.fileExists(expanded) then return expanded end local xdg = environment("XDG_CONFIG_HOME") local configRoot = xdg ~= "" and xdg or (environment("HOME") .. "/.config") local hyprDir = normalizePath(configRoot .. "/hypr") local candidates = { normalizePath(hyprDir .. "/hyprland.lua"), normalizePath(hyprDir .. "/init.lua"), "/etc/xdg/hypr/hyprland.lua", } for _, path in ipairs(candidates) do if noctalia.fileExists(path) then return path end end local entries = noctalia.listDir(hyprDir) if type(entries) ~= "table" then return expanded end table.sort(entries) local bestPath, bestScore = nil, -1 for _, name in ipairs(entries) do if type(name) == "string" and name:match("%.lua$") and name ~= "keymap.lua" and not name:lower():find("backup", 1, true) then local path = normalizePath(hyprDir .. "/" .. name) local source = noctalia.readFile(path) if type(source) == "string" and #source <= MAX_SOURCE_BYTES then local _, bindCount = source:gsub("hl%.bind%s*%(", "") local _, requireCount = source:gsub("%f[%a]require%f[^%a]", "") local score = bindCount * 10 + requireCount * 2 if name:lower():find("keybind", 1, true) then score = score + 5 end if score > bestScore then bestPath, bestScore = path, score end end end end return bestScore > 0 and bestPath or expanded end local function modulePath(currentFile, moduleName) local name = trim(moduleName) if name == "" then return nil end if name:sub(-4) == ".lua" then name = name:sub(1, -5) end if not name:find("/", 1, true) then name = name:gsub("%.", "/") end name = name .. ".lua" if name:sub(1, 1) == "/" then return normalizePath(name) end return normalizePath(dirname(currentFile) .. "/" .. name) end local ESCAPES = { a = "\a", b = "\b", f = "\f", n = "\n", r = "\r", t = "\t", v = "\v", ["\\"] = "\\", ['"'] = '"', ["'"] = "'", } -- Returns a quoted literal assigned to `field` and whether it is immediately -- concatenated (for example: description = "Workspace " .. i). local function assignmentLiteral(line, field) local pattern = "%f[%w_]" .. field .. "%f[^%w_]" local _, fieldEnd = line:find(pattern) if fieldEnd == nil then return nil, false end local suffix = line:sub(fieldEnd + 1) local _, assignmentEnd = suffix:find("^%s*=%s*") if assignmentEnd == nil then return nil, false end local literal = suffix:sub(assignmentEnd + 1) local quote = literal:sub(1, 1) if quote ~= '"' and quote ~= "'" then return nil, false end local simpleValue, simpleTail if quote == '"' then simpleValue, simpleTail = literal:match('^"([^"\\]*)"(.*)$') else simpleValue, simpleTail = literal:match("^'([^'\\]*)'(.*)$") end if simpleValue ~= nil then return simpleValue, simpleTail:match("^%s*%.%.") ~= nil end local out = {} local escaped = false for index = 2, #literal do local char = literal:sub(index, index) if escaped then out[#out + 1] = ESCAPES[char] or char escaped = false elseif char == "\\" then escaped = true elseif char == quote then local tail = literal:sub(index + 1) return table.concat(out), tail:match("^%s*%.%.") ~= nil else out[#out + 1] = char end end return nil, false end local function requiredModules(line) local modules = {} local seen = {} for moduleName in line:gmatch('require%s*%(%s*"([^"]+)"%s*%)') do appendUnique(modules, seen, moduleName) end for moduleName in line:gmatch("require%s*%(%s*'([^']+)'%s*%)") do appendUnique(modules, seen, moduleName) end for moduleName in line:gmatch('require%s+"([^"]+)"') do appendUnique(modules, seen, moduleName) end for moduleName in line:gmatch("require%s+'([^']+)'") do appendUnique(modules, seen, moduleName) end return modules end local function multiKeySequence(line) local combo = line:match('hl%.bind%s*%(%s*"([^"]+)"') or line:match("hl%.bind%s*%(%s*'([^']+)'") if combo == nil then return nil end local keys = {} for token in combo:gmatch("[^+]+") do local value = trim(token) local upper = value:upper() if upper ~= "SUPER" and upper ~= "CTRL" and upper ~= "CONTROL" and upper ~= "SHIFT" and upper ~= "ALT" and upper ~= "META" and upper ~= "MOD" and upper ~= "MOD1" and upper ~= "MOD4" then keys[#keys + 1] = value end end return #keys > 1 and keys or nil end local function generatedCommand(line) local startIndex, endIndex = line:find("hl%.dsp%.exec_cmd%s*%(%s*") if endIndex == nil then return nil end local suffix = line:sub(endIndex + 1) local quote = suffix:sub(1, 1) if quote == '"' or quote == "'" then local simpleValue if quote == '"' then simpleValue = suffix:match('^"([^"\\]*)"') else simpleValue = suffix:match("^'([^'\\]*)'") end if simpleValue ~= nil then return simpleValue end local out = {} local escaped = false for index = 2, #suffix do local char = suffix:sub(index, index) if escaped then out[#out + 1] = ESCAPES[char] or char escaped = false elseif char == "\\" then escaped = true elseif char == quote then return table.concat(out) else out[#out + 1] = char end end return nil end local equals = suffix:match("^%[(=*)%[") if equals == nil then return nil end local openingLength = #equals + 2 local closeIndex = suffix:find("]" .. equals .. "]", openingLength + 1, true) if closeIndex == nil then return nil end return suffix:sub(openingLength + 1, closeIndex - 1) end local function generatedAction(line) local balanced = line:match("(hl%.dsp%.[%w_%.]+%b())") if balanced ~= nil then return balanced end local startIndex = line:find("hl%.dsp%.") if startIndex == nil then return nil end local quote, escaped, depth, opened = nil, false, 0, false for index = startIndex, #line do local char = line:sub(index, index) if quote ~= nil then if escaped then escaped = false elseif char == "\\" then escaped = true elseif char == quote then quote = nil end elseif char == '"' or char == "'" then quote = char elseif char == "(" then depth = depth + 1 opened = true elseif char == ")" then depth = depth - 1 if opened and depth == 0 then return line:sub(startIndex, index) end end end return nil end local function xorNibbleSlow(left, right) local result, place = 0, 1 for _ = 1, 4 do if left % 2 ~= right % 2 then result = result + place end left = math.floor(left / 2) right = math.floor(right / 2) place = place * 2 end return result end local xorNibbles = {} for left = 0, 15 do xorNibbles[left] = {} for right = 0, 15 do xorNibbles[left][right] = xorNibbleSlow(left, right) end end local function xorByte(left, right) return xorNibbles[left % 16][right % 16] + xorNibbles[math.floor(left / 16)][math.floor(right / 16)] * 16 end local xorByteFast = type(bit32) == "table" and type(bit32.bxor) == "function" and bit32.bxor or xorByte local function fingerprint(value) local hash = 2166136261 for index = 1, #value do local low = hash % 256 hash = hash - low + xorByteFast(low, value:byte(index)) hash = (hash * 403 + (hash % 256) * 16777216) % 4294967296 end return string.format("%08x", hash) end local function sourceLines(source) local lines = {} for line in (source .. "\n"):gmatch("([^\n]*)\n") do lines[#lines + 1] = line end return lines end local function hexDecode(value) if value == "" or #value % 2 ~= 0 or value:find("[^0-9a-f]") ~= nil then return nil end local output = {} for index = 1, #value, 2 do local byte = tonumber(value:sub(index, index + 1), 16) if byte == nil then return nil end output[#output + 1] = string.char(byte) end return table.concat(output) end -- Exact v1 sentinel parser. It runs before ordinary comment handling so only -- blocks emitted by the writer become restorable targets. local function hiddenBlockAt(lines, startLine) local namespace = "^([\t ]*)%-%- Keymap hidden " if lines[startLine]:match(namespace) == nil then return nil, startLine, false end local indent, blockId, originalFingerprint = lines[startLine]:match( namespace .. "v1 begin ([0-9a-f]+) ([0-9a-f]+)$" ) if indent == nil or #blockId ~= 8 or #originalFingerprint ~= 8 then return nil, startLine, true end local marker = indent .. "-- Keymap hidden v1" local escapedMarker = marker:gsub("(%W)", "%%%1") local encoded = {} local cursor = startLine + 1 while cursor <= #lines do local chunk = lines[cursor]:match("^" .. escapedMarker .. " data ([0-9a-f]+)$") if chunk ~= nil then if #chunk > 96 or #chunk % 2 ~= 0 then return nil, cursor, true end encoded[#encoded + 1] = chunk cursor = cursor + 1 else local endId = lines[cursor]:match("^" .. escapedMarker .. " end ([0-9a-f]+)$") if endId == nil then return nil, math.max(startLine, cursor - 1), true end local original = hexDecode(table.concat(encoded)) if #encoded == 0 or endId ~= blockId or original == nil or original == "" or #original > MAX_HIDDEN_BYTES or fingerprint(original) ~= originalFingerprint or fingerprint("Hyprland\0" .. original) ~= blockId then return nil, cursor, true end return { block_id = blockId, original = original, original_fingerprint = originalFingerprint, raw_snippet = table.concat(lines, "\n", startLine, cursor), }, cursor, true end end return nil, #lines, true end local function scanLuaSources(rootPath) local headers = {} local headerSeen = {} local exactCategories = {} local prefixes = {} local prefixSeen = {} local keySequences = {} local origins = {} local commands = {} local actions = {} local warnings = {} local hidden = {} local queue = { rootPath } local queued = { [rootPath] = true } local visited = {} local cursor = 1 local filesRead = 0 while cursor <= #queue and filesRead < MAX_LUA_FILES do local path = queue[cursor] cursor = cursor + 1 if not visited[path] then visited[path] = true local source = noctalia.readFile(path) if type(source) ~= "string" then if path == rootPath then warnings[#warnings + 1] = "lua_config_unreadable" end else filesRead = filesRead + 1 if #source > MAX_SOURCE_BYTES then source = source:sub(1, MAX_SOURCE_BYTES) warnings[#warnings + 1] = "lua_source_truncated" end local containsHidden = source:find("-- Keymap hidden ", 1, true) ~= nil local currentCategory = nil local pendingBindCategory = nil local pendingMarkerLine = nil local pendingMarkerRaw = nil local lines = sourceLines(source) local lineNumber = 1 while lineNumber <= #lines do local line = lines[lineNumber] local block, blockEnd, candidate = nil, lineNumber, false if containsHidden then block, blockEnd, candidate = hiddenBlockAt(lines, lineNumber) end if candidate then if block == nil then warnings[#warnings + 1] = "hidden_block_invalid:" .. path .. ":" .. tostring(lineNumber) else local originalLine = block.original:match("([^\n]*hl%.bind[^\n]*)") or "" local combo = originalLine:match('hl%.bind%s*%(%s*"([^"]+)"') or originalLine:match("hl%.bind%s*%(%s*'([^']+)'") or "" local modifiers, keys = {}, {} for token in combo:gmatch("[^+]+") do local value = trim(token) local upper = value:upper() if upper == "SUPER" or upper == "CTRL" or upper == "CONTROL" or upper == "SHIFT" or upper == "ALT" or upper == "META" or upper == "MOD" or upper == "MOD1" or upper == "MOD4" then modifiers[#modifiers + 1] = upper == "CONTROL" and "Ctrl" or upper:sub(1, 1) .. upper:sub(2):lower() elseif value ~= "" then keys[#keys + 1] = value end end local category = block.original:match( "^%s*%-%-%s*Keymap bind%-category:%s*([^\n]-)%s*\n" ) or currentCategory local description = assignmentLiteral(originalLine, "description") or assignmentLiteral(originalLine, "desc") or "" hidden[#hidden + 1] = { hidden = true, id = "hidden:hypr:" .. fingerprint(path .. "\0" .. tostring(lineNumber) .. "\0" .. block.block_id), source = path, line = lineNumber, start_line = lineNumber, end_line = blockEnd, raw_snippet = block.raw_snippet, fingerprint = fingerprint(block.raw_snippet), original_fingerprint = block.original_fingerprint, modifiers = modifiers, key = keys[#keys] or "", keys = #keys > 1 and keys or nil, description = description, dispatcher = generatedAction(originalLine) or "", command = generatedCommand(originalLine) or "", action = generatedAction(originalLine) or "", activation = originalLine:match("release%s*=%s*true") and "release" or "press", category = category or "", capabilities = { restore = true, delete = true }, } end lineNumber = blockEnd + 1 else local bindCategory = line:match( "^%s*%-%-%s*Keymap bind%-category:%s*(.-)%s*$" ) local header = line:match("^%s*%-%-%s*%d+%.%s*(.-)%s*$") if bindCategory ~= nil and bindCategory ~= "" then pendingBindCategory = bindCategory pendingMarkerLine = lineNumber pendingMarkerRaw = line elseif header ~= nil and header ~= "" then currentCategory = header appendUnique(headers, headerSeen, header) elseif not line:match("^%s*%-%-") then if line:find("require", 1, true) ~= nil then local modules = requiredModules(line) for _, moduleName in ipairs(modules) do local requiredPath = modulePath(path, moduleName) if requiredPath ~= nil and not queued[requiredPath] and noctalia.fileExists(requiredPath) then queued[requiredPath] = true queue[#queue + 1] = requiredPath end end end local effectiveCategory = pendingBindCategory or currentCategory local hasBind = line:find("hl.bind", 1, true) ~= nil if effectiveCategory ~= nil and hasBind then local description, dynamic = assignmentLiteral(line, "description") if description == nil then description, dynamic = assignmentLiteral(line, "desc") end if description ~= nil and trim(description) ~= "" then local command = generatedCommand(line) local action = generatedAction(line) if dynamic then local prefixKey = description .. "\0" .. effectiveCategory if not prefixSeen[prefixKey] then prefixSeen[prefixKey] = true prefixes[#prefixes + 1] = { prefix = description, category = effectiveCategory, } end elseif exactCategories[description] == nil then exactCategories[description] = effectiveCategory end if keySequences[description] == nil then keySequences[description] = multiKeySequence(line) end if origins[description] == nil then local rawSnippet = pendingMarkerRaw ~= nil and (pendingMarkerRaw .. "\n" .. line) or line origins[description] = { source = path, line = pendingMarkerLine or lineNumber, start_line = pendingMarkerLine or lineNumber, end_line = lineNumber, managed = path:match("([^/]+)$") == "keymap.lua", raw_snippet = rawSnippet, -- The writer verifies this complete snippet byte-for-byte at -- the recorded line range. Avoid hashing every bind inside the -- service's tightly budgeted refresh callback. fingerprint = EXACT_SOURCE_FINGERPRINT, action = action, capabilities = { combo = not dynamic, category = not dynamic, description = not dynamic, command = not dynamic and command ~= nil, activation = not dynamic, }, } end if commands[description] == nil then commands[description] = command end if actions[description] == nil then actions[description] = action end end end if hasBind then pendingBindCategory = nil pendingMarkerLine = nil pendingMarkerRaw = nil end end lineNumber = lineNumber + 1 end end end end end if cursor <= #queue then warnings[#warnings + 1] = "lua_scan_limit_reached" end return { headers = headers, exactCategories = exactCategories, prefixes = prefixes, keySequences = keySequences, origins = origins, commands = commands, actions = actions, warnings = warnings, hidden = hidden, } end local function hasMaskBit(mask, bit) return math.floor(mask / bit) % 2 == 1 end local function decodeModifiers(value) local mask = tonumber(value) or 0 local modifiers = {} if hasMaskBit(mask, 64) then modifiers[#modifiers + 1] = "Super" end if hasMaskBit(mask, 1) then modifiers[#modifiers + 1] = "Shift" end if hasMaskBit(mask, 4) then modifiers[#modifiers + 1] = "Ctrl" end if hasMaskBit(mask, 8) then modifiers[#modifiers + 1] = "Alt" end if hasMaskBit(mask, 16) then modifiers[#modifiers + 1] = "Mod2" end if hasMaskBit(mask, 32) then modifiers[#modifiers + 1] = "Mod3" end if hasMaskBit(mask, 128) then modifiers[#modifiers + 1] = "Mod5" end return modifiers end local KEY_NAMES = { RETURN = "Enter", SPACE = "Space", ESCAPE = "Esc", PRINT = "PrtSc", PRIOR = "PgUp", NEXT = "PgDn", BRACKETLEFT = "[", BRACKETRIGHT = "]", LEFT = "Left", RIGHT = "Right", UP = "Up", DOWN = "Down", MOUSE_DOWN = "Scroll Down", MOUSE_UP = "Scroll Up", ["MOUSE:272"] = "Left Click", ["MOUSE:273"] = "Right Click", ["MOUSE:274"] = "Middle Click", XF86AUDIORAISEVOLUME = "Vol Up", XF86AUDIOLOWERVOLUME = "Vol Down", XF86AUDIOMUTE = "Mute", XF86AUDIOMICMUTE = "Mic Mute", XF86AUDIOPLAY = "Play", XF86AUDIOPAUSE = "Pause", XF86AUDIONEXT = "Next", XF86AUDIOPREV = "Prev", XF86AUDIOSTOP = "Stop", XF86AUDIOMEDIA = "Media", XF86MONBRIGHTNESSUP = "Bright Up", XF86MONBRIGHTNESSDOWN = "Bright Down", XF86CALCULATOR = "Calc", XF86MAIL = "Mail", XF86SEARCH = "Search", XF86EXPLORER = "Files", XF86WWW = "Browser", XF86HOMEPAGE = "Home", XF86FAVORITES = "Favorites", XF86POWEROFF = "Power", XF86SLEEP = "Sleep", XF86EJECT = "Eject", } local function formatKey(value) local key = tostring(value or "") return KEY_NAMES[key:upper()] or key end local function boolFlag(value) return value == true and "1" or "0" end local function stableBindId(bind) local dispatcher = tostring(bind.dispatcher or "") local dispatchIdentity = dispatcher if dispatcher ~= "__lua" then dispatchIdentity = dispatcher .. ":" .. tostring(bind.arg or "") end local flags = boolFlag(bind.release) .. boolFlag(bind.mouse) .. boolFlag(bind.long_press) local submap = tostring(bind.submap or "") local modmask = tostring(bind.modmask or 0) local key = tostring(bind.key or "") return string.format( "hypr:%d:%s%d:%s%d:%s%d:%s%d:%s", #submap, submap, #modmask, modmask, #key, key, #flags, flags, #dispatchIdentity, dispatchIdentity ) end -- Hyprland 0.56 can emit invalid JSON for native Lua binds while its plain -- `hyprctl binds` output remains complete. Keep a strict, line-oriented -- fallback so the plugin still reads the live registry instead of guessing -- solely from source files. local function parseHyprctlText(output) if type(output) ~= "string" or output == "" then return nil end local records = {} local current = nil local function finish() if current == nil then return end if type(current.key) == "string" and current.key ~= "" then current.modmask = tonumber(current.modmask) or 0 current.has_description = type(current.description) == "string" and current.description ~= "" local kind = tostring(current._kind or "") current.release = kind:find("r", 1, true) ~= nil current.mouse = kind:find("m", 1, true) ~= nil records[#records + 1] = current end current = nil end for line in (output .. "\n"):gmatch("([^\n]*)\n") do local kind = line:match("^bind([%a]*)%s*$") if kind ~= nil then finish() current = { _kind = kind } elseif current ~= nil then local key, value = line:match("^%s+([%w_]+):%s?(.*)$") if key ~= nil then current[key] = value end end end finish() return #records > 0 and records or nil end local function categoryForDescription(description, metadata) local exact = metadata.exactCategories[description] if exact ~= nil then return exact end local bestCategory = nil local bestLength = -1 for _, entry in ipairs(metadata.prefixes) do if description:sub(1, #entry.prefix) == entry.prefix and #entry.prefix > bestLength then bestCategory = entry.category bestLength = #entry.prefix end end return bestCategory end local function slug(value) local id = value:lower():gsub("[^%a%d]+", "-"):gsub("^-+", ""):gsub("-+$", "") return id ~= "" and id or "category" end local function cleanBind(bind) return { id = bind.id, modifiers = bind.modifiers, key = bind.key, keys = bind.keys, description = bind.description, dispatcher = bind.dispatcher, activation = bind.release == true and "release" or "press", source = bind.source, line = bind.line, managed = bind.managed == true, command = bind.command, action = bind.action, start_line = bind.start_line, end_line = bind.end_line, raw_snippet = bind.raw_snippet, fingerprint = bind.fingerprint, capabilities = bind.capabilities, } end local function descriptionTemplate(description) local template, replacements = description:gsub("%f[%d]%d+%f[%D]", "%%N%%") return template, replacements > 0 end local function mergedBind(run) local first = run[1].bind local last = run[#run].bind local range = tostring(run[1].number) .. "-" .. tostring(run[#run].number) local description = first.description:gsub("%f[%d]%d+%f[%D]", range, 1) return { id = "range:" .. first.id .. ":" .. last.id, modifiers = first.modifiers, key = range, description = description, dispatcher = first.dispatcher, activation = first.release == true and "release" or "press", } end -- Merge runs even when Hyprland interleaves them (Super+1, Super+Alt+1, -- Super+2, Super+Alt+2, ...). The V4 adjacent-only implementation missed this. local function mergeSequential(binds) if #binds < 3 then local output = {} for _, bind in ipairs(binds) do output[#output + 1] = cleanBind(bind) end return output end local groups = {} for index, bind in ipairs(binds) do local rawKey = tostring(bind._rawKey or "") local number = rawKey:match("^%d+$") and tonumber(rawKey) or nil local template, hasNumber = descriptionTemplate(bind.description) if number ~= nil and hasNumber then local signature = table.concat(bind.modifiers, "+") .. "|" .. bind.dispatcher .. "|" .. template groups[signature] = groups[signature] or {} groups[signature][#groups[signature] + 1] = { index = index, number = number, bind = bind, } end end local replacements = {} local skipped = {} for _, group in pairs(groups) do table.sort(group, function(a, b) if a.number == b.number then return a.index < b.index end return a.number < b.number end) local runStart = 1 for cursor = 2, #group + 1 do local continues = cursor <= #group and group[cursor].number == group[cursor - 1].number + 1 if not continues then local runLength = cursor - runStart if runLength >= 3 then local run = {} local insertionIndex = group[runStart].index for itemIndex = runStart, cursor - 1 do local item = group[itemIndex] run[#run + 1] = item if item.index < insertionIndex then insertionIndex = item.index end end replacements[insertionIndex] = mergedBind(run) for _, item in ipairs(run) do if item.index ~= insertionIndex then skipped[item.index] = true end end end runStart = cursor end end end local output = {} for index, bind in ipairs(binds) do if replacements[index] ~= nil then output[#output + 1] = replacements[index] elseif not skipped[index] then output[#output + 1] = cleanBind(bind) end end return output end local function buildCategories(liveBinds, metadata) local showUndescribed = config("show_undescribed", true) == true local shouldMerge = config("merge_sequential", true) == true local otherName = noctalia.tr("category.other") local undescribedName = noctalia.tr("category.undescribed") local byName = {} local total = 0 local uncategorized = 0 for _, raw in ipairs(liveBinds) do if type(raw) == "table" and raw.key ~= nil then local description = "" if raw.has_description ~= false and type(raw.description) == "string" then description = raw.description end local isUndescribed = description == "" if showUndescribed or not isUndescribed then local categoryName if isUndescribed then categoryName = undescribedName else categoryName = categoryForDescription(description, metadata) if categoryName == nil then categoryName = otherName uncategorized = uncategorized + 1 end end byName[categoryName] = byName[categoryName] or {} byName[categoryName][#byName[categoryName] + 1] = { id = stableBindId(raw), modifiers = decodeModifiers(raw.modmask), key = formatKey(raw.key), keys = (function() local sequence = metadata.keySequences[description] if type(sequence) ~= "table" then return nil end local formatted = {} for _, value in ipairs(sequence) do formatted[#formatted + 1] = formatKey(value) end return formatted end)(), source = type(metadata.origins[description]) == "table" and metadata.origins[description].source or nil, line = type(metadata.origins[description]) == "table" and metadata.origins[description].line or nil, managed = type(metadata.origins[description]) == "table" and metadata.origins[description].managed == true, command = metadata.commands[description], action = metadata.actions[description], start_line = type(metadata.origins[description]) == "table" and metadata.origins[description].start_line or nil, end_line = type(metadata.origins[description]) == "table" and metadata.origins[description].end_line or nil, raw_snippet = type(metadata.origins[description]) == "table" and metadata.origins[description].raw_snippet or nil, fingerprint = type(metadata.origins[description]) == "table" and metadata.origins[description].fingerprint or nil, capabilities = type(metadata.origins[description]) == "table" and metadata.origins[description].capabilities or nil, description = description, dispatcher = tostring(raw.dispatcher or ""), release = raw.release == true, _rawKey = tostring(raw.key), } total = total + 1 end end end local categories = {} local emitted = {} local usedIds = {} local function emit(name, preferredId) local binds = byName[name] if binds == nil or #binds == 0 or emitted[name] then return end emitted[name] = true local baseId = preferredId or slug(name) local id = baseId local suffix = 2 while usedIds[id] do id = baseId .. "-" .. tostring(suffix) suffix = suffix + 1 end usedIds[id] = true categories[#categories + 1] = { id = id, name = name, binds = shouldMerge and mergeSequential(binds) or (function() local output = {} for _, bind in ipairs(binds) do output[#output + 1] = cleanBind(bind) end return output end)(), } end for _, name in ipairs(metadata.headers) do emit(name) end -- These synthetic buckets are deliberately last and have fixed IDs for UI use. local otherBinds = byName[otherName] local undescribedBinds = byName[undescribedName] for name, _ in pairs(byName) do if name ~= otherName and name ~= undescribedName then emit(name) end end if otherBinds ~= nil then emit(otherName, "other") end if undescribedBinds ~= nil then emit(undescribedName, "undescribed") end return categories, total, uncategorized end local function snapshot(status, source, errorCode, categories, total, warnings, updatedAt, hidden) return { status = status, error = errorCode or "", compositor = snapshotCompositor, source = source, updated_at = updatedAt or "", total = total or 0, categories = categories or {}, warnings = warnings or {}, hidden = hidden or {}, } end local function publishError(source, errorCode, warnings) noctalia.state.set(SNAPSHOT_KEY, snapshot("error", source, errorCode, {}, 0, warnings, os.date("%H:%M:%S"))) end local function finishRefresh() refreshing = false if refreshQueued then refreshQueued = false refresh() end end function refresh() refreshGeneration = refreshGeneration + 1 local requestGeneration = refreshGeneration local compositor = configuredCompositor() if compositor == nil then snapshotCompositor = "Unknown" noctalia.state.set( SNAPSHOT_KEY, snapshot("error", "", "compositor_unknown", {}, 0, {}, os.date("%H:%M:%S")) ) return end if compositor ~= "hyprland" then return end snapshotCompositor = "Hyprland" if refreshing then refreshQueued = true return end refreshing = true local source = expandedConfigPath() -- The panel keeps its last ready snapshot while this status is loading. -- Avoid serializing the complete bind tree a second time on every refresh. noctalia.state.set( SNAPSHOT_KEY, snapshot("loading", source, "", {}, 0, {}, "", {}) ) if not noctalia.commandExists("hyprctl") then publishError(source, "hyprctl_not_found", {}) finishRefresh() return end local metadata = scanLuaSources(source) local function publishLive(liveBinds) local categories, total, uncategorized = buildCategories(liveBinds, metadata) if uncategorized > 0 then metadata.warnings[#metadata.warnings + 1] = "uncategorized_binds:" .. tostring(uncategorized) end if total == 0 then metadata.warnings[#metadata.warnings + 1] = "no_binds" end noctalia.state.set( SNAPSHOT_KEY, snapshot("ready", source, "", categories, total, metadata.warnings, os.date("%H:%M:%S"), metadata.hidden) ) finishRefresh() end local function requestTextFallback() local fallbackStarted = noctalia.runAsync("hyprctl binds", function(fallbackResult) if requestGeneration ~= refreshGeneration or configuredCompositor() ~= "hyprland" then finishRefresh() return end if fallbackResult.timedOut == true then publishError(source, "hyprctl_timeout", metadata.warnings) finishRefresh() return end local decoded = fallbackResult.exitCode == 0 and parseHyprctlText(fallbackResult.stdout) or nil if decoded == nil then publishError(source, "hyprctl_invalid_json", metadata.warnings) finishRefresh() return end publishLive(decoded) end, HYPRCTL_TIMEOUT_MS) if not fallbackStarted then publishError(source, "hyprctl_start_failed", metadata.warnings) finishRefresh() end end local started = noctalia.runAsync("hyprctl binds -j", function(result) -- A config change can activate another compositor while hyprctl is still -- running. Never let that stale result replace the newer adapter snapshot. if requestGeneration ~= refreshGeneration or configuredCompositor() ~= "hyprland" then finishRefresh() return end if result.timedOut == true then publishError(source, "hyprctl_timeout", metadata.warnings) finishRefresh() return end if result.exitCode ~= 0 or type(result.stdout) ~= "string" or result.stdout == "" then publishError(source, "hyprctl_failed", metadata.warnings) finishRefresh() return end local ok, decoded = pcall(noctalia.json.decode, result.stdout) if not ok or type(decoded) ~= "table" then requestTextFallback() return end publishLive(decoded) end, HYPRCTL_TIMEOUT_MS) if not started then publishError(source, "hyprctl_start_failed", metadata.warnings) finishRefresh() end end function onIpc(event, _payload) if event == "refresh" then local current = tonumber(noctalia.state.get(REFRESH_REQUEST_KEY)) or 0 noctalia.state.set(REFRESH_REQUEST_KEY, current + 1) end end function onConfigChanged() refresh() end noctalia.state.watch(REFRESH_REQUEST_KEY, function(_request) refresh() end) refresh()