Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion keybind-cheatsheet/plugin.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
209 changes: 171 additions & 38 deletions keybind-cheatsheet/service.luau
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading