From dc6494acd374752b09bd75ddd8bf1887ba3e264c Mon Sep 17 00:00:00 2001 From: MateusAquino Date: Mon, 10 Aug 2026 09:56:40 -0300 Subject: [PATCH 1/2] fix(keymap): repair refreshing behavior (niri), options not working and key rendering - Refresh requests issued while the panel was closed replayed on reopen and ran the parser inside the 25ms watch-callback budget, aborting mid-parse and pinning the snapshot at "loading" forever; refreshes now defer to the service update tick and the parsers run as coroutines with wall-clock-throttled slices. - merge_sequential silently never merged numbered Niri runs (every bind carries a fingerprint, which disabled merging); show_undescribed now also hides Niri binds with an empty or null hotkey-overlay-title. - Wide chords and merged rows render as distinct key pills (one line per combination) instead of collapsing into a single unreadable label. - Added merge_similar setting: same-action shortcuts fold into one read-only row listing every combo. - Unified the key display-name tables across Hyprland, Niri, and MangoWC. --- keymap/CHANGELOG.md | 36 +++ keymap/README.md | 3 +- keymap/mangowc_service.luau | 240 ++++++++++++-- keymap/niri_service.luau | 436 +++++++++++++++++++------- keymap/panel.luau | 113 ++++--- keymap/plugin.toml | 9 +- keymap/service.luau | 139 +++++++- keymap/tests/hypr_cpu_budget_test.lua | 7 +- keymap/tests/merge_similar_test.lua | 107 +++++++ keymap/tests/niri_cpu_budget_test.lua | 7 + keymap/tests/niri_scanner_test.lua | 101 ++++++ keymap/tests/niri_settings_test.lua | 165 ++++++++++ keymap/translations/en.json | 8 +- 13 files changed, 1166 insertions(+), 205 deletions(-) create mode 100644 keymap/tests/merge_similar_test.lua create mode 100644 keymap/tests/niri_scanner_test.lua create mode 100644 keymap/tests/niri_settings_test.lua diff --git a/keymap/CHANGELOG.md b/keymap/CHANGELOG.md index 3d62d32b..d2cc8f93 100644 --- a/keymap/CHANGELOG.md +++ b/keymap/CHANGELOG.md @@ -2,6 +2,42 @@ All notable changes to Keymap are documented in this file. +## [1.5.0] - 2026-08-10 + +### Added + +- New `merge_similar` setting: shortcuts that trigger the same action (for + example "Close Window" on Super+W and Alt+F4) collapse into a single + read-only row listing every key combination. + +### Fixed + +- Fixed panel stuck on "Loading keybindings" after reopening: refreshes were + replaying the full request backlog. Refresh execution is also deferred + from the 25 ms state-watch callback into the service's own update tick, so + a parse can no longer be aborted by a shared callback budget mid-flight; +- Fixed missing debugging logs/errors: Internal parser errors now surface their + actual Lua error text on the error panel instead of an opaque "unknown error"; +- Fixed `show_undescribed=false` option, which now also hides Niri binds whose + `hotkey-overlay-title` is missing, empty (`""`), or `null` values; +- Fixed `merge_sequential` option, which now correctly merges numbered runs (such + as Workspace 1–9); +- Fixed long keybindings being replaced with a single merged keybind. + +### Changed + +- Unified the key display-name tables across Hyprland, Niri, and MangoWC so + the same key reads identically for every compositor (including missing + `XF86Calculator`, `XF86Mail`, touchpad scrolls, and punctuation). + +### Tests + +- Added `niri_settings_test.lua` covering sequential merging, + undescribed-title filtering, similar-action merging, and refresh-watcher + echo suppression. +- Added `merge_similar_test.lua` covering MangoWC and Hyprland similar-action + merging plus Hyprland watcher echo suppression. + ## [1.3.4] - 2026-07-29 ### Fixed diff --git a/keymap/README.md b/keymap/README.md index aa43a5bf..003eee70 100644 --- a/keymap/README.md +++ b/keymap/README.md @@ -288,7 +288,8 @@ them automatically. | `niri_config` | `~/.config/niri/config.kdl` | Niri KDL root. | | `mangowc_config` | `~/.config/mango/config.conf` | MangoWC config root. | | `merge_sequential` | `true` | Fold related numbered shortcuts into one row. | -| `show_undescribed` | `true` | Show Hyprland binds without descriptions. | +| `merge_similar` | `false` | Fold shortcuts that trigger the same action into one read-only row. | +| `show_undescribed` | `true` | Show Hyprland binds without descriptions and Niri binds without a `hotkey-overlay-title`. | | `keyboard_layout` | `100` | Default 100%, 96%, 80%, 75%, 65%, or 60% view. | | `columns` | `3` | One to four balanced category columns. | | `card_color` / `card_opacity` | `surface_variant` / `35` | Card background role or custom color and opacity. | diff --git a/keymap/mangowc_service.luau b/keymap/mangowc_service.luau index f703a376..4ac996d8 100644 --- a/keymap/mangowc_service.luau +++ b/keymap/mangowc_service.luau @@ -15,6 +15,35 @@ local EXACT_SOURCE_FINGERPRINT = "exact-v1" local refreshing = false local refreshQueued = false +local lastRefreshRequest = nil +local pendingRefresh = false +local parseCoroutine = nil +local parseSource = "" +local parseUpdatedAt = "" +-- The host gives each callback a 25ms CPU budget. Large MangoWC trees exceed +-- that when parsed in one go inside the instrumented VM, so the parser yields +-- to the next update tick once a slice gets expensive. +local PARSE_LINE_SLICE = 40 +-- Prefer a wall-clock slice when the sandbox exposes os.clock: the host's +-- interrupt meter makes per-line costs unpredictable, and overrunning the +-- budget aborts the whole refresh. +local PARSE_SLICE_SECONDS = 0.012 +local sliceStartedAt = nil + +local function noteSliceStart() + if type(os) == "table" and type(os.clock) == "function" then + sliceStartedAt = os.clock() + else + sliceStartedAt = nil + end +end + +local function sliceExpired() + if sliceStartedAt == nil then + return false + end + return os.clock() - sliceStartedAt >= PARSE_SLICE_SECONDS +end local function config(key, fallback) local value = noctalia.getConfig(key) @@ -219,21 +248,30 @@ end -- Locate an ordinary inline comment while preserving the plugin convention -- #"description" and quoted hashes in shell commands. local function findUnquotedComment(line) + -- find-based token hop: the per-character sub() loop was the parser's + -- dominant cost under the host's interrupt-driven CPU meter. + if line:find('[#"\'\\]') == nil then + return nil + end 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 + local index = 1 + while index <= #line do + local nextAt = line:find('[#"\'\\]', index) + if nextAt == nil then return nil end + local char = line:sub(nextAt, nextAt) + if char == "\\" and (inSingle or inDouble) then + index = nextAt + 2 elseif char == "'" and not inDouble then inSingle = not inSingle + index = nextAt + 1 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 + index = nextAt + 1 + elseif char == "#" and not inSingle and not inDouble and line:sub(nextAt + 1, nextAt + 1) ~= '"' then + return nextAt + else + index = nextAt + 1 end end return nil @@ -281,16 +319,32 @@ local function extractCategory(line) return trim(rest:match("^%d+%.%s*(.+)$") or rest) end +-- Display names shared with the Hyprland and Niri services. Keep the three +-- tables identical so a key reads the same no matter which compositor reports +-- it. 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 = "]", + LEFT = "Left", RIGHT = "Right", UP = "Up", DOWN = "Down", + WHEELSCROLLUP = "Scroll Up", WHEELSCROLLDOWN = "Scroll Down", + WHEELSCROLLLEFT = "Scroll Left", WHEELSCROLLRIGHT = "Scroll Right", + TOUCHPADSCROLLUP = "Touchpad Up", TOUCHPADSCROLLDOWN = "Touchpad Down", + TOUCHPADSCROLLLEFT = "Touchpad Left", TOUCHPADSCROLLRIGHT = "Touchpad Right", + MOUSELEFT = "Left Click", MOUSERIGHT = "Right Click", MOUSEMIDDLE = "Middle Click", + MOUSEFORWARD = "Mouse Forward", MOUSEBACK = "Mouse Back", + 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", XF86MONBRIGHTNESSUP = "Bright Up", - XF86MONBRIGHTNESSDOWN = "Bright Down", + 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 AXIS_NAMES = { @@ -685,6 +739,9 @@ local function parseFile(context, path, optional, depth) end for rawLine in (source .. "\n"):gmatch("([^\n]*)\n") do lineNumber = lineNumber + 1 + -- Yield once a slice gets expensive so a multi-file parse never holds + -- the callback past its budget; update() resumes on the next tick. + if lineNumber % PARSE_LINE_SLICE == 0 and sliceExpired() then coroutine.yield() end if not hiddenConsumed[lineNumber] then local commentAt = findUnquotedComment(rawLine) local effective = trim(commentAt ~= nil and rawLine:sub(1, commentAt - 1) or rawLine) @@ -860,6 +917,65 @@ local function mergeSequential(binds) return output end +-- Collapse binds that trigger the same action into one row carrying every +-- key combination. Grouped rows lose edit provenance on purpose: one row +-- cannot be rewritten to a single source location. +local function mergeSimilar(binds) + local grouped = {} + local order = {} + for index, bind in ipairs(binds) do + local signature = "action:" .. tostring(bind.action) + local group = grouped[signature] + if group == nil then + group = { first = index, parts = {} } + grouped[signature] = group + order[#order + 1] = signature + end + group.parts[#group.parts + 1] = bind + end + local mergedAt = {} + local swallowed = {} + for _, signature in ipairs(order) do + local group = grouped[signature] + if #group.parts > 1 then + local first = group.parts[1] + local combos = {} + local ids = {} + for _, part in ipairs(group.parts) do + combos[#combos + 1] = { modifiers = part.modifiers, key = part.key } + ids[#ids + 1] = part.id + end + mergedAt[group.first] = { + id = "similar:" .. table.concat(ids, "|"), + modifiers = {}, + key = combos[1].key, + combos = combos, + description = first.description, + dispatcher = first.dispatcher, + mode = first.mode, + kind = first.kind, + flags = first.flags, + activation = first.activation, + command = first.command, + action = first.action, + capabilities = disabledEditCapabilities(), + } + for partIndex = 2, #group.parts do + swallowed[group.parts[partIndex]] = true + end + end + end + local output = {} + for index, bind in ipairs(binds) do + if mergedAt[index] ~= nil then + output[#output + 1] = mergedAt[index] + elseif not swallowed[bind] then + output[#output + 1] = bind + end + end + return output +end + local function parseConfig(source) local context = { defaultCategory = noctalia.tr("category.other"), @@ -868,6 +984,7 @@ local function parseConfig(source) keymode = "default", fatalError = nil, } parseFile(context, source, false, 0) + local shouldMergeSimilar = config("merge_similar", false) == true if config("merge_sequential", true) == true then for _, category in ipairs(context.categories) do category.binds = mergeSequential(category.binds) @@ -879,12 +996,17 @@ local function parseConfig(source) category.binds = clean end end + if shouldMergeSimilar then + for _, category in ipairs(context.categories) do + category.binds = mergeSimilar(category.binds) + 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, + status = status, error = errorCode or "", error_detail = "", compositor = "MangoWC", source = source, updated_at = updatedAt or "", total = total or 0, categories = categories or {}, warnings = warnings or {}, hidden = hidden or {}, @@ -917,6 +1039,8 @@ function refresh() refreshing = true local source = configPath() + parseSource = source + parseUpdatedAt = os.date("%H:%M:%S") -- 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( @@ -924,39 +1048,95 @@ function refresh() snapshot("loading", source, "", {}, 0, {}, "", {}) ) - local parsed = parseConfig(source) - if #parsed.files == 0 then - publishError(source, "mangowc_config_unreadable", parsed.warnings) - finishRefresh() + parseCoroutine = coroutine.create(function() + local parsed = parseConfig(source) + if #parsed.files == 0 then + noctalia.state.set(SNAPSHOT_KEY, snapshot("error", source, "mangowc_config_unreadable", {}, 0, parsed.warnings, parseUpdatedAt)) + return + end + if parsed.fatalError ~= nil then + noctalia.state.set(SNAPSHOT_KEY, snapshot("error", source, parsed.fatalError, {}, 0, parsed.warnings, parseUpdatedAt)) + return + end + if parsed.total == 0 and #parsed.hidden == 0 then + noctalia.state.set(SNAPSHOT_KEY, snapshot("error", source, "mangowc_no_binds", {}, 0, parsed.warnings, parseUpdatedAt)) + return + end + noctalia.state.set( + SNAPSHOT_KEY, + snapshot("ready", source, "", parsed.categories, parsed.total, parsed.warnings, parseUpdatedAt, parsed.hidden) + ) + end) + -- Resume once within this budget; a large config suspends and continues on + -- the next update tick instead of blowing the 25ms callback budget. + pumpParse() +end + +-- Resume the in-flight parse until it either finishes or yields. Called from +-- refresh() and from update() ticks, so every slice runs inside its own +-- budget window. +function pumpParse() + if parseCoroutine == nil then return end - if parsed.fatalError ~= nil then - publishError(source, parsed.fatalError, parsed.warnings) + noteSliceStart() + local ok, err = coroutine.resume(parseCoroutine) + if not ok then + parseCoroutine = nil + local failed = snapshot("error", parseSource, "mangowc_parse_failed", {}, 0, {}, parseUpdatedAt) + failed.error_detail = tostring(err) + noctalia.state.set(SNAPSHOT_KEY, failed) finishRefresh() return end - if parsed.total == 0 and #parsed.hidden == 0 then - publishError(source, "mangowc_no_binds", parsed.warnings) + if coroutine.status(parseCoroutine) == "dead" then + parseCoroutine = nil 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() + -- Still suspended: left parked for the next update() tick. end function onIpc(event, _payload) - if event == "refresh" then refresh() end + 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() + pendingRefresh = true +end + +-- The watch callback only records intent and acknowledges the request with a +-- cheap loading marker: parsing inside the 25ms callback slot risks +-- exhausting the budget when other work shares it, and a budget abort there +-- would leave the snapshot stuck at "loading". The periodic update tick +-- performs the actual refresh in slices, one budget window at a time. +function update() + if pendingRefresh then + pendingRefresh = false + refresh() + elseif parseCoroutine ~= nil then + pumpParse() + end end -noctalia.state.watch(REFRESH_REQUEST_KEY, function(_request) - refresh() +-- Refresh requests issued before this service loaded are covered by the boot +-- refresh below; the watcher only reacts to genuinely new counter values, +-- ignoring host echoes of a value this service already handled. +lastRefreshRequest = noctalia.state.get(REFRESH_REQUEST_KEY) +noctalia.state.watch(REFRESH_REQUEST_KEY, function(request) + if request ~= lastRefreshRequest then + lastRefreshRequest = request + pendingRefresh = true + -- Acknowledge immediately so the panel leaves its ready state even + -- though the parse itself waits for the update tick. + if isActive() then + local current = noctalia.state.get(SNAPSHOT_KEY) + local source = type(current) == "table" and current.source or nil + noctalia.state.set(SNAPSHOT_KEY, snapshot("loading", source, "", {}, 0, {}, "", {})) + end + end end) refresh() diff --git a/keymap/niri_service.luau b/keymap/niri_service.luau index 06b380e0..d4857cb8 100644 --- a/keymap/niri_service.luau +++ b/keymap/niri_service.luau @@ -9,9 +9,38 @@ local MAX_FILES = 64 local MAX_SOURCE_BYTES = 512 * 1024 local MAX_HIDDEN_BYTES = 2 * 1024 * 1024 local EXACT_SOURCE_FINGERPRINT = "exact-v1" +-- The host gives each callback a 25ms CPU budget. Real Niri configs with +-- includes exceed that when parsed in one go inside the instrumented VM, so +-- the parser yields to the next update tick once a slice gets expensive. +local PARSE_LINE_SLICE = 40 +-- Prefer a wall-clock slice when the sandbox exposes os.clock: the host's +-- interrupt meter makes per-line costs unpredictable, and overrunning the +-- budget aborts the whole refresh. +local PARSE_SLICE_SECONDS = 0.012 +local sliceStartedAt = nil + +local function noteSliceStart() + if type(os) == "table" and type(os.clock) == "function" then + sliceStartedAt = os.clock() + else + sliceStartedAt = nil + end +end + +local function sliceExpired() + if sliceStartedAt == nil then + return false + end + return os.clock() - sliceStartedAt >= PARSE_SLICE_SECONDS +end local refreshing = false local refreshQueued = false +local lastRefreshRequest = nil +local pendingRefresh = false +local parseCoroutine = nil +local parseSource = "" +local parseUpdatedAt = "" local function config(key, fallback) local value = noctalia.getConfig(key) @@ -185,70 +214,92 @@ end -- Returns executable code and a trailing // comment. Comment markers inside -- strings are preserved. KDL block comments remain stateful across lines. +-- Every host call runs under an interrupt-driven CPU budget, so this stays +-- find-based: character loops over plain code are the parser's hot path. local function stripComments(line, inBlockComment) - local code = {} - local comment = nil - local quote = nil - local escaped = false - local index = 1 - while index <= #line do - local char = line:sub(index, index) - local pair = line:sub(index, index + 1) - if inBlockComment then - if pair == "*/" then - inBlockComment = false - index = index + 2 - else - index = index + 1 - end - elseif quote ~= nil then - code[#code + 1] = char - if escaped then - escaped = false - elseif char == "\\" then - escaped = true - elseif char == quote then - quote = nil - end - index = index + 1 - elseif char == '"' or char == "'" then - quote = char - code[#code + 1] = char - index = index + 1 - elseif pair == "//" then - comment = line:sub(index + 2) - break - elseif pair == "/*" then - inBlockComment = true - index = index + 2 + if not inBlockComment and line:find('[/"\'\']') == nil then + return line, nil, false + end + if inBlockComment then + local closeAt = line:find("*/", 1, true) + if closeAt == nil then + return "", nil, true + end + local rest, comment, stillInBlock = stripComments(line:sub(closeAt + 2), false) + return rest, comment, stillInBlock + end + -- Find the first interesting token: comment opener or quote. + local slashAt = line:find("//", 1, true) + local blockAt = line:find("/*", 1, true) + local quoteAt = line:find('["\']') + local cut = nil + if slashAt ~= nil and (cut == nil or slashAt < cut) then cut = slashAt end + if blockAt ~= nil and (cut == nil or blockAt < cut) then cut = blockAt end + if quoteAt ~= nil and (cut == nil or quoteAt < cut) then cut = quoteAt end + if cut == nil then + return line, nil, false + end + if slashAt == cut then + return line:sub(1, cut - 1), line:sub(cut + 2), false + end + if blockAt == cut then + local rest, comment, stillInBlock = stripComments(line:sub(cut + 2), true) + return line:sub(1, cut - 1) .. rest, comment, stillInBlock + end + -- Quote first: find its closing quote, then re-scan the remainder. + local quote = line:sub(cut, cut) + local scan = cut + 1 + while scan <= #line do + local nextAt = line:find('[%\\' .. quote .. ']', scan) + if nextAt == nil then + -- Unterminated quote: treat the rest as literal code. + return line, nil, false + end + local char = line:sub(nextAt, nextAt) + if char == "\\" then + scan = nextAt + 2 else - code[#code + 1] = char - index = index + 1 + local rest, comment, inBlock = stripComments(line:sub(nextAt + 1), false) + return line:sub(1, nextAt) .. rest, comment, inBlock end end - return table.concat(code), comment, inBlockComment + return line, nil, false end local function braceDelta(text) local delta = 0 local quote = nil local escaped = false - for index = 1, #text do - local char = text:sub(index, index) + -- find-based token hop: orders of magnitude cheaper under the host's + -- interrupt-driven CPU meter than one sub() call per character. + if text:find('[{}"\'\\]') == nil then + return 0 + end + local index = 1 + while index <= #text do + local char if quote ~= nil then - if escaped then - escaped = false - elseif char == "\\" then - escaped = true - elseif char == quote then + local nextAt = text:find('[%\\' .. quote .. ']', index) + if nextAt == nil then break end + char = text:sub(nextAt, nextAt) + if char == "\\" then + index = nextAt + 2 + else quote = nil + index = nextAt + 1 end - elseif char == '"' or char == "'" then - quote = char - elseif char == "{" then - delta = delta + 1 - elseif char == "}" then - delta = delta - 1 + else + local nextAt = text:find('[{}"\'\\]', index) + if nextAt == nil then break end + char = text:sub(nextAt, nextAt) + if char == '"' or char == "'" then + quote = char + elseif char == "{" then + delta = delta + 1 + elseif char == "}" then + delta = delta - 1 + end + index = nextAt + 1 end end return delta @@ -256,21 +307,32 @@ end local function firstOpenBrace(text) local quote = nil - local escaped = false - for index = 1, #text do - local char = text:sub(index, index) + if text:find('[{"\'\\]', 1) == nil then + return nil + end + local index = 1 + while index <= #text do if quote ~= nil then - if escaped then - escaped = false - elseif char == "\\" then - escaped = true - elseif char == quote then + local nextAt = text:find('[%\\' .. quote .. ']', index) + if nextAt == nil then return nil end + if text:sub(nextAt, nextAt) == "\\" then + index = nextAt + 2 + else quote = nil + index = nextAt + 1 + end + else + local nextAt = text:find('[{"\'\\]', index) + if nextAt == nil then return nil end + local char = text:sub(nextAt, nextAt) + if char == '"' or char == "'" then + quote = char + index = nextAt + 1 + elseif char == "{" then + return nextAt + else + index = nextAt + 1 end - elseif char == '"' or char == "'" then - quote = char - elseif char == "{" then - return index end end return nil @@ -279,26 +341,32 @@ end local function contentBeforeOuterClose(text) local depth = 1 local quote = nil - local escaped = false - for index = 1, #text do - local char = text:sub(index, index) + local index = 1 + while index <= #text do if quote ~= nil then - if escaped then - escaped = false - elseif char == "\\" then - escaped = true - elseif char == quote then + local nextAt = text:find('[%\\' .. quote .. ']', index) + if nextAt == nil then return text end + if text:sub(nextAt, nextAt) == "\\" then + index = nextAt + 2 + else quote = nil + index = nextAt + 1 end - elseif char == '"' or char == "'" then - quote = char - elseif char == "{" then - depth = depth + 1 - elseif char == "}" then - depth = depth - 1 - if depth == 0 then - return text:sub(1, index - 1) + else + local nextAt = text:find('[{}"\'\\]', index) + if nextAt == nil then return text end + local char = text:sub(nextAt, nextAt) + if char == '"' or char == "'" then + quote = char + elseif char == "{" then + depth = depth + 1 + elseif char == "}" then + depth = depth - 1 + if depth == 0 then + return text:sub(1, nextAt - 1) + end end + index = nextAt + 1 end end return text @@ -324,20 +392,34 @@ local MODIFIER_ALIASES = { ISO_Level5_Shift = "ISO_Level5_Shift", } +-- Display names shared with the Hyprland and MangoWC services. Keep the three +-- tables identical so a key reads the same no matter which compositor reports +-- it. 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", + EQUAL = "=", MINUS = "-", PLUS = "+", + COMMA = ",", PERIOD = ".", SEMICOLON = ";", APOSTROPHE = "'", GRAVE = "`", + SLASH = "/", BACKSLASH = "\\", WHEELSCROLLUP = "Scroll Up", WHEELSCROLLDOWN = "Scroll Down", WHEELSCROLLLEFT = "Scroll Left", WHEELSCROLLRIGHT = "Scroll Right", TOUCHPADSCROLLUP = "Touchpad Up", TOUCHPADSCROLLDOWN = "Touchpad Down", TOUCHPADSCROLLLEFT = "Touchpad Left", TOUCHPADSCROLLRIGHT = "Touchpad Right", MOUSELEFT = "Left Click", MOUSERIGHT = "Right Click", MOUSEMIDDLE = "Middle Click", MOUSEFORWARD = "Mouse Forward", MOUSEBACK = "Mouse Back", + 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", + 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 splitCombo(combo) @@ -557,11 +639,16 @@ local function hiddenTarget(block, path, startLine, endLine, inheritedCategory) } end +-- Clean records stay editable, so numeric runs keep their source provenance +-- through the displayed range (the writer re-checks every constituent snippet +-- before replacing the whole run). + local function cleanBind(bind) return { id = bind.id, modifiers = bind.modifiers, key = bind.key, description = bind.description, dispatcher = bind.dispatcher, activation = "press", command = bind.command, action = bind.action, + has_overlay_title = bind.has_overlay_title, source = bind.source, start_line = bind.start_line, end_line = bind.end_line, raw_snippet = bind.raw_snippet, fingerprint = bind.fingerprint, managed = bind.managed == true, capabilities = bind.capabilities, @@ -608,11 +695,7 @@ local function mergeSequential(binds) end local output = {} for index, bind in ipairs(binds) do - -- An editable range cannot safely carry the provenance of one real bind. - -- Preserve every effective record individually once provenance is present. - if bind.fingerprint ~= nil then - output[#output + 1] = cleanBind(bind) - elseif replacements[index] ~= nil then + if replacements[index] ~= nil then output[#output + 1] = replacements[index] elseif not skipped[index] then output[#output + 1] = cleanBind(bind) @@ -621,14 +704,77 @@ local function mergeSequential(binds) return output end +-- Collapse binds that trigger the same action into one row carrying every +-- key combination (for example "Close Window" on Super+W and Alt+F4). +-- Grouped rows lose edit provenance on purpose: one row cannot be rewritten +-- to a single source location. +local function mergeSimilar(binds) + local grouped = {} + local order = {} + for index, bind in ipairs(binds) do + local signature = (bind.has_overlay_title == true and "title:" or "action:") + .. (bind.has_overlay_title == true and bind.description or bind.action) + local group = grouped[signature] + if group == nil then + group = { first = index, parts = {} } + grouped[signature] = group + order[#order + 1] = signature + end + group.parts[#group.parts + 1] = bind + end + local mergedAt = {} + local swallowed = {} + for _, signature in ipairs(order) do + local group = grouped[signature] + if #group.parts > 1 then + local first = group.parts[1] + local combos = {} + local ids = {} + for _, part in ipairs(group.parts) do + combos[#combos + 1] = { modifiers = part.modifiers, key = part.key } + ids[#ids + 1] = part.id + end + mergedAt[group.first] = { + id = "similar:" .. table.concat(ids, "|"), + modifiers = {}, + key = combos[1].key, + combos = combos, + description = first.description, + dispatcher = first.dispatcher, + activation = "press", + command = first.command, + action = first.action, + capabilities = { + combo = false, category = false, description = false, + command = false, activation = false, + }, + } + for partIndex = 2, #group.parts do + swallowed[group.parts[partIndex]] = true + end + end + end + local output = {} + for index, bind in ipairs(binds) do + if mergedAt[index] ~= nil then + output[#output + 1] = mergedAt[index] + elseif not swallowed[bind] then + output[#output + 1] = bind + end + end + return output +end + local function buildCategories(records) + local showUndescribed = config("show_undescribed", true) == true local shouldMerge = config("merge_sequential", true) == true + local shouldMergeSimilar = config("merge_similar", false) == true local byName = {} local order = {} local seen = {} local total = 0 for _, bind in ipairs(records) do - if not bind._overridden then + if not bind._overridden and (showUndescribed or bind.has_overlay_title ~= false) then if not seen[bind._category] then seen[bind._category] = true order[#order + 1] = bind._category @@ -658,6 +804,9 @@ local function buildCategories(records) output[#output + 1] = cleanBind(bind) end end + if shouldMergeSimilar then + output = mergeSimilar(output) + end categories[#categories + 1] = { id = id, name = name, binds = output } end return categories, total @@ -674,10 +823,9 @@ local function parseBind(combo, attributes, action, category, records, activeByC if previous ~= nil then previous._overridden = true end - local description = titleAttribute(attributes) - if description == nil or description == "" then - description = actionDescription(normalizedAction, verb) - end + local overlayTitle = titleAttribute(attributes) + local hasOverlayTitle = overlayTitle ~= nil and overlayTitle ~= "" + local description = hasOverlayTitle and overlayTitle or actionDescription(normalizedAction, verb) local command = "" if verb == "spawn-sh" then command = quotedLiteral(normalizedAction:sub(#verb + 1), 1) or "" @@ -687,6 +835,7 @@ local function parseBind(combo, attributes, action, category, records, activeByC id = stableBindId({ signature, verb, normalizedAction }), modifiers = modifiers, key = key, description = description, dispatcher = verb, activation = "press", command = command, action = normalizedAction, + has_overlay_title = hasOverlayTitle, source = provenance.source, start_line = provenance.start_line, end_line = provenance.end_line, raw_snippet = rawSnippet, fingerprint = EXACT_SOURCE_FINGERPRINT, @@ -804,6 +953,10 @@ local function parseConfig(root, rootSource) for rawLine in (source .. "\n"):gmatch("([^\n]*)\n") do lineNumber = lineNumber + 1 + -- Yield once a slice gets expensive so a multi-file parse never + -- holds the callback past its budget; update() resumes on the + -- next tick with a fresh window. + if lineNumber % PARSE_LINE_SLICE == 0 and sliceExpired() then coroutine.yield() end local code, comment code, comment, inBlockComment = stripComments(rawLine, inBlockComment) local stripped = trim(code) @@ -891,10 +1044,11 @@ local function parseConfig(root, rootSource) return categories, total, hidden, warnings, fatalError end -local function snapshot(status, source, errorCode, categories, total, warnings, updatedAt, hidden) +local function snapshot(status, source, errorCode, categories, total, warnings, updatedAt, hidden, errorDetail) return { status = status, error = errorCode or "", + error_detail = errorDetail or "", compositor = "Niri", source = source, updated_at = updatedAt or "", @@ -925,9 +1079,11 @@ function refresh() end refreshing = true local source = sourcePath() + parseSource = source + parseUpdatedAt = os.date("%H:%M:%S") -- The panel keeps its last ready snapshot while this status is loading. -- Do not copy the full bind tree through the shared-state serializer merely - -- to replace it again at the end of this synchronous refresh. + -- to replace it again at the end of this refresh. noctalia.state.set( SNAPSHOT_KEY, snapshot("loading", source, "", {}, 0, {}, "", {}) @@ -937,49 +1093,103 @@ function refresh() if type(rootSource) ~= "string" then noctalia.state.set( SNAPSHOT_KEY, - snapshot("error", source, "niri_config_unreadable", {}, 0, {}, os.date("%H:%M:%S")) + snapshot("error", source, "niri_config_unreadable", {}, 0, {}, parseUpdatedAt) ) finishRefresh() return end - local categories, total, hidden, warnings, fatalError = parseConfig(source, rootSource) - if fatalError ~= nil then - noctalia.state.set( - SNAPSHOT_KEY, - snapshot("error", source, fatalError, {}, 0, warnings, os.date("%H:%M:%S")) - ) - elseif total == 0 and #hidden == 0 then - noctalia.state.set( - SNAPSHOT_KEY, - snapshot("error", source, "niri_no_binds", {}, 0, warnings, os.date("%H:%M:%S")) - ) - else + parseCoroutine = coroutine.create(function() + local categories, total, hidden, warnings, fatalError = parseConfig(source, rootSource) + if fatalError ~= nil then + noctalia.state.set( + SNAPSHOT_KEY, + snapshot("error", source, fatalError, {}, 0, warnings, parseUpdatedAt) + ) + elseif total == 0 and #hidden == 0 then + noctalia.state.set( + SNAPSHOT_KEY, + snapshot("error", source, "niri_no_binds", {}, 0, warnings, parseUpdatedAt) + ) + else + noctalia.state.set( + SNAPSHOT_KEY, + snapshot("ready", source, "", categories, total, warnings, parseUpdatedAt, hidden) + ) + end + end) + -- Resume once within this budget; a large config suspends and continues on + -- the next update tick instead of blowing the 25ms callback budget. + pumpParse() +end + +-- Resume the in-flight parse until it either finishes or yields. Called from +-- refresh() and from update() ticks, so every slice runs inside its own +-- budget window. +function pumpParse() + if parseCoroutine == nil then + return + end + noteSliceStart() + local ok, err = coroutine.resume(parseCoroutine) + if not ok then + parseCoroutine = nil noctalia.state.set( SNAPSHOT_KEY, - snapshot("ready", source, "", categories, total, warnings, os.date("%H:%M:%S"), hidden) + snapshot("error", parseSource, "niri_parse_failed", {}, 0, {}, parseUpdatedAt, {}, tostring(err)) ) + finishRefresh() + return + end + if coroutine.status(parseCoroutine) == "dead" then + parseCoroutine = nil + finishRefresh() end - finishRefresh() + -- Still suspended: left parked for the next update() tick. end function onIpc(event, _payload) if event == "refresh" then - refresh() + local current = tonumber(noctalia.state.get(REFRESH_REQUEST_KEY)) or 0 + noctalia.state.set(REFRESH_REQUEST_KEY, current + 1) end end function onConfigChanged() - refresh() + pendingRefresh = true end --- Manual lifecycle: refreshes are driven by initial load, config changes, IPC, --- and the shared refresh request. The host's periodic update hook is a no-op. +-- The watch callback only records intent and acknowledges the request with a +-- cheap loading marker: parsing inside the 25ms callback slot risks +-- exhausting the budget when other work shares it, and a budget abort there +-- would leave the snapshot stuck at "loading". The periodic update tick +-- performs the actual refresh in slices, one budget window at a time. function update() + if pendingRefresh then + pendingRefresh = false + refresh() + elseif parseCoroutine ~= nil then + pumpParse() + end end -noctalia.state.watch(REFRESH_REQUEST_KEY, function(_request) - refresh() +-- Refresh requests issued before this service loaded are covered by the boot +-- refresh below; the watcher only reacts to genuinely new counter values, +-- ignoring host echoes of a value this service already handled. +lastRefreshRequest = noctalia.state.get(REFRESH_REQUEST_KEY) +noctalia.state.watch(REFRESH_REQUEST_KEY, function(request) + if request ~= lastRefreshRequest then + lastRefreshRequest = request + pendingRefresh = true + -- Acknowledge immediately so the panel leaves its ready state even + -- though the parse itself waits for the update tick. Keep the previous + -- categories out of the marker: the panel hides them while loading. + if detectedCompositor() == "niri" then + local current = noctalia.state.get(SNAPSHOT_KEY) + local source = type(current) == "table" and current.source or nil + noctalia.state.set(SNAPSHOT_KEY, snapshot("loading", source, "", {}, 0, {}, "", {})) + end + end end) refresh() diff --git a/keymap/panel.luau b/keymap/panel.luau index 28989a17..b6277380 100644 --- a/keymap/panel.luau +++ b/keymap/panel.luau @@ -337,19 +337,25 @@ local function keyboardIndex() local any = {} for _, category in ipairs(asArray(snapshot.categories)) do for _, bind in ipairs(asArray(category.binds)) do - local signature = modifierSignature(bind.modifiers) - local indexedKeys = type(bind.keys) == "table" and bind.keys or expandedKeys(bind.key) - for _, rawKey in ipairs(indexedKeys) do - local key = canonicalKey(rawKey) - local entry = { - bind = bind, - category = asString(category.name, tr("panel.uncategorized")), - } - local chord = signature .. "|" .. key - exact[chord] = exact[chord] or {} - exact[chord][#exact[chord] + 1] = entry - any[key] = any[key] or {} - any[key][#any[key] + 1] = entry + -- Merged rows carry every combination: index each one at its own chord + -- instead of pointing the display label at a phantom key. + local combos = type(bind.combos) == "table" and #bind.combos > 0 and bind.combos + or { { modifiers = bind.modifiers, key = bind.key, keys = bind.keys } } + for _, combo in ipairs(combos) do + local signature = modifierSignature(combo.modifiers) + local indexedKeys = type(combo.keys) == "table" and combo.keys or expandedKeys(combo.key) + for _, rawKey in ipairs(indexedKeys) do + local key = canonicalKey(rawKey) + local entry = { + bind = bind, + category = asString(category.name, tr("panel.uncategorized")), + } + local chord = signature .. "|" .. key + exact[chord] = exact[chord] or {} + exact[chord][#exact[chord] + 1] = entry + any[key] = any[key] or {} + any[key][#any[key] + 1] = entry + end end end end @@ -395,6 +401,14 @@ local function bindMatches(bind, needle) return true end end + if type(bind.combos) == "table" then + for _, combo in ipairs(bind.combos) do + if contains(combo.key, needle) then return true end + for _, modifier in ipairs(asArray(combo.modifiers)) do + if contains(modifier, needle) then return true end + end + end + end return false end @@ -2175,44 +2189,34 @@ end local function bindRow(bind, categoryName, columnCount, rowIndex) local width = keyWidth(columnCount) - local keys = {} - local tokens = {} - local estimatedWidth = 0 - for _, modifier in ipairs(asArray(bind.modifiers)) do - tokens[#tokens + 1] = asString(modifier, "?") - estimatedWidth = estimatedWidth + #asString(modifier, "?") * 7 + 18 - keys[#keys + 1] = keyPill(modifier, false) - end - local mainKey = asString(bind.key, "?") - tokens[#tokens + 1] = mainKey - estimatedWidth = estimatedWidth + #mainKey * 7 + 18 + math.max(0, #tokens - 1) * 4 - keys[#keys + 1] = keyPill(mainKey, true) + local combos = type(bind.combos) == "table" and #bind.combos > 0 and bind.combos + or { { modifiers = bind.modifiers, key = bind.key } } local keyArea - if estimatedWidth > width then - keyArea = ui.row({ - width = width, - fill = asString(cfg("key_color"), "surface"), - border = "outline", - borderWidth = 1, - radius = 7, - paddingH = 6, - paddingV = 2, - align = "center", - justify = "center", - }, { - ui.label({ - text = table.concat(tokens, " + "), - color = asString(cfg("key_text_color"), "on_surface"), - fontSize = 10, - fontWeight = "bold", - textAlign = "center", - maxWidth = width - 12, - maxLines = 2, - }), - }) - else + local single = #combos == 1 + if single then + local keys = {} + for _, modifier in ipairs(asArray(combos[1].modifiers)) do + keys[#keys + 1] = keyPill(modifier, false) + end + keys[#keys + 1] = keyPill(asString(combos[1].key, "?"), true) + -- Always render distinct pills: collapsing to a single bordered label made + -- wide chords like Super+Shift+Scroll Down read as one giant key. The row + -- sizes to its content and only wraps visually inside the fixed slot. keyArea = ui.row({ width = width, gap = 4, align = "center" }, keys) + else + -- One line of pills per key combination, so merged shortcuts still read + -- as distinct physical presses instead of one opaque label. + local lines = {} + for _, combo in ipairs(combos) do + local pills = {} + for _, modifier in ipairs(asArray(combo.modifiers)) do + pills[#pills + 1] = keyPill(modifier, false) + end + pills[#pills + 1] = keyPill(asString(combo.key, "?"), true) + lines[#lines + 1] = ui.row({ gap = 4, align = "center" }, pills) + end + keyArea = ui.column({ width = width, gap = 4 }, lines) end local description = asString(bind.description, tr("panel.no_description")) @@ -2517,6 +2521,11 @@ local function statusBody(status, errorText) if translatedError == errorKey then translatedError = tr("panel.unknown_error") end + -- Internal parser failures carry the raw Lua error for diagnosis. + local detail = asString(snapshot.error_detail) + if isError and detail ~= "" then + translatedError = translatedError .. "\n" .. detail + end return ui.column({ flexGrow = 1, align = "center", justify = "center", gap = 12 }, { ui.glyph({ name = isError and "alert-circle" or "refresh", @@ -2534,7 +2543,7 @@ local function statusBody(status, errorText) color = "on_surface_variant", textAlign = "center", maxWidth = 680, - maxLines = 4, + maxLines = 8, }), ui.label({ text = tr("panel.path_hint"), @@ -2836,7 +2845,11 @@ function onOpen(_context) hiddenDeleteConfirmBindId = "" clearCategoryRename() end - if snapshot.status == nil or snapshot.status == "idle" then + -- A "loading" snapshot observed at open time cannot still be in flight: the + -- service keeps running while the panel is closed, so any refresh it had + -- accepted has long since published. Treat it as stale (e.g. an interrupted + -- callback from an older plugin version) and request a fresh snapshot. + if snapshot.status == nil or snapshot.status == "idle" or snapshot.status == "loading" then requestRefresh() end render() diff --git a/keymap/plugin.toml b/keymap/plugin.toml index 3f43dfa5..428cd6e8 100644 --- a/keymap/plugin.toml +++ b/keymap/plugin.toml @@ -1,6 +1,6 @@ id = "blackbartblues/keymap" name = "Keymap" -version = "1.4.0" +version = "1.5.0" plugin_api = 9 author = "blackbartblues" license = "MIT" @@ -53,6 +53,13 @@ label_key = "settings.merge_sequential.label" description_key = "settings.merge_sequential.description" default = true +[[setting]] +key = "merge_similar" +type = "bool" +label_key = "settings.merge_similar.label" +description_key = "settings.merge_similar.description" +default = false + [[setting]] key = "show_undescribed" type = "bool" diff --git a/keymap/service.luau b/keymap/service.luau index 0d56ca99..4e353558 100644 --- a/keymap/service.luau +++ b/keymap/service.luau @@ -16,6 +16,8 @@ local refreshing = false local refreshQueued = false local refreshGeneration = 0 local snapshotCompositor = "Hyprland" +local lastRefreshRequest = nil +local pendingRefresh = false local function config(key, fallback) local value = noctalia.getConfig(key) @@ -640,6 +642,9 @@ local function decodeModifiers(value) return modifiers end +-- Display names shared with the Niri and MangoWC services. Keep the three +-- tables identical so a key reads the same no matter which compositor reports +-- it. local KEY_NAMES = { RETURN = "Enter", SPACE = "Space", @@ -653,6 +658,29 @@ local KEY_NAMES = { RIGHT = "Right", UP = "Up", DOWN = "Down", + EQUAL = "=", + MINUS = "-", + PLUS = "+", + COMMA = ",", + PERIOD = ".", + SEMICOLON = ";", + APOSTROPHE = "'", + GRAVE = "`", + SLASH = "/", + BACKSLASH = "\\", + WHEELSCROLLUP = "Scroll Up", + WHEELSCROLLDOWN = "Scroll Down", + WHEELSCROLLLEFT = "Scroll Left", + WHEELSCROLLRIGHT = "Scroll Right", + TOUCHPADSCROLLUP = "Touchpad Up", + TOUCHPADSCROLLDOWN = "Touchpad Down", + TOUCHPADSCROLLLEFT = "Touchpad Left", + TOUCHPADSCROLLRIGHT = "Touchpad Right", + MOUSELEFT = "Left Click", + MOUSERIGHT = "Right Click", + MOUSEMIDDLE = "Middle Click", + MOUSEFORWARD = "Mouse Forward", + MOUSEBACK = "Mouse Back", MOUSE_DOWN = "Scroll Down", MOUSE_UP = "Scroll Up", ["MOUSE:272"] = "Left Click", @@ -885,9 +913,72 @@ local function mergeSequential(binds) return output end +-- Collapse binds that trigger the same action into one row carrying every +-- key combination (for example "Close window" on Super+W and Alt+F4). +-- Grouped rows lose edit provenance on purpose: one row cannot be rewritten +-- to a single source location. +local function mergeSimilar(binds) + local grouped = {} + local order = {} + for index, bind in ipairs(binds) do + local signature = "action:" + .. tostring(bind.description) .. "\0" + .. tostring(bind.dispatcher) .. "\0" .. tostring(bind.command) .. "\0" .. tostring(bind.action) + local group = grouped[signature] + if group == nil then + group = { first = index, parts = {} } + grouped[signature] = group + order[#order + 1] = signature + end + group.parts[#group.parts + 1] = bind + end + local mergedAt = {} + local swallowed = {} + for _, signature in ipairs(order) do + local group = grouped[signature] + if #group.parts > 1 then + local first = group.parts[1] + local combos = {} + local ids = {} + for _, part in ipairs(group.parts) do + combos[#combos + 1] = { modifiers = part.modifiers, key = part.key } + ids[#ids + 1] = part.id + end + mergedAt[group.first] = { + id = "similar:" .. table.concat(ids, "|"), + modifiers = {}, + key = combos[1].key, + combos = combos, + description = first.description, + dispatcher = first.dispatcher, + activation = first.release == true and "release" or "press", + command = first.command, + action = first.action, + capabilities = { + combo = false, category = false, description = false, + command = false, activation = false, + }, + } + for partIndex = 2, #group.parts do + swallowed[group.parts[partIndex]] = true + end + end + end + local output = {} + for index, bind in ipairs(binds) do + if mergedAt[index] ~= nil then + output[#output + 1] = mergedAt[index] + elseif not swallowed[bind] then + output[#output + 1] = 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 shouldMergeSimilar = config("merge_similar", false) == true local otherName = noctalia.tr("category.other") local undescribedName = noctalia.tr("category.undescribed") local byName = {} @@ -971,12 +1062,17 @@ local function buildCategories(liveBinds, metadata) 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) + binds = (function() + local cleaned + if shouldMerge then + cleaned = mergeSequential(binds) + else + cleaned = {} + for _, bind in ipairs(binds) do + cleaned[#cleaned + 1] = cleanBind(bind) + end end - return output + return shouldMergeSimilar and mergeSimilar(cleaned) or cleaned end)(), } end @@ -1006,6 +1102,7 @@ local function snapshot(status, source, errorCode, categories, total, warnings, return { status = status, error = errorCode or "", + error_detail = "", compositor = snapshotCompositor, source = source, updated_at = updatedAt or "", @@ -1144,11 +1241,37 @@ function onIpc(event, _payload) end function onConfigChanged() - refresh() + pendingRefresh = true end -noctalia.state.watch(REFRESH_REQUEST_KEY, function(_request) - refresh() +-- The watch callback only records intent and acknowledges the request with a +-- cheap loading marker: starting hyprctl and scanning Lua sources inside the +-- 25ms callback slot risks exhausting the budget when other work shares it, +-- and a budget abort there would leave the snapshot stuck at "loading". The +-- periodic update tick performs the actual refresh in its own budget slot. +function update() + if pendingRefresh then + pendingRefresh = false + refresh() + end +end + +-- Refresh requests issued before this service loaded are covered by the boot +-- refresh below; the watcher only reacts to genuinely new counter values, +-- ignoring host echoes of a value this service already handled. +lastRefreshRequest = noctalia.state.get(REFRESH_REQUEST_KEY) +noctalia.state.watch(REFRESH_REQUEST_KEY, function(request) + if request ~= lastRefreshRequest then + lastRefreshRequest = request + pendingRefresh = true + -- Acknowledge immediately so the panel leaves its ready state even + -- though the refresh itself waits for the update tick. + if configuredCompositor() == "hyprland" then + local current = noctalia.state.get(SNAPSHOT_KEY) + local source = type(current) == "table" and current.source or nil + noctalia.state.set(SNAPSHOT_KEY, snapshot("loading", source, "", {}, 0, {}, "", {})) + end + end end) refresh() diff --git a/keymap/tests/hypr_cpu_budget_test.lua b/keymap/tests/hypr_cpu_budget_test.lua index 4d8d1e03..696cd969 100644 --- a/keymap/tests/hypr_cpu_budget_test.lua +++ b/keymap/tests/hypr_cpu_budget_test.lua @@ -104,8 +104,13 @@ assert(loadfile("service.luau"))() debug.sethook() local initialInstructionBlocks = instructionBlocks instructionBlocks, firstAsyncInstructionBlocks = 0, nil +-- The panel only ever increments through state.set; mirror that so the +-- service's staleness guard sees a genuine increment instead of an echo. +-- The watcher defers the refresh to the update tick, so invoke update() the +-- way the host's service timer would. debug.sethook(function() instructionBlocks = instructionBlocks + 1 end, "", 1000) -watchers["keymap.refresh_request"](1) +noctalia.state.set("keymap.refresh_request", (tonumber(noctalia.state.get("keymap.refresh_request")) or 0) + 1) +update() debug.sethook() local refreshInstructionBlocks = instructionBlocks local refreshScanInstructionBlocks = firstAsyncInstructionBlocks diff --git a/keymap/tests/merge_similar_test.lua b/keymap/tests/merge_similar_test.lua new file mode 100644 index 00000000..34081248 --- /dev/null +++ b/keymap/tests/merge_similar_test.lua @@ -0,0 +1,107 @@ +local function stateMock() + local values = {} + local watchers = {} + return values, { + get = function(key) return values[key] end, + set = function(key, value) + values[key] = value + if watchers[key] ~= nil then watchers[key](value) end + end, + watch = function(key, callback) watchers[key] = callback end, + } +end + +-- MangoWC: merge_similar groups same-action binds into one read-only row. +do + local source = table.concat({ + 'bind=SUPER,W,killclient #"Close Window"', + 'bind=ALT,F4,killclient #"Close Window"', + 'bind=SUPER,F,togglefullscreen #"Fullscreen"', + "", + }, "\n") + local values, state = stateMock() + noctalia = { + state = state, + getConfig = function(key) + return ({ + compositor = "mangowc", mangowc_config = "/fixture/config.conf", + merge_sequential = false, merge_similar = true, + })[key] + end, + getenv = function(key) return key == "MANGO_INSTANCE_SIGNATURE" and "test" or "" end, + expandPath = function(path) return path end, + fileExists = function(path) return path == "/fixture/config.conf" end, + readFile = function(path) return path == "/fixture/config.conf" and source or nil end, + tr = function(key) return key == "category.other" and "Other" or key end, + } + assert(loadfile("mangowc_service.luau"))() + local binds = {} + for _, category in ipairs(values["keymap.snapshot"].categories or {}) do + for _, bind in ipairs(category.binds or {}) do binds[#binds + 1] = bind end + end + assert(#binds == 2, "mangowc similar binds not merged, got " .. #binds) + assert(type(binds[1].combos) == "table" and #binds[1].combos == 2, "merged row lost combos") + assert(binds[1].combos[1].key == "W" and binds[1].combos[2].key == "F4", "merged row combo order wrong") + assert(binds[1].capabilities.combo == false, "merged row must be read-only") +end + +-- Hyprland: merge_similar groups same-action binds; watcher ignores echoes. +do + local source = table.concat({ + 'hl.bind("SUPER + W", hl.dsp.exec_cmd("close"), { description = "Close Window" })', + 'hl.bind("ALT + F4", hl.dsp.exec_cmd("close"), { description = "Close Window" })', + 'hl.bind("SUPER + F", hl.dsp.exec_cmd("max"), { description = "Maximize" })', + }, "\n") + local plain = table.concat({ + "bindd\n\tmodmask: 64\n\tsubmap: \n\tkey: W\n\tkeycode: 0\n\tcatchall: false\n\tdescription: Close Window\n\tdispatcher: __lua\n\targ: 1\n", + "bindd\n\tmodmask: 8\n\tsubmap: \n\tkey: F4\n\tkeycode: 0\n\tcatchall: false\n\tdescription: Close Window\n\tdispatcher: __lua\n\targ: 2\n", + "bindd\n\tmodmask: 64\n\tsubmap: \n\tkey: F\n\tkeycode: 0\n\tcatchall: false\n\tdescription: Maximize\n\tdispatcher: __lua\n\targ: 3\n", + }, "\n") + local values, state = stateMock() + local configValues = { + compositor = "hyprland", hyprland_config = "/fixture/hyprland.lua", + merge_sequential = false, show_undescribed = true, merge_similar = true, + } + noctalia = { + state = state, + getConfig = function(key) return configValues[key] end, + getenv = function(key) return key == "HYPRLAND_INSTANCE_SIGNATURE" and "test" or "" end, + expandPath = function(path) return path end, + fileExists = function(path) return path == "/fixture/hyprland.lua" end, + listDir = function() return nil end, + readFile = function(path) return path == "/fixture/hyprland.lua" and source or nil end, + commandExists = function(command) return command == "hyprctl" end, + runAsync = function(command, callback) + callback({ exitCode = 0, timedOut = false, stdout = command == "hyprctl binds" and plain or "invalid-json" }) + return true + end, + json = { decode = function() error("malformed JSON fixture") end }, + tr = function(key) return key == "category.other" and "Other" or key end, + } + assert(loadfile("service.luau"))() + local binds = {} + for _, category in ipairs(values["keymap.snapshot"].categories or {}) do + for _, bind in ipairs(category.binds or {}) do binds[#binds + 1] = bind end + end + assert(#binds == 2, "hyprland similar binds not merged, got " .. #binds) + assert(type(binds[1].combos) == "table" and #binds[1].combos == 2, "merged row lost combos") + assert(binds[1].combos[1].key == "W" and binds[1].combos[2].key == "F4", "merged row combo order wrong") + assert(binds[1].capabilities.combo == false, "merged row must be read-only") + + local refreshes = 0 + noctalia.state.watch("keymap.snapshot", function(snapshot) + if snapshot.status == "ready" then refreshes = refreshes + 1 end + end) + -- The watcher only records intent; update() performs the refresh. + noctalia.state.set("keymap.refresh_request", 1) + update() + local afterFirst = refreshes + noctalia.state.set("keymap.refresh_request", 1) + update() + assert(refreshes == afterFirst, "echo of refresh_request triggered another refresh") + noctalia.state.set("keymap.refresh_request", 2) + update() + assert(refreshes == afterFirst + 1, "increment did not trigger exactly one refresh") +end + +print("merge similar tests: ok") diff --git a/keymap/tests/niri_cpu_budget_test.lua b/keymap/tests/niri_cpu_budget_test.lua index 70fe8334..1682431e 100644 --- a/keymap/tests/niri_cpu_budget_test.lua +++ b/keymap/tests/niri_cpu_budget_test.lua @@ -83,6 +83,13 @@ noctalia = { local instructionBlocks = 0 debug.sethook(function() instructionBlocks = instructionBlocks + 1 end, "", 1000) assert(loadfile("niri_service.luau"))() +-- The parser yields to stay inside each callback's CPU budget; the host's +-- update timer resumes it. Pump ticks until the snapshot settles. +local pumps = 0 +while values["keymap.snapshot"].status == "loading" and pumps < 40 do + update() + pumps = pumps + 1 +end debug.sethook() bit32 = originalBit32 string.sub = originalStringSub diff --git a/keymap/tests/niri_scanner_test.lua b/keymap/tests/niri_scanner_test.lua new file mode 100644 index 00000000..484dd792 --- /dev/null +++ b/keymap/tests/niri_scanner_test.lua @@ -0,0 +1,101 @@ +local src = assert(io.open("niri_service.luau")):read("*a") + +-- reference implementations (original char loops) +local function refBraceDelta(text) + local delta = 0 + local quote = nil + local escaped = false + for index = 1, #text do + local char = text: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 delta = delta + 1 + elseif char == "}" then delta = delta - 1 end + end + return delta +end +local function refFirstOpenBrace(text) + local quote = nil + local escaped = false + for index = 1, #text do + local char = text: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 return index end + end + return nil +end +local function refContentBeforeOuterClose(text) + local depth = 1 + local quote = nil + local escaped = false + for index = 1, #text do + local char = text: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 + elseif char == "}" then + depth = depth - 1 + if depth == 0 then return text:sub(1, index - 1) end + end + end + return text +end + +-- extract new implementations +local function extract(name) + local startAt = assert(src:find("local function " .. name, 1, true), name) + local endAt = assert(src:find("\nend", startAt, true), name) + return assert(load(src:sub(startAt, endAt + 3) .. "\nreturn " .. name))() +end +local braceDelta = extract("braceDelta") +local firstOpenBrace = extract("firstOpenBrace") +local contentBeforeOuterClose = extract("contentBeforeOuterClose") + +local corpus = { + '', '{', '}', '{}', '{{}}', 'a{b}c', + ' spawn-sh "foot"; ', ' spawn-sh "a { b"; ', + " spawn 'single { quote'; ", + ' bind hotkey-overlay-title="x" { focus-workspace 1; } ', + '{"unclosed', ' "{" ', ' \\{ ', + '{ nested { deep } close } tail', + 'no braces at all', + ' } leading close', +} +-- add real escaped-quote sample +corpus[#corpus + 1] = ' spawn-sh "a ' .. string.char(92) .. '" b { "; ' + +for i, text in ipairs(corpus) do + local rd, nd = refBraceDelta(text), braceDelta(text) + assert(rd == nd, string.format("case %d braceDelta %s ~= %s (%q)", i, rd, nd, text)) + local rf, nf = refFirstOpenBrace(text), firstOpenBrace(text) + assert(rf == nf, string.format("case %d firstOpenBrace %s ~= %s (%q)", i, tostring(rf), tostring(nf), text)) + local rc, nc = refContentBeforeOuterClose(text), contentBeforeOuterClose(text) + assert(rc == nc, string.format("case %d contentBeforeOuterClose %q ~= %q", i, rc, nc)) +end + +-- fuzz with random token soup +local chars = { '{', '}', '"', "'", "\\", 'a', ' ', ';' } +math.randomseed(42) +for i = 1, 3000 do + local parts = {} + for j = 1, math.random(0, 20) do parts[#parts + 1] = chars[math.random(#chars)] end + local text = table.concat(parts) + local rd, nd = refBraceDelta(text), braceDelta(text) + assert(rd == nd, string.format("fuzz %d braceDelta %s ~= %s (%q)", i, rd, nd, text)) + local rf, nf = refFirstOpenBrace(text), firstOpenBrace(text) + assert(rf == nf, string.format("fuzz %d firstOpenBrace %s ~= %s (%q)", i, tostring(rf), tostring(nf), text)) + local rc, nc = refContentBeforeOuterClose(text), contentBeforeOuterClose(text) + assert(rc == nc, string.format("fuzz %d content %q ~= %q", i, rc, nc)) +end + +print("scanner equivalence: ok (12 corpus + 3000 fuzz)") diff --git a/keymap/tests/niri_settings_test.lua b/keymap/tests/niri_settings_test.lua new file mode 100644 index 00000000..7da482c8 --- /dev/null +++ b/keymap/tests/niri_settings_test.lua @@ -0,0 +1,165 @@ +local function stateMock() + local values = {} + local watchers = {} + return values, { + get = function(key) return values[key] end, + set = function(key, value) + values[key] = value + if watchers[key] ~= nil then watchers[key](value) end + end, + watch = function(key, callback) watchers[key] = callback end, + } +end + +local function niriHost(values, state, source, configValues) + return { + state = state, + getConfig = function(key) return configValues[key] end, + getenv = function(key) return key == "NIRI_SOCKET" and "test" or "" end, + fileExists = function(path) return path == "/fixture/config.kdl" end, + listDir = function() return nil end, + readFile = function(path) return path == "/fixture/config.kdl" and source or nil end, + tr = function(key, args) + if args ~= nil and args.command ~= nil then return "Run " .. args.command end + if args ~= nil and args.action ~= nil then return "Action " .. args.action end + return key == "category.other" and "Other" or key + end, + } +end + +local function allBinds(snapshot) + local found = {} + for _, category in ipairs(snapshot.categories or {}) do + for _, bind in ipairs(category.binds or {}) do + found[#found + 1] = bind + end + end + return found +end + +-- merge_sequential collapses numbered workspace runs into a single range row. +do + local source = [[binds { + Super+1 hotkey-overlay-title="Workspace 1" { focus-workspace 1; } + Super+2 hotkey-overlay-title="Workspace 2" { focus-workspace 2; } + Super+3 hotkey-overlay-title="Workspace 3" { focus-workspace 3; } + Super+4 hotkey-overlay-title="Workspace 4" { focus-workspace 4; } + Super+Shift+1 hotkey-overlay-title="Move to Workspace 1" { move-window-to-workspace 1; } + Super+Shift+2 hotkey-overlay-title="Move to Workspace 2" { move-window-to-workspace 2; } + Super+Shift+3 hotkey-overlay-title="Move to Workspace 3" { move-window-to-workspace 3; } + Super+Shift+4 hotkey-overlay-title="Move to Workspace 4" { move-window-to-workspace 4; } +}]] + local values, state = stateMock() + noctalia = niriHost(values, state, source, { + compositor = "niri", niri_config = "/fixture/config.kdl", + merge_sequential = true, show_undescribed = true, merge_similar = false, + }) + assert(loadfile("niri_service.luau"))() + local snapshot = values["keymap.snapshot"] + assert(snapshot.status == "ready", "sequential fixture not ready") + local binds = allBinds(snapshot) + assert(#binds == 2, "sequential runs not merged, got " .. #binds) + assert(binds[1].key == "1-4", "unexpected range key " .. tostring(binds[1].key)) + assert(binds[1].description == "Workspace 1-4", "unexpected range description " .. tostring(binds[1].description)) + assert(binds[2].description == "Move to Workspace 1-4", "second run not merged") + + -- and the flag turns merging off again + values, state = stateMock() + noctalia = niriHost(values, state, source, { + compositor = "niri", niri_config = "/fixture/config.kdl", + merge_sequential = false, show_undescribed = true, merge_similar = false, + }) + assert(loadfile("niri_service.luau"))() + assert(#allBinds(values["keymap.snapshot"]) == 8, "merge_sequential=false still merged") +end + +-- show_undescribed=false drops binds with an empty or null hotkey-overlay-title. +do + local source = [[binds { + Super+Q hotkey-overlay-title="Close window" { close-window; } + Super+R hotkey-overlay-title="" { spawn-sh "rofi -show run"; } + Super+T hotkey-overlay-title=null { spawn-sh "foot"; } + Super+Y { toggle-overview; } +}]] + local values, state = stateMock() + noctalia = niriHost(values, state, source, { + compositor = "niri", niri_config = "/fixture/config.kdl", + merge_sequential = false, show_undescribed = false, merge_similar = false, + }) + assert(loadfile("niri_service.luau"))() + local snapshot = values["keymap.snapshot"] + assert(snapshot.total == 1, "undescribed binds not filtered, total=" .. tostring(snapshot.total)) + assert(allBinds(snapshot)[1].description == "Close window", "wrong bind survived filtering") + + values, state = stateMock() + noctalia = niriHost(values, state, source, { + compositor = "niri", niri_config = "/fixture/config.kdl", + merge_sequential = false, show_undescribed = true, merge_similar = false, + }) + assert(loadfile("niri_service.luau"))() + assert(values["keymap.snapshot"].total == 4, "show_undescribed=true lost binds") +end + +-- merge_similar groups same-action binds into one read-only row. +do + local source = [[binds { + Super+W hotkey-overlay-title="Close Window" { close-window; } + Alt+F4 hotkey-overlay-title="Close Window" { close-window; } + Super+F hotkey-overlay-title="Maximize" { maximize-column; } +}]] + local values, state = stateMock() + noctalia = niriHost(values, state, source, { + compositor = "niri", niri_config = "/fixture/config.kdl", + merge_sequential = false, show_undescribed = true, merge_similar = true, + }) + assert(loadfile("niri_service.luau"))() + local binds = allBinds(values["keymap.snapshot"]) + assert(#binds == 2, "similar binds not merged, got " .. #binds) + assert(type(binds[1].combos) == "table" and #binds[1].combos == 2, "merged row lost combos") + assert(binds[1].combos[1].key == "W" and binds[1].combos[2].key == "F4", "merged row combo order wrong") + assert(#binds[1].combos[1].modifiers == 1 and binds[1].combos[1].modifiers[1] == "Super", "merged row lost modifiers") + assert(#binds[1].combos[2].modifiers == 1 and binds[1].combos[2].modifiers[1] == "Alt", "merged row lost modifiers") + assert(binds[1].capabilities.combo == false, "merged row must be read-only") + assert(binds[1].fingerprint == nil, "merged row must not carry provenance") + + -- the same fixture stays untouched when the flag is off + values, state = stateMock() + noctalia = niriHost(values, state, source, { + compositor = "niri", niri_config = "/fixture/config.kdl", + merge_sequential = false, show_undescribed = true, merge_similar = false, + }) + assert(loadfile("niri_service.luau"))() + assert(#allBinds(values["keymap.snapshot"]) == 3, "merge_similar=false still merged") +end + +-- The refresh watcher ignores echoes of an already-handled counter value but +-- reacts to genuine increments. +do + local source = [[binds { + Super+Q hotkey-overlay-title="Close window" { close-window; } +}]] + local values, state = stateMock() + noctalia = niriHost(values, state, source, { + compositor = "niri", niri_config = "/fixture/config.kdl", + merge_sequential = false, show_undescribed = true, merge_similar = false, + }) + assert(loadfile("niri_service.luau"))() + local refreshes = 0 + noctalia.state.watch("keymap.snapshot", function(snapshot) + if snapshot.status == "ready" then refreshes = refreshes + 1 end + end) + -- The watcher only records intent; update() performs the refresh. + noctalia.state.set("keymap.refresh_request", 1) + update() + local afterFirst = refreshes + -- host echo of a stale value: no new refresh + noctalia.state.set("keymap.refresh_request", 1) + update() + assert(refreshes == afterFirst, "echo of refresh_request triggered another refresh") + -- genuine increment: exactly one new refresh + noctalia.state.set("keymap.refresh_request", 2) + update() + assert(refreshes == afterFirst + 1, "increment did not trigger exactly one refresh") +end + +print("niri settings tests: ok") diff --git a/keymap/translations/en.json b/keymap/translations/en.json index 99077b38..d3848e65 100644 --- a/keymap/translations/en.json +++ b/keymap/translations/en.json @@ -287,7 +287,9 @@ "mangowc_required_source_missing": "A required MangoWC source could not be read. Fix the configuration path and try again.", "niri_config_unreadable": "The configured Niri KDL file could not be read.", "niri_no_binds": "No active keybindings were found in the Niri configuration.", + "niri_parse_failed": "The Niri parser hit an internal error. The warning banner text contains details.", "niri_required_include_missing": "A required Niri include could not be read. Fix the configuration path and try again.", + "mangowc_parse_failed": "The MangoWC parser hit an internal error. The warning banner text contains details.", "unknown": "The service returned an unknown error." }, "keyboard": { @@ -419,6 +421,10 @@ "description": "Combine related numbered shortcuts, such as workspaces 1–9, into one row.", "label": "Merge sequential shortcuts" }, + "merge_similar": { + "description": "Show one row per action when several shortcuts trigger it. Grouped rows are read-only.", + "label": "Merge similar shortcuts" + }, "modifier_color": { "description": "Theme role or custom background color for this modifier." }, @@ -436,7 +442,7 @@ "label": "Shift text" }, "show_undescribed": { - "description": "Include Hyprland shortcuts without explicit descriptions. Niri and MangoWC use generated action descriptions.", + "description": "Include shortcuts without explicit descriptions. Niri binds without a hotkey-overlay-title and Hyprland binds without descriptions are hidden when off.", "label": "Show shortcuts without descriptions" }, "super_color": { From 2aabbc64cfc12d380b13feda84be7c993080bb81 Mon Sep 17 00:00:00 2001 From: MateusAquino Date: Wed, 12 Aug 2026 12:35:52 -0300 Subject: [PATCH 2/2] fix(keymap): parser lifecycle, merged combo handling and tests --- keymap/mangowc_service.luau | 115 +++++++++----- keymap/niri_service.luau | 59 +++++-- keymap/panel.luau | 64 +++++--- keymap/service.luau | 6 +- keymap/tests/coroutine_slice_test.lua | 209 +++++++++++++++++++++++++ keymap/tests/example_configs_test.lua | 10 ++ keymap/tests/mangowc_scale_test.lua | 6 + keymap/tests/merge_similar_test.lua | 80 +++++++++- keymap/tests/niri_scale_test.lua | 6 + keymap/tests/parser_lifecycle_test.lua | 129 +++++++++++++++ keymap/writer_service.luau | 24 +-- 11 files changed, 619 insertions(+), 89 deletions(-) create mode 100644 keymap/tests/coroutine_slice_test.lua create mode 100644 keymap/tests/parser_lifecycle_test.lua diff --git a/keymap/mangowc_service.luau b/keymap/mangowc_service.luau index 4ac996d8..331b2d23 100644 --- a/keymap/mangowc_service.luau +++ b/keymap/mangowc_service.luau @@ -20,15 +20,25 @@ local pendingRefresh = false local parseCoroutine = nil local parseSource = "" local parseUpdatedAt = "" --- The host gives each callback a 25ms CPU budget. Large MangoWC trees exceed --- that when parsed in one go inside the instrumented VM, so the parser yields --- to the next update tick once a slice gets expensive. -local PARSE_LINE_SLICE = 40 --- Prefer a wall-clock slice when the sandbox exposes os.clock: the host's --- interrupt meter makes per-line costs unpredictable, and overrunning the --- budget aborts the whole refresh. -local PARSE_SLICE_SECONDS = 0.012 +local fastTicks = false + +-- The host meters every callback with an interrupt-driven CPU budget. Large +-- MangoWC trees exceed it when parsed in one resume, so the parser yields to +-- the next update tick every few lines. A wall-clock early-out tightens the +-- slice further when the sandbox exposes os.clock; without it, the fixed +-- line budget alone keeps each resume safely small. +local PARSE_LINE_SLICE = 25 +local PARSE_SLICE_SECONDS = 0.005 local sliceStartedAt = nil +local parseGeneration = 0 +local linesThisResume = 0 + +local function setFastTicks(enabled) + if fastTicks == enabled then return end + if type(noctalia.setUpdateInterval) ~= "function" then return end + fastTicks = enabled + noctalia.setUpdateInterval(enabled and 60 or 1000) +end local function noteSliceStart() if type(os) == "table" and type(os.clock) == "function" then @@ -707,41 +717,48 @@ local function parseFile(context, path, optional, depth) 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 + if source:find("# Keymap hidden ", 1, true) ~= nil then + local hiddenLines = sourceLines(source) + local hiddenCategory = nil + local hiddenKeymode = context.keymode + local hiddenCursor = 1 + 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 end for rawLine in (source .. "\n"):gmatch("([^\n]*)\n") do lineNumber = lineNumber + 1 -- Yield once a slice gets expensive so a multi-file parse never holds - -- the callback past its budget; update() resumes on the next tick. - if lineNumber % PARSE_LINE_SLICE == 0 and sliceExpired() then coroutine.yield() end + -- the callback past its budget; update() resumes on the next tick. The + -- counter spans included files, so many short files cannot dodge it. + linesThisResume = linesThisResume + 1 + if linesThisResume >= PARSE_LINE_SLICE or sliceExpired() then + linesThisResume = 0 + coroutine.yield() + end if not hiddenConsumed[lineNumber] then local commentAt = findUnquotedComment(rawLine) local effective = trim(commentAt ~= nil and rawLine:sub(1, commentAt - 1) or rawLine) @@ -841,7 +858,7 @@ local function mergedBind(run) 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, + command = first.command, action = first.action, managed = first.managed == true, source = first.source, line = first.line, start_line = startLine, end_line = endLine, @@ -1030,6 +1047,13 @@ end function refresh() if not isActive() then + -- Invalidate any in-flight parse from a previous compositor selection: + -- its result must never overwrite the new owner's snapshot. + parseGeneration = parseGeneration + 1 + parseCoroutine = nil + refreshing = false + refreshQueued = false + setFastTicks(false) return end if refreshing then @@ -1037,6 +1061,8 @@ function refresh() return end refreshing = true + parseGeneration = parseGeneration + 1 + local generation = parseGeneration local source = configPath() parseSource = source @@ -1050,6 +1076,9 @@ function refresh() parseCoroutine = coroutine.create(function() local parsed = parseConfig(source) + if generation ~= parseGeneration then + return + end if #parsed.files == 0 then noctalia.state.set(SNAPSHOT_KEY, snapshot("error", source, "mangowc_config_unreadable", {}, 0, parsed.warnings, parseUpdatedAt)) return @@ -1068,7 +1097,6 @@ function refresh() ) end) -- Resume once within this budget; a large config suspends and continues on - -- the next update tick instead of blowing the 25ms callback budget. pumpParse() end @@ -1080,9 +1108,11 @@ function pumpParse() return end noteSliceStart() + linesThisResume = 0 local ok, err = coroutine.resume(parseCoroutine) if not ok then parseCoroutine = nil + setFastTicks(false) local failed = snapshot("error", parseSource, "mangowc_parse_failed", {}, 0, {}, parseUpdatedAt) failed.error_detail = tostring(err) noctalia.state.set(SNAPSHOT_KEY, failed) @@ -1091,9 +1121,12 @@ function pumpParse() end if coroutine.status(parseCoroutine) == "dead" then parseCoroutine = nil + setFastTicks(false) finishRefresh() + return end -- Still suspended: left parked for the next update() tick. + setFastTicks(true) end function onIpc(event, _payload) @@ -1118,6 +1151,8 @@ function update() refresh() elseif parseCoroutine ~= nil then pumpParse() + else + setFastTicks(false) end end diff --git a/keymap/niri_service.luau b/keymap/niri_service.luau index d4857cb8..c2e96b2e 100644 --- a/keymap/niri_service.luau +++ b/keymap/niri_service.luau @@ -9,14 +9,13 @@ local MAX_FILES = 64 local MAX_SOURCE_BYTES = 512 * 1024 local MAX_HIDDEN_BYTES = 2 * 1024 * 1024 local EXACT_SOURCE_FINGERPRINT = "exact-v1" --- The host gives each callback a 25ms CPU budget. Real Niri configs with --- includes exceed that when parsed in one go inside the instrumented VM, so --- the parser yields to the next update tick once a slice gets expensive. -local PARSE_LINE_SLICE = 40 --- Prefer a wall-clock slice when the sandbox exposes os.clock: the host's --- interrupt meter makes per-line costs unpredictable, and overrunning the --- budget aborts the whole refresh. -local PARSE_SLICE_SECONDS = 0.012 +-- The host meters every callback with an interrupt-driven CPU budget. Real +-- Niri configs exceed it when parsed in one resume, so the parser yields to +-- the next update tick every few lines. A wall-clock early-out tightens the +-- slice further when the sandbox exposes os.clock; without it, the fixed +-- line budget alone keeps each resume safely small. +local PARSE_LINE_SLICE = 25 +local PARSE_SLICE_SECONDS = 0.005 local sliceStartedAt = nil local function noteSliceStart() @@ -41,6 +40,16 @@ local pendingRefresh = false local parseCoroutine = nil local parseSource = "" local parseUpdatedAt = "" +local parseGeneration = 0 +local linesThisResume = 0 +local fastTicks = false + +local function setFastTicks(enabled) + if fastTicks == enabled then return end + if type(noctalia.setUpdateInterval) ~= "function" then return end + fastTicks = enabled + noctalia.setUpdateInterval(enabled and 60 or 1000) +end local function config(key, fallback) local value = noctalia.getConfig(key) @@ -712,8 +721,8 @@ local function mergeSimilar(binds) local grouped = {} local order = {} for index, bind in ipairs(binds) do - local signature = (bind.has_overlay_title == true and "title:" or "action:") - .. (bind.has_overlay_title == true and bind.description or bind.action) + local signature = "action:" + .. tostring(bind.dispatcher) .. "\0" .. tostring(bind.action) local group = grouped[signature] if group == nil then group = { first = index, parts = {} } @@ -955,8 +964,13 @@ local function parseConfig(root, rootSource) lineNumber = lineNumber + 1 -- Yield once a slice gets expensive so a multi-file parse never -- holds the callback past its budget; update() resumes on the - -- next tick with a fresh window. - if lineNumber % PARSE_LINE_SLICE == 0 and sliceExpired() then coroutine.yield() end + -- next tick with a fresh window. The counter spans included files, + -- so many short includes cannot dodge the yield check. + linesThisResume = linesThisResume + 1 + if linesThisResume >= PARSE_LINE_SLICE or sliceExpired() then + linesThisResume = 0 + coroutine.yield() + end local code, comment code, comment, inBlockComment = stripComments(rawLine, inBlockComment) local stripped = trim(code) @@ -1071,6 +1085,13 @@ function refresh() -- Each compositor service receives the same events. Only the selected or -- auto-detected service may publish, preventing snapshot races. if detectedCompositor() ~= "niri" then + -- Invalidate any in-flight parse from a previous compositor selection: + -- its result must never overwrite the new owner's snapshot. + parseGeneration = parseGeneration + 1 + parseCoroutine = nil + refreshing = false + refreshQueued = false + setFastTicks(false) return end if refreshing then @@ -1078,6 +1099,8 @@ function refresh() return end refreshing = true + parseGeneration = parseGeneration + 1 + local generation = parseGeneration local source = sourcePath() parseSource = source parseUpdatedAt = os.date("%H:%M:%S") @@ -1101,6 +1124,10 @@ function refresh() parseCoroutine = coroutine.create(function() local categories, total, hidden, warnings, fatalError = parseConfig(source, rootSource) + if generation ~= parseGeneration then + -- A compositor switch or a newer refresh superseded this parse. + return + end if fatalError ~= nil then noctalia.state.set( SNAPSHOT_KEY, @@ -1119,7 +1146,6 @@ function refresh() end end) -- Resume once within this budget; a large config suspends and continues on - -- the next update tick instead of blowing the 25ms callback budget. pumpParse() end @@ -1131,9 +1157,11 @@ function pumpParse() return end noteSliceStart() + linesThisResume = 0 local ok, err = coroutine.resume(parseCoroutine) if not ok then parseCoroutine = nil + setFastTicks(false) noctalia.state.set( SNAPSHOT_KEY, snapshot("error", parseSource, "niri_parse_failed", {}, 0, {}, parseUpdatedAt, {}, tostring(err)) @@ -1143,9 +1171,12 @@ function pumpParse() end if coroutine.status(parseCoroutine) == "dead" then parseCoroutine = nil + setFastTicks(false) finishRefresh() + return end -- Still suspended: left parked for the next update() tick. + setFastTicks(true) end function onIpc(event, _payload) @@ -1170,6 +1201,8 @@ function update() refresh() elseif parseCoroutine ~= nil then pumpParse() + else + setFastTicks(false) end end diff --git a/keymap/panel.luau b/keymap/panel.luau index b6277380..d587098b 100644 --- a/keymap/panel.luau +++ b/keymap/panel.luau @@ -1268,17 +1268,22 @@ local function creatorConflict(index) local signature = activeModifierSignature() for _, category in ipairs(asArray(snapshot.categories)) do for _, bind in ipairs(asArray(category.binds)) do - if not (formMode == "edit" and asString(bind.id) == editingBindId) - and modifierSignature(bind.modifiers) == signature - and asString(bind.activation, "press") == creatorActivation then - local rawKeys = type(bind.keys) == "table" and bind.keys or expandedKeys(bind.key) - if #rawKeys == #creatorKeys then - local matches = true - for index, rawKey in ipairs(rawKeys) do - if canonicalKey(rawKey) ~= creatorKeys[index] then matches = false break end - end - if matches then - return { bind = bind, category = asString(category.name, tr("panel.uncategorized")) } + if not (formMode == "edit" and asString(bind.id) == editingBindId) then + local combos = type(bind.combos) == "table" and #bind.combos > 0 and bind.combos + or { { modifiers = bind.modifiers, key = bind.key, keys = bind.keys } } + for _, combo in ipairs(combos) do + if modifierSignature(combo.modifiers) == signature + and asString(bind.activation, "press") == creatorActivation then + local rawKeys = type(combo.keys) == "table" and combo.keys or expandedKeys(combo.key) + if #rawKeys == #creatorKeys then + local matches = true + for index, rawKey in ipairs(rawKeys) do + if canonicalKey(rawKey) ~= creatorKeys[index] then matches = false break end + end + if matches then + return { bind = bind, category = asString(category.name, tr("panel.uncategorized")) } + end + end end end end @@ -1744,7 +1749,7 @@ selectKeyboardKey = function(code) end -local function keyPill(token, isKey) +local function keyPill(token, isKey, compact) local fill local textColor if isKey then @@ -1758,7 +1763,7 @@ local function keyPill(token, isKey) border = "outline", borderWidth = 1, radius = 7, - paddingH = 7, + paddingH = compact and 4 or 7, paddingV = 2, align = "center", justify = "center", @@ -1766,7 +1771,7 @@ local function keyPill(token, isKey) ui.label({ text = asString(token, "?"), color = asString(textColor, "on_surface"), - fontSize = 11, + fontSize = compact and 9 or 11, fontWeight = "bold", maxLines = 1, }), @@ -2195,26 +2200,37 @@ local function bindRow(bind, categoryName, columnCount, rowIndex) local keyArea local single = #combos == 1 if single then + local tokens = #asArray(combos[1].modifiers) + 1 + local estimatedWidth = 0 + for _, modifier in ipairs(asArray(combos[1].modifiers)) do + estimatedWidth = estimatedWidth + #asString(modifier, "?") * 7 + 18 + end + estimatedWidth = estimatedWidth + #asString(combos[1].key, "?") * 7 + 18 + math.max(0, tokens - 1) * 4 + local compact = estimatedWidth > width local keys = {} for _, modifier in ipairs(asArray(combos[1].modifiers)) do - keys[#keys + 1] = keyPill(modifier, false) + keys[#keys + 1] = keyPill(modifier, false, compact) end - keys[#keys + 1] = keyPill(asString(combos[1].key, "?"), true) - -- Always render distinct pills: collapsing to a single bordered label made - -- wide chords like Super+Shift+Scroll Down read as one giant key. The row - -- sizes to its content and only wraps visually inside the fixed slot. - keyArea = ui.row({ width = width, gap = 4, align = "center" }, keys) + keys[#keys + 1] = keyPill(asString(combos[1].key, "?"), true, compact) + keyArea = ui.row({ width = width, gap = compact and 2 or 4, align = "center" }, keys) else -- One line of pills per key combination, so merged shortcuts still read -- as distinct physical presses instead of one opaque label. local lines = {} for _, combo in ipairs(combos) do + local estimatedWidth = 0 + local mods = asArray(combo.modifiers) + for _, modifier in ipairs(mods) do + estimatedWidth = estimatedWidth + #asString(modifier, "?") * 7 + 18 + end + estimatedWidth = estimatedWidth + #asString(combo.key, "?") * 7 + 18 + math.max(0, #mods) * 4 + local compact = estimatedWidth > width local pills = {} - for _, modifier in ipairs(asArray(combo.modifiers)) do - pills[#pills + 1] = keyPill(modifier, false) + for _, modifier in ipairs(mods) do + pills[#pills + 1] = keyPill(modifier, false, compact) end - pills[#pills + 1] = keyPill(asString(combo.key, "?"), true) - lines[#lines + 1] = ui.row({ gap = 4, align = "center" }, pills) + pills[#pills + 1] = keyPill(asString(combo.key, "?"), true, compact) + lines[#lines + 1] = ui.row({ gap = compact and 2 or 4, align = "center" }, pills) end keyArea = ui.column({ width = width, gap = 4 }, lines) end diff --git a/keymap/service.luau b/keymap/service.luau index 4e353558..d33ee559 100644 --- a/keymap/service.luau +++ b/keymap/service.luau @@ -921,7 +921,10 @@ local function mergeSimilar(binds) local grouped = {} local order = {} for index, bind in ipairs(binds) do + local identifiable = tostring(bind.command or "") ~= "" or tostring(bind.action or "") ~= "" + if identifiable then local signature = "action:" + .. tostring(bind.activation) .. "\0" .. tostring(bind.description) .. "\0" .. tostring(bind.dispatcher) .. "\0" .. tostring(bind.command) .. "\0" .. tostring(bind.action) local group = grouped[signature] @@ -931,6 +934,7 @@ local function mergeSimilar(binds) order[#order + 1] = signature end group.parts[#group.parts + 1] = bind + end end local mergedAt = {} local swallowed = {} @@ -951,7 +955,7 @@ local function mergeSimilar(binds) combos = combos, description = first.description, dispatcher = first.dispatcher, - activation = first.release == true and "release" or "press", + activation = first.activation == "release" and "release" or "press", command = first.command, action = first.action, capabilities = { diff --git a/keymap/tests/coroutine_slice_test.lua b/keymap/tests/coroutine_slice_test.lua new file mode 100644 index 00000000..bb66a0de --- /dev/null +++ b/keymap/tests/coroutine_slice_test.lua @@ -0,0 +1,209 @@ +-- Coroutine parser measurement tests. +-- +-- The host meters each callback with a VM-level CPU interrupt, so every +-- resume of the parse coroutine must stay small on its own. Plain Lua's +-- debug.sethook does not follow coroutines, and the parse coroutine is a +-- service upvalue, so these tests wrap coroutine.create before loading the +-- service: every coroutine the service spawns gets a per-thread hook, and +-- each resume slice is metered individually. +local function stateMock() + local values = {} + local watchers = {} + return values, { + get = function(key) return values[key] end, + set = function(key, value) + values[key] = value + if watchers[key] ~= nil then watchers[key](value) end + end, + watch = function(key, callback) watchers[key] = callback end, + } +end + +-- Wrap noctalia.state.watch to capture the service's update/pump entry points. +-- The service exposes update() and (indirectly) pumpParse() as globals; we +-- meter every update() call by installing a hook on the live parse coroutine +-- right before each pump. The coroutine itself is reachable only through the +-- service's upvalues, so instead of fishing it out we measure update() as a +-- whole: with a parked parse, update() IS the resume slice. +local function makeInstrumentedHost(values, state, sources, configValues) + return { + state = state, + getConfig = function(key) return configValues[key] end, + getenv = function(key) return key == "NIRI_SOCKET" and "test" or "" end, + fileExists = function(path) return sources[path] ~= nil end, + listDir = function() return nil end, + readFile = function(path) return sources[path] end, + tr = function(key, args) + if args ~= nil and args.command ~= nil then return "Run " .. args.command end + if args ~= nil and args.action ~= nil then return "Action " .. args.action end + return key == "category.other" and "Other" or key + end, + } +end + +local function buildFixture(bindCount, includeCount) + local rootLines = {} + for index = 1, 200 do + rootLines[#rootLines + 1] = "// stock documentation line " .. tostring(index) + end + rootLines[#rootLines + 1] = "binds {" + for index = 1, bindCount do + rootLines[#rootLines + 1] = string.format( + ' Mod+Key%d repeat=false cooldown-ms=150 { focus-workspace %d; }', index, index + ) + end + rootLines[#rootLines + 1] = "}" + for index = 1, includeCount do + rootLines[#rootLines + 1] = string.format('include "inc%d.kdl"', index) + end + local sources = { ["/fixture/config.kdl"] = table.concat(rootLines, "\n") } + for index = 1, includeCount do + local lines = { "binds {" } + for bind = 1, 10 do + lines[#lines + 1] = string.format( + ' Mod+Alt+Key%d_%d hotkey-overlay-title="Included %d %d" { spawn-sh "cmd"; }', + index, bind, index, bind + ) + end + lines[#lines + 1] = "}" + sources["/fixture/inc" .. tostring(index) .. ".kdl"] = table.concat(lines, "\n") + end + return sources +end + +-- A large multi-file fixture must take the multi-tick path: at least one +-- coroutine yield, and every resume slice individually metered and bounded. +local function meterServiceParses(chunk) + -- Intercept coroutine creation/resume so the service's parse coroutine + -- carries a counting hook; debug.sethook on the caller does not follow + -- into coroutine bodies, so this is the only way to meter slices. + local sliceBlocks = {} + local hooked = {} + local realCreate = coroutine.create + local realResume = coroutine.resume + local blockCounter = 0 + coroutine.create = function(fn) + local co = realCreate(fn) + hooked[co] = true + debug.sethook(co, function() blockCounter = blockCounter + 1 end, "", 500) + return co + end + coroutine.resume = function(co, ...) + if hooked[co] then + blockCounter = 0 + local results = table.pack(realResume(co, ...)) + sliceBlocks[#sliceBlocks + 1] = blockCounter + return table.unpack(results, 1, results.n) + end + return realResume(co, ...) + end + chunk() + coroutine.create = realCreate + coroutine.resume = realResume + return sliceBlocks +end + +-- Slices are metered in 500-instruction blocks. The host budget is wall +-- clock, not instruction count, so instead of an absolute bound we require +-- the work to be spread: every slice must stay within a modest multiple of +-- the mean, and no slice may be a runaway. The final slice runs the +-- unchunked category/merge phase, so the bound leaves it real headroom. +local function assertSliceSpread(sliceBlocks, label) + assert(#sliceBlocks >= 2, label .. ": parse never took the multi-tick path") + local totalSliceBlocks = 0 + for _, blocks in ipairs(sliceBlocks) do totalSliceBlocks = totalSliceBlocks + blocks end + assert(totalSliceBlocks > 0, label .. ": slice metering recorded zero instructions; the hook is not measuring") + local mean = totalSliceBlocks / #sliceBlocks + for index, blocks in ipairs(sliceBlocks) do + assert(blocks <= math.max(60, mean * 4), + string.format("%s: resume slice %d dominates: %d blocks (mean %.1f)", label, index, blocks, mean)) + end +end + +do + local fakeNow = 0 + local realClock = os.clock + os.clock = function() fakeNow = fakeNow + 0.0002 return fakeNow end + + local sources = buildFixture(120, 8) + local values, state = stateMock() + noctalia = makeInstrumentedHost(values, state, sources, { + compositor = "niri", niri_config = "/fixture/config.kdl", + merge_sequential = false, show_undescribed = true, merge_similar = false, + }) + + local sliceBlocks + local function run() + assert(loadfile("niri_service.luau"))() + local ticks = 0 + while values["keymap.snapshot"].status == "loading" and ticks < 300 do + update() + ticks = ticks + 1 + end + end + sliceBlocks = meterServiceParses(run) + os.clock = realClock + + local snapshot = values["keymap.snapshot"] + assert(snapshot.status == "ready", "instrumented fixture did not settle: " .. tostring(snapshot.status)) + assert(snapshot.total == 200, "instrumented fixture lost binds: " .. tostring(snapshot.total)) + assertSliceSpread(sliceBlocks, "niri") +end + +-- Identical coverage for the MangoWC coroutine parser. +do + local fakeNow = 0 + local realClock = os.clock + os.clock = function() fakeNow = fakeNow + 0.0002 return fakeNow end + + local lines = {} + for index = 1, 200 do + lines[#lines + 1] = "# stock documentation line " .. tostring(index) + end + for index = 1, 150 do + lines[#lines + 1] = string.format('bind=SUPER,F%d,spawn,command-%d #"Action %d"', index, index, index) + end + lines[#lines + 1] = "source=./extra.conf" + local extra = {} + for index = 1, 60 do + extra[#extra + 1] = string.format('bind=ALT,F%d,spawn,extra-%d #"Extra %d"', index, index, index) + end + local sources = { + ["/fixture/config.conf"] = table.concat(lines, "\n"), + ["/fixture/extra.conf"] = table.concat(extra, "\n"), + } + + local values, state = stateMock() + noctalia = { + state = state, + getConfig = function(key) + return ({ + compositor = "mangowc", mangowc_config = "/fixture/config.conf", + merge_sequential = false, merge_similar = false, + })[key] + end, + getenv = function(key) return key == "MANGO_INSTANCE_SIGNATURE" and "test" or "" end, + expandPath = function(path) return path end, + fileExists = function(path) return sources[path] ~= nil end, + readFile = function(path) return sources[path] end, + tr = function(key) return key == "category.other" and "Other" or key end, + } + local sliceBlocks + local function run() + assert(loadfile("mangowc_service.luau"))() + local ticks = 0 + while values["keymap.snapshot"].status == "loading" and ticks < 300 do + update() + ticks = ticks + 1 + end + end + sliceBlocks = meterServiceParses(run) + os.clock = realClock + + local snapshot = values["keymap.snapshot"] + assert(snapshot.status == "ready", "mangowc instrumented fixture did not settle: " .. tostring(snapshot.status)) + assert(snapshot.total == 210, "mangowc instrumented fixture lost binds: " .. tostring(snapshot.total)) + assertSliceSpread(sliceBlocks, "mangowc") +end + +print("coroutine slice tests: ok") diff --git a/keymap/tests/example_configs_test.lua b/keymap/tests/example_configs_test.lua index 05f2adae..fc8deae3 100644 --- a/keymap/tests/example_configs_test.lua +++ b/keymap/tests/example_configs_test.lua @@ -135,6 +135,11 @@ do tr = function(key) return key == "category.other" and "Other" or key end, } assert(loadfile("niri_service.luau"))() + local niriPumps = 0 + while values["keymap.snapshot"].status == "loading" and niriPumps < 100 do + update() + niriPumps = niriPumps + 1 + end assertExample(values["keymap.snapshot"], "Niri", 39) assert(values["keymap.snapshot"].source == "/example-home/.config/niri/config.kdl") end @@ -159,6 +164,11 @@ do tr = function(key) return key == "category.other" and "Other" or key end, } assert(loadfile("mangowc_service.luau"))() + local mangoPumps = 0 + while values["keymap.snapshot"].status == "loading" and mangoPumps < 100 do + update() + mangoPumps = mangoPumps + 1 + end assertExample(values["keymap.snapshot"], "MangoWC", 40) assert(values["keymap.snapshot"].source == "/example-home/.config/mango/config.conf") end diff --git a/keymap/tests/mangowc_scale_test.lua b/keymap/tests/mangowc_scale_test.lua index 5a293608..58858f4c 100644 --- a/keymap/tests/mangowc_scale_test.lua +++ b/keymap/tests/mangowc_scale_test.lua @@ -64,6 +64,12 @@ noctalia = { local instructionBlocks = 0 debug.sethook(function() instructionBlocks = instructionBlocks + 1 end, "", 1000) assert(loadfile("mangowc_service.luau"))() +-- The parser yields between slices; pump update ticks until it settles. +local pumps = 0 +while values["keymap.snapshot"].status == "loading" and pumps < 100 do + update() + pumps = pumps + 1 +end debug.sethook() bit32 = originalBit32 diff --git a/keymap/tests/merge_similar_test.lua b/keymap/tests/merge_similar_test.lua index 34081248..a084da4f 100644 --- a/keymap/tests/merge_similar_test.lua +++ b/keymap/tests/merge_similar_test.lua @@ -48,6 +48,7 @@ end -- Hyprland: merge_similar groups same-action binds; watcher ignores echoes. do local source = table.concat({ + '-- 1. General', 'hl.bind("SUPER + W", hl.dsp.exec_cmd("close"), { description = "Close Window" })', 'hl.bind("ALT + F4", hl.dsp.exec_cmd("close"), { description = "Close Window" })', 'hl.bind("SUPER + F", hl.dsp.exec_cmd("max"), { description = "Maximize" })', @@ -104,4 +105,81 @@ do assert(refreshes == afterFirst + 1, "increment did not trigger exactly one refresh") end -print("merge similar tests: ok") + + +-- MangoWC: merged numeric ranges keep their action, so merge_similar cannot +-- collapse two different ranges into one. +do + local lines = {} + for index = 1, 4 do + lines[#lines + 1] = string.format('bind=SUPER,%d,view,%d #"Workspace %d"', index, index, index) + end + for index = 1, 4 do + lines[#lines + 1] = string.format('bind=SUPER+SHIFT,%d,movetoworkspace,%d #"Move %d"', index, index, index) + end + local source = table.concat(lines, "\n") .. "\n" + local values, state = stateMock() + noctalia = { + state = state, + getConfig = function(key) + return ({ + compositor = "mangowc", mangowc_config = "/fixture/config.conf", + merge_sequential = true, merge_similar = true, + })[key] + end, + getenv = function(key) return key == "MANGO_INSTANCE_SIGNATURE" and "test" or "" end, + expandPath = function(path) return path end, + fileExists = function(path) return path == "/fixture/config.conf" end, + readFile = function(path) return path == "/fixture/config.conf" and source or nil end, + tr = function(key) return key == "category.other" and "Other" or key end, + } + assert(loadfile("mangowc_service.luau"))() + local binds = {} + for _, category in ipairs(values["keymap.snapshot"].categories or {}) do + for _, bind in ipairs(category.binds or {}) do binds[#binds + 1] = bind end + end + assert(#binds == 2, "different mangowc ranges collapsed: " .. #binds) + assert(binds[1].id:match("^range:") and binds[2].id:match("^range:"), "ranges lost their ids") +end + +-- Hyprland: undescribed binds with unknown actions never merge. +do + local source = table.concat({ + 'hl.bind("SUPER + A", hl.dsp.exec_cmd("one"))', + 'hl.bind("SUPER + B", hl.dsp.exec_cmd("two"))', + }, "\n") + local plain = table.concat({ + "bindd\n\tmodmask: 64\n\tsubmap: \n\tkey: A\n\tkeycode: 0\n\tcatchall: false\n\tdescription: \n\tdispatcher: __lua\n\targ: 1\n", + "bindd\n\tmodmask: 64\n\tsubmap: \n\tkey: B\n\tkeycode: 0\n\tcatchall: false\n\tdescription: \n\tdispatcher: __lua\n\targ: 2\n", + }, "\n") + local values, state = stateMock() + noctalia = { + state = state, + getConfig = function(key) + return ({ + compositor = "hyprland", hyprland_config = "/fixture/hyprland.lua", + merge_sequential = false, show_undescribed = true, merge_similar = true, + })[key] + end, + getenv = function(key) return key == "HYPRLAND_INSTANCE_SIGNATURE" and "test" or "" end, + expandPath = function(path) return path end, + fileExists = function(path) return path == "/fixture/hyprland.lua" end, + listDir = function() return nil end, + readFile = function(path) return path == "/fixture/hyprland.lua" and source or nil end, + commandExists = function(command) return command == "hyprctl" end, + runAsync = function(command, callback) + callback({ exitCode = 0, timedOut = false, stdout = command == "hyprctl binds" and plain or "invalid-json" }) + return true + end, + json = { decode = function() error("malformed JSON fixture") end }, + tr = function(key) return key end, + } + assert(loadfile("service.luau"))() + local binds = {} + for _, category in ipairs(values["keymap.snapshot"].categories or {}) do + for _, bind in ipairs(category.binds or {}) do binds[#binds + 1] = bind end + end + assert(#binds == 2, "unidentifiable hyprland binds merged: " .. #binds) +end + +print("merge similar extended tests: ok") diff --git a/keymap/tests/niri_scale_test.lua b/keymap/tests/niri_scale_test.lua index 915cd63d..0b72f915 100644 --- a/keymap/tests/niri_scale_test.lua +++ b/keymap/tests/niri_scale_test.lua @@ -64,6 +64,12 @@ noctalia = { } assert(loadfile("niri_service.luau"))() +-- The parser yields between slices; pump update ticks until it settles. +local pumps = 0 +while values["keymap.snapshot"].status == "loading" and pumps < 100 do + update() + pumps = pumps + 1 +end bit32 = originalBit32 local snapshot = values["keymap.snapshot"] diff --git a/keymap/tests/parser_lifecycle_test.lua b/keymap/tests/parser_lifecycle_test.lua new file mode 100644 index 00000000..cd42bec5 --- /dev/null +++ b/keymap/tests/parser_lifecycle_test.lua @@ -0,0 +1,129 @@ +-- Regression tests for the coroutine parser lifecycle: multi-tick resumes, +-- stale-generation cancellation, and merge_similar grouping correctness. +local function stateMock() + local values = {} + local watchers = {} + return values, { + get = function(key) return values[key] end, + set = function(key, value) + values[key] = value + if watchers[key] ~= nil then watchers[key](value) end + end, + watch = function(key, callback) watchers[key] = callback end, + } +end + +local function niriHost(values, state, sources, configValues) + return { + state = state, + getConfig = function(key) return configValues[key] end, + getenv = function(key) return key == "NIRI_SOCKET" and "test" or "" end, + fileExists = function(path) return sources[path] ~= nil end, + listDir = function() return nil end, + readFile = function(path) return sources[path] end, + tr = function(key, args) + if args ~= nil and args.command ~= nil then return "Run " .. args.command end + if args ~= nil and args.action ~= nil then return "Action " .. args.action end + return key == "category.other" and "Other" or key + end, + } +end + +local function pumpUntilSettled(values, maxTicks) + local ticks = 0 + while values["keymap.snapshot"].status == "loading" and ticks < (maxTicks or 200) do + update() + ticks = ticks + 1 + end + return values["keymap.snapshot"], ticks +end + +-- A parse that must yield resumes across update ticks and still completes. +do + -- os.clock that advances aggressively forces a yield at every 40-line slice. + local fakeNow = 0 + local realClock = os.clock + os.clock = function() fakeNow = fakeNow + 0.01 return fakeNow end + + local lines = { "binds {" } + for index = 1, 120 do + lines[#lines + 1] = string.format( + ' Super+Key%d hotkey-overlay-title="Action %d" { spawn-sh "cmd%d"; }', index, index, index + ) + end + lines[#lines + 1] = "}" + local values, state = stateMock() + noctalia = niriHost(values, state, { ["/fixture/config.kdl"] = table.concat(lines, "\n") }, { + compositor = "niri", niri_config = "/fixture/config.kdl", + merge_sequential = false, show_undescribed = true, merge_similar = false, + }) + assert(loadfile("niri_service.luau"))() + local snapshot, ticks = pumpUntilSettled(values) + os.clock = realClock + assert(snapshot.status == "ready", "multi-tick parse did not settle: " .. tostring(snapshot.status)) + assert(snapshot.total == 120, "multi-tick parse lost binds: " .. tostring(snapshot.total)) + assert(ticks >= 1, "parse never yielded across ticks") +end + +-- A parse superseded by a compositor switch must never publish. +do + local fakeNow = 0 + local realClock = os.clock + os.clock = function() fakeNow = fakeNow + 0.01 return fakeNow end + + local lines = { "binds {" } + for index = 1, 200 do + lines[#lines + 1] = string.format( + ' Super+Key%d hotkey-overlay-title="Action %d" { spawn-sh "cmd%d"; }', index, index, index + ) + end + lines[#lines + 1] = "}" + local values, state = stateMock() + local host = niriHost(values, state, { ["/fixture/config.kdl"] = table.concat(lines, "\n") }, { + compositor = "niri", niri_config = "/fixture/config.kdl", + merge_sequential = false, show_undescribed = true, merge_similar = false, + }) + noctalia = host + assert(loadfile("niri_service.luau"))() + assert(values["keymap.snapshot"].status == "loading", "large fixture should still be parsing after load") + -- Simulate the user switching the compositor setting mid-parse. + host.getConfig = function(key) + return ({ compositor = "hyprland", niri_config = "/fixture/config.kdl" })[key] + end + local stale = values["keymap.snapshot"] + onConfigChanged() + update() + update() + os.clock = realClock + assert(values["keymap.snapshot"] == stale, "superseded parse overwrote the snapshot") +end + +-- merge_similar must not merge binds that share a title but run different actions. +do + local source = [[binds { + Super+A hotkey-overlay-title="Same title" { spawn-sh "one"; } + Super+B hotkey-overlay-title="Same title" { spawn-sh "two"; } + Super+W hotkey-overlay-title="Close Window" { close-window; } + Alt+F4 hotkey-overlay-title="Close Window" { close-window; } +}]] + local values, state = stateMock() + noctalia = niriHost(values, state, { ["/fixture/config.kdl"] = source }, { + compositor = "niri", niri_config = "/fixture/config.kdl", + merge_sequential = false, show_undescribed = true, merge_similar = true, + }) + assert(loadfile("niri_service.luau"))() + local binds = {} + for _, category in ipairs(values["keymap.snapshot"].categories or {}) do + for _, bind in ipairs(category.binds or {}) do binds[#binds + 1] = bind end + end + assert(#binds == 3, "same-title different-action binds merged: " .. #binds) + local merged + for _, bind in ipairs(binds) do + if bind.id:match("^similar:") then merged = bind end + end + assert(merged ~= nil, "close-window pair did not merge") + assert(#merged.combos == 2, "merged row lost combos") + assert(merged.activation == "press", "merged row activation wrong") +end + +print("parser lifecycle tests: ok") diff --git a/keymap/writer_service.luau b/keymap/writer_service.luau index 01a3c23f..c5b2aca2 100644 --- a/keymap/writer_service.luau +++ b/keymap/writer_service.luau @@ -267,17 +267,21 @@ local function conflictsWithSnapshot(request, excludedId) local keys = comparableKeys(request.keys) for _, category in ipairs(type(snapshot) == "table" and snapshot.categories or {}) do for _, bind in ipairs(type(category.binds) == "table" and category.binds or {}) do - local bindKeys = comparableKeys(type(bind.keys) == "table" and bind.keys or { bind.key }) - local sameKeys = #bindKeys == #keys - if sameKeys then - for index, key in ipairs(keys) do - if bindKeys[index] ~= key then sameKeys = false break end + local combos = type(bind.combos) == "table" and #bind.combos > 0 and bind.combos + or { { modifiers = bind.modifiers, key = bind.key, keys = bind.keys } } + for _, combo in ipairs(combos) do + local bindKeys = comparableKeys(type(combo.keys) == "table" and combo.keys or { combo.key }) + local sameKeys = #bindKeys == #keys + if sameKeys then + for index, key in ipairs(keys) do + if bindKeys[index] ~= key then sameKeys = false break end + end + end + if tostring(bind.id or "") ~= tostring(excludedId or "") and sameKeys + and comparableModifiers(combo.modifiers) == modifiers + and tostring(bind.activation or "press") == request.activation then + return true end - end - if tostring(bind.id or "") ~= tostring(excludedId or "") and sameKeys - and comparableModifiers(bind.modifiers) == modifiers - and tostring(bind.activation or "press") == request.activation then - return true end end end