--!nonstrict -- Keymap service for MangoWC / mangowm. -- -- Mango's IPC does not expose the loaded keybinding registry. The recursively -- included configuration tree is therefore the authoritative available source. local SNAPSHOT_KEY = "keymap.snapshot" local REFRESH_REQUEST_KEY = "keymap.refresh_request" local DEFAULT_CONFIG = "~/.config/mango/config.conf" local SYSTEM_CONFIG = "/etc/mango/config.conf" local MAX_FILES = 64 local MAX_SOURCE_BYTES = 512 * 1024 local MAX_HIDDEN_BYTES = 2 * 1024 * 1024 local EXACT_SOURCE_FINGERPRINT = "exact-v1" local refreshing = false local refreshQueued = false local function config(key, fallback) local value = noctalia.getConfig(key) if value == nil then return fallback end return value end local function trim(value) if type(value) ~= "string" then return "" end return value:match("^%s*(.-)%s*$") or "" end local function envSet(name) local value = noctalia.getenv(name) return type(value) == "string" and value ~= "" end local function environment(name) local value = noctalia.getenv(name) return type(value) == "string" and value or "" end local function desktopHas(value, wanted) local normalized = tostring(value or ""):lower() return normalized:find(wanted, 1, true) ~= nil end -- Shared detection order used by all compositor-specific services. local function detectedCompositor() if envSet("NIRI_SOCKET") then return "niri" end if envSet("HYPRLAND_INSTANCE_SIGNATURE") then return "hyprland" end if envSet("MANGO_INSTANCE_SIGNATURE") then return "mangowc" end local current = noctalia.getenv("XDG_CURRENT_DESKTOP") local session = noctalia.getenv("XDG_SESSION_DESKTOP") local desktopSession = noctalia.getenv("DESKTOP_SESSION") if desktopHas(current, "niri") or desktopHas(session, "niri") or desktopHas(desktopSession, "niri") then return "niri" end if desktopHas(current, "hyprland") or desktopHas(session, "hyprland") or desktopHas(desktopSession, "hyprland") then return "hyprland" end if desktopHas(current, "mango") or desktopHas(session, "mango") or desktopHas(desktopSession, "mango") or desktopHas(current, "mangowc") or desktopHas(session, "mangowc") or desktopHas(desktopSession, "mangowc") then return "mangowc" end return "" end local function selectedCompositor() local selected = tostring(config("compositor", "auto")):lower() if selected == "hyprland" or selected == "niri" or selected == "mangowc" then return selected end return detectedCompositor() end local function isActive() return selectedCompositor() == "mangowc" end local function dirname(path) local directory = path:match("^(.*)/[^/]*$") return directory ~= nil and directory ~= "" and directory or "." 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 expandPath(path) local expanded = noctalia.expandPath(path) if type(expanded) == "string" and expanded ~= "" then return normalizePath(expanded) end return normalizePath(path) end local function configPath() local configured = trim(config("mangowc_config", DEFAULT_CONFIG)) if configured == "" then configured = DEFAULT_CONFIG end local path = expandPath(configured) if noctalia.fileExists(path) then return path end local xdg = environment("XDG_CONFIG_HOME") local mangoDir = normalizePath((xdg ~= "" and xdg or (environment("HOME") .. "/.config")) .. "/mango") local candidates = { normalizePath(mangoDir .. "/config.conf"), SYSTEM_CONFIG } for _, candidate in ipairs(candidates) do if noctalia.fileExists(candidate) then return candidate end end local entries = noctalia.listDir(mangoDir) if type(entries) ~= "table" then return path end table.sort(entries) local bestPath, bestScore = nil, -1 for _, name in ipairs(entries) do if type(name) == "string" and name:match("%.conf$") and name ~= "keymap.conf" and not name:lower():find("backup", 1, true) then local candidate = normalizePath(mangoDir .. "/" .. name) local source = noctalia.readFile(candidate) if type(source) == "string" and #source <= MAX_SOURCE_BYTES then local _, bindCount = source:gsub("bind[%w%-]*%s*=", "") local _, sourceCount = source:gsub("source[%w%-]*%s*=", "") local score = bindCount * 10 + sourceCount * 2 if name:lower():find("bind", 1, true) then score = score + 5 end if score > bestScore then bestPath, bestScore = candidate, score end end end end return bestScore > 0 and bestPath or path end local function stripOuterQuotes(value) local text = trim(value) local quote = text:sub(1, 1) if #text >= 2 and (quote == '"' or quote == "'") and text:sub(-1) == quote then return text:sub(2, -2) end return text end local function resolveSource(rootDirectory, source) local path = stripOuterQuotes(source) if path == "" then return nil end if path:find("*", 1, true) or path:find("?", 1, true) or path:find("[", 1, true) then return nil, "mangowc_source_glob_not_supported:" .. path end path = noctalia.expandPath(path) or path if path:sub(1, 1) == "/" then return normalizePath(path) end path = path:gsub("^%./", "") return normalizePath(rootDirectory .. "/" .. path) end local function slug(value) local id = value:lower():gsub("[^%a%d]+", "-"):gsub("^-+", ""):gsub("-+$", "") return id ~= "" and id or "category" end local function titleCase(value) local text = tostring(value or ""):gsub("_", " "):gsub("%-", " ") text = text:gsub("(%l)(%u)", "%1 %2") return (text:gsub("%f[%a]%a", string.upper)) end local function splitCsv(value) local parts = {} local start = 1 while true do local comma = value:find(",", start, true) if comma == nil then parts[#parts + 1] = trim(value:sub(start)) break end parts[#parts + 1] = trim(value:sub(start, comma - 1)) start = comma + 1 end return parts end local function joinTail(parts, first) local output = {} for index = first, #parts do output[#output + 1] = parts[index] end return trim(table.concat(output, ",")) end -- Locate an ordinary inline comment while preserving the plugin convention -- #"description" and quoted hashes in shell commands. local function findUnquotedComment(line) local inSingle = false local inDouble = false local escaped = false for index = 1, #line do local char = line:sub(index, index) if escaped then escaped = false elseif char == "\\" and (inSingle or inDouble) then escaped = true elseif char == "'" and not inDouble then inSingle = not inSingle elseif char == '"' and not inSingle then inDouble = not inDouble elseif char == "#" and not inSingle and not inDouble and line:sub(index + 1, index + 1) ~= '"' then return index end end return nil end local function extractDescription(line) local description = line:match('#"(.*)"%s*$') local marker = line:match('()#".*"%s*$') if description == nil or marker == nil then return line, nil end description = description:gsub('\\"', '"'):gsub("\\\\", "\\") return trim(line:sub(1, marker - 1)), description end local function extractCategory(line) local rest = trim(line) if rest:sub(1, 1) ~= "#" then return nil end local managed = trim(rest:match("^#%s*Keymap category:%s*(.+)$") or "") if managed ~= "" then return managed end rest = trim(rest:gsub("^#+", "", 1)) if rest == "" or #rest > 100 or rest:match("^[%w%-]+%s*=") then return nil end if rest:match("^[─━═=%-_*#/\\%s]+$") or rest:match("^[%(%[]") then return nil end if rest:match("^%-%>") or rest:match("^=%>") then return nil end local firstWord = rest:match("^(%a+)") if firstWord ~= nil then local upper = firstWord:upper() if upper == "TODO" or upper == "FIXME" or upper == "NOTE" or upper == "HACK" or upper == "XXX" or upper == "BUG" or upper == "WIP" then return nil end end rest = trim(rest:gsub("[─━═=%-_*][─━═=%-_*][─━═=%-_*]+%s*$", "")) if rest == "" then return nil end return trim(rest:match("^%d+%.%s*(.+)$") or rest) end local KEY_NAMES = { RETURN = "Enter", ESCAPE = "Esc", SPACE = "Space", PRINT = "PrtSc", PRIOR = "PgUp", NEXT = "PgDn", EQUAL = "=", MINUS = "-", PLUS = "+", COMMA = ",", PERIOD = ".", SEMICOLON = ";", APOSTROPHE = "'", GRAVE = "`", SLASH = "/", BACKSLASH = "\\", BRACKETLEFT = "[", BRACKETRIGHT = "]", XF86AUDIORAISEVOLUME = "Vol Up", XF86AUDIOLOWERVOLUME = "Vol Down", XF86AUDIOMUTE = "Mute", XF86AUDIOMICMUTE = "Mic Mute", XF86AUDIOPLAY = "Play", XF86AUDIOPAUSE = "Pause", XF86AUDIONEXT = "Next", XF86AUDIOPREV = "Prev", XF86AUDIOSTOP = "Stop", XF86MONBRIGHTNESSUP = "Bright Up", XF86MONBRIGHTNESSDOWN = "Bright Down", } local AXIS_NAMES = { UP = "Scroll Up", DOWN = "Scroll Down", LEFT = "Scroll Left", RIGHT = "Scroll Right", } local BUTTON_NAMES = { BTN_LEFT = "Left Click", BTN_RIGHT = "Right Click", BTN_MIDDLE = "Middle Click", BTN_SIDE = "Mouse Side", BTN_EXTRA = "Mouse Extra", BTN_FORWARD = "Mouse Forward", BTN_BACK = "Mouse Back", BTN_TASK = "Mouse Task", ["CODE:272"] = "Left Click", ["CODE:273"] = "Right Click", ["CODE:274"] = "Middle Click", } local MODIFIER_NAMES = { SUPER = "Super", SUPER_L = "Super", SUPER_R = "Super", LOGO = "Super", CTRL = "Ctrl", CONTROL = "Ctrl", CTRL_L = "Ctrl", CTRL_R = "Ctrl", SHIFT = "Shift", SHIFT_L = "Shift", SHIFT_R = "Shift", ALT = "Alt", ALT_L = "Alt", ALT_R = "Alt", MOD1 = "Alt", HYPER = "Hyper", HYPER_L = "Hyper", HYPER_R = "Hyper", } local MODIFIER_CODES = { ["37"] = "Ctrl", ["50"] = "Shift", ["62"] = "Shift", ["64"] = "Alt", ["105"] = "Ctrl", ["108"] = "Alt", ["133"] = "Super", ["134"] = "Super", } local function parseModifiers(value) local modifiers = {} local seen = {} for token in (value .. "+"):gmatch("(.-)%+") do local upper = trim(token):upper() local name = MODIFIER_NAMES[upper] if name == nil then name = MODIFIER_CODES[upper:match("^CODE:(%d+)$") or ""] end if name ~= nil and not seen[name] then seen[name] = true modifiers[#modifiers + 1] = name elseif upper ~= "" and upper ~= "NONE" and name == nil then local code = upper:match("^CODE:(%d+)$") local rawName = code ~= nil and ("Code " .. code) or titleCase(upper:lower()) if not seen[rawName] then seen[rawName] = true modifiers[#modifiers + 1] = rawName end end end return modifiers end local function formatKey(value) local key = trim(value) return KEY_NAMES[key:upper()] or key end local NO_ARG_ACTIONS = { killclient = "actions.mangowc.killclient", togglefullscreen = "actions.mangowc.togglefullscreen", togglefakefullscreen = "actions.mangowc.togglefakefullscreen", togglemaximizescreen = "actions.mangowc.togglemaximizescreen", togglefloating = "actions.mangowc.togglefloating", toggle_all_floating = "actions.mangowc.toggle_all_floating", toggleglobal = "actions.mangowc.toggleglobal", toggleoverview = "actions.mangowc.toggleoverview", togglejump = "actions.mangowc.togglejump", toggleoverlay = "actions.mangowc.toggleoverlay", toggle_scratchpad = "actions.mangowc.toggle_scratchpad", minimized = "actions.mangowc.minimized", restore_minimized = "actions.mangowc.restore_minimized", reload_config = "actions.mangowc.reload_config", quit = "actions.mangowc.quit", switch_proportion_preset = "actions.mangowc.switch_proportion_preset", switch_keyboard_layout = "actions.mangowc.switch_keyboard_layout", zoom = "actions.mangowc.zoom", restart = "actions.mangowc.restart", incnmaster = "actions.mangowc.incnmaster", switch_layout = "actions.mangowc.switch_layout", togglegaps = "actions.mangowc.togglegaps", dwindle_toggle_split_direction = "actions.mangowc.dwindle_toggle_split_direction", } local DIRECTION_ACTIONS = { focusdir = "actions.mangowc.focusdir", exchange_client = "actions.mangowc.exchange_client", focusmon = "actions.mangowc.focusmon", tagmon = "actions.mangowc.tagmon", groupjoin = "actions.mangowc.groupjoin", groupfocus = "actions.mangowc.groupfocus", smartmovewin = "actions.mangowc.smartmovewin", smartresizewin = "actions.mangowc.smartresizewin", scroller_stack = "actions.mangowc.scroller_stack", } local function formatAction(action, args) local normalized = trim(action):lower() local argument = trim(args) if NO_ARG_ACTIONS[normalized] ~= nil and (argument == "" or argument == "0") then return noctalia.tr(NO_ARG_ACTIONS[normalized]) end if DIRECTION_ACTIONS[normalized] ~= nil then local label = noctalia.tr(DIRECTION_ACTIONS[normalized]) return argument ~= "" and noctalia.tr("actions.with_argument", { action = label, argument = titleCase(argument) }) or label end if normalized == "spawn" or normalized == "spawn_shell" or normalized == "spawn_on_empty" then return argument ~= "" and noctalia.tr("actions.run", { command = argument }) or noctalia.tr("actions.run_command") end if normalized == "view" then local tag = argument:match("^([^,]+)") or argument return tag ~= "" and noctalia.tr("actions.view_tag_number", { tag = tag }) or noctalia.tr("actions.view_tag") end if normalized == "tag" or normalized == "tagsilent" then local tag = argument:match("^([^,]+)") or argument return tag ~= "" and noctalia.tr("actions.move_to_tag_number", { tag = tag }) or noctalia.tr("actions.move_to_tag") end if normalized == "setlayout" then return argument ~= "" and noctalia.tr("actions.layout_value", { layout = argument }) or noctalia.tr("actions.set_layout") end if normalized == "setkeymode" then return argument ~= "" and noctalia.tr("actions.key_mode_value", { mode = argument }) or noctalia.tr("actions.set_key_mode") end return noctalia.tr("actions.native", { action = normalized .. (argument ~= "" and (" " .. argument) or ""), }) end local function parseFlags(suffix) local flags = {} for index = 1, #suffix do local flag = suffix:sub(index, index):lower() if flag == "l" then flags.locked = true elseif flag == "s" then flags.keysym = true elseif flag == "r" then flags.release = true elseif flag == "p" then flags.pass = true end end return flags end local function addCategory(context, name) local category = context.byCategory[name] if category ~= nil then return category end local baseId = slug(name) local id = baseId local suffix = 2 while context.categoryIds[id] do id = baseId .. "-" .. tostring(suffix) suffix = suffix + 1 end context.categoryIds[id] = true category = { id = id, name = name, binds = {} } context.byCategory[name] = category context.categories[#context.categories + 1] = category return category end local function stableBindId(context, fields) local encoded = {} for _, value in ipairs(fields) do local field = tostring(value or "") encoded[#encoded + 1] = tostring(#field) .. ":" .. field end local identity = table.concat(encoded) local count = (context.bindIds[identity] or 0) + 1 context.bindIds[identity] = count return "mango:" .. identity .. (count > 1 and (":" .. tostring(count)) or "") end -- FNV-1a over the exact source line. The editor recalculates this before a -- write so a bind is never replaced after the file changed behind its back. -- Splitting the prime into 2^24 + 403 retains exact 32-bit arithmetic in -- runtimes where Lua numbers are doubles. local function xorByte(left, right) local result = 0 local place = 1 for _ = 1, 8 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 xorByteFast = type(bit32) == "table" and type(bit32.bxor) == "function" and bit32.bxor or xorByte local function fingerprint(rawSnippet) local hash = 2166136261 for index = 1, #rawSnippet do local low = hash % 256 hash = hash - low + xorByteFast(low, rawSnippet: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 output[#output + 1] = string.char(tonumber(value:sub(index, index + 1), 16)) end return table.concat(output) end 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 escaped = marker:gsub("(%W)", "%%%1") local encoded, cursor = {}, startLine + 1 while cursor <= #lines do local chunk = lines[cursor]:match("^" .. escaped .. " 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("^" .. escaped .. " 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("MangoWC\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 editCapabilities(kind, action) local isKeyboard = kind == "bind" return { combo = isKeyboard, category = isKeyboard, description = isKeyboard, command = isKeyboard and trim(action):lower() == "spawn_shell", activation = isKeyboard, } end local function disabledEditCapabilities() return { combo = false, category = false, description = false, command = false, activation = false, } end local function addBind( context, categoryName, kind, suffix, parts, description, source, lineNumber, rawSnippet, startLine ) local modifiers = {} local key = "" local action = "" local args = "" if kind == "bind" then if #parts < 3 then return false end modifiers, key, action, args = parseModifiers(parts[1]), formatKey(parts[2]), parts[3], joinTail(parts, 4) elseif kind == "axisbind" then if #parts < 3 then return false end modifiers = parseModifiers(parts[1]) key, action, args = AXIS_NAMES[parts[2]:upper()] or titleCase(parts[2]), parts[3], joinTail(parts, 4) elseif kind == "mousebind" then if #parts < 3 then return false end modifiers = parseModifiers(parts[1]) key, action, args = BUTTON_NAMES[parts[2]:upper()] or titleCase(parts[2]), parts[3], joinTail(parts, 4) elseif kind == "gesturebind" then if #parts < 4 then return false end modifiers = parseModifiers(parts[1]) key, action, args = tostring(parts[3]) .. "-finger " .. titleCase(parts[2]), parts[4], joinTail(parts, 5) elseif kind == "switchbind" then if #parts < 2 then return false end local state = parts[1]:lower() key = state == "fold" and "Lid Closed" or (state == "unfold" and "Lid Open" or titleCase(parts[1])) action, args = parts[2], joinTail(parts, 3) else return false end local fields = { context.keymode, kind, table.concat(modifiers, "+"), key, suffix, action, args } local category = addCategory(context, categoryName) local activation = parseFlags(suffix).release == true and "release" or "press" category.binds[#category.binds + 1] = { id = stableBindId(context, fields), modifiers = modifiers, key = key, description = description or formatAction(action, args), dispatcher = trim(action), command = args, action = formatAction(action, args), mode = context.keymode, kind = kind, flags = parseFlags(suffix), activation = activation, source = source, line = startLine or lineNumber, start_line = startLine or lineNumber, end_line = lineNumber, raw_snippet = rawSnippet, fingerprint = EXACT_SOURCE_FINGERPRINT, managed = source:match("([^/]+)$") == "keymap.conf", capabilities = editCapabilities(kind, action), _rawKey = kind == "bind" and trim(parts[2]) or key, } context.total = context.total + 1 return true end local function hiddenTarget(block, path, startLine, endLine, inheritedCategory, keymode) local bindLine = "" for raw in (block.original .. "\n"):gmatch("([^\n]*)\n") do if trim(raw):match("^bind[lsrp]*%s*=") or trim(raw):match("^axisbind%s*=") or trim(raw):match("^mousebind%s*=") or trim(raw):match("^gesturebind%s*=") or trim(raw):match("^switchbind%s*=") then bindLine = raw end end local effective, description = extractDescription(trim(bindLine)) local directive, value = effective:match("^([%w%-]+)%s*=%s*(.*)$") directive = tostring(directive or ""):lower() local suffix = directive:match("^bind([lsrp]*)$") local kind = suffix ~= nil and "bind" or directive if kind ~= "bind" and kind ~= "axisbind" and kind ~= "mousebind" and kind ~= "gesturebind" and kind ~= "switchbind" then kind, suffix = "bind", "" end local markedCategory = block.original:match( "^%s*#%s*Keymap bind%-category:%s*([^\n]-)%s*\n" ) local temporary = { defaultCategory = inheritedCategory or "", filesRead = 0, files = {}, visited = {}, warnings = {}, categories = {}, byCategory = {}, categoryIds = {}, bindIds = {}, total = 0, keymode = keymode or "default", } addBind(temporary, markedCategory or inheritedCategory or temporary.defaultCategory, kind, suffix or "", splitCsv(value or ""), description, path, endLine, block.original, startLine) local bind = temporary.categories[1] and temporary.categories[1].binds[1] or {} bind.hidden = true bind.id = "hidden:mango:" .. fingerprint(path .. "\0" .. tostring(startLine) .. "\0" .. block.block_id) bind.source, bind.line, bind.start_line, bind.end_line = path, startLine, startLine, endLine bind.raw_snippet, bind.fingerprint = block.raw_snippet, fingerprint(block.raw_snippet) bind.original_fingerprint = block.original_fingerprint bind.category = markedCategory or inheritedCategory or temporary.defaultCategory bind.capabilities = { restore = true, delete = true } return bind end local function parseFile(context, path, optional, depth) if context.filesRead >= MAX_FILES then context.warnings[#context.warnings + 1] = "mangowc_file_limit_reached" return end if context.visited[path] then return end context.visited[path] = true local source = noctalia.readFile(path) if type(source) ~= "string" then if not optional then local prefix = depth == 0 and "mangowc_config_unreadable:" or "mangowc_required_source_unreadable:" context.warnings[#context.warnings + 1] = prefix .. path if depth > 0 then context.fatalError = "mangowc_required_source_missing" end end return end context.filesRead = context.filesRead + 1 context.files[#context.files + 1] = path if #source > MAX_SOURCE_BYTES then source = source:sub(1, MAX_SOURCE_BYTES) context.warnings[#context.warnings + 1] = "mangowc_source_truncated:" .. path end local currentCategory = nil local pendingBindCategory = nil local pendingMarkerLine = nil local pendingMarkerRaw = nil local lineNumber = 0 local hiddenLines = sourceLines(source) local hiddenCategory = nil local hiddenKeymode = context.keymode local hiddenCursor = 1 local hiddenConsumed = {} while hiddenCursor <= #hiddenLines do local block, blockEnd, candidate = hiddenBlockAt(hiddenLines, hiddenCursor) if candidate then for consumed = hiddenCursor, blockEnd do hiddenConsumed[consumed] = true end if block == nil then context.warnings[#context.warnings + 1] = "hidden_block_invalid:" .. path .. ":" .. tostring(hiddenCursor) else context.hidden[#context.hidden + 1] = hiddenTarget( block, path, hiddenCursor, blockEnd, hiddenCategory or context.defaultCategory, hiddenKeymode ) end hiddenCursor = blockEnd + 1 else local raw = hiddenLines[hiddenCursor] local category = extractCategory(raw) if category ~= nil then hiddenCategory = category end local effective = trim(raw:sub(1, (findUnquotedComment(raw) or (#raw + 1)) - 1)) local directive, value = effective:match("^([%w%-]+)%s*=%s*(.*)$") if directive ~= nil and directive:lower() == "keymode" then hiddenKeymode = trim(value) ~= "" and trim(value) or "default" end hiddenCursor = hiddenCursor + 1 end end for rawLine in (source .. "\n"):gmatch("([^\n]*)\n") do lineNumber = lineNumber + 1 if not hiddenConsumed[lineNumber] then local commentAt = findUnquotedComment(rawLine) local effective = trim(commentAt ~= nil and rawLine:sub(1, commentAt - 1) or rawLine) if effective == "" then local bindCategory = rawLine:match( "^%s*#%s*Keymap bind%-category:%s*(.-)%s*$" ) if bindCategory ~= nil and bindCategory ~= "" then pendingBindCategory = bindCategory pendingMarkerLine = lineNumber pendingMarkerRaw = rawLine else local category = extractCategory(rawLine) if category ~= nil then currentCategory = category end end else local cleanLine, description = extractDescription(effective) local directive, value = cleanLine:match("^([%w%-]+)%s*=%s*(.*)$") if directive ~= nil then directive = directive:lower() if directive == "source" or directive == "source-optional" then local included, warning = resolveSource(dirname(path), value) if warning ~= nil then context.warnings[#context.warnings + 1] = warning elseif included ~= nil then parseFile(context, included, directive == "source-optional", depth + 1) end elseif directive == "keymode" then context.keymode = trim(value) ~= "" and trim(value) or "default" else local suffix = directive:match("^bind([lsrp]*)$") local kind = suffix ~= nil and "bind" or nil if kind == nil and (directive == "axisbind" or directive == "mousebind" or directive == "gesturebind" or directive == "switchbind") then kind, suffix = directive, "" end if kind ~= nil then local rawSnippet = pendingMarkerRaw ~= nil and (pendingMarkerRaw .. "\n" .. rawLine) or rawLine if not addBind( context, pendingBindCategory or currentCategory or context.defaultCategory, kind, suffix, splitCsv(value), description, path, lineNumber, rawSnippet, pendingMarkerLine ) then context.warnings[#context.warnings + 1] = "mangowc_invalid_bind:" .. path .. ":" .. tostring(lineNumber) end end end end pendingBindCategory = nil pendingMarkerLine = nil pendingMarkerRaw = nil end end end end local function cleanBind(bind) return { id = bind.id, modifiers = bind.modifiers, key = bind.key, description = bind.description, dispatcher = bind.dispatcher, mode = bind.mode, kind = bind.kind, flags = bind.flags, activation = type(bind.flags) == "table" and bind.flags.release == true and "release" or "press", command = bind.command, action = bind.action, managed = bind.managed == true, source = bind.source, line = bind.line, 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 startLine = first.start_line or first.line local endLine = first.end_line or first.line for _, item in ipairs(run) do local itemStart = item.bind.start_line or item.bind.line local itemEnd = item.bind.end_line or item.bind.line if itemStart ~= nil and (startLine == nil or itemStart < startLine) then startLine = itemStart end if itemEnd ~= nil and (endLine == nil or itemEnd > endLine) then endLine = itemEnd end end return { id = "range:" .. first.id .. ":" .. last.id, modifiers = first.modifiers, key = range, description = first.description:gsub("%f[%d]%d+%f[%D]", range, 1), dispatcher = first.dispatcher, mode = first.mode, kind = first.kind, flags = first.flags, activation = type(first.flags) == "table" and first.flags.release == true and "release" or "press", command = first.command, managed = first.managed == true, source = first.source, line = first.line, start_line = startLine, end_line = endLine, raw_snippet = "", fingerprint = "", -- A displayed numeric range can represent interleaved, non-contiguous -- source lines, so it deliberately cannot be edited as a single bind. capabilities = disabledEditCapabilities(), } end -- Merge interleaved numeric runs (for example view 1, tag 1, view 2, -- tag 2...). Mode is part of the signature so keymodes never bleed together. 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({ tostring(bind.mode or "default"), table.concat(bind.modifiers or {}, "+"), tostring(bind.dispatcher or ""), 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) return a.number == b.number and a.index < b.index or 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 if cursor - runStart >= 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 parseConfig(source) local context = { defaultCategory = noctalia.tr("category.other"), filesRead = 0, files = {}, visited = {}, warnings = {}, categories = {}, byCategory = {}, categoryIds = {}, bindIds = {}, total = 0, hidden = {}, keymode = "default", fatalError = nil, } parseFile(context, source, false, 0) if config("merge_sequential", true) == true then for _, category in ipairs(context.categories) do category.binds = mergeSequential(category.binds) end else for _, category in ipairs(context.categories) do local clean = {} for _, bind in ipairs(category.binds) do clean[#clean + 1] = cleanBind(bind) end category.binds = clean end end return context end local function snapshot(status, source, errorCode, categories, total, warnings, updatedAt, hidden) return { status = status, error = errorCode or "", compositor = "MangoWC", 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() if not isActive() then return end if refreshing then refreshQueued = true return end refreshing = true local source = configPath() -- 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, {}, "", {}) ) local parsed = parseConfig(source) if #parsed.files == 0 then publishError(source, "mangowc_config_unreadable", parsed.warnings) finishRefresh() return end if parsed.fatalError ~= nil then publishError(source, parsed.fatalError, parsed.warnings) finishRefresh() return end if parsed.total == 0 and #parsed.hidden == 0 then publishError(source, "mangowc_no_binds", parsed.warnings) finishRefresh() return end noctalia.state.set( SNAPSHOT_KEY, snapshot("ready", source, "", parsed.categories, parsed.total, parsed.warnings, os.date("%H:%M:%S"), parsed.hidden) ) finishRefresh() end function onIpc(event, _payload) if event == "refresh" then refresh() end end function onConfigChanged() refresh() end noctalia.state.watch(REFRESH_REQUEST_KEY, function(_request) refresh() end) refresh()