feat: add Keymap plugin (#68)

This commit is contained in:
blacku
2026-07-20 21:36:15 -04:00
committed by GitHub
parent 0fdd6a2b2f
commit e41961025e
30 changed files with 14547 additions and 0 deletions
@@ -0,0 +1,121 @@
local function stateMock()
local values = {}
local watchers = {}
return values, {
get = function(key) return values[key] end,
set = function(key, value)
values[key] = value
if watchers[key] ~= nil then watchers[key](value) end
end,
watch = function(key, callback) watchers[key] = callback end,
}
end
local function findCategory(snapshot, name)
for _, category in ipairs(snapshot.categories or {}) do
if category.name == name then return category end
end
return nil
end
local function assertMarkedBind(snapshot, categoryName, expectedSnippet, expectedStart, expectedEnd)
assert(snapshot.status == "ready", categoryName .. ": snapshot not ready")
local category = findCategory(snapshot, categoryName)
assert(type(category) == "table", categoryName .. ": category missing")
assert(#category.binds == 1, categoryName .. ": unexpected bind count")
local bind = category.binds[1]
assert(bind.raw_snippet == expectedSnippet, categoryName .. ": marker missing from provenance")
assert(bind.start_line == expectedStart and bind.end_line == expectedEnd, categoryName .. ": line range mismatch")
assert(bind.capabilities.category == true, categoryName .. ": category editing disabled")
end
do
local values, state = stateMock()
local source = table.concat({
"-- 1. General",
"-- Keymap bind-category: Media",
'hl.bind("SUPER + G", hl.dsp.exec_cmd([[playerctl play-pause]]), { description = "Media action" })',
'hl.bind("SUPER + H", hl.dsp.exec_cmd([[true]]), { description = "General action" })',
"",
}, "\n")
noctalia = {
state = state,
getConfig = function(key)
local config = { compositor = "hyprland", hyprland_config = "/tmp/hyprland.lua", merge_sequential = false }
return config[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 == "/tmp/hyprland.lua" end,
readFile = function(path) return path == "/tmp/hyprland.lua" and source or nil end,
commandExists = function(command) return command == "hyprctl" end,
tr = function(key) return key end,
runAsync = function(_command, callback)
callback({ exitCode = 0, timedOut = false, stdout = "[]" })
return true
end,
json = {
decode = function()
return {
{ modmask = 64, key = "G", dispatcher = "__lua", arg = "", description = "Media action", has_description = true },
{ modmask = 64, key = "H", dispatcher = "__lua", arg = "", description = "General action", has_description = true },
}
end,
},
}
assert(loadfile("service.luau"))()
local snapshot = values["keymap.snapshot"]
assertMarkedBind(
snapshot, "Media",
'-- Keymap bind-category: Media\n'
.. 'hl.bind("SUPER + G", hl.dsp.exec_cmd([[playerctl play-pause]]), { description = "Media action" })',
2, 3
)
assert(#findCategory(snapshot, "General").binds == 1, "Hyprland marker leaked into following bind")
end
do
local values, state = stateMock()
local marker = " // Keymap bind-category: Media"
local bindLine = ' Mod+G hotkey-overlay-title="Media action" { spawn-sh "playerctl play-pause"; }'
local source = table.concat({ "binds {", marker, bindLine, "}", "" }, "\n")
noctalia = {
state = state,
getConfig = function(key)
local config = { compositor = "niri", niri_config = "/tmp/config.kdl", merge_sequential = false }
return config[key]
end,
getenv = function(key) return key == "NIRI_SOCKET" and "test" or "" end,
fileExists = function(path) return path == "/tmp/config.kdl" end,
readFile = function(path) return path == "/tmp/config.kdl" and source or nil end,
tr = function(key) return key end,
}
assert(loadfile("niri_service.luau"))()
assertMarkedBind(values["keymap.snapshot"], "Media", marker .. "\n" .. bindLine, 2, 3)
end
do
local values, state = stateMock()
local marker = "# Keymap bind-category: Media"
local moved = 'bind=SUPER,G,spawn_shell,playerctl play-pause #"Media action"'
local general = 'bind=SUPER,H,spawn_shell,true #"General action"'
local source = table.concat({ "# General", marker, moved, general, "" }, "\n")
noctalia = {
state = state,
getConfig = function(key)
local config = { compositor = "mangowc", mangowc_config = "/tmp/mango.conf", merge_sequential = false }
return config[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 == "/tmp/mango.conf" end,
readFile = function(path) return path == "/tmp/mango.conf" and source or nil end,
tr = function(key) return key end,
}
assert(loadfile("mangowc_service.luau"))()
local snapshot = values["keymap.snapshot"]
assertMarkedBind(snapshot, "Media", marker .. "\n" .. moved, 2, 3)
assert(#findCategory(snapshot, "General").binds == 1, "MangoWC marker leaked into following bind")
end
print("category marker parser tests: ok")
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""Structural regression checks for Keymap's source-backed command catalog."""
import json
import re
from collections import Counter
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
catalog = json.loads((ROOT / "command_library.json").read_text(encoding="utf-8"))
translations = json.loads((ROOT / "translations" / "en.json").read_text(encoding="utf-8"))
panel = (ROOT / "panel.luau").read_text(encoding="utf-8")
assert catalog["schema"] == 1
assert set(catalog["sources"]) == {"noctalia", "hyprland", "niri", "mangowc"}
assert all(catalog["sources"][source]["revision"] for source in catalog["sources"])
entries = catalog["entries"]
counts = Counter(entry["source"] for entry in entries)
assert counts == {"noctalia": 98, "hyprland": 51, "niri": 135, "mangowc": 78}, counts
ids = [entry["id"] for entry in entries]
assert len(ids) == len(set(ids)), "command-library ids must be unique"
assert ids == sorted(ids, key=lambda entry_id: next(
(item["source"], item["category"], item["id"])
for item in entries if item["id"] == entry_id
)), "catalog ordering must remain deterministic"
category_translations = translations["panel"]["command_library"]["categories"]
for entry in entries:
assert set(entry) == {"id", "source", "category", "kind", "template", "usage"}
assert entry["id"].startswith(entry["source"] + "/")
assert entry["category"] in category_translations
assert entry["kind"] == ("shell" if entry["source"] == "noctalia" else "native")
assert entry["template"].strip() == entry["template"] and entry["template"]
assert not re.search(r"[\r\n\x00-\x1f]", entry["template"])
assert entry["template"].count("{{") == entry["template"].count("}}")
by_id = {entry["id"]: entry for entry in entries}
for required_id in (
"noctalia/panel-open",
"noctalia/session",
"hyprland/window.close",
"hyprland/workspace.swap_monitors",
"niri/close-window",
"niri/toggle-overview",
"mangowc/killclient",
"mangowc/reload_config",
):
assert required_id in by_id, required_id
assert all(entry["template"].startswith("noctalia msg ")
for entry in entries if entry["source"] == "noctalia")
assert all(entry["template"].startswith("hl.dsp.") and entry["template"].endswith(")")
for entry in entries if entry["source"] == "hyprland")
assert all(";" not in entry["template"] and "{" not in re.sub(r"\{\{[^}]+\}\}", "", entry["template"])
for entry in entries if entry["source"] == "niri")
assert all("#" not in entry["template"]
for entry in entries if entry["source"] == "mangowc")
# The retained UI deliberately renders only a small result window and removes
# callbacks that are no longer part of the current render.
assert "local visibleCount = math.min(#matches, 6)" in panel
assert "finishDynamicCallbackRender()" in panel
assert 'registerDynamicCallback(callbackName' in panel
print(f"command library tests: ok ({len(entries)} entries: {dict(counts)})")
+166
View File
@@ -0,0 +1,166 @@
local function read(path)
local file = assert(io.open(path, "rb"))
local value = file:read("*a")
file:close()
return value
end
local function stateMock()
local values, watchers = {}, {}
return values, {
get = function(key) return values[key] end,
set = function(key, value)
values[key] = value
if watchers[key] ~= nil then watchers[key](value) end
end,
watch = function(key, callback) watchers[key] = callback end,
}
end
local function assertExample(snapshot, compositor, expectedTotal)
assert(type(snapshot) == "table" and snapshot.status == "ready", compositor .. ": snapshot not ready")
assert(snapshot.compositor == compositor, compositor .. ": wrong compositor")
assert(snapshot.total == expectedTotal, compositor .. ": unexpected bind count " .. tostring(snapshot.total))
local expected = {
["Applications"] = true,
["Windows"] = true,
["Workspaces"] = true,
["Screenshots"] = true,
["Noctalia"] = true,
["Media"] = true,
["Utilities"] = true,
}
local found = {}
for _, category in ipairs(snapshot.categories or {}) do
found[category.name] = true
for _, bind in ipairs(category.binds or {}) do
assert(type(bind.id) == "string" and bind.id ~= "", compositor .. ": bind id missing")
assert(#bind.id <= 249, compositor .. ": bind id exceeds DnD target budget")
assert(#("before|" .. bind.id) <= 256, compositor .. ": reorder target exceeds core limit")
end
end
for name, _ in pairs(expected) do
assert(found[name], compositor .. ": missing category " .. name)
end
end
do
local values, state = stateMock()
local source = read("examples/hyprland.lua")
local descriptions = {}
local modifierMasks = { SUPER = 64, SHIFT = 1, CTRL = 4, ALT = 8 }
for line in source:gmatch("[^\r\n]+") do
local combo = line:match('hl%.bind%s*%(%s*"([^"]+)"')
local description = line:match('description%s*=%s*"([^"]+)"')
if combo ~= nil and description ~= nil then
local modmask, key = 0, nil
for token in combo:gmatch("[^+]+") do
local normalized = token:match("^%s*(.-)%s*$")
local mask = modifierMasks[normalized:upper()]
if mask ~= nil then modmask = modmask + mask else key = normalized end
end
assert(key ~= nil, "Hyprland example bind has no non-modifier key: " .. combo)
descriptions[#descriptions + 1] = {
modmask, key, description, line:match("release%s*=%s*true") ~= nil,
}
end
end
local plainBlocks = {}
for index, item in ipairs(descriptions) do
plainBlocks[#plainBlocks + 1] = table.concat({
item[4] == true and "bindrd" or "bindd",
"\tmodmask: " .. tostring(item[1]),
"\tsubmap: ",
"\tkey: " .. item[2],
"\tkeycode: 0",
"\tcatchall: false",
"\tdescription: " .. item[3],
"\tdispatcher: __lua",
"\targ: " .. tostring(index),
}, "\n")
end
local plain = table.concat(plainBlocks, "\n\n") .. "\n"
noctalia = {
state = state,
getConfig = function(key)
local config = {
compositor = "hyprland", hyprland_config = "/missing/hyprland.lua",
merge_sequential = false, show_undescribed = true,
}
return config[key]
end,
getenv = function(key)
if key == "HYPRLAND_INSTANCE_SIGNATURE" then return "test" end
if key == "HOME" then return "/example-home" end
return ""
end,
expandPath = function(path) return path end,
fileExists = function(path) return path == "/example-home/.config/hypr/shortcuts.lua" end,
listDir = function(path)
return path == "/example-home/.config/hypr"
and { "colors.lua", "keymap.lua", "old.lua.backup", "shortcuts.lua" } or nil
end,
readFile = function(path) return path == "/example-home/.config/hypr/shortcuts.lua" and source or nil end,
commandExists = function(command) return command == "hyprctl" end,
tr = function(key)
return ({ ["category.other"] = "Other", ["category.undescribed"] = "Without description" })[key] or key
end,
runAsync = function(command, callback)
callback({ exitCode = 0, timedOut = false, stdout = command == "hyprctl binds" and plain or "invalid-json" })
return true
end,
json = { decode = function() error("malformed JSON fixture") end },
}
assert(loadfile("service.luau"))()
assertExample(values["keymap.snapshot"], "Hyprland", 40)
assert(values["keymap.snapshot"].source == "/example-home/.config/hypr/shortcuts.lua")
end
do
local values, state = stateMock()
local source = read("examples/niri.kdl")
noctalia = {
state = state,
getConfig = function(key)
local config = { compositor = "niri", niri_config = "/missing/niri.kdl", merge_sequential = false }
return config[key]
end,
getenv = function(key)
if key == "NIRI_SOCKET" then return "test" end
if key == "HOME" then return "/example-home" end
return ""
end,
fileExists = function(path) return path == "/example-home/.config/niri/config.kdl" end,
readFile = function(path) return path == "/example-home/.config/niri/config.kdl" and source or nil end,
tr = function(key) return key == "category.other" and "Other" or key end,
}
assert(loadfile("niri_service.luau"))()
assertExample(values["keymap.snapshot"], "Niri", 39)
assert(values["keymap.snapshot"].source == "/example-home/.config/niri/config.kdl")
end
do
local values, state = stateMock()
local source = read("examples/mangowc.conf")
noctalia = {
state = state,
getConfig = function(key)
local config = { compositor = "mangowc", mangowc_config = "/missing/mangowc.conf", merge_sequential = false }
return config[key]
end,
getenv = function(key)
if key == "MANGO_INSTANCE_SIGNATURE" then return "test" end
if key == "HOME" then return "/example-home" end
return ""
end,
expandPath = function(path) return path end,
fileExists = function(path) return path == "/example-home/.config/mango/config.conf" end,
readFile = function(path) return path == "/example-home/.config/mango/config.conf" and source or nil end,
tr = function(key) return key == "category.other" and "Other" or key end,
}
assert(loadfile("mangowc_service.luau"))()
assertExample(values["keymap.snapshot"], "MangoWC", 40)
assert(values["keymap.snapshot"].source == "/example-home/.config/mango/config.conf")
end
print("example config tests: ok")
@@ -0,0 +1,115 @@
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, right, place = math.floor(left / 2), math.floor(right / 2), place * 2
end
return result
end
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 * 403 + (hash % 256) * 16777216) % 4294967296
end
return string.format("%08x", hash)
end
local function hex(value)
local output = {}
for index = 1, #value do output[#output + 1] = string.format("%02x", value:byte(index)) end
return table.concat(output)
end
local function hiddenSnippet(compositor, comment, original)
local indent = original:match("^([\t ]*)") or ""
local marker = indent .. comment .. " Keymap hidden v1"
local blockId = fingerprint(compositor .. "\0" .. original)
local encoded = hex(original)
local lines = { marker .. " begin " .. blockId .. " " .. fingerprint(original) }
for offset = 1, #encoded, 96 do lines[#lines + 1] = marker .. " data " .. encoded:sub(offset, offset + 95) end
lines[#lines + 1] = marker .. " end " .. blockId
return table.concat(lines, "\n")
end
local function stateMock()
local values, watchers = {}, {}
return values, {
get = function(key) return values[key] end,
set = function(key, value)
values[key] = value
if watchers[key] then watchers[key](value) end
end,
watch = function(key, callback) watchers[key] = callback end,
}
end
local function assertHidden(snapshot, source, original, raw)
assert(snapshot.status == "ready", "hidden-only config must be ready")
assert(snapshot.total == 0 and #(snapshot.categories or {}) == 0, "hidden bind leaked into active data")
assert(#(snapshot.hidden or {}) == 1, "hidden target missing")
local target = snapshot.hidden[1]
assert(target.hidden == true and target.source == source, "hidden provenance missing")
assert(target.start_line <= target.end_line and target.raw_snippet == raw, "hidden raw block/range mismatch")
assert(target.fingerprint == fingerprint(raw), "hidden block fingerprint mismatch")
assert(target.original_fingerprint == fingerprint(original), "original fingerprint mismatch")
assert(target.capabilities.restore == true and target.capabilities.delete == true, "hidden capabilities missing")
assert(type(target.id) == "string" and target.id ~= "", "hidden id missing")
assert(#(snapshot.warnings or {}) >= 1, "malformed sentinel was not reported")
end
do
local values, state = stateMock()
local root, child = "/tmp/hidden/hyprland.lua", "/tmp/hidden/child.lua"
local original = '-- Keymap bind-category: Media\nhl.bind("SUPER + H", hl.dsp.exec_cmd("true"), { description = "Hidden Hypr" })'
local raw = hiddenSnippet("Hyprland", "--", original)
local files = {
[root] = 'require("child")\n',
[child] = raw .. "\n-- Keymap hidden v1 begin BAD bad\n",
}
noctalia = {
state = state, getConfig = function(key) return ({ compositor = "hyprland", hyprland_config = root })[key] end,
getenv = function() return "" end, expandPath = function(path) return path end,
fileExists = function(path) return files[path] ~= nil end, readFile = function(path) return files[path] end,
commandExists = function() return true end, tr = function(key) return key end,
runAsync = function(_, callback) callback({ exitCode = 0, timedOut = false, stdout = "[]" }); return true end,
json = { decode = function() return {} end },
}
assert(loadfile("service.luau"))()
assertHidden(values["keymap.snapshot"], child, original, raw)
end
do
local values, state = stateMock()
local root, child = "/tmp/hidden/config.kdl", "/tmp/hidden/child.kdl"
local original = ' // Keymap bind-category: Media\n Super+H hotkey-overlay-title="Hidden Niri" { spawn-sh "true"; }'
local raw = hiddenSnippet("Niri", "//", original)
local files = { [root] = 'include "child.kdl"\n', [child] = "binds {\n" .. raw .. "\n}\n// Keymap hidden V1 begin bad bad\n" }
noctalia = {
state = state, getConfig = function(key) return ({ compositor = "niri", niri_config = root })[key] end,
getenv = function() return "" end, fileExists = function(path) return files[path] ~= nil end,
readFile = function(path) return files[path] end, tr = function(key) return key end,
}
assert(loadfile("niri_service.luau"))()
assertHidden(values["keymap.snapshot"], child, original, raw)
end
do
local values, state = stateMock()
local root, child = "/tmp/hidden/mango.conf", "/tmp/hidden/child.conf"
local original = '# Keymap bind-category: Media\nbind=SUPER,H,spawn_shell,true #"Hidden Mango"'
local raw = hiddenSnippet("MangoWC", "#", original)
local files = { [root] = "source=child.conf\n", [child] = raw .. "\n# Keymap hidden v1 begin 00000000 00000000\n" }
noctalia = {
state = state, getConfig = function(key) return ({ compositor = "mangowc", mangowc_config = root })[key] end,
getenv = function() return "" end, expandPath = function(path) return path end,
fileExists = function(path) return files[path] ~= nil end, readFile = function(path) return files[path] end,
tr = function(key) return key end,
}
assert(loadfile("mangowc_service.luau"))()
assertHidden(values["keymap.snapshot"], child, original, raw)
end
print("hidden sentinel parser tests: ok")
+67
View File
@@ -0,0 +1,67 @@
local stateValues = {}
local watchers = {}
local sourcePath = "/tmp/keybind-test/keybind.lua"
local source = table.concat({
"-- 1. Applications",
[[hl.bind("SUPER + RETURN", hl.dsp.exec_cmd("kitty"), { description = "Terminal" })]],
[=[hl.bind("SUPER + Q", hl.dsp.exec_cmd([[browser --private]]), { description = "Browser" })]=],
[[hl.bind("SUPER + P", hl.dsp.exec_cmd("printf \"ok\""), { description = "Quoted" })]],
[[hl.bind("SUPER + W", hl.dsp.window.close(), { description = "Close Window" })]],
}, "\n") .. "\n"
local liveBinds = {
{ key = "RETURN", modmask = 64, description = "Terminal", has_description = true, dispatcher = "__lua" },
{ key = "Q", modmask = 64, description = "Browser", has_description = true, dispatcher = "__lua" },
{ key = "P", modmask = 64, description = "Quoted", has_description = true, dispatcher = "__lua" },
{ key = "W", modmask = 64, description = "Close Window", has_description = true, dispatcher = "__lua" },
}
noctalia = {
getConfig = function(key)
local values = {
compositor = "hyprland", hyprland_config = sourcePath,
show_undescribed = true, merge_sequential = false,
}
return values[key]
end,
getenv = function() return "" end,
expandPath = function(path) return path end,
readFile = function(path) return path == sourcePath and source or nil end,
fileExists = function(path) return path == sourcePath end,
commandExists = function(command) return command == "hyprctl" end,
runAsync = function(_command, callback, _timeout)
callback({ exitCode = 0, timedOut = false, stdout = "live-binds" })
return true
end,
json = { decode = function(value) assert(value == "live-binds") return liveBinds end },
tr = function(key)
if key == "category.other" then return "Other" end
if key == "category.undescribed" then return "Without description" end
return key
end,
state = {
get = function(key) return stateValues[key] end,
set = function(key, value)
stateValues[key] = value
if watchers[key] ~= nil then watchers[key](value) end
end,
watch = function(key, callback) watchers[key] = callback end,
},
}
assert(loadfile("service.luau"))()
local snapshot = stateValues["keymap.snapshot"]
assert(type(snapshot) == "table" and snapshot.status == "ready", "service did not publish a ready snapshot")
local byDescription = {}
for _, category in ipairs(snapshot.categories or {}) do
for _, bind in ipairs(category.binds or {}) do byDescription[bind.description] = bind end
end
assert(byDescription.Terminal.command == "kitty", "double-quoted exec_cmd was not parsed")
assert(byDescription.Terminal.capabilities.command == true, "double-quoted command was not marked editable")
assert(byDescription.Browser.command == "browser --private", "long-string exec_cmd regressed")
assert(byDescription.Quoted.command == 'printf "ok"', "escaped quoted command was decoded incorrectly")
assert(byDescription["Close Window"].capabilities.command == false, "native action was exposed as a shell command")
print("hypr command parser tests: ok")
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env python3
import json
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
TRANSLATIONS = json.loads((ROOT / "translations" / "en.json").read_text())
def resolves(key: str) -> bool:
value = TRANSLATIONS
for part in key.split("."):
if not isinstance(value, dict) or part not in value:
return False
value = value[part]
return isinstance(value, (str, dict))
required = set()
manifest = (ROOT / "plugin.toml").read_text()
required.update(re.findall(r'(?:label_key|description_key)\s*=\s*"([^"]+)"', manifest))
for path in ROOT.glob("*.luau"):
source = path.read_text()
required.update(re.findall(r'(?:noctalia\.)?trp?\(\s*"([^"]+)"', source))
required.update(re.findall(r'"((?:actions|category)\.[a-z0-9_.]+)"', source))
missing = sorted(key for key in required if not key.endswith(".") and not resolves(key))
assert not missing, "missing English translation keys: " + ", ".join(missing)
# Physical modifier legends are standardized key names. Other literal UI prose
# must go through noctalia.tr so new locales can translate it.
allowed_key_labels = {"Super", "Ctrl", "Shift", "Alt"}
literal_ui = []
panel_source = (ROOT / "panel.luau").read_text()
for match in re.finditer(r'\b(?:text|placeholder|tooltip)\s*=\s*"([^"]+)"', panel_source):
if match.group(1) not in allowed_key_labels:
literal_ui.append(match.group(1))
assert not literal_ui, "untranslated literal UI strings: " + ", ".join(literal_ui)
print(f"i18n tests: ok ({len(required)} referenced keys)")
+118
View File
@@ -0,0 +1,118 @@
local sourceFile = assert(io.open("panel.luau", "rb"))
local source = sourceFile:read("*a")
sourceFile:close()
local beginMarker = "-- BEGIN KEYBOARD LAYOUT DATA"
local endMarker = "-- END KEYBOARD LAYOUT DATA"
local beginAt = assert(source:find(beginMarker, 1, true), "layout data start marker missing")
local bodyAt = assert(source:find("\n", beginAt, true)) + 1
local endAt = assert(source:find(endMarker, bodyAt, true), "layout data end marker missing")
local layoutSource = source:sub(bodyAt, endAt - 1)
local loader = assert(load(layoutSource .. [[
return {
order = KEYBOARD_LAYOUT_ORDER,
layouts = KEYBOARD_LAYOUTS,
}
]], "keyboard layout data", "t", _G))
local data = loader()
local expectedOrder = { "100", "96", "80", "75", "65", "60" }
local expectedRows = { ["100"] = 6, ["96"] = 6, ["80"] = 6, ["75"] = 6, ["65"] = 5, ["60"] = 5 }
local expectedPixels = { ["100"] = 1100, ["96"] = 908, ["80"] = 884, ["75"] = 764, ["65"] = 764, ["60"] = 716 }
local function keyWidth(units)
return math.floor(units * 44 + (units - 1) * 4 + 0.5)
end
local globalIds = {}
for orderIndex, layoutId in ipairs(expectedOrder) do
assert(data.order[orderIndex] == layoutId, "unexpected layout order at " .. orderIndex)
local layout = assert(data.layouts[layoutId], "missing layout " .. layoutId)
assert(layout.id == layoutId)
assert(#layout.rows == expectedRows[layoutId], "unexpected row count for " .. layoutId)
assert(keyWidth(layout.rowUnits) == expectedPixels[layoutId], "unexpected pixel width for " .. layoutId)
local layoutIds = {}
local hasLetters, hasDigits = {}, {}
for rowIndex, row in ipairs(layout.rows) do
local units = 0
assert(#row > 0, "empty row in " .. layoutId)
for _, spec in ipairs(row) do
local isSpacer = spec.spacer ~= nil
local isKey = spec.id ~= nil
assert(isSpacer ~= isKey, "spec must be exactly one of spacer or key in " .. layoutId)
local width = tonumber(isSpacer and spec.spacer or spec.units)
assert(width and width > 0 and width * 4 % 1 == 0, "invalid quarter-unit width in " .. layoutId)
units = units + width
if isKey then
assert(type(spec.id) == "string" and spec.id:match("^[a-z0-9_]+$"), "invalid key id")
assert(not layoutIds[spec.id], "duplicate key id " .. spec.id .. " in " .. layoutId)
layoutIds[spec.id] = true
assert(type(spec.label) == "string" and spec.label ~= "", "key label missing for " .. spec.id)
if spec.bindable == false then
assert(spec.code == nil, "passive key must not expose a compositor code")
else
assert(type(spec.code) == "string" and spec.code ~= "", "bindable code missing for " .. spec.id)
end
if globalIds[spec.id] ~= nil then
assert(globalIds[spec.id] == spec.code, "key id changed semantic code: " .. spec.id)
else
globalIds[spec.id] = spec.code or false
end
local letter = spec.id:match("^key_([a-z])$")
local digit = spec.id:match("^digit_(%d)$")
if letter then hasLetters[letter] = true end
if digit then hasDigits[digit] = true end
end
end
assert(math.abs(units - layout.rowUnits) < 0.0001,
string.format("%s row %d is %.2fu instead of %.2fu", layoutId, rowIndex, units, layout.rowUnits))
end
for byte = string.byte("a"), string.byte("z") do
assert(hasLetters[string.char(byte)], "letter missing from " .. layoutId)
end
for digit = 0, 9 do assert(hasDigits[tostring(digit)], "digit missing from " .. layoutId) end
end
assert(#data.order == #expectedOrder, "unexpected extra keyboard layout")
local function hasId(layoutId, wanted)
for _, row in ipairs(data.layouts[layoutId].rows) do
for _, spec in ipairs(row) do if spec.id == wanted then return true end end
end
return false
end
local function keyX(layoutId, rowIndex, wanted)
local x = 0
for _, spec in ipairs(data.layouts[layoutId].rows[rowIndex]) do
if spec.id == wanted then return x end
x = x + tonumber(spec.spacer or spec.units)
end
return nil
end
assert(hasId("100", "kp_1") and hasId("96", "kp_1"), "full layouts need a numpad")
for _, layoutId in ipairs({ "80", "75", "65", "60" }) do
assert(not hasId(layoutId, "kp_1"), layoutId .. " unexpectedly contains a numpad")
end
for _, layoutId in ipairs({ "100", "96", "80", "75" }) do
assert(hasId(layoutId, "f1"), layoutId .. " needs a function row")
end
assert(not hasId("65", "f1") and not hasId("60", "f1"))
assert(hasId("65", "arrow_up") and not hasId("60", "arrow_up"))
assert(hasId("65", "fn") and not hasId("75", "fn"), "Fn must be passive and limited to the 65% view")
assert(keyX("96", 5, "arrow_up") == 14 and keyX("96", 5, "kp_1") == 15)
assert(keyX("96", 6, "arrow_left") == 13 and keyX("96", 6, "arrow_down") == 14)
assert(keyX("96", 6, "arrow_right") == 15 and keyX("96", 6, "kp_0") == 16)
for _, layoutId in ipairs({ "75", "65" }) do
local shiftRow = layoutId == "75" and 5 or 4
local bottomRow = layoutId == "75" and 6 or 5
assert(keyX(layoutId, shiftRow, "arrow_up") == 14)
assert(keyX(layoutId, shiftRow, "end") == 15)
assert(keyX(layoutId, bottomRow, "arrow_left") == 13)
assert(keyX(layoutId, bottomRow, "arrow_down") == 14)
assert(keyX(layoutId, bottomRow, "arrow_right") == 15)
end
print("keyboard layout tests: ok")
+413
View File
@@ -0,0 +1,413 @@
local files = { ["command_library.json"] = "test-command-library" }
local stateValues = {}
local watchers = {}
local commands = {}
local failure = {
preflight = false,
validator = false,
reloadOnce = false,
}
local reloadAttempts = 0
local function commandResult(command)
local result = { exitCode = 0, timedOut = false }
if failure.preflight and command:find("[ ! -L ", 1, true) == 1 then
result.exitCode = 1
elseif failure.validator and command == failure.validatorCommand then
result.exitCode = 1
elseif failure.reloadOnce and command == failure.reloadCommand then
reloadAttempts = reloadAttempts + 1
if reloadAttempts == 1 then result.exitCode = 1 end
end
return result
end
noctalia = {
state = {
get = function(key) return stateValues[key] end,
set = function(key, value)
stateValues[key] = value
if watchers[key] ~= nil then watchers[key](value) end
end,
watch = function(key, callback) watchers[key] = callback end,
},
readFile = function(path) return files[path] end,
fileExists = function(path) return files[path] ~= nil end,
writeFile = function(path, content)
files[path] = content
return true
end,
renameFile = function(source, target)
if files[source] == nil then return false end
files[target] = files[source]
files[source] = nil
return true
end,
removeFile = function(path)
files[path] = nil
return true
end,
runAsync = function(command, callback, timeout)
commands[#commands + 1] = { command = command, timeout = timeout }
callback(commandResult(command))
return true
end,
json = { decode = function(encoded)
if encoded == "test-command-library" then
return {
schema = 1,
entries = {
{ id = "hyprland/window.close", source = "hyprland", kind = "native", template = "hl.dsp.window.close()" },
{ id = "hyprland/exec_cmd", source = "hyprland", kind = "native", template = "hl.dsp.exec_cmd({{command}})" },
{ id = "niri/close-window", source = "niri", kind = "native", template = "close-window" },
{ id = "mangowc/killclient", source = "mangowc", kind = "native", template = "killclient" },
},
}
end
return {}
end },
}
local CASES = {
{
id = "hyprland",
compositor = "Hyprland",
rootName = "hyprland.lua",
managedName = "keymap.lua",
comment = "--",
root = "-- user config\n",
includeLine = 'require("keymap")',
validatorPrefix = "Hyprland --verify-config -c ",
reloadCommand = "hyprctl reload",
first = {
modifiers = { "SUPER", "SHIFT" }, keys = { "A" }, activation = "press",
command = "noctalia msg panel-open launcher", description = "Open app", category = "Applications",
entry = '-- 1. Applications\nhl.bind("SUPER + SHIFT + A", hl.dsp.exec_cmd([[noctalia msg panel-open launcher]]), { description = "Open app" })\n',
},
second = {
modifiers = { "SUPER" }, keys = { "B" }, activation = "release",
command = "second-command", description = "Second action", category = "System",
entry = '-- 1. System\nhl.bind("SUPER + B", hl.dsp.exec_cmd([[second-command]]), { release = true, description = "Second action" })\n',
},
},
{
id = "niri",
compositor = "Niri",
rootName = "config.kdl",
managedName = "keymap.kdl",
comment = "//",
root = "// user config\n",
includeLine = 'include "keymap.kdl"',
validatorPrefix = "niri validate -c ",
reloadCommand = "niri msg action load-config-file",
first = {
modifiers = { "SUPER", "SHIFT" }, keys = { "A" }, activation = "press",
command = "launch-app", description = "Open app", category = "Applications",
entry = ' //"Applications"\n Mod+Shift+A repeat=false hotkey-overlay-title="Open app" { spawn-sh "launch-app"; }\n',
},
second = {
modifiers = { "CTRL", "ALT" }, keys = { "B" }, activation = "press",
command = "second-command", description = "Second action", category = "System",
entry = ' //"System"\n Ctrl+Alt+B repeat=false hotkey-overlay-title="Second action" { spawn-sh "second-command"; }\n',
},
},
{
id = "mangowc",
compositor = "MangoWC",
rootName = "config.conf",
managedName = "keymap.conf",
comment = "#",
root = "# user config\n",
includeLine = "source=./keymap.conf",
validatorPrefix = "mango -c ",
validatorSuffix = " -p",
reloadCommand = "mmsg dispatch reload_config",
first = {
modifiers = { "SUPER", "ALT" }, keys = { "A" }, activation = "release",
command = "launch-app", description = "Open app", category = "Applications",
entry = '# Keymap category: Applications\nbindr=SUPER+ALT,A,spawn_shell,launch-app #"Open app"\n',
},
second = {
modifiers = {}, keys = { "B" }, activation = "press",
command = "second-command", description = "Second action", category = "System",
entry = '# Keymap category: System\nbind=NONE,B,spawn_shell,second-command #"Second action"\n',
},
},
}
local function reset(case)
files = {}
stateValues = {}
commands = {}
failure = { preflight = false, validator = false, reloadOnce = false }
reloadAttempts = 0
local directory = "/tmp/keymap-create-test/" .. case.id
case.rootPath = directory .. "/" .. case.rootName
case.managedPath = directory .. "/" .. case.managedName
files[case.rootPath] = case.root
stateValues["keymap.snapshot"] = {
status = "ready",
compositor = case.compositor,
source = case.rootPath,
categories = {},
hidden = {},
}
end
local function createRequest(case, spec, requestId, source)
return {
request_id = requestId,
compositor = case.compositor,
source = source or case.rootPath,
modifiers = spec.modifiers,
keys = spec.keys,
activation = spec.activation,
command = spec.command,
command_kind = spec.command_kind,
library_entry_id = spec.library_entry_id,
description = spec.description,
category = spec.category,
}
end
local function submit(case, spec, requestId, source)
noctalia.state.set("keymap.create_request", createRequest(case, spec, requestId, source))
local result = stateValues["keymap.create_result"]
assert(type(result) == "table", case.id .. ": missing create result")
assert(result.request_id == requestId, case.id .. ": result request id mismatch")
return result
end
local function includeBlock(case)
return case.comment .. " BEGIN Keymap managed include\n"
.. case.includeLine .. "\n"
.. case.comment .. " END Keymap managed include"
end
local function expectedRoot(case)
return case.root .. "\n" .. includeBlock(case) .. "\n"
end
local function managedHeader(case)
return case.comment .. " Managed by Noctalia Keymap.\n"
.. case.comment .. " Existing entries are preserved; new entries are appended.\n"
end
local function expectedManaged(case, entries)
local content
if case.compositor == "Niri" then
content = managedHeader(case) .. "binds {\n"
for _, entry in ipairs(entries) do content = content .. entry end
return content .. "}\n"
end
if case.compositor == "MangoWC" then
content = managedHeader(case) .. "\nkeymode=default\n\n"
else
content = managedHeader(case) .. "\n"
end
for _, entry in ipairs(entries) do content = content .. entry end
return content
end
local function validatorCommand(case)
return case.validatorPrefix .. "'" .. case.rootPath .. "'" .. (case.validatorSuffix or "")
end
local function assertNormalCommandSequence(case, label)
assert(#commands == 3, label .. ": expected preflight, validator and reload")
assert(commands[1].command:find("[ ! -L ", 1, true) == 1, label .. ": preflight was not first")
assert(commands[1].timeout == 2000, label .. ": unexpected preflight timeout")
assert(commands[2].command == validatorCommand(case), label .. ": wrong validator command")
assert(commands[2].timeout == 8000, label .. ": unexpected validator timeout")
assert(commands[3].command == case.reloadCommand, label .. ": wrong reload command")
assert(commands[3].timeout == 5000, label .. ": unexpected reload timeout")
end
local function assertNoTemporaryFiles(label)
for path, _ in pairs(files) do
assert(not path:match("%.keymap%-.+%.tmp$"), label .. ": temporary file left behind: " .. path)
end
end
local function runHappyPath(case)
reset(case)
local firstResult = submit(case, case.first, case.id .. "-first")
assert(firstResult.ok == true, case.id .. ": first create failed: " .. tostring(firstResult.error))
assert(firstResult.managed_path == case.managedPath, case.id .. ": wrong managed path")
assert(files[case.rootPath] == expectedRoot(case), case.id .. ": marked include differs")
assert(files[case.managedPath] == expectedManaged(case, { case.first.entry }),
case.id .. ": first managed content differs\n" .. tostring(files[case.managedPath]))
assertNormalCommandSequence(case, case.id .. " first create")
assertNoTemporaryFiles(case.id .. " first create")
commands = {}
local secondResult = submit(case, case.second, case.id .. "-second")
assert(secondResult.ok == true, case.id .. ": second create failed: " .. tostring(secondResult.error))
assert(files[case.rootPath] == expectedRoot(case), case.id .. ": second create duplicated include")
local _, includeCount = files[case.rootPath]:gsub(case.includeLine:gsub("([^%w])", "%%%1"), "")
assert(includeCount == 1, case.id .. ": include line count is " .. tostring(includeCount))
assert(files[case.managedPath] == expectedManaged(case, { case.first.entry, case.second.entry }),
case.id .. ": second managed content differs\n" .. tostring(files[case.managedPath]))
assertNormalCommandSequence(case, case.id .. " second create")
local rootBefore = files[case.rootPath]
local managedBefore = files[case.managedPath]
commands = {}
local duplicateResult = submit(case, case.second, case.id .. "-duplicate")
assert(duplicateResult.ok == true, case.id .. ": exact duplicate was not idempotent")
assert(files[case.rootPath] == rootBefore and files[case.managedPath] == managedBefore,
case.id .. ": exact duplicate changed files")
assert(#commands == 1 and commands[1].command:find("[ ! -L ", 1, true) == 1,
case.id .. ": exact duplicate unexpectedly validated or reloaded")
assertNoTemporaryFiles(case.id .. " duplicate")
end
local function runVerifyRollback(case)
reset(case)
failure.validator = true
failure.validatorCommand = validatorCommand(case)
local result = submit(case, case.first, case.id .. "-verify-rollback")
assert(result.ok == false and result.error == "verify_failed",
case.id .. ": expected verify_failed, got " .. tostring(result.error))
assert(files[case.rootPath] == case.root, case.id .. ": verify rollback did not restore root")
assert(files[case.managedPath] == nil, case.id .. ": verify rollback left a new managed file")
assert(#commands == 2 and commands[2].command == validatorCommand(case),
case.id .. ": unexpected verify rollback command sequence")
assertNoTemporaryFiles(case.id .. " verify rollback")
end
local function runReloadRollbackOnAppend(case)
reset(case)
local baseline = submit(case, case.first, case.id .. "-reload-baseline")
assert(baseline.ok == true, case.id .. ": reload rollback baseline failed")
local rootBefore = files[case.rootPath]
local managedBefore = files[case.managedPath]
commands = {}
failure.reloadOnce = true
failure.reloadCommand = case.reloadCommand
reloadAttempts = 0
local result = submit(case, case.second, case.id .. "-reload-rollback")
assert(result.ok == false and result.error == "reload_failed",
case.id .. ": expected reload_failed, got " .. tostring(result.error))
assert(files[case.rootPath] == rootBefore, case.id .. ": reload rollback changed root")
assert(files[case.managedPath] == managedBefore, case.id .. ": reload rollback did not restore managed file")
assert(#commands == 4, case.id .. ": expected preflight, validator, failed reload and recovery reload")
assert(commands[1].command:find("[ ! -L ", 1, true) == 1, case.id .. ": missing preflight")
assert(commands[2].command == validatorCommand(case), case.id .. ": missing validator")
assert(commands[3].command == case.reloadCommand and commands[4].command == case.reloadCommand,
case.id .. ": restored configuration was not reloaded")
assert(reloadAttempts == 2, case.id .. ": expected two reload attempts")
assertNoTemporaryFiles(case.id .. " reload rollback")
end
assert(loadfile("writer_service.luau"))()
for _, case in ipairs(CASES) do
runHappyPath(case)
runVerifyRollback(case)
runReloadRollbackOnAppend(case)
end
local NATIVE_CASES = {
{
base = CASES[1], id = "hypr-native", command = "hl.dsp.window.close()",
library_entry_id = "hyprland/window.close",
entry = '-- 1. Windows\nhl.bind("SUPER + N", hl.dsp.window.close(), { description = "Close window" })\n',
},
{
base = CASES[2], id = "niri-native", command = "close-window",
library_entry_id = "niri/close-window",
entry = ' //"Windows"\n Mod+N repeat=false hotkey-overlay-title="Close window" { close-window; }\n',
},
{
base = CASES[3], id = "mango-native", command = "killclient",
library_entry_id = "mangowc/killclient",
entry = '# Keymap category: Windows\nbind=SUPER,N,killclient #"Close window"\n',
},
}
for _, native in ipairs(NATIVE_CASES) do
local case = native.base
reset(case)
local spec = {
modifiers = { "SUPER" }, keys = { "N" }, activation = "press",
command = native.command, command_kind = "native",
library_entry_id = native.library_entry_id,
description = "Close window", category = "Windows",
}
local result = submit(case, spec, native.id)
assert(result.ok == true, native.id .. ": native create failed: " .. tostring(result.error))
assert(files[case.managedPath] == expectedManaged(case, { native.entry }),
native.id .. ": wrong native managed entry")
end
do
local case = CASES[1]
reset(case)
local spec = {
modifiers = { "SUPER" }, keys = { "N" }, activation = "press",
command = "hl.dsp.window.kill()", command_kind = "native",
library_entry_id = "hyprland/window.close",
description = "Tampered action", category = "Windows",
}
local result = submit(case, spec, "native-tampered")
assert(result.ok == false and result.error == "library_entry_invalid",
"tampered native action was accepted")
assert(files[case.managedPath] == nil, "tampered native action changed files")
end
do
local case = CASES[1]
reset(case)
local spec = {
modifiers = { "SUPER" }, keys = { "N" }, activation = "press",
command = "hl.dsp.exec_cmd({{command}})", command_kind = "native",
library_entry_id = "hyprland/exec_cmd",
description = "Incomplete action", category = "Applications",
}
local result = submit(case, spec, "native-placeholder")
assert(result.ok == false and result.error == "library_arguments_required",
"unresolved native placeholder was accepted")
assert(files[case.managedPath] == nil, "unresolved native placeholder changed files")
end
do
local case = CASES[3]
reset(case)
local spec = {
modifiers = { "SUPER" }, keys = { "N" }, activation = "press",
command = "hl.dsp.window.close()", command_kind = "native",
library_entry_id = "hyprland/window.close",
description = "Foreign action", category = "Windows",
}
local result = submit(case, spec, "native-foreign-source")
assert(result.ok == false and result.error == "library_entry_invalid",
"native action from another compositor was accepted")
assert(files[case.managedPath] == nil, "foreign native action changed files")
end
do
local case = CASES[1]
reset(case)
stateValues["keymap.snapshot"].source = case.rootPath .. ".other"
local result = submit(case, case.first, "stale-context")
assert(result.ok == false and result.error == "stale_context", "stale context was accepted")
assert(files[case.rootPath] == case.root and files[case.managedPath] == nil,
"stale context changed files")
assert(#commands == 0, "stale context reached preflight")
end
do
local case = CASES[1]
reset(case)
failure.preflight = true
local result = submit(case, case.first, "symlink-preflight")
assert(result.ok == false and result.error == "symlink_unsupported", "symlink preflight was accepted")
assert(files[case.rootPath] == case.root and files[case.managedPath] == nil,
"failed symlink preflight changed files")
assert(#commands == 1 and commands[1].command:find("[ ! -L ", 1, true) == 1,
"symlink rejection did not stop after preflight")
end
print("writer create tests: ok")
File diff suppressed because it is too large Load Diff