fix(keymap): avoid Luau callback timeouts (#76)

This commit is contained in:
blacku
2026-07-21 17:06:37 -04:00
committed by GitHub
parent f616f341df
commit 7cc2a499e6
10 changed files with 385 additions and 76 deletions
+21
View File
@@ -0,0 +1,21 @@
# Changelog
All notable changes to Keymap are documented in this file.
## [1.3.1] - 2026-07-21
### Fixed
- Prevented startup timeouts while parsing larger Niri, Hyprland, and MangoWC configurations.
- Prevented intermittent callback timeouts when rapidly switching keyboard modifier layers.
### Changed
- Accelerated stable fingerprints with Luau's native `bit32` operations while retaining a plain-Lua fallback.
- Avoided reading Niri's root configuration twice during a refresh.
- Made loading snapshots lightweight instead of serializing the previous bind tree again.
- Cached panel translations, settings, keyboard indexes, colors, and dynamic key callbacks between renders.
### Tests
- Added 93-bind scale regressions for Niri, Hyprland, and MangoWC.
+15 -24
View File
@@ -442,35 +442,30 @@ end
-- FNV-1a over the exact source line. The editor recalculates this before a
-- write so a bind is never replaced after the file changed behind its back.
-- Multiplication is split into 16-bit words to retain exact 32-bit arithmetic
-- in runtimes where Lua numbers are doubles.
local function xorLowByte(value, byte)
local low = value % 256
-- Splitting the prime into 2^24 + 403 retains exact 32-bit arithmetic in
-- runtimes where Lua numbers are doubles.
local function xorByte(left, right)
local result = 0
local place = 1
for _ = 1, 8 do
if low % 2 ~= byte % 2 then
if left % 2 ~= right % 2 then
result = result + place
end
low = math.floor(low / 2)
byte = math.floor(byte / 2)
left = math.floor(left / 2)
right = math.floor(right / 2)
place = place * 2
end
return value - (value % 256) + result
return result
end
local xorByteFast = type(bit32) == "table" and type(bit32.bxor) == "function" and bit32.bxor or xorByte
fingerprint = function(rawSnippet)
local hash = 2166136261
for index = 1, #rawSnippet do
hash = xorLowByte(hash, rawSnippet:byte(index))
local low = hash % 65536
local high = math.floor(hash / 65536)
local lowProduct = low * 403
local resultLow = lowProduct % 65536
local resultHigh = (
math.floor(lowProduct / 65536) + low * 256 + high * 403
) % 65536
hash = resultHigh * 65536 + resultLow
local low = hash % 256
hash = hash - low + xorByteFast(low, rawSnippet:byte(index))
hash = (hash * 403 + (hash % 256) * 16777216) % 4294967296
end
return string.format("%08x", hash)
end
@@ -918,15 +913,11 @@ function refresh()
refreshing = true
local source = configPath()
local previous = noctalia.state.get(SNAPSHOT_KEY)
local sameCompositor = type(previous) == "table" and previous.compositor == "MangoWC"
local previousCategories = sameCompositor and previous.categories or {}
local previousTotal = sameCompositor and previous.total or 0
local previousUpdatedAt = sameCompositor and previous.updated_at or ""
local previousHidden = sameCompositor and previous.hidden or {}
-- 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(
SNAPSHOT_KEY,
snapshot("loading", source, "", previousCategories, previousTotal, {}, previousUpdatedAt, previousHidden)
snapshot("loading", source, "", {}, 0, {}, "", {})
)
local parsed = parseConfig(source)
+18 -17
View File
@@ -452,14 +452,17 @@ local function xorByte(left, right)
return result
end
-- FNV-1a 32-bit without bit libraries. Splitting the prime (0x01000193) into
-- 2^24 + 403 keeps every intermediate below the exact integer limit of a
-- Lua/Luau number.
local xorByteFast = type(bit32) == "table" and type(bit32.bxor) == "function" and bit32.bxor or xorByte
-- Splitting the FNV prime (0x01000193) into 2^24 + 403 keeps every
-- intermediate below the exact integer limit of a Lua/Luau number. Luau's
-- native bit32 fast path avoids eight interpreted operations for every byte;
-- the arithmetic fallback keeps the parser usable in plain-Lua tests.
local function stableFingerprint(value)
local hash = 2166136261
for index = 1, #value do
local low = hash % 256
hash = hash - low + xorByte(low, value:byte(index))
hash = hash - low + xorByteFast(low, value:byte(index))
hash = (hash * 403 + (hash % 256) * 16777216) % 4294967296
end
return string.format("%08x", hash)
@@ -688,7 +691,7 @@ local function parseBind(combo, attributes, action, category, records, activeByC
activeByCombo[signature] = bind
end
local function parseConfig(root)
local function parseConfig(root, rootSource)
local records = {}
local hidden = {}
local activeByCombo = {}
@@ -706,7 +709,7 @@ local function parseConfig(root)
end
local parseFile
parseFile = function(path, optional)
parseFile = function(path, optional, providedSource)
if visiting[path] then
warn("niri_include_cycle:" .. path)
return
@@ -715,7 +718,7 @@ local function parseConfig(root)
warn("niri_file_limit_reached")
return
end
local source = noctalia.readFile(path)
local source = providedSource or noctalia.readFile(path)
if type(source) ~= "string" then
warn((optional and "niri_optional_include_missing:" or "niri_include_unreadable:") .. path)
if not optional then
@@ -858,7 +861,7 @@ local function parseConfig(root)
visiting[path] = nil
end
parseFile(root, false)
parseFile(root, false, rootSource)
local categories, total = buildCategories(records)
return categories, total, hidden, warnings, fatalError
end
@@ -897,18 +900,16 @@ function refresh()
end
refreshing = true
local source = sourcePath()
local previous = noctalia.state.get(SNAPSHOT_KEY)
local keepPrevious = type(previous) == "table" and previous.compositor == "Niri"
local previousCategories = keepPrevious and previous.categories or {}
local previousTotal = keepPrevious and previous.total or 0
local previousUpdatedAt = keepPrevious and previous.updated_at or ""
local previousHidden = keepPrevious and previous.hidden or {}
-- 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.
noctalia.state.set(
SNAPSHOT_KEY,
snapshot("loading", source, "", previousCategories, previousTotal, {}, previousUpdatedAt, previousHidden)
snapshot("loading", source, "", {}, 0, {}, "", {})
)
if type(noctalia.readFile(source)) ~= "string" then
local rootSource = noctalia.readFile(source)
if type(rootSource) ~= "string" then
noctalia.state.set(
SNAPSHOT_KEY,
snapshot("error", source, "niri_config_unreadable", {}, 0, {}, os.date("%H:%M:%S"))
@@ -917,7 +918,7 @@ function refresh()
return
end
local categories, total, hidden, warnings, fatalError = parseConfig(source)
local categories, total, hidden, warnings, fatalError = parseConfig(source, rootSource)
if fatalError ~= nil then
noctalia.state.set(
SNAPSHOT_KEY,
+64 -16
View File
@@ -112,12 +112,36 @@ do
end
end
local NIL_CACHE_VALUE = {}
local configCache = {}
local translationCache = {}
local function tr(key, args)
return noctalia.tr(key, args)
if args ~= nil then
return noctalia.tr(key, args)
end
local cached = translationCache[key]
if cached ~= nil then
return cached
end
local value = noctalia.tr(key)
translationCache[key] = value
return value
end
local function cfg(key)
return noctalia.getConfig(key)
local cached = configCache[key]
if cached ~= nil then
return cached ~= NIL_CACHE_VALUE and cached or nil
end
local value = noctalia.getConfig(key)
configCache[key] = value ~= nil and value or NIL_CACHE_VALUE
return value
end
local function clearHostValueCaches()
configCache = {}
translationCache = {}
end
local function asString(value, fallback)
@@ -297,7 +321,13 @@ local function expandedKeys(value)
return { canonical }
end
local keyboardIndexSnapshot = nil
local keyboardIndexCache = nil
local function keyboardIndex()
if keyboardIndexSnapshot == snapshot and keyboardIndexCache ~= nil then
return keyboardIndexCache
end
local exact = {}
local any = {}
for _, category in ipairs(asArray(snapshot.categories)) do
@@ -318,14 +348,20 @@ local function keyboardIndex()
end
end
end
return { exact = exact, any = any }
keyboardIndexSnapshot = snapshot
keyboardIndexCache = { exact = exact, any = any }
return keyboardIndexCache
end
local keyCallbackCache = {}
local function keyCallback(id, code)
local name = "onKeyboardKey_" .. id
return registerDynamicCallback(name, function()
selectKeyboardKey(code)
end)
local callback = keyCallbackCache[name]
if callback == nil then
callback = function() selectKeyboardKey(code) end
keyCallbackCache[name] = callback
end
return registerDynamicCallback(name, callback)
end
local function contains(haystack, needle)
@@ -848,21 +884,32 @@ local function keyboardUnitsWidth(units)
return math.max(1, math.floor(value * KEYBOARD_UNIT + (value - 1) * KEYBOARD_GAP + 0.5))
end
local colorWithOpacityCache = {}
local function colorWithOpacity(value, fallback, opacity)
local cacheKey = asString(value) .. "\0" .. asString(fallback) .. "\0" .. tostring(opacity)
local cached = colorWithOpacityCache[cacheKey]
if cached ~= nil then return cached end
local color = asString(value, fallback)
local result
if string.sub(color, 1, 1) == "#" then
local alpha = string.format("%02X", math.floor(math.max(0, math.min(1, opacity)) * 255 + 0.5))
if #color == 7 then
return color .. alpha
result = color .. alpha
elseif #color == 9 then
return string.sub(color, 1, 7) .. alpha
result = string.sub(color, 1, 7) .. alpha
else
result = color
end
return color
else
result = color .. "/" .. string.format("%.2f", opacity)
end
return color .. "/" .. string.format("%.2f", opacity)
colorWithOpacityCache[cacheKey] = result
return result
end
local function keyboardKeyNode(spec, index)
local EMPTY_ENTRIES = {}
local function keyboardKeyNode(spec, index, signature)
if spec.spacer ~= nil then
-- `ui.spacer` is flexible: inside a fixed-width row it absorbs remaining
-- space and moves every key that follows it. Keyboard gaps must be rigid,
@@ -892,9 +939,8 @@ local function keyboardKeyNode(spec, index)
end
local code = canonicalKey(spec.code)
local signature = activeModifierSignature()
local exactEntries = asArray(index.exact[signature .. "|" .. code])
local anyEntries = asArray(index.any[code])
local exactEntries = index.exact[signature .. "|" .. code] or EMPTY_ENTRIES
local anyEntries = index.any[code] or EMPTY_ENTRIES
local physicalModifier = MODIFIER_ALIASES[normalized(spec.code)]
local modifierActive = physicalModifier ~= nil and activeModifiers[physicalModifier] == true
local selected = creatorOpen and creatorHasKey(code) or selectedKeyboardKey == code
@@ -1571,7 +1617,7 @@ local function keyboardBody()
for rowIndex, row in ipairs(layout.rows) do
local keys = {}
for _, spec in ipairs(row) do
keys[#keys + 1] = keyboardKeyNode(spec, index)
keys[#keys + 1] = keyboardKeyNode(spec, index, signature)
end
keyboardRows[#keyboardRows + 1] = ui.row({
key = "keyboard-row-" .. layout.id .. "-" .. tostring(rowIndex),
@@ -2627,7 +2673,7 @@ end
render = function()
renderCallbackNames = {}
local status = asString(snapshot.status, "idle")
local categories = filteredCategories()
local categories = viewMode == "list" and filteredCategories() or EMPTY_ENTRIES
local requestedColumns = math.max(1, math.min(4, math.floor(tonumber(cfg("columns")) or 3)))
local body
if status == "error" then
@@ -2728,6 +2774,7 @@ noctalia.state.watch(UPDATE_RESULT_KEY, function(result)
end)
function onOpen(_context)
clearHostValueCaches()
local configuredLayout = asString(cfg("keyboard_layout"), "100")
keyboardLayoutId = KEYBOARD_LAYOUTS[configuredLayout] ~= nil and configuredLayout or "100"
local current = noctalia.state.get(SNAPSHOT_KEY)
@@ -2755,6 +2802,7 @@ function onOpen(_context)
end
function onConfigChanged()
clearHostValueCaches()
render()
end
+1 -1
View File
@@ -1,6 +1,6 @@
id = "blackbartblues/keymap"
name = "Keymap"
version = "1.3.0"
version = "1.3.1"
plugin_api = 5
author = "blackbartblues"
license = "MIT"
+6 -7
View File
@@ -314,11 +314,13 @@ local function xorByte(left, right)
return result
end
local xorByteFast = type(bit32) == "table" and type(bit32.bxor) == "function" and bit32.bxor or xorByte
local function fingerprint(value)
local hash = 2166136261
for index = 1, #value do
local low = hash % 256
hash = hash - low + xorByte(low, value:byte(index))
hash = hash - low + xorByteFast(low, value:byte(index))
hash = (hash * 403 + (hash % 256) * 16777216) % 4294967296
end
return string.format("%08x", hash)
@@ -1003,14 +1005,11 @@ function refresh()
refreshing = true
local source = expandedConfigPath()
local previous = noctalia.state.get(SNAPSHOT_KEY)
local previousCategories = type(previous) == "table" and previous.categories or {}
local previousTotal = type(previous) == "table" and previous.total or 0
local previousUpdatedAt = type(previous) == "table" and previous.updated_at or ""
local previousHidden = type(previous) == "table" and previous.hidden or {}
-- 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(
SNAPSHOT_KEY,
snapshot("loading", source, "", previousCategories, previousTotal, {}, previousUpdatedAt, previousHidden)
snapshot("loading", source, "", {}, 0, {}, "", {})
)
if not noctalia.commandExists("hyprctl") then
+87
View File
@@ -0,0 +1,87 @@
local sourceLines = {}
local liveBinds = {}
for index = 1, 93 do
if index % 20 == 1 then
sourceLines[#sourceLines + 1] = "-- " .. tostring(math.floor(index / 20) + 1) .. ". Group "
.. tostring(math.floor(index / 20) + 1)
end
sourceLines[#sourceLines + 1] = string.format(
'hl.bind("SUPER+Key%d", action, { description = "Action %d" })', index, index
)
liveBinds[#liveBinds + 1] = {
modmask = 64, key = "Key" .. tostring(index), description = "Action " .. tostring(index),
has_description = true, dispatcher = "__lua", arg = tostring(index), submap = "",
}
end
local source = table.concat(sourceLines, "\n")
local values, watchers = {
["keymap.snapshot"] = {
status = "ready", compositor = "Hyprland", total = 1,
categories = { { name = "Previous", binds = { { id = "previous" } } } },
},
}, {}
local reads, xorCalls, loadingCategoryCount = 0, 0, -1
local originalBit32 = bit32
bit32 = {
bxor = function(left, right)
xorCalls = xorCalls + 1
local result, place = 0, 1
for _ = 1, 8 do
if left % 2 ~= right % 2 then result = result + place end
left = math.floor(left / 2)
right = math.floor(right / 2)
place = place * 2
end
return result
end,
}
noctalia = {
state = {
get = function(key) return values[key] end,
set = function(key, value)
if key == "keymap.snapshot" and value.status == "loading" then
loadingCategoryCount = #(value.categories or {})
end
values[key] = value
if watchers[key] ~= nil then watchers[key](value) end
end,
watch = function(key, callback) watchers[key] = callback end,
},
getConfig = function(key)
return ({
compositor = "hyprland", hyprland_config = "/fixture/hyprland.lua",
merge_sequential = false, show_undescribed = 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)
if path ~= "/fixture/hyprland.lua" then return nil end
reads = reads + 1
return source
end,
commandExists = function(command) return command == "hyprctl" end,
json = { decode = function() return liveBinds end },
runAsync = function(command, callback)
callback({ exitCode = 0, timedOut = false, stdout = "fixture" })
return true
end,
tr = function(key) return key == "category.other" and "Other" or key end,
}
assert(loadfile("service.luau"))()
bit32 = originalBit32
local snapshot = values["keymap.snapshot"]
assert(snapshot.status == "ready", "large Hyprland fixture did not parse")
assert(snapshot.total == 93, "large Hyprland fixture lost binds")
assert(#snapshot.categories == 5, "large Hyprland fixture lost category markers")
assert(reads == 1, "Hyprland root config should only be read once per refresh")
assert(loadingCategoryCount == 0, "loading snapshot should not reserialize the previous bind tree")
assert(xorCalls > 0, "Hyprland parser did not use the native-xor fingerprint path")
print("hypr scale tests: ok")
+75
View File
@@ -0,0 +1,75 @@
local sourceLines = {}
for index = 1, 93 do
if index % 20 == 1 then
sourceLines[#sourceLines + 1] = "# Group " .. tostring(math.floor(index / 20) + 1)
end
sourceLines[#sourceLines + 1] = string.format(
'bind=SUPER,Key%d,spawn_shell,true #"Action %d"', index, index
)
end
local source = table.concat(sourceLines, "\n")
local values, watchers = {
["keymap.snapshot"] = {
status = "ready", compositor = "MangoWC", total = 1,
categories = { { name = "Previous", binds = { { id = "previous" } } } },
},
}, {}
local reads, xorCalls, loadingCategoryCount = 0, 0, -1
local originalBit32 = bit32
bit32 = {
bxor = function(left, right)
xorCalls = xorCalls + 1
local result, place = 0, 1
for _ = 1, 8 do
if left % 2 ~= right % 2 then result = result + place end
left = math.floor(left / 2)
right = math.floor(right / 2)
place = place * 2
end
return result
end,
}
noctalia = {
state = {
get = function(key) return values[key] end,
set = function(key, value)
if key == "keymap.snapshot" and value.status == "loading" then
loadingCategoryCount = #(value.categories or {})
end
values[key] = value
if watchers[key] ~= nil then watchers[key](value) end
end,
watch = function(key, callback) watchers[key] = callback end,
},
getConfig = function(key)
return ({ compositor = "mangowc", mangowc_config = "/fixture/config.conf", merge_sequential = 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 path == "/fixture/config.conf" end,
listDir = function() return nil end,
readFile = function(path)
if path ~= "/fixture/config.conf" then return nil end
reads = reads + 1
return source
end,
tr = function(key, args)
if args ~= nil and args.action ~= nil then return args.action end
return key == "category.other" and "Other" or key
end,
}
assert(loadfile("mangowc_service.luau"))()
bit32 = originalBit32
local snapshot = values["keymap.snapshot"]
assert(snapshot.status == "ready", "large MangoWC fixture did not parse")
assert(snapshot.total == 93, "large MangoWC fixture lost binds")
assert(#snapshot.categories == 5, "large MangoWC fixture lost category markers")
assert(reads == 1, "MangoWC root config should only be read once per refresh")
assert(loadingCategoryCount == 0, "loading snapshot should not reserialize the previous bind tree")
assert(xorCalls > 0, "MangoWC parser did not use the native-xor fingerprint path")
print("MangoWC scale tests: ok")
+84
View File
@@ -0,0 +1,84 @@
local bindLines = { "binds {" }
for index = 1, 93 do
if index % 20 == 1 then
bindLines[#bindLines + 1] = ' // #"Group ' .. tostring(math.floor(index / 20) + 1) .. '"'
end
bindLines[#bindLines + 1] = string.format(
' Mod+Key%d repeat=false cooldown-ms=150 { focus-workspace %d; }', index, index
)
end
bindLines[#bindLines + 1] = "}"
local source = table.concat(bindLines, "\n")
local values, watchers = {
["keymap.snapshot"] = {
status = "ready", compositor = "Niri", total = 1,
categories = { { name = "Previous", binds = { { id = "previous" } } } },
},
}, {}
local reads = 0
local xorCalls = 0
local loadingCategoryCount = -1
local originalBit32 = bit32
bit32 = {
bxor = function(left, right)
xorCalls = xorCalls + 1
local result, place = 0, 1
for _ = 1, 8 do
if left % 2 ~= right % 2 then result = result + place end
left = math.floor(left / 2)
right = math.floor(right / 2)
place = place * 2
end
return result
end,
}
noctalia = {
state = {
get = function(key) return values[key] end,
set = function(key, value)
if key == "keymap.snapshot" and value.status == "loading" then
loadingCategoryCount = #(value.categories or {})
end
values[key] = value
if watchers[key] ~= nil then watchers[key](value) end
end,
watch = function(key, callback) watchers[key] = callback end,
},
getConfig = function(key)
return ({ compositor = "niri", niri_config = "/fixture/config.kdl", merge_sequential = false })[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)
if path ~= "/fixture/config.kdl" then return nil end
reads = reads + 1
return source
end,
tr = function(key, args)
if args ~= nil and args.action ~= nil then return args.action end
return key == "category.other" and "Other" or key
end,
}
assert(loadfile("niri_service.luau"))()
bit32 = originalBit32
local snapshot = values["keymap.snapshot"]
assert(snapshot.status == "ready", "large Niri fixture did not parse")
assert(snapshot.total == 93, "large Niri fixture lost binds")
assert(#snapshot.categories == 5, "large Niri fixture lost category markers")
assert(reads == 1, "Niri root config should only be read once per refresh")
assert(loadingCategoryCount == 0, "loading snapshot should not reserialize the previous bind tree")
assert(xorCalls > 0, "Niri parser did not use the native-xor fingerprint path")
for _, category in ipairs(snapshot.categories) do
for _, bind in ipairs(category.binds) do
assert(bind.id:match("^niri:[0-9a-f]+$"), "invalid bind fingerprint")
assert(bind.fingerprint:match("^[0-9a-f]+$"), "invalid source fingerprint")
end
end
print("niri scale tests: ok")
+14 -11
View File
@@ -756,21 +756,24 @@ processValidatedRequest = function(request)
verifyAndReload(request, transaction)
end
local function contentFingerprint(value)
local function xorByte(left, right)
local result, place = 0, 1
for _ = 1, 8 do
if left % 2 ~= right % 2 then result = result + place end
left = math.floor(left / 2)
right = math.floor(right / 2)
place = place * 2
end
return result
local function xorByte(left, right)
local result, place = 0, 1
for _ = 1, 8 do
if left % 2 ~= right % 2 then result = result + place end
left = math.floor(left / 2)
right = math.floor(right / 2)
place = place * 2
end
return result
end
local xorByteFast = type(bit32) == "table" and type(bit32.bxor) == "function" and bit32.bxor or xorByte
local function contentFingerprint(value)
local hash = 2166136261
for index = 1, #value do
local low = hash % 256
hash = hash - low + xorByte(low, value:byte(index))
hash = hash - low + xorByteFast(low, value:byte(index))
hash = (hash * 403 + (hash % 256) * 16777216) % 4294967296
end
return string.format("%08x", hash)