From c39886682da66228f7e66a0e47064e5729b561ee Mon Sep 17 00:00:00 2001 From: UmedjonBA Date: Sun, 30 Aug 2026 01:45:49 +0300 Subject: [PATCH 1/2] keybind-cheatsheet: fix the panel never loading on mid-size configs (CPU budget) On a ~800-line niri config (130 bindings) the plugin never leaves "Reading keybindings...": the Luau runtime kills every callback that exceeds its CPU budget, and both the config parse and the panel render run past it. Interacting with an already-drawn panel (search, refresh, edit mode) failed the same way, which also left internal state stuck. Service: - Parse niri configs with a small sh/sed/awk pipeline instead of the in-process tokenizer: tokenizing a 33 KB config alone measured 31.6 ms. Chunking it with a coroutine does not help - the budget accumulates over the coroutine's whole lifetime (verified: 3-op slices still died, at slice 2194). The Lua parsers are untouched and still cover the self-test fixtures; the awk output matches tests/fixtures/niri exactly. - Convert the emitted rows into bindings a few per update() tick, since an async callback's budget is smaller still. - Faster tokenizer paths (whole strings, whitespace runs and words in one match each) for the parsers that remain in-process. Panel: - Cache each binding's search haystack, resolved category and built row node; a full 130-row redraw drops from ~22 ms to ~6-7 ms. - Render progressively: at most 24 rows per pass, the rest fills in over a chain of async callbacks, and every render outside that chain restarts from a small chunk, so a click never pays for the whole tree. - render() only schedules; the draw happens in its own callback. - Split onOpen (preferences JSON + snapshot + draw) into async steps. - Drop the sticky in-progress flags: a callback killed mid-render used to leave them set, and the panel stopped redrawing entirely until restart. Version 0.2.5; declares the awk/sed/sh the niri path now uses. Co-Authored-By: Claude Fable 5 --- keybind-cheatsheet/README.md | 9 +- keybind-cheatsheet/panel.luau | 163 +++++++++++++++++++-- keybind-cheatsheet/plugin.toml | 4 +- keybind-cheatsheet/service.luau | 243 +++++++++++++++++++++++++++----- 4 files changed, 370 insertions(+), 49 deletions(-) diff --git a/keybind-cheatsheet/README.md b/keybind-cheatsheet/README.md index f9e4575d..20118e46 100644 --- a/keybind-cheatsheet/README.md +++ b/keybind-cheatsheet/README.md @@ -29,8 +29,13 @@ configuration paths use the portable `~/.config/...` form. ## Requirements -Install `hyprctl` on `PATH` when using a Hyprland Lua configuration. Mango, -Niri, and classic Hyprland configurations do not spawn external commands. +Install `hyprctl` on `PATH` when using a Hyprland Lua configuration. + +For Niri, the configuration is read by a small `sh`/`sed`/`awk` pipeline +(all part of a base system): parsing a mid-size KDL config in Luau exceeds +the plugin CPU budget, which used to leave the panel stuck on "Reading +keybindings...". Mango and classic Hyprland configurations are still parsed +in-process and spawn no external commands. No clipboard command is required. Color paste uses Noctalia's native clipboard API. diff --git a/keybind-cheatsheet/panel.luau b/keybind-cheatsheet/panel.luau index a566018a..af72d136 100644 --- a/keybind-cheatsheet/panel.luau +++ b/keybind-cheatsheet/panel.luau @@ -28,6 +28,30 @@ local panelOpen = false local panelError = nil local refreshError = nil local snapshot = nil +local invalidateBindingCaches -- defined below, called from the state watcher above it + +-- Progressive rendering. Building the whole tree at once (a full keymap is +-- 130+ rows, each with several key pills) does not fit the plugin CPU +-- budget: onOpen and every keystroke got killed mid-render, leaving the +-- panel half-drawn with dead buttons. Each render draws at most +-- `renderLimit` rows; update() raises the limit and re-renders, so the rest +-- of the list fills in over the next few ticks (each tick is a fresh +-- budget) and the panel stays interactive throughout. +local RENDER_FIRST = 24 +local RENDER_STEP = 40 +local renderLimit = RENDER_FIRST +local renderedRows = 0 +local renderTruncated = false +local scheduleFill -- defined below; called at the end of render +local renderNow -- the actual (expensive) draw; `render` only schedules it +local renderPending = false +local invalidateRowCache -- defined with the row cache, called from invalidateBindingCaches above it +-- The fill-in chain passes keepLimit = true to renderNow; every other +-- render (a click, a keystroke, a state update) restarts from a small +-- chunk, so a handler never pays for the full 130-row tree on top of its +-- own work. Deliberately a parameter and not a shared flag: a callback +-- killed by the CPU budget would leave a flag stuck forever, and with it +-- the whole panel. local query = "" local searchRevision = 0 local view = "bindings" @@ -200,6 +224,7 @@ local function loadPreferences() end local function savePreferences() + if invalidateBindingCaches ~= nil then invalidateBindingCaches() end local path = preferencesPath() if path == nil then return end local encoded = noctalia.json.encode(preferences, true) @@ -212,6 +237,7 @@ end local function applySnapshot(value) snapshot = type(value) == "table" and value or nil + invalidateBindingCaches() if snapshot == nil then bindings = {} parseWarnings = {} @@ -444,17 +470,44 @@ local function effectiveCategory(binding) return binding.compositor == "niri" and niriCategoryFor(binding.action) or tr("other") end +-- The search haystack and the resolved category are rebuilt for every +-- binding on every render otherwise; with a full keymap (130+ bindings) +-- that alone pushes onOpen and each keystroke past the plugin CPU budget, +-- which leaves the panel half-drawn and its buttons dead. Both are cached +-- per binding id and dropped whenever the data behind them changes. +local haystackCache = {} +local categoryCache = {} + +invalidateBindingCaches = function() + haystackCache = {} + categoryCache = {} + if invalidateRowCache ~= nil then invalidateRowCache() end +end + +local function cachedCategory(binding) + local cached = categoryCache[binding.id] + if cached == nil then + cached = effectiveCategory(binding) + categoryCache[binding.id] = cached + end + return cached +end + local function bindingMatches(binding) local needle = lower(trim(query)) if needle == "" then return true end - local haystack = lower(table.concat({ - table.concat(binding.modifiers, " "), - binding.key, - formatKey(binding.key), - effectiveDescription(binding), - binding.action, - effectiveCategory(binding), - }, " ")) + local haystack = haystackCache[binding.id] + if haystack == nil then + haystack = lower(table.concat({ + table.concat(binding.modifiers, " "), + binding.key, + formatKey(binding.key), + effectiveDescription(binding), + binding.action, + cachedCategory(binding), + }, " ")) + haystackCache[binding.id] = haystack + end return haystack:find(needle, 1, true) ~= nil end @@ -519,7 +572,7 @@ local function visibleGroups() local hidden = preferences.hidden[binding.id] == true local undescribed = authoredDescription(binding) == "" and binding.compositor ~= "niri" if bindingAvailableInView(hidden, undescribed, managing, showUndescribed) and bindingMatches(binding) then - local category = effectiveCategory(binding) + local category = cachedCategory(binding) local group = byName[category] if group == nil then group = { name = category, bindings = {}, sourceOrder = #groups + 1, order = 0, weight = 1 } @@ -585,8 +638,25 @@ local function saveCustomDescription(binding, value) render() end +-- Built ui nodes are immutable descriptions, so a row that looks the same +-- can be reused verbatim: rebuilding all 130 rows costs ~22 ms, which is +-- most of a callback's CPU budget. The key carries everything a row's +-- appearance depends on, so any change misses the cache naturally. +local rowCache = {} + +invalidateRowCache = function() + rowCache = {} +end + local function bindingRow(binding, occurrence) local hidden = preferences.hidden[binding.id] == true + local cacheKey = binding.id .. "#" .. occurrence .. "|" .. (hidden and "h" or "-") + .. "|" .. view .. "|" .. (editingId == (binding.id .. "#" .. occurrence) and "e" or "-") + .. "|" .. (preferences.descriptions[binding.id] or "") + local cached = rowCache[cacheKey] + if cached ~= nil then + return cached + end local contentOpacity = bindingContentOpacity(hidden) local pills = {} for index, modifier in ipairs(binding.modifiers) do @@ -657,7 +727,9 @@ local function bindingRow(binding, occurrence) table.insert(row, ui.button({ glyph = "pencil", width = 22, height = 22, glyphSize = 12, variant = "ghost", controlSize = "sm", tooltip = tr("edit_description"), onClick = edit })) table.insert(row, ui.button({ glyph = hidden and "eye-off" or "eye", width = 22, height = 22, glyphSize = 13, variant = "ghost", controlSize = "sm", selected = hidden, tooltip = hidden and tr("show_binding") or tr("hide_binding"), onClick = visibilityChanged })) end - return ui.row({ key = identity, gap = 6, align = "center", paddingV = 0 }, row) + local node = ui.row({ key = identity, gap = 6, align = "center", paddingV = 0 }, row) + rowCache[cacheKey] = node + return node end local function categoryNode(group) @@ -667,6 +739,11 @@ local function categoryNode(group) } local occurrences = {} for _, binding in ipairs(group.bindings) do + if renderedRows >= renderLimit then + renderTruncated = true + break + end + renderedRows += 1 occurrences[binding.id] = (occurrences[binding.id] or 0) + 1 table.insert(children, bindingRow(binding, occurrences[binding.id])) end @@ -674,6 +751,8 @@ local function categoryNode(group) end local function bindingsBody() + renderedRows = 0 + renderTruncated = false local groups = visibleGroups() if #groups == 0 then return ui.column({ flexGrow = 1, align = "center", justify = "center" }, { @@ -798,7 +877,10 @@ local function renderHeader() return ui.row({ align = "center", justify = "space_between", gap = 7 }, children) end -render = function() +renderNow = function(keepLimit) + if not keepLimit then + renderLimit = RENDER_FIRST + end if not panelOpen then return end resetCallbacks() local contentState = "bindings" @@ -834,6 +916,9 @@ render = function() table.insert(children, bindingsBody()) end panel.render(ui.column({ key = "keybind-cheatsheet-" .. contentState, flexGrow = 1, gap = 8, align = "stretch" }, children)) + if renderTruncated and scheduleFill ~= nil then + scheduleFill() + end end noctalia.state.watch(SNAPSHOT_KEY, function(value) @@ -860,16 +945,68 @@ local function releasePanelState(clearModel) end end +-- Any handler (button, keystroke, state update) calls render(); the draw +-- itself happens in a fresh async callback, so the handler's own budget is +-- not spent on a 130-row tree and never gets killed mid-click. +render = function() + if renderPending then + return + end + renderPending = true + local started = noctalia.runAsync("true", function() + renderPending = false + renderNow() + end, 2000) + if not started then + renderPending = false + renderNow() + end +end + +-- Called on the panel's own tick: each call is a fresh CPU budget, so the +-- remaining rows are added a chunk at a time until everything is on screen. +-- Panels get no update() tick, so the fill-in is driven by a chain of +-- trivial async commands instead: each callback runs with a fresh CPU +-- budget, raises the limit and re-renders, until nothing is left to draw. +local fillPending = false + +scheduleFill = function() + if fillPending or not renderTruncated then + return + end + fillPending = true + local started = noctalia.runAsync("true", function() + fillPending = false + if renderTruncated then + renderLimit += RENDER_STEP + renderNow(true) + end + end, 2000) + if not started then + fillPending = false + end +end + function onOpen(_context) + renderLimit = RENDER_FIRST panelOpen = true view = "bindings" query = "" searchRevision += 1 editingId = nil editDraft = "" - loadPreferences() - applySnapshot(noctalia.state.get(SNAPSHOT_KEY)) + -- Reading the preferences JSON and re-applying the snapshot are each + -- expensive enough that doing both plus a render inside onOpen exceeds + -- the CPU budget; they run as their own async steps, and the panel draws + -- immediately from whatever state it already has. render() + noctalia.runAsync("true", function() + loadPreferences() + noctalia.runAsync("true", function() + applySnapshot(noctalia.state.get(SNAPSHOT_KEY)) + render() + end, 2000) + end, 2000) end function onClose() diff --git a/keybind-cheatsheet/plugin.toml b/keybind-cheatsheet/plugin.toml index 11e35dd1..2dd9bfe6 100644 --- a/keybind-cheatsheet/plugin.toml +++ b/keybind-cheatsheet/plugin.toml @@ -1,10 +1,10 @@ id = "kenn/keybind-cheatsheet" name = "Keybind Cheatsheet" -version = "0.2.4" +version = "0.2.5" plugin_api = 9 author = "kenn" license = "MIT" -dependencies = ["hyprctl"] +dependencies = ["awk", "hyprctl", "sed", "sh"] tags = ["bar", "panel", "utility", "system", "hyprland", "mangowc", "niri"] icon = "keyboard" description = "Searchable keybindings for Mango, Hyprland, and Niri." diff --git a/keybind-cheatsheet/service.luau b/keybind-cheatsheet/service.luau index 916b4498..08c6275c 100644 --- a/keybind-cheatsheet/service.luau +++ b/keybind-cheatsheet/service.luau @@ -530,45 +530,63 @@ local function niriTokens(content) line += newlines index = ending + 2 elseif char == "\"" then + -- Fast path: an unescaped string is one find() plus one sub(), not a + -- per-character interpreter loop - the loop below made mid-size configs + -- (~550+ lines) exceed the plugin CPU budget, killing the parse + -- silently and leaving the panel on "Reading keybindings..." forever. local startLine = line - local value = {} - index += 1 - local escaped = false - while index <= #content do - local stringChar = content:sub(index, index) - if escaped then - local replacements = { n = "\n", r = "\r", t = "\t" } - table.insert(value, replacements[stringChar] or stringChar) - escaped = false - elseif stringChar == "\\" then - escaped = true - elseif stringChar == "\"" then - index += 1 - break - else - if stringChar == "\n" then - line += 1 + local closing = content:find("\"", index + 1, true) + local segment = closing ~= nil and content:sub(index + 1, closing - 1) or nil + if segment ~= nil and segment:find("\\", 1, true) == nil then + local _, newlines = segment:gsub("\n", "") + line += newlines + table.insert(tokens, { type = "string", value = segment, line = startLine }) + index = closing + 1 + else + local value = {} + index += 1 + local escaped = false + while index <= #content do + local stringChar = content:sub(index, index) + if escaped then + local replacements = { n = "\n", r = "\r", t = "\t" } + table.insert(value, replacements[stringChar] or stringChar) + escaped = false + elseif stringChar == "\\" then + escaped = true + elseif stringChar == "\"" then + index += 1 + break + else + if stringChar == "\n" then + line += 1 + end + table.insert(value, stringChar) end - table.insert(value, stringChar) + index += 1 end - index += 1 + table.insert(tokens, { type = "string", value = table.concat(value), line = startLine }) end - table.insert(tokens, { type = "string", value = table.concat(value), line = startLine }) elseif char == "{" or char == "}" or char == ";" then table.insert(tokens, { type = char, value = char, line = line }) index += 1 + elseif char == " " or char == "\t" or char == "\r" then + -- Whole whitespace runs in one match: indentation was costing one + -- interpreter iteration per space. + local run = content:match("^[ \t\r]+", index) + index += #run else - local start = index - while index <= #content do - char = content:sub(index, index) - nextChar = content:sub(index + 1, index + 1) - if char:match("%s") or char == "\"" or char == "{" or char == "}" or char == ";" - or (char == "/" and (nextChar == "/" or nextChar == "*")) then - break - end - index += 1 + -- One match per word instead of one iteration per character; a word + -- only ends early when a comment starts inside the run ("//", "/*"). + local word = content:match('^[^%s"{};]+', index) + local slash = word:find("//", 1, true) + local block = word:find("/%*") + local cut = math.min(slash or (#word + 1), block or (#word + 1)) + if cut <= #word then + word = word:sub(1, cut - 1) end - table.insert(tokens, { type = "word", value = content:sub(start, index - 1), line = line }) + table.insert(tokens, { type = "word", value = word, line = line }) + index += #word end end return tokens @@ -1244,6 +1262,168 @@ local function refreshHyprLua(generation, request) end end +-- ── Shell-side niri parse ──────────────────────────────────────────────────── +-- The Luau runtime here is heavily instrumented: tokenizing a 33 KB config +-- measured 31.6 ms - alone past the plugin CPU budget, which accumulates per +-- coroutine, so chunking cannot help either. The niri config is therefore +-- parsed by awk in a subprocess (one short line per bind), and the Lua side +-- only converts ~a hundred rows into binding tables. +local pendingRows = nil -- rows queued for per-tick conversion (see drainPendingRows) + +local NIRI_AWK = [==[ +# Emits: BIND\tline\thotkey\ttitle\taction\tcategory for a niri KDL config. +function emit() { + gsub(/\t/, " ", action); gsub(/"/, "", action) + gsub(/^[ ]+|[ ;]+$/, "", action) + printf "BIND\t%d\t%s\t%s\t%s\t%s\n", bindline, hotkey, title, action, cat + inbind = 0; hotkey = ""; title = ""; action = "" +} +{ + line = $0 + if (!inbinds) { + if (line ~ /^[[:space:]]*binds[[:space:]]*\{/) { inbinds = 1; depth = 1 } + next + } + if (line ~ /^[[:space:]]*\/\//) { + if (match(line, /#[[:space:]]*"[^"]*"/)) { + t = substr(line, RSTART, RLENGTH) + sub(/#[[:space:]]*"/, "", t); sub(/"$/, "", t) + cat = t + } + next + } + if (!inbind && match(line, /^[[:space:]]*[A-Za-z0-9+._]+([[:space:]]|\{)/)) { + split(line, hp, /[[:space:]]+/) + cand = hp[1] == "" ? hp[2] : hp[1] + if (cand != "}" && cand != "{") { + inbind = 1; hotkey = cand; bindline = NR; title = ""; action = "" + if (match(line, /hotkey-overlay-title="[^"]*"/)) { + t = substr(line, RSTART, RLENGTH) + sub(/hotkey-overlay-title="/, "", t); sub(/"$/, "", t) + title = t + } + bracepos = index(line, "{") + if (bracepos > 0) { + binddepth = 1 + body = substr(line, bracepos + 1) + n = length(body) + for (i = 1; i <= n; i++) { + c = substr(body, i, 1) + if (c == "{") binddepth++ + else if (c == "}") { binddepth--; if (binddepth == 0) { emit(); break } } + else action = action c + } + } else binddepth = 0 + next + } + } + if (inbind) { + n = length(line) + for (i = 1; i <= n; i++) { + c = substr(line, i, 1) + if (c == "{") { binddepth++; if (binddepth == 1) continue } + else if (c == "}") { binddepth--; if (binddepth == 0) { emit(); break } } + else if (binddepth >= 1) action = action c + } + next + } + n = length(line) + for (i = 1; i <= n; i++) { + c = substr(line, i, 1) + if (c == "{") depth++ + else if (c == "}") { depth--; if (depth == 0) inbinds = 0 } + } +} +]==] + +local function refreshNiriShell(generation, request) + local root = request.root + local rootDir = pathDirname(root) + local cmd = table.concat({ + "root=" .. "'" .. root:gsub("'", "'\\''") .. "'", + 'files="$root"', + -- one include level is enough for the usual config.kdl layout + 'for inc in $(sed -n \'s/^[[:space:]]*include[[:space:]]*"\\([^"]*\\)".*/\\1/p\' "$root" 2>/dev/null); do', + ' case "$inc" in /*) files="$files $inc" ;; *) files="$files ' .. rootDir:gsub("'", "") .. '/$inc" ;; esac', + "done", + "awk " .. "'" .. NIRI_AWK:gsub("'", "'\\''") .. "'" .. ' $files 2>/dev/null', + }, "\n") + + local accepted = noctalia.runAsync(cmd, function(result) + if generation ~= refreshGeneration then return end + local context = { bindings = {}, warnings = {}, sources = {}, globs = {}, rootRead = false } + if noctalia.fileInfo ~= nil then + local info = noctalia.fileInfo(root) + context.rootRead = info ~= nil and not info.isDir + else + context.rootRead = true + end + table.insert(context.sources, { path = root }) + -- Only the cheap split happens here: even converting ~130 rows into + -- binding tables blows the (very small) async-callback CPU budget, so + -- the conversion is drained a few rows per update() tick instead. + local rows = {} + if not result.timedOut and result.exitCode == 0 then + for row in (result.stdout or ""):gmatch("[^\n]+") do + table.insert(rows, row) + end + end + pendingRows = { generation = generation, request = request, root = root, rows = rows, index = 1, context = context } + end, 10000) + + if not accepted then + finishRefresh(generation, request, {}, {}, tr("missing_config"), nil) + end +end + +-- A few rows per tick: each update() call is a fresh CPU-budget window. +local ROWS_PER_TICK = 10 + +local function drainPendingRows() + local pending = pendingRows + if pending == nil then return end + if pending.generation ~= refreshGeneration then + pendingRows = nil + return + end + local context = pending.context + local processed = 0 + while pending.index <= #pending.rows and processed < ROWS_PER_TICK do + local row = pending.rows[pending.index] + pending.index += 1 + processed += 1 + local lineNo, hotkey, title, action = row:match("^BIND\t(%d+)\t([^\t]*)\t([^\t]*)\t([^\t]*)\t") + local cat = row:match("\t([^\t]*)$") + if hotkey ~= nil and hotkey ~= "" then + local keyParts = {} + for part in hotkey:gmatch("[^+]+") do + table.insert(keyParts, part) + end + local key = table.remove(keyParts) or hotkey + addBinding(context.bindings, { + compositor = "niri", + bindingType = "bind", + modifiers = normalizeModifiers(table.concat(keyParts, " "), {}), + key = key, + action = action or "", + description = title or "", + category = (cat ~= nil and cat ~= "") and cat or niriCategoryFor(action or ""), + sourceFile = pending.root, + sourceLine = tonumber(lineNo), + }) + end + end + if pending.index > #pending.rows then + pendingRows = nil + finishRefresh(pending.generation, pending.request, context.bindings, context.warnings, configReadError(context), context) + end +end + +function update() + drainPendingRows() +end +noctalia.setUpdateInterval(50) + local function performRefresh(generation, request) if request == nil then finishRefresh(generation, { compositor = "", parser = "", root = "" }, {}, {}, tr("unsupported"), nil) @@ -1251,8 +1431,7 @@ local function performRefresh(generation, request) local context = readConfig(request.root, parseMangoContent) finishRefresh(generation, request, context.bindings, context.warnings, configReadError(context), context) elseif request.parser == "niri" then - local context = readConfig(request.root, parseNiriContent) - finishRefresh(generation, request, context.bindings, context.warnings, configReadError(context), context) + refreshNiriShell(generation, request) elseif request.parser == "hypr-lua" then refreshHyprLua(generation, request) elseif request.parser == "hypr-conf" then From b845824d459bb6d9806317e006b6c28f5edf1f1c Mon Sep 17 00:00:00 2001 From: Umedjon Bazarov <170195993+UmedjonBA@users.noreply.github.com> Date: Mon, 31 Aug 2026 02:26:29 +0300 Subject: [PATCH 2/2] keybind-cheatsheet: drop the awk pipeline, timers, and helper spawns for event-driven budget slices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback on the CPU-budget fix asked to keep the tokenizer fast-paths and the panel caches, but to stay strictly event-driven: no setUpdateInterval(50) tick loop running while idle, no NIRI_AWK shell dependency, and no /bin/true processes spawned to schedule deferred work. The replacement rests on one runtime fact: noctalia.state.set enqueues each watch callback as its own event with its own fresh CPU budget, so a plugin can continue heavy work by pinging itself through the shared state — no timers, no subprocesses, and nothing at all runs while idle. Service: - The niri refresh is a staged job driven by PARSE_STEP_KEY: read one file, scan its includes, tokenize in resumable 300-iteration slices, walk the tokens in resumable 150-token slices, then publish. The sliced parse is byte-identical to the single-shot one (verified by deep-comparing all 133 bindings of a real 34 KB config). - The include scan is plain find() plus short anchored matches: an unanchored pattern scan over the whole content exceeds a budget window by itself on the instrumented runtime. - The whitespace fast-path in the tokenizer was dead code (the generic one-char %s branch shadowed it); whitespace runs are now really one match each, cutting tokenize slices from 21 to 10 on that config. - The self-test runs one fixture case per step for the same reason; all four cases still pass. Panel: - A small step queue (PANEL_STEP_KEY) replaces the runAsync("true") chains: onOpen draws immediately and loads preferences/snapshot as separate steps, and the progressive fill-in advances through the same queue. render() now draws directly in the handler; the warm row-cache render fits the handler budget. plugin.toml dependencies go back to ["hyprctl"]; the README paragraph about the shell pipeline is replaced with the event-driven scheme. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019mqDLPVJ2WXgRCioAWLEEo --- keybind-cheatsheet/README.md | 15 +- keybind-cheatsheet/panel.luau | 105 +++-- keybind-cheatsheet/plugin.toml | 2 +- keybind-cheatsheet/service.luau | 679 ++++++++++++++++++-------------- 4 files changed, 440 insertions(+), 361 deletions(-) diff --git a/keybind-cheatsheet/README.md b/keybind-cheatsheet/README.md index 20118e46..5cf48e10 100644 --- a/keybind-cheatsheet/README.md +++ b/keybind-cheatsheet/README.md @@ -29,13 +29,14 @@ configuration paths use the portable `~/.config/...` form. ## Requirements -Install `hyprctl` on `PATH` when using a Hyprland Lua configuration. - -For Niri, the configuration is read by a small `sh`/`sed`/`awk` pipeline -(all part of a base system): parsing a mid-size KDL config in Luau exceeds -the plugin CPU budget, which used to leave the panel stuck on "Reading -keybindings...". Mango and classic Hyprland configurations are still parsed -in-process and spawn no external commands. +Install `hyprctl` on `PATH` when using a Hyprland Lua configuration. Mango, +Niri, and classic Hyprland configurations do not spawn external commands. + +Parsing a mid-size Niri config plus rendering its full keymap exceeds one +Luau CPU-budget window, so the refresh and the panel spread the work across +event-driven steps (each state-watch callback runs with a fresh budget). +There are no timers, polling loops, or helper processes; nothing runs while +the plugin is idle. No clipboard command is required. Color paste uses Noctalia's native clipboard API. diff --git a/keybind-cheatsheet/panel.luau b/keybind-cheatsheet/panel.luau index af72d136..ba5a157b 100644 --- a/keybind-cheatsheet/panel.luau +++ b/keybind-cheatsheet/panel.luau @@ -18,6 +18,7 @@ local PANEL_ID = "kenn/keybind-cheatsheet:cheatsheet" local SNAPSHOT_KEY = "keybind-cheatsheet.snapshot" local REFRESH_REQUEST_KEY = "keybind-cheatsheet.refresh-request" local SELF_TEST_REQUEST_KEY = "keybind-cheatsheet.self-test-request" +local PANEL_STEP_KEY = "keybind-cheatsheet.panel-step" local PREFERENCES_FILE = "preferences.json" local bindings = {} @@ -30,28 +31,27 @@ local refreshError = nil local snapshot = nil local invalidateBindingCaches -- defined below, called from the state watcher above it --- Progressive rendering. Building the whole tree at once (a full keymap is --- 130+ rows, each with several key pills) does not fit the plugin CPU --- budget: onOpen and every keystroke got killed mid-render, leaving the --- panel half-drawn with dead buttons. Each render draws at most --- `renderLimit` rows; update() raises the limit and re-renders, so the rest --- of the list fills in over the next few ticks (each tick is a fresh --- budget) and the panel stays interactive throughout. +-- Progressive rendering. Building the whole tree at once with a cold row +-- cache (a full keymap is 130+ rows, each with several key pills) does not +-- fit the plugin CPU budget: onOpen and snapshot changes got killed +-- mid-render, leaving the panel half-drawn with dead buttons. Each render +-- draws at most `renderLimit` rows; the rest fills in over a chain of +-- state-ping callbacks (each one a fresh budget, see scheduleFill) and the +-- panel stays interactive throughout. local RENDER_FIRST = 24 local RENDER_STEP = 40 local renderLimit = RENDER_FIRST local renderedRows = 0 local renderTruncated = false local scheduleFill -- defined below; called at the end of render -local renderNow -- the actual (expensive) draw; `render` only schedules it -local renderPending = false +local panelSteps = {} -- queued panel-step functions (see schedulePanelStep below) +local fillQueued = false local invalidateRowCache -- defined with the row cache, called from invalidateBindingCaches above it --- The fill-in chain passes keepLimit = true to renderNow; every other --- render (a click, a keystroke, a state update) restarts from a small --- chunk, so a handler never pays for the full 130-row tree on top of its --- own work. Deliberately a parameter and not a shared flag: a callback --- killed by the CPU budget would leave a flag stuck forever, and with it --- the whole panel. +-- The fill-in chain passes keepLimit = true to render; every other render +-- (a click, a keystroke, a state update) restarts from a small chunk, so a +-- handler never pays for the full 130-row tree on top of its own work. +-- Deliberately a parameter and not a shared flag: a callback killed by the +-- CPU budget would leave a flag stuck forever, and with it the whole panel. local query = "" local searchRevision = 0 local view = "bindings" @@ -877,7 +877,7 @@ local function renderHeader() return ui.row({ align = "center", justify = "space_between", gap = 7 }, children) end -renderNow = function(keepLimit) +render = function(keepLimit) if not keepLimit then renderLimit = RENDER_FIRST end @@ -932,6 +932,8 @@ local function releasePanelState(clearModel) query = "" editingId = nil editDraft = "" + panelSteps = {} + fillQueued = false resetCallbacks() if clearModel then bindings = {} @@ -945,46 +947,35 @@ local function releasePanelState(clearModel) end end --- Any handler (button, keystroke, state update) calls render(); the draw --- itself happens in a fresh async callback, so the handler's own budget is --- not spent on a 130-row tree and never gets killed mid-click. -render = function() - if renderPending then - return - end - renderPending = true - local started = noctalia.runAsync("true", function() - renderPending = false - renderNow() - end, 2000) - if not started then - renderPending = false - renderNow() - end +-- Work that does not fit the current callback's CPU budget is queued as a +-- panel step: the panel pings itself through the shared state, and the watch +-- callback fires as its own event with its own budget and runs one queued +-- step. Panels get no update() tick, and a timer or a helper process would +-- keep running while idle; this chain only exists while steps are queued. +local panelStepCounter = 0 + +local function schedulePanelStep(fn) + table.insert(panelSteps, fn) + panelStepCounter += 1 + noctalia.state.set(PANEL_STEP_KEY, panelStepCounter) end --- Called on the panel's own tick: each call is a fresh CPU budget, so the --- remaining rows are added a chunk at a time until everything is on screen. --- Panels get no update() tick, so the fill-in is driven by a chain of --- trivial async commands instead: each callback runs with a fresh CPU --- budget, raises the limit and re-renders, until nothing is left to draw. -local fillPending = false +noctalia.state.watch(PANEL_STEP_KEY, function(_value) + local fn = table.remove(panelSteps, 1) + if fn ~= nil then fn() end +end) +-- The fill flag is cleared before the render, not after: a callback killed +-- by the CPU budget would otherwise leave it stuck and the fill chain dead. scheduleFill = function() - if fillPending or not renderTruncated then - return - end - fillPending = true - local started = noctalia.runAsync("true", function() - fillPending = false - if renderTruncated then - renderLimit += RENDER_STEP - renderNow(true) - end - end, 2000) - if not started then - fillPending = false - end + if fillQueued then return end + fillQueued = true + schedulePanelStep(function() + fillQueued = false + if not panelOpen or not renderTruncated then return end + renderLimit += RENDER_STEP + render(true) + end) end function onOpen(_context) @@ -997,16 +988,16 @@ function onOpen(_context) editDraft = "" -- Reading the preferences JSON and re-applying the snapshot are each -- expensive enough that doing both plus a render inside onOpen exceeds - -- the CPU budget; they run as their own async steps, and the panel draws + -- the CPU budget; they run as their own steps, and the panel draws -- immediately from whatever state it already has. render() - noctalia.runAsync("true", function() + schedulePanelStep(function() loadPreferences() - noctalia.runAsync("true", function() + schedulePanelStep(function() applySnapshot(noctalia.state.get(SNAPSHOT_KEY)) render() - end, 2000) - end, 2000) + end) + end) end function onClose() diff --git a/keybind-cheatsheet/plugin.toml b/keybind-cheatsheet/plugin.toml index 2dd9bfe6..0a92f814 100644 --- a/keybind-cheatsheet/plugin.toml +++ b/keybind-cheatsheet/plugin.toml @@ -4,7 +4,7 @@ version = "0.2.5" plugin_api = 9 author = "kenn" license = "MIT" -dependencies = ["awk", "hyprctl", "sed", "sh"] +dependencies = ["hyprctl"] tags = ["bar", "panel", "utility", "system", "hyprland", "mangowc", "niri"] icon = "keyboard" description = "Searchable keybindings for Mango, Hyprland, and Niri." diff --git a/keybind-cheatsheet/service.luau b/keybind-cheatsheet/service.luau index 08c6275c..565343e9 100644 --- a/keybind-cheatsheet/service.luau +++ b/keybind-cheatsheet/service.luau @@ -15,6 +15,8 @@ end local SNAPSHOT_KEY = "keybind-cheatsheet.snapshot" local REFRESH_REQUEST_KEY = "keybind-cheatsheet.refresh-request" local SELF_TEST_REQUEST_KEY = "keybind-cheatsheet.self-test-request" +local PARSE_STEP_KEY = "keybind-cheatsheet.parse-step" +local SELF_TEST_STEP_KEY = "keybind-cheatsheet.self-test-step" local MAX_PARSE_DEPTH = 32 local MAX_PARSE_FILES = 256 local BINDINGS_CACHE_FILE = "bindings-cache.json" @@ -506,18 +508,33 @@ local function parseHyprContent(content, sourceFile, context) return includes end -local function niriTokens(content) - local tokens = {} - local index = 1 - local line = 1 - while index <= #content do +-- The tokenizer is resumable: the staged refresh below advances it a bounded +-- number of iterations per budget window, because even the fast-path scan of +-- a 34 KB config does not fit one window on the instrumented runtime. +local function newNiriTokenizer(content) + return { content = content, tokens = {}, index = 1, line = 1 } +end + +-- Advances by up to maxIterations loop iterations (each one token or run); +-- returns true once the whole content is tokenized. +local function niriTokenizeStep(state, maxIterations) + local content = state.content + local tokens = state.tokens + local index = state.index + local line = state.line + local remaining = maxIterations or math.huge + while index <= #content and remaining > 0 do + remaining -= 1 local char = content:sub(index, index) local nextChar = content:sub(index + 1, index + 1) - if char == "\n" then - line += 1 - index += 1 - elseif char:match("%s") then - index += 1 + if char == "\n" or char == " " or char == "\t" or char == "\r" then + -- Whole whitespace runs (indentation and blank lines included) in one + -- match: one interpreter iteration per character was the single biggest + -- cost of the old scanner. + local run = content:match("^%s+", index) + local _, newlines = run:gsub("\n", "") + line += newlines + index += #run elseif char == "/" and nextChar == "/" then local ending = content:find("\n", index + 2, true) or (#content + 1) table.insert(tokens, { type = "comment", value = content:sub(index + 2, ending - 1), line = line }) @@ -570,11 +587,6 @@ local function niriTokens(content) elseif char == "{" or char == "}" or char == ";" then table.insert(tokens, { type = char, value = char, line = line }) index += 1 - elseif char == " " or char == "\t" or char == "\r" then - -- Whole whitespace runs in one match: indentation was costing one - -- interpreter iteration per space. - local run = content:match("^[ \t\r]+", index) - index += #run else -- One match per word instead of one iteration per character; a word -- only ends early when a comment starts inside the run ("//", "/*"). @@ -589,7 +601,15 @@ local function niriTokens(content) index += #word end end - return tokens + state.index = index + state.line = line + return index > #content +end + +local function niriTokens(content) + local state = newNiriTokenizer(content) + niriTokenizeStep(state, nil) + return state.tokens end local function niriActionText(tokens) @@ -638,85 +658,135 @@ local function niriHeaderDescription(header) return "" end -local function parseNiriContent(content, sourceFile, context) +local function niriIncludes(content) + -- Plain find() candidates plus short anchored matches around them: on this + -- runtime both the per-line gmatch scan and a single whole-content pattern + -- scan each blew a whole CPU-budget window by themselves, while a native + -- substring search is microseconds. local includes = {} - for rawLine in (content .. "\n"):gmatch("(.-)\r?\n") do - local includePath = rawLine:match('^%s*include%s+["\']([^"\']+)["\']') - if includePath ~= nil then - table.insert(includes, { path = includePath, optional = false }) + local from = 1 + while true do + local found = content:find("include", from, true) + if found == nil then + break + end + from = found + 7 + local lineStart = found + while lineStart > 1 and content:sub(lineStart - 1, lineStart - 1) ~= "\n" do + lineStart -= 1 + end + if content:sub(lineStart, found - 1):match("^[ \t]*$") ~= nil then + local quote = content:match("^[ \t]+([\"'])", found + 7) + if quote ~= nil then + local opening = content:find(quote, found + 7, true) + local closing = content:find(quote, opening + 1, true) + local lineEnd = content:find("\n", opening + 1, true) or (#content + 1) + if closing ~= nil and closing < lineEnd and closing > opening + 1 then + table.insert(includes, { path = content:sub(opening + 1, closing - 1), optional = false }) + end + end end end + return includes +end - local tokens = niriTokens(content) - local index = 1 - while index <= #tokens do - if tokens[index].type == "word" and tokens[index].value == "binds" - and tokens[index + 1] ~= nil and tokens[index + 1].type == "{" then - index += 2 - local category = "" - while index <= #tokens and tokens[index].type ~= "}" do - local token = tokens[index] - if token.type == "comment" then - local heading = token.value:match('#%s*"([^"]+)"') or token.value:match("#%s*'([^']+)'") - if heading ~= nil then - category = trim(heading) - end - index += 1 - elseif token.type == "word" then - local hotkey = token.value - local sourceLine = token.line - local header = {} - index += 1 - while index <= #tokens and tokens[index].type ~= "{" and tokens[index].type ~= "}" do - table.insert(header, tokens[index]) - index += 1 - end - if index <= #tokens and tokens[index].type == "{" then - local depth = 1 - local actionTokens = {} - index += 1 - while index <= #tokens and depth > 0 do - if tokens[index].type == "{" then - depth += 1 - table.insert(actionTokens, tokens[index]) - elseif tokens[index].type == "}" then - depth -= 1 - if depth > 0 then - table.insert(actionTokens, tokens[index]) - end - else - table.insert(actionTokens, tokens[index]) - end - index += 1 - end - local action = niriActionText(actionTokens) - local keyParts = {} - for part in hotkey:gmatch("[^+]+") do - table.insert(keyParts, part) +-- The token walk is resumable for the same reason as the tokenizer: the +-- staged refresh advances it a bounded number of tokens per budget window. +local function newNiriWalk(tokens, sourceFile) + return { tokens = tokens, sourceFile = sourceFile, index = 1, category = "", inBinds = false } +end + +-- Advances the walk by up to maxTokens tokens; returns true when done. A +-- whole binding is parsed in one iteration (a binding is only a handful of +-- tokens), so the slice cap stays effective. +local function niriWalkStep(walk, context, maxTokens) + local tokens = walk.tokens + local sourceFile = walk.sourceFile + local limit = maxTokens ~= nil and walk.index + maxTokens or math.huge + while walk.index <= #tokens and walk.index < limit do + local token = tokens[walk.index] + if not walk.inBinds then + if token.type == "word" and token.value == "binds" + and tokens[walk.index + 1] ~= nil and tokens[walk.index + 1].type == "{" then + walk.inBinds = true + walk.category = "" + walk.index += 2 + else + walk.index += 1 + end + elseif token.type == "}" then + walk.inBinds = false + walk.index += 1 + elseif token.type == "comment" then + local heading = token.value:match('#%s*"([^"]+)"') or token.value:match("#%s*'([^']+)'") + if heading ~= nil then + walk.category = trim(heading) + end + walk.index += 1 + elseif token.type == "word" then + local index = walk.index + local hotkey = token.value + local sourceLine = token.line + local header = {} + index += 1 + while index <= #tokens and tokens[index].type ~= "{" and tokens[index].type ~= "}" do + table.insert(header, tokens[index]) + index += 1 + end + if index <= #tokens and tokens[index].type == "{" then + local depth = 1 + local actionTokens = {} + index += 1 + while index <= #tokens and depth > 0 do + if tokens[index].type == "{" then + depth += 1 + table.insert(actionTokens, tokens[index]) + elseif tokens[index].type == "}" then + depth -= 1 + if depth > 0 then + table.insert(actionTokens, tokens[index]) end - local key = table.remove(keyParts) or hotkey - local description = niriHeaderDescription(header) - addBinding(context.bindings, { - compositor = "niri", - bindingType = "bind", - modifiers = normalizeModifiers(table.concat(keyParts, " "), {}), - key = key, - action = action, - description = description, - category = category ~= "" and category or niriCategoryFor(action), - sourceFile = sourceFile, - sourceLine = sourceLine, - }) else - index += 1 + table.insert(actionTokens, tokens[index]) end - else index += 1 end + local action = niriActionText(actionTokens) + local keyParts = {} + for part in hotkey:gmatch("[^+]+") do + table.insert(keyParts, part) + end + local key = table.remove(keyParts) or hotkey + local description = niriHeaderDescription(header) + addBinding(context.bindings, { + compositor = "niri", + bindingType = "bind", + modifiers = normalizeModifiers(table.concat(keyParts, " "), {}), + key = key, + action = action, + description = description, + category = walk.category ~= "" and walk.category or niriCategoryFor(action), + sourceFile = sourceFile, + sourceLine = sourceLine, + }) + else + index += 1 end + walk.index = index + else + walk.index += 1 end - index += 1 end + return walk.index > #tokens +end + +local function parseNiriTokens(tokens, sourceFile, context) + niriWalkStep(newNiriWalk(tokens, sourceFile), context, nil) +end + +local function parseNiriContent(content, sourceFile, context) + local includes = niriIncludes(content) + parseNiriTokens(niriTokens(content), sourceFile, context) return includes end @@ -1232,6 +1302,155 @@ local function configReadError(context) return tr("missing_config") end +-- ── Staged niri parse ──────────────────────────────────────────────────────── +-- Even with the tokenizer fast-paths, scanning a mid-size config does not fit +-- one CPU-budget window, and the budget accumulates over a coroutine's whole +-- lifetime, so coroutines cannot spread it (verified: 3-op slices still +-- died). The refresh is split into bounded steps instead — read a file, queue +-- its includes, tokenize a slice, walk a slice of tokens, publish — each run +-- in its own state-watch callback: setting PARSE_STEP_KEY fires the watcher +-- as a fresh event with a fresh CPU budget. Strictly event-driven: no timers, +-- no subprocesses, nothing runs while idle. +local TOKENIZE_SLICE = 300 -- tokenizer iterations per budget window +local WALK_SLICE = 150 -- tokens walked per budget window (binding conversion is the heavy half) +local parseJob = nil +local parseStepCounter = 0 + +local function scheduleParseStep() + parseStepCounter += 1 + noctalia.state.set(PARSE_STEP_KEY, parseStepCounter) +end + +local function newParseContext() + return { + bindings = {}, + variables = {}, + warnings = {}, + visited = {}, + fileCount = 0, + rootRead = false, + sources = {}, + globs = {}, + } +end + +local function startNiriParse(generation, request, retried) + parseJob = { + generation = generation, + request = request, + context = newParseContext(), + queue = { { pattern = normalizePath(expandEnvironment(request.root)), depth = 0, optional = false, isRoot = true } }, + retried = retried == true, + } + scheduleParseStep() +end + +-- One iteration of walkConfig's visit: expand a pattern into concrete file +-- entries, or read one file and hand its content to the include/tokenize/walk +-- phases above, each of which runs in its own budget window. +local function parseStepFile(job, entry) + local context = job.context + if entry.depth > MAX_PARSE_DEPTH then + table.insert(context.warnings, "Include depth exceeded at " .. entry.pattern) + return + end + if context.fileCount >= MAX_PARSE_FILES then + table.insert(context.warnings, "File limit reached at " .. (entry.pattern or entry.file)) + return + end + if entry.file == nil then + local files = expandGlob(entry.pattern, context) + if #files == 0 then + if not entry.optional then + table.insert(context.warnings, "Could not read " .. entry.pattern) + end + return + end + for _, file in ipairs(files) do + table.insert(job.queue, { file = normalizePath(file), depth = entry.depth, optional = entry.optional, isRoot = entry.isRoot }) + end + return + end + local file = entry.file + if context.visited[file] then + return + end + context.visited[file] = true + context.fileCount += 1 + local content, err = noctalia.readFile(file) + if content == nil then + if not entry.optional then + table.insert(context.warnings, err or ("Could not read " .. file)) + end + return + end + local info = noctalia.fileInfo(file) + if info ~= nil then + table.insert(context.sources, { path = file, size = info.size, mtime = info.mtime }) + end + if entry.isRoot then + context.rootRead = true + end + job.content = content + job.file = file + job.depth = entry.depth +end + +local function parseStep() + local job = parseJob + if job == nil then return end + if job.generation ~= refreshGeneration then + parseJob = nil + return + end + local context = job.context + if job.walk ~= nil then + if niriWalkStep(job.walk, context, WALK_SLICE) then + job.walk = nil + job.file = nil + end + scheduleParseStep() + return + end + if job.tokenizer ~= nil then + if niriTokenizeStep(job.tokenizer, TOKENIZE_SLICE) then + job.walk = newNiriWalk(job.tokenizer.tokens, job.file) + job.tokenizer = nil + end + scheduleParseStep() + return + end + if job.content ~= nil then + -- The include scan is a whole-file pass of its own, so it gets its own + -- budget window before tokenizing starts. + for _, include in ipairs(niriIncludes(job.content)) do + table.insert(job.queue, { pattern = resolvePath(include.path, job.file), depth = job.depth + 1, optional = include.optional == true, isRoot = false }) + end + job.tokenizer = newNiriTokenizer(job.content) + job.content = nil + scheduleParseStep() + return + end + local entry = table.remove(job.queue, 1) + if entry == nil then + if not context.rootRead and not job.retried then + -- Same second chance readConfig gave a transiently unreadable root. + startNiriParse(job.generation, job.request, true) + return + end + parseJob = nil + finishRefresh(job.generation, job.request, context.bindings, context.warnings, configReadError(context), context) + return + end + parseStepFile(job, entry) + scheduleParseStep() +end + +noctalia.state.watch(PARSE_STEP_KEY, function(value) + if tonumber(value) ~= parseStepCounter then return end + parseStep() +end) + local function refreshHyprLua(generation, request) local scan = scanHyprLua(request.root) if not noctalia.commandExists("hyprctl") then @@ -1262,168 +1481,6 @@ local function refreshHyprLua(generation, request) end end --- ── Shell-side niri parse ──────────────────────────────────────────────────── --- The Luau runtime here is heavily instrumented: tokenizing a 33 KB config --- measured 31.6 ms - alone past the plugin CPU budget, which accumulates per --- coroutine, so chunking cannot help either. The niri config is therefore --- parsed by awk in a subprocess (one short line per bind), and the Lua side --- only converts ~a hundred rows into binding tables. -local pendingRows = nil -- rows queued for per-tick conversion (see drainPendingRows) - -local NIRI_AWK = [==[ -# Emits: BIND\tline\thotkey\ttitle\taction\tcategory for a niri KDL config. -function emit() { - gsub(/\t/, " ", action); gsub(/"/, "", action) - gsub(/^[ ]+|[ ;]+$/, "", action) - printf "BIND\t%d\t%s\t%s\t%s\t%s\n", bindline, hotkey, title, action, cat - inbind = 0; hotkey = ""; title = ""; action = "" -} -{ - line = $0 - if (!inbinds) { - if (line ~ /^[[:space:]]*binds[[:space:]]*\{/) { inbinds = 1; depth = 1 } - next - } - if (line ~ /^[[:space:]]*\/\//) { - if (match(line, /#[[:space:]]*"[^"]*"/)) { - t = substr(line, RSTART, RLENGTH) - sub(/#[[:space:]]*"/, "", t); sub(/"$/, "", t) - cat = t - } - next - } - if (!inbind && match(line, /^[[:space:]]*[A-Za-z0-9+._]+([[:space:]]|\{)/)) { - split(line, hp, /[[:space:]]+/) - cand = hp[1] == "" ? hp[2] : hp[1] - if (cand != "}" && cand != "{") { - inbind = 1; hotkey = cand; bindline = NR; title = ""; action = "" - if (match(line, /hotkey-overlay-title="[^"]*"/)) { - t = substr(line, RSTART, RLENGTH) - sub(/hotkey-overlay-title="/, "", t); sub(/"$/, "", t) - title = t - } - bracepos = index(line, "{") - if (bracepos > 0) { - binddepth = 1 - body = substr(line, bracepos + 1) - n = length(body) - for (i = 1; i <= n; i++) { - c = substr(body, i, 1) - if (c == "{") binddepth++ - else if (c == "}") { binddepth--; if (binddepth == 0) { emit(); break } } - else action = action c - } - } else binddepth = 0 - next - } - } - if (inbind) { - n = length(line) - for (i = 1; i <= n; i++) { - c = substr(line, i, 1) - if (c == "{") { binddepth++; if (binddepth == 1) continue } - else if (c == "}") { binddepth--; if (binddepth == 0) { emit(); break } } - else if (binddepth >= 1) action = action c - } - next - } - n = length(line) - for (i = 1; i <= n; i++) { - c = substr(line, i, 1) - if (c == "{") depth++ - else if (c == "}") { depth--; if (depth == 0) inbinds = 0 } - } -} -]==] - -local function refreshNiriShell(generation, request) - local root = request.root - local rootDir = pathDirname(root) - local cmd = table.concat({ - "root=" .. "'" .. root:gsub("'", "'\\''") .. "'", - 'files="$root"', - -- one include level is enough for the usual config.kdl layout - 'for inc in $(sed -n \'s/^[[:space:]]*include[[:space:]]*"\\([^"]*\\)".*/\\1/p\' "$root" 2>/dev/null); do', - ' case "$inc" in /*) files="$files $inc" ;; *) files="$files ' .. rootDir:gsub("'", "") .. '/$inc" ;; esac', - "done", - "awk " .. "'" .. NIRI_AWK:gsub("'", "'\\''") .. "'" .. ' $files 2>/dev/null', - }, "\n") - - local accepted = noctalia.runAsync(cmd, function(result) - if generation ~= refreshGeneration then return end - local context = { bindings = {}, warnings = {}, sources = {}, globs = {}, rootRead = false } - if noctalia.fileInfo ~= nil then - local info = noctalia.fileInfo(root) - context.rootRead = info ~= nil and not info.isDir - else - context.rootRead = true - end - table.insert(context.sources, { path = root }) - -- Only the cheap split happens here: even converting ~130 rows into - -- binding tables blows the (very small) async-callback CPU budget, so - -- the conversion is drained a few rows per update() tick instead. - local rows = {} - if not result.timedOut and result.exitCode == 0 then - for row in (result.stdout or ""):gmatch("[^\n]+") do - table.insert(rows, row) - end - end - pendingRows = { generation = generation, request = request, root = root, rows = rows, index = 1, context = context } - end, 10000) - - if not accepted then - finishRefresh(generation, request, {}, {}, tr("missing_config"), nil) - end -end - --- A few rows per tick: each update() call is a fresh CPU-budget window. -local ROWS_PER_TICK = 10 - -local function drainPendingRows() - local pending = pendingRows - if pending == nil then return end - if pending.generation ~= refreshGeneration then - pendingRows = nil - return - end - local context = pending.context - local processed = 0 - while pending.index <= #pending.rows and processed < ROWS_PER_TICK do - local row = pending.rows[pending.index] - pending.index += 1 - processed += 1 - local lineNo, hotkey, title, action = row:match("^BIND\t(%d+)\t([^\t]*)\t([^\t]*)\t([^\t]*)\t") - local cat = row:match("\t([^\t]*)$") - if hotkey ~= nil and hotkey ~= "" then - local keyParts = {} - for part in hotkey:gmatch("[^+]+") do - table.insert(keyParts, part) - end - local key = table.remove(keyParts) or hotkey - addBinding(context.bindings, { - compositor = "niri", - bindingType = "bind", - modifiers = normalizeModifiers(table.concat(keyParts, " "), {}), - key = key, - action = action or "", - description = title or "", - category = (cat ~= nil and cat ~= "") and cat or niriCategoryFor(action or ""), - sourceFile = pending.root, - sourceLine = tonumber(lineNo), - }) - end - end - if pending.index > #pending.rows then - pendingRows = nil - finishRefresh(pending.generation, pending.request, context.bindings, context.warnings, configReadError(context), context) - end -end - -function update() - drainPendingRows() -end -noctalia.setUpdateInterval(50) - local function performRefresh(generation, request) if request == nil then finishRefresh(generation, { compositor = "", parser = "", root = "" }, {}, {}, tr("unsupported"), nil) @@ -1431,7 +1488,7 @@ local function performRefresh(generation, request) local context = readConfig(request.root, parseMangoContent) finishRefresh(generation, request, context.bindings, context.warnings, configReadError(context), context) elseif request.parser == "niri" then - refreshNiriShell(generation, request) + startNiriParse(generation, request) elseif request.parser == "hypr-lua" then refreshHyprLua(generation, request) elseif request.parser == "hypr-conf" then @@ -1503,58 +1560,33 @@ local function containsAll(values, required) return true, nil end -local function runSelfTest() - local pluginDir = noctalia.pluginDir() or "." - local fixtureRoot = pluginDir .. "/tests/fixtures" - local expectedRaw = noctalia.readFile(pluginDir .. "/tests/expected.json") - local expected = expectedRaw ~= nil and noctalia.json.decode(expectedRaw) or nil - local report = { passed = true, cases = {} } - if type(expected) ~= "table" then - report.passed = false - report.error = "Could not read tests/expected.json" +-- The self-test runs one fixture case per SELF_TEST_STEP_KEY callback: all +-- four in one budget window no longer fit on the instrumented runtime, even +-- with byte-sized fixtures. +local SELF_TEST_CASES = { "mango", "hypr_conf", "niri", "hypr_lua" } +local selfTestJob = nil +local selfTestStepCounter = 0 + +local function scheduleSelfTestStep() + selfTestStepCounter += 1 + noctalia.state.set(SELF_TEST_STEP_KEY, selfTestStepCounter) +end + +local function selfTestCaseBindings(name, fixtureRoot) + if name == "mango" then + return walkConfig(fixtureRoot .. "/mango/main.conf", parseMangoContent).bindings + elseif name == "hypr_conf" then + return walkConfig(fixtureRoot .. "/hypr/hyprland.conf", parseHyprContent).bindings + elseif name == "niri" then + return walkConfig(fixtureRoot .. "/niri/config.kdl", parseNiriContent).bindings else - local cases = { - mango = function() - return walkConfig(fixtureRoot .. "/mango/main.conf", parseMangoContent).bindings - end, - hypr_conf = function() - return walkConfig(fixtureRoot .. "/hypr/hyprland.conf", parseHyprContent).bindings - end, - niri = function() - return walkConfig(fixtureRoot .. "/niri/config.kdl", parseNiriContent).bindings - end, - hypr_lua = function() - local scan = scanHyprLua(fixtureRoot .. "/hypr/hyprland.lua") - local raw = noctalia.readFile(fixtureRoot .. "/hypr/binds.json") or "[]" - return parseHyprJson(raw, scan.rules) or {} - end, - } - for name, execute in pairs(cases) do - local parsed = execute() - local expectedCase = expected[name] - local descriptions = {} - local categories = {} - for _, binding in ipairs(parsed) do - table.insert(descriptions, binding.description) - table.insert( - categories, - binding.description == "" and tr("without_description") - or (binding.category ~= "" and binding.category or tr("other")) - ) - end - local descriptionsOk, missingDescription = containsAll(descriptions, expectedCase.descriptions) - local categoriesOk, missingCategory = containsAll(categories, expectedCase.categories) - local passed = #parsed == expectedCase.count and descriptionsOk and categoriesOk - report.cases[name] = { - passed = passed, - expectedCount = expectedCase.count, - actualCount = #parsed, - missingDescription = missingDescription, - missingCategory = missingCategory, - } - if not passed then report.passed = false end - end + local scan = scanHyprLua(fixtureRoot .. "/hypr/hyprland.lua") + local raw = noctalia.readFile(fixtureRoot .. "/hypr/binds.json") or "[]" + return parseHyprJson(raw, scan.rules) or {} end +end + +local function finishSelfTest(report) local encoded = noctalia.json.encode(report, true) or "{}" local dataDir = noctalia.pluginDataDir() if dataDir ~= nil then noctalia.writeFile(dataDir .. "/selftest.json", encoded) end @@ -1566,6 +1598,61 @@ local function runSelfTest() end end +local function selfTestStep() + local job = selfTestJob + if job == nil then return end + local report = job.report + local name = SELF_TEST_CASES[job.index] + if name == nil or report.error ~= nil then + selfTestJob = nil + finishSelfTest(report) + return + end + job.index += 1 + local parsed = selfTestCaseBindings(name, job.fixtureRoot) + local expectedCase = job.expected[name] + local descriptions = {} + local categories = {} + for _, binding in ipairs(parsed) do + table.insert(descriptions, binding.description) + table.insert( + categories, + binding.description == "" and tr("without_description") + or (binding.category ~= "" and binding.category or tr("other")) + ) + end + local descriptionsOk, missingDescription = containsAll(descriptions, expectedCase.descriptions) + local categoriesOk, missingCategory = containsAll(categories, expectedCase.categories) + local passed = #parsed == expectedCase.count and descriptionsOk and categoriesOk + report.cases[name] = { + passed = passed, + expectedCount = expectedCase.count, + actualCount = #parsed, + missingDescription = missingDescription, + missingCategory = missingCategory, + } + if not passed then report.passed = false end + scheduleSelfTestStep() +end + +noctalia.state.watch(SELF_TEST_STEP_KEY, function(value) + if tonumber(value) ~= selfTestStepCounter then return end + selfTestStep() +end) + +local function runSelfTest() + local pluginDir = noctalia.pluginDir() or "." + local expectedRaw = noctalia.readFile(pluginDir .. "/tests/expected.json") + local expected = expectedRaw ~= nil and noctalia.json.decode(expectedRaw) or nil + local report = { passed = true, cases = {} } + if type(expected) ~= "table" then + report.passed = false + report.error = "Could not read tests/expected.json" + end + selfTestJob = { index = 1, fixtureRoot = pluginDir .. "/tests/fixtures", expected = expected, report = report } + scheduleSelfTestStep() +end + function onConfigChanged() local request = currentRequest() local current = noctalia.state.get(SNAPSHOT_KEY)