Skip to content
Draft
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
6 changes: 6 additions & 0 deletions keybind-cheatsheet/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,12 @@ uses no update interval, filesystem watcher, polling loop, network request, or
persistent subprocess. Hyprland Lua mode runs the fixed command
`hyprctl binds -j` asynchronously.

Parsing is kept as cheap as the host's per-callback CPU budget demands: the
niri tokenizer scans with `string.find` rather than character by character, and
it only reads the `binds { … }` blocks instead of the whole file. A refresh
that the host still aborts no longer wedges the service — an in-flight refresh
older than 15 seconds is treated as lost, so the next request runs.

The last successful parsed snapshot is stored as `bindings-cache.json`. The
panel reads the shared in-memory snapshot and performs no configuration I/O in
`onOpen()`. A failed refresh keeps the previous bindings visible and reports
Expand Down
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.1"
version = "0.2.2"
plugin_api = 9
author = "kenn"
license = "MIT"
Expand Down
231 changes: 185 additions & 46 deletions keybind-cheatsheet/service.luau
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,14 @@ local MAX_PARSE_DEPTH = 32
local MAX_PARSE_FILES = 256
local BINDINGS_CACHE_FILE = "bindings-cache.json"
local CACHE_SCHEMA = 1

-- A refresh is synchronous everywhere except the Hyprland Lua path, so it only
-- stays pending across calls for as long as `hyprctl binds -j` takes. Past this
-- the in-flight refresh is treated as lost — a callback aborted by the host
-- never gets to clear the flag, and without this every later refresh would be
-- silently swallowed as "already running".
local STALE_REFRESH_SECONDS = 15
local refreshing = false
local refreshStartedAt = 0
local refreshQueued = false
local refreshGeneration = 0
local lastGood = nil
Expand Down Expand Up @@ -506,74 +512,189 @@ local function parseHyprContent(content, sourceFile, context)
return includes
end

local function niriTokens(content)
local function countNewlines(text)
local _, newlines = text:gsub("\n", "")
return newlines
end

-- Whitespace runs, string bodies and bare words are located with a single
-- string.find each instead of being walked one character at a time: a real
-- config is tens of kilobytes, and the per-character loop spent enough Luau
-- instructions to blow the callback budget when a refresh was requested at
-- runtime, which left `refreshing` stuck and froze the cheatsheet.
local function niriTokens(content, lineOffset)
local tokens = {}
local size = #content
local index = 1
local line = 1
while index <= #content do
local line = 1 + (lineOffset or 0)
while index <= size do
local start = content:find("%S", index)
if start == nil then
break
end
if start > index then
line += countNewlines(content:sub(index, start - 1))
index = start
end

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
elseif char == "/" and nextChar == "/" then
if 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 })
index = ending
elseif char == "/" and nextChar == "*" then
local ending = content:find("*/", index + 2, true) or (#content - 1)
local value = content:sub(index + 2, ending - 1)
table.insert(tokens, { type = "comment", value = value, line = line })
local _, newlines = value:gsub("\n", "")
line += newlines
line += countNewlines(value)
index = ending + 2
elseif char == "\"" then
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
local pieces = {}
local cursor = index + 1
while cursor <= size do
local stop = content:find("[\"\\]", cursor)
if stop == nil then
local tail = content:sub(cursor)
table.insert(pieces, tail)
line += countNewlines(tail)
cursor = size + 1
break
else
if stringChar == "\n" then
line += 1
end
table.insert(value, stringChar)
end
index += 1
if stop > cursor then
local chunk = content:sub(cursor, stop - 1)
table.insert(pieces, chunk)
-- Only literal newlines advance the line counter; an escaped "\n"
-- becomes a newline in the value but occupies one source line.
line += countNewlines(chunk)
end
if content:sub(stop, stop) == "\"" then
cursor = stop + 1
break
end
local replacements = { n = "\n", r = "\r", t = "\t" }
local escaped = content:sub(stop + 1, stop + 1)
table.insert(pieces, replacements[escaped] or escaped)
cursor = stop + 2
end
table.insert(tokens, { type = "string", value = table.concat(value), line = startLine })
table.insert(tokens, { type = "string", value = table.concat(pieces), line = startLine })
index = cursor
elseif char == "{" or char == "}" or char == ";" then
table.insert(tokens, { type = char, value = char, line = line })
index += 1
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
-- A word ends at whitespace, a quote, a brace, a semicolon, or a comment
-- opener; a lone slash stays part of the word (paths, for instance).
local cursor = index
local stop
while true do
stop = content:find("[%s\"{};/]", cursor)
if stop == nil then
stop = size + 1
break
end
index += 1
if content:sub(stop, stop) ~= "/" then break end
local after = content:sub(stop + 1, stop + 1)
if after == "/" or after == "*" then break end
cursor = stop + 1
end
table.insert(tokens, { type = "word", value = content:sub(start, index - 1), line = line })
table.insert(tokens, { type = "word", value = content:sub(index, stop - 1), line = line })
index = stop
end
end
return tokens
end

-- Walks from an opening brace to its matching close, stepping over strings and
-- comments so a brace inside either one does not unbalance the count.
local function niriBlockEnd(content, openIndex)
local size = #content
local depth = 0
local cursor = openIndex
while cursor <= size do
local stop = content:find("[\"{}/]", cursor)
if stop == nil then return size end
local char = content:sub(stop, stop)
if char == "{" then
depth += 1
cursor = stop + 1
elseif char == "}" then
depth -= 1
if depth <= 0 then return stop end
cursor = stop + 1
elseif char == "\"" then
cursor = stop + 1
while cursor <= size do
local quote = content:find("[\"\\]", cursor)
if quote == nil then return size end
if content:sub(quote, quote) == "\"" then
cursor = quote + 1
break
end
cursor = quote + 2
end
else
local nextChar = content:sub(stop + 1, stop + 1)
if nextChar == "/" then
cursor = (content:find("\n", stop + 2, true) or size) + 1
elseif nextChar == "*" then
local ending = content:find("*/", stop + 2, true)
cursor = ending ~= nil and ending + 2 or size + 1
else
cursor = stop + 1
end
end
end
return size
end

-- Every binding lives in `binds { … }`, but a real config spends most of its
-- bytes on outputs, layout, window rules and animations. Tokenizing those too
-- is what pushed a refresh past the host's per-callback CPU budget, so the
-- tokenizer is pointed at the binds blocks alone. The line scan is anchored and
-- skips block comments, so a commented-out `// binds {` example is not mistaken
-- for the real thing; if nothing matches, the whole file is parsed as before.
local function insideBlockComment(content, position)
local cursor = 1
while true do
local opened = content:find("/*", cursor, true)
if opened == nil or opened >= position then return false end
local closed = content:find("*/", opened + 2, true)
if closed == nil or closed >= position then return true end
cursor = closed + 2
end
end

local function niriBindsRegions(content)
local regions = {}
local searchAt = 1
local blockStart = content:find("^[ \t]*binds[ \t]*{") ~= nil and 1 or nil
while true do
local braceAt
if blockStart == nil then
blockStart, braceAt = content:find("\n[ \t]*binds[ \t]*{", searchAt)
if blockStart == nil then break end
blockStart += 1
else
braceAt = content:find("{", blockStart, true)
end

if insideBlockComment(content, blockStart) then
searchAt = blockStart + 1
else
local blockEnd = niriBlockEnd(content, braceAt :: number)
table.insert(regions, {
text = content:sub(blockStart, blockEnd),
offset = countNewlines(content:sub(1, blockStart - 1)),
})
searchAt = blockEnd + 1
end
blockStart = nil
end
return regions
end

local function niriActionText(tokens)
local values = {}
for _, token in ipairs(tokens) do
Expand Down Expand Up @@ -621,15 +742,32 @@ local function niriHeaderDescription(header)
end

local function parseNiriContent(content, sourceFile, context)
-- One pattern pass over the file rather than a match per line: the host bills
-- the callback for every string call, and a real config is a thousand lines.
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 firstInclude = content:match('^[ \t]*include%s+"([^"]+)"')
if firstInclude ~= nil then
table.insert(includes, { path = firstInclude, optional = false })
end
for includePath in content:gmatch('\n[ \t]*include%s+"([^"]+)"') do
table.insert(includes, { path = includePath, optional = false })
end

local regions = niriBindsRegions(content)
local tokens
if #regions == 0 then
tokens = niriTokens(content)
elseif #regions == 1 then
tokens = niriTokens(regions[1].text, regions[1].offset)
else
tokens = {}
for _, region in ipairs(regions) do
for _, token in ipairs(niriTokens(region.text, region.offset)) do
table.insert(tokens, token)
end
end
end

local tokens = niriTokens(content)
local index = 1
while index <= #tokens do
if tokens[index].type == "word" and tokens[index].value == "binds"
Expand Down Expand Up @@ -1210,7 +1348,7 @@ local function performRefresh(generation, request)
end

refresh = function(request)
if refreshing then
if refreshing and os.time() - refreshStartedAt < STALE_REFRESH_SECONDS then
refreshQueued = true
return
end
Expand All @@ -1219,6 +1357,7 @@ refresh = function(request)
refreshGeneration += 1
local generation = refreshGeneration
refreshing = true
refreshStartedAt = os.time()
local previous = matchingLastGood(request)
if previous == nil then
publish("loading", request, nil, "", true)
Expand Down