diff --git a/keybind-cheatsheet/plugin.toml b/keybind-cheatsheet/plugin.toml index 9f588e86..1c3c3a70 100644 --- a/keybind-cheatsheet/plugin.toml +++ b/keybind-cheatsheet/plugin.toml @@ -1,6 +1,6 @@ id = "kenn/keybind-cheatsheet" name = "Keybind Cheatsheet" -version = "0.2.6" +version = "0.2.7" plugin_api = 9 author = "kenn" license = "MIT" diff --git a/keybind-cheatsheet/service.luau b/keybind-cheatsheet/service.luau index 69134f71..2bbf83dd 100644 --- a/keybind-cheatsheet/service.luau +++ b/keybind-cheatsheet/service.luau @@ -1056,35 +1056,50 @@ local function modifiersFromMask(mask) return result end -local function parseHyprJson(raw, rules) +local function hyprBindingFromEntry(entry) + if type(entry) ~= "table" then return nil end + local description = entry.has_description == false and "" or (entry.description or "") + local key = entry.key or "" + if key == "" and tonumber(entry.keycode) ~= nil and tonumber(entry.keycode) ~= 0 then + key = "code:" .. tostring(entry.keycode) + end + local dispatcher = entry.dispatcher or "" + local argument = entry.arg or "" + if key == "" or dispatcher == "" then return nil end + local binding = { + compositor = "hyprland", + bindingType = entry.mouse == true and "bindm" or "bind", + modifiers = modifiersFromMask(entry.modmask), + key = key, + action = trim(dispatcher .. " " .. argument), + description = description, + category = "", + sourceFile = "hyprctl binds -j", + sourceLine = 0, + submap = entry.submap or "", + } + addBinding({}, binding) + return binding +end + +local function parseHyprJsonEntries(raw) local decoded, err = noctalia.json.decode(raw) if type(decoded) ~= "table" then return nil, err or "Invalid hyprctl JSON" end + return decoded, nil +end + +local function parseHyprJson(raw, rules) + local decoded, err = parseHyprJsonEntries(raw) + if decoded == nil then return nil, err end local result = {} for _, entry in ipairs(decoded) do - if type(entry) == "table" then - local description = entry.has_description == false and "" or (entry.description or "") - local key = entry.key or "" - if key == "" and tonumber(entry.keycode) ~= nil and tonumber(entry.keycode) ~= 0 then - key = "code:" .. tostring(entry.keycode) - end - local dispatcher = entry.dispatcher or "" - local argument = entry.arg or "" - if key ~= "" and dispatcher ~= "" then - addBinding(result, { - compositor = "hyprland", - bindingType = entry.mouse == true and "bindm" or "bind", - modifiers = modifiersFromMask(entry.modmask), - key = key, - action = trim(dispatcher .. " " .. argument), - description = description, - category = description ~= "" and categoryFromHyprRules(description, rules) or "", - sourceFile = "hyprctl binds -j", - sourceLine = 0, - submap = entry.submap or "", - }) - end + local binding = hyprBindingFromEntry(entry) + if binding ~= nil then + binding.category = binding.description ~= "" and categoryFromHyprRules(binding.description, rules) or "" + binding.baseCategory = binding.category + table.insert(result, binding) end end return result, nil @@ -1477,29 +1492,147 @@ noctalia.state.watch(PARSE_STEP_KEY, function(value) parseStep() end) +-- The Hyprland Lua refresh is staged across several state-watch callbacks: +-- one ~25 ms Luau CPU budget cannot cover decoding the full `hyprctl binds -j` +-- payload, building every binding, running the Lua category scan, categorizing, +-- and writing the cache in a single callback — on mid-size configs the callback +-- is killed mid-flight and the panel is left on "Reading keybinds..." forever +-- (#606, #663). scheduleHyprStep pings HYPR_STEP_KEY after each phase (decode, +-- each HYPR_BINDING_SLICE-sized build/categorize slice, scan, finish), and each +-- state-watch callback continues the job with a fresh budget window. +local HYPR_STEP_KEY = "keybind-cheatsheet.hypr-step" +local HYPR_BINDING_SLICE = 16 +local hyprJob = nil +local hyprStepCounter = 0 + +local function scheduleHyprStep(job, raw) + hyprStepCounter += 1 + local value = { generation = job.generation, request = job.request, step = hyprStepCounter } + if raw ~= nil then value.raw = raw end + noctalia.state.set(HYPR_STEP_KEY, value) +end + +local function finishHyprJob(job) + if job.generation ~= refreshGeneration or hyprJob ~= job then return end + local scan = job.scan + local warnings = scan ~= nil and scan.warnings or {} + local sourceSnapshot = scan + local result = job.bindings or {} + local err = job.error + hyprJob = nil + noctalia.state.set(HYPR_STEP_KEY, nil) + finishRefresh(job.generation, job.request, result, warnings, err, sourceSnapshot) +end + +local function hyprStep(value) + local job = hyprJob + if job == nil or type(value) ~= "table" + or value.generation ~= refreshGeneration + or value.generation ~= job.generation + or value.step ~= hyprStepCounter + or not requestsMatch(value.request, job.request) then + return + end + + if job.phase == "decode" then + job.raw = value.raw or "" + if job.result.exitCode ~= 0 or job.result.timedOut then + job.error = trim(job.result.stderr) + if job.error == "" then job.error = tr("hyprctl_failed") end + job.phase = "scan" + scheduleHyprStep(job) + return + end + local decoded, err = parseHyprJsonEntries(job.raw) + if decoded == nil then + job.error = err + job.phase = "scan" + scheduleHyprStep(job) + return + end + job.entries = decoded + job.raw = nil + job.index = 1 + job.bindings = {} + job.phase = "build" + end + + if job.phase == "build" then + local limit = math.min(#job.entries, job.index + HYPR_BINDING_SLICE - 1) + while job.index <= limit do + local binding = hyprBindingFromEntry(job.entries[job.index]) + if binding ~= nil then table.insert(job.bindings, binding) end + job.index += 1 + end + if job.index <= #job.entries then + scheduleHyprStep(job) + return + end + job.phase = "scan" + scheduleHyprStep(job) + return + end + + if job.phase == "scan" then + -- Whole-config scan gets its own budget window (this is what + -- blew up on large Lua configs in #606). + job.scan = scanHyprLua(job.request.root) + if job.error ~= nil then + job.bindings = {} + job.phase = "finish" + scheduleHyprStep(job) + return + end + job.index = 1 + job.phase = "categorize" + scheduleHyprStep(job) + return + end + + if job.phase == "categorize" then + local bindings = job.bindings or {} + local limit = math.min(#bindings, job.index + HYPR_BINDING_SLICE - 1) + while job.index <= limit do + local binding = bindings[job.index] + if binding ~= nil and binding.description ~= "" then + binding.category = categoryFromHyprRules(binding.description, job.scan.rules) + binding.baseCategory = binding.category + end + job.index += 1 + end + if job.index <= #bindings then + scheduleHyprStep(job) + return + end + job.phase = "finish" + scheduleHyprStep(job) + return + end + + if job.phase == "finish" then + finishHyprJob(job) + end +end + +noctalia.state.watch(HYPR_STEP_KEY, hyprStep) + local function refreshHyprLua(generation, request) if not noctalia.commandExists("hyprctl") then finishRefresh(generation, request, {}, {}, tr("hyprctl_missing"), nil) return end + -- The completion callback only stashes the raw output; parsing continues + -- in the staged HYPR_STEP_KEY callbacks above (fresh budget per phase). local accepted = noctalia.runAsync("hyprctl binds -j", function(result) if generation ~= refreshGeneration then return end - local scan = scanHyprLua(request.root) - if result.exitCode ~= 0 or result.timedOut then - local message = trim(result.stderr) - finishRefresh( - generation, - request, - {}, - scan.warnings, - message ~= "" and message or tr("hyprctl_failed"), - scan - ) - return - end - local parsed, err = parseHyprJson(result.stdout, scan.rules) - finishRefresh(generation, request, parsed or {}, scan.warnings, err, scan) + hyprJob = { + generation = generation, + request = request, + result = result, + phase = "decode", + } + scheduleHyprStep(hyprJob, result.stdout or "") end, 10000) if not accepted then