Files
community-plugins/keymap/panel.luau
T

3302 lines
112 KiB
Luau

--!nonstrict
-- Service/UI state contract
-- -------------------------
-- `keymap.snapshot` is replaced atomically by the service:
-- {
-- status = "idle" | "loading" | "ready" | "error",
-- error = string,
-- compositor = "Hyprland",
-- source = string?,
-- updated_at = string?,
-- total = number,
-- warnings = { string },
-- categories = {
-- {
-- id = string,
-- name = string,
-- binds = {
-- {
-- id = string,
-- modifiers = { "SUPER", "CTRL", ... },
-- key = string,
-- description = string,
-- dispatcher = string?,
-- activation = "press" | "release",
-- },
-- },
-- },
-- },
-- }
-- The panel increments `keymap.refresh_request` to request a new
-- snapshot. It never mutates the service-owned snapshot.
local SNAPSHOT_KEY = "keymap.snapshot"
local REFRESH_KEY = "keymap.refresh_request"
local CREATE_REQUEST_KEY = "keymap.create_request"
local CREATE_RESULT_KEY = "keymap.create_result"
local UPDATE_REQUEST_KEY = "keymap.update_request"
local UPDATE_RESULT_KEY = "keymap.update_result"
local EMPTY_SNAPSHOT = {
status = "idle",
error = "",
compositor = "",
total = 0,
categories = {},
hidden = {},
}
local snapshot = EMPTY_SNAPSHOT
local searchQuery = ""
local searchRevision = 0
local viewMode = "keyboard"
local keyboardLayoutId = ""
local selectedKeyboardKey = nil
local activeModifiers = { SUPER = false, CTRL = false, SHIFT = false, ALT = false }
local creatorOpen = false
local creatorKeys = {}
local creatorKeysText = ""
local creatorActivation = "press"
local creatorCommand = ""
local creatorCommandKind = "shell"
local creatorLibraryEntryId = ""
local creatorDescription = ""
local creatorCategoryIndex = 0
local creatorNewCategory = ""
local creatorCategoryNamesFrozen = {}
local creatorContextCompositor = ""
local creatorContextSource = ""
local creatorRevision = 0
local creatorRequestId = ""
local creatorRequestCounter = 0
local creatorBusy = false
local creatorError = ""
local commandLibraryOpen = false
local commandLibraryQuery = ""
local commandLibrarySourceIndex = 0
local commandLibraryCategoryIndex = 0
local commandLibraryReadinessIndex = 0
local commandLibraryRevision = 0
local formMode = "create"
local editorMode = false
local editingBindId = ""
local editingCategory = ""
local editingCapabilities = {}
local editingAction = ""
local editingRequestId = ""
local editingOperation = "update"
local editingSnapshotBefore = nil
local deleteConfirmBindId = ""
local hiddenDeleteConfirmBindId = ""
local renamingCategoryId = ""
local renamingCategoryOriginal = ""
local renamingCategoryValue = ""
local renamingCategoryRevision = 0
local render
local selectKeyboardKey
local COMMAND_LIBRARY = { entries = {} }
do
local encoded = noctalia.readFile("command_library.json")
if type(encoded) == "string" and type(noctalia.json) == "table"
and type(noctalia.json.decode) == "function" then
local ok, decoded = pcall(noctalia.json.decode, encoded)
if ok and type(decoded) == "table" and decoded.schema == 1
and type(decoded.entries) == "table" then
COMMAND_LIBRARY = decoded
end
end
end
local NIL_CACHE_VALUE = {}
local configCache = {}
local translationCache = {}
local function 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)
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)
if type(value) == "string" and value ~= "" then
return value
end
return fallback or ""
end
local function asArray(value)
if type(value) == "table" then
return value
end
return {}
end
local function shellQuote(value)
return "'" .. asString(value):gsub("'", "'\\''") .. "'"
end
local function sourceDirectory()
local source = asString(snapshot.source)
if source == "" then return "" end
local expanded = noctalia.expandPath(source)
local directory = expanded:match("^(.*)/[^/]*$")
if directory == nil then return "." end
if directory == "" then return "/" end
return directory
end
local function normalized(value)
return string.lower(noctalia.string.trim(asString(value)))
end
local MODIFIER_ORDER = { "SUPER", "CTRL", "SHIFT", "ALT" }
local MODIFIER_ALIASES = {
super = "SUPER", meta = "SUPER", win = "SUPER", logo = "SUPER", mod = "SUPER", mod4 = "SUPER",
ctrl = "CTRL", control = "CTRL",
shift = "SHIFT",
alt = "ALT", option = "ALT", mod1 = "ALT",
}
local KEY_ALIASES = {
escape = "esc", esc = "esc",
["return"] = "enter", enter = "enter", kp_enter = "numenter", kpenter = "numenter",
space = "space", spacebar = "space",
print = "prtsc", printscreen = "prtsc", sys_req = "prtsc", prtsc = "prtsc",
scroll_lock = "scrolllock", scrolllock = "scrolllock",
pause = "pause", ["break"] = "pause",
insert = "insert", ins = "insert", delete = "delete", del = "delete",
home = "home", ["end"] = "end", prior = "pgup", page_up = "pgup", pageup = "pgup", pgup = "pgup",
next = "pgdn", page_down = "pgdn", pagedown = "pgdn", pgdn = "pgdn",
left = "left", arrowleft = "left", right = "right", arrowright = "right",
up = "up", arrowup = "up", down = "down", arrowdown = "down",
grave = "grave", asciitilde = "grave", minus = "minus", underscore = "minus",
equal = "equal", plus = "equal",
bracketleft = "bracketleft", braceleft = "bracketleft",
bracketright = "bracketright", braceright = "bracketright",
backslash = "backslash", bar = "backslash",
semicolon = "semicolon", colon = "semicolon", apostrophe = "apostrophe", quotedbl = "apostrophe",
comma = "comma", less = "comma", period = "period", greater = "period", slash = "slash", question = "slash",
num_lock = "numlock", numlock = "numlock", kp_divide = "numdivide", kpdivide = "numdivide",
kp_multiply = "nummultiply", kpmultiply = "nummultiply", kp_subtract = "numminus", kpsubtract = "numminus",
kp_add = "numplus", kpadd = "numplus", kp_decimal = "numdecimal", kpdecimal = "numdecimal",
num_plus = "numplus", ["num_+"] = "numplus", num_minus = "numminus", ["num_-"] = "numminus",
num_multiply = "nummultiply", ["num_*"] = "nummultiply",
num_divide = "numdivide", ["num_/"] = "numdivide",
kp_insert = "num0", kpinsert = "num0", kp_end = "num1", kpend = "num1",
kp_down = "num2", kpdown = "num2", kp_next = "num3", kpnext = "num3",
kp_left = "num4", kpleft = "num4", kp_begin = "num5", kpbegin = "num5",
kp_right = "num6", kpright = "num6", kp_home = "num7", kphome = "num7",
kp_up = "num8", kpup = "num8", kp_prior = "num9", kpprior = "num9",
kp_delete = "numdecimal", kpdelete = "numdecimal",
}
local SYMBOL_ALIASES = {
["~"] = "grave", ["!"] = "1", ["@"] = "2", ["#"] = "3",
["$"] = "4", ["%"] = "5", ["^"] = "6", ["&"] = "7", ["*"] = "8",
["("] = "9", [")"] = "0", ["-"] = "minus", ["_"] = "minus", ["="] = "equal", ["+"] = "equal",
["["] = "bracketleft", ["{"] = "bracketleft", ["]"] = "bracketright", ["}"] = "bracketright",
["\\"] = "backslash", ["|"] = "backslash", [";"] = "semicolon", [":"] = "semicolon",
["'"] = "apostrophe", ["\""] = "apostrophe", [","] = "comma", ["<"] = "comma",
["."] = "period", [">"] = "period", ["/"] = "slash", ["?"] = "slash",
}
SYMBOL_ALIASES[string.char(96)] = "grave"
local function canonicalModifier(value)
return MODIFIER_ALIASES[normalized(value)] or string.upper(asString(value))
end
local function canonicalKey(value)
local raw = normalized(value)
if SYMBOL_ALIASES[raw] ~= nil then
return SYMBOL_ALIASES[raw]
end
local key = raw:gsub("%s+", "_"):gsub("%-", "_")
local rangeStart, rangeEnd = key:match("^(%d)_(%d)$")
if rangeStart ~= nil then
return rangeStart .. "-" .. rangeEnd
end
if KEY_ALIASES[key] ~= nil then
return KEY_ALIASES[key]
end
local numpad = key:match("^kp_?(%d)$") or key:match("^num_?(%d)$")
if numpad ~= nil then
return "num" .. numpad
end
if key:match("^f%d%d?$") or key:match("^[a-z]$") or key:match("^%d$") then
return key
end
return key
end
local function modifierSignature(modifiers)
local enabled = {}
local extras = {}
for _, modifier in ipairs(asArray(modifiers)) do
local canonical = canonicalModifier(modifier)
if activeModifiers[canonical] ~= nil then
enabled[canonical] = true
else
extras[#extras + 1] = canonical
end
end
local ordered = {}
for _, modifier in ipairs(MODIFIER_ORDER) do
if enabled[modifier] then
ordered[#ordered + 1] = modifier
end
end
table.sort(extras)
for _, modifier in ipairs(extras) do
ordered[#ordered + 1] = modifier
end
return table.concat(ordered, "+")
end
local function activeModifierSignature()
local ordered = {}
for _, modifier in ipairs(MODIFIER_ORDER) do
if activeModifiers[modifier] then
ordered[#ordered + 1] = modifier
end
end
return table.concat(ordered, "+")
end
local function expandedKeys(value)
local canonical = canonicalKey(value)
local first, last = canonical:match("^(%d)%-(%d)$")
if first ~= nil and tonumber(first) <= tonumber(last) then
local result = {}
for number = tonumber(first), tonumber(last) do
result[#result + 1] = tostring(number)
end
return result
end
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
for _, bind in ipairs(asArray(category.binds)) do
local signature = modifierSignature(bind.modifiers)
local indexedKeys = type(bind.keys) == "table" and bind.keys or expandedKeys(bind.key)
for _, rawKey in ipairs(indexedKeys) do
local key = canonicalKey(rawKey)
local entry = {
bind = bind,
category = asString(category.name, tr("panel.uncategorized")),
}
local chord = signature .. "|" .. key
exact[chord] = exact[chord] or {}
exact[chord][#exact[chord] + 1] = entry
any[key] = any[key] or {}
any[key][#any[key] + 1] = entry
end
end
end
keyboardIndexSnapshot = snapshot
keyboardIndexCache = { exact = exact, any = any }
return keyboardIndexCache
end
local keyCallbackCache = {}
local function keyCallback(id, code)
local callback = keyCallbackCache[id]
if callback == nil then
callback = function() selectKeyboardKey(code) end
keyCallbackCache[id] = callback
end
return callback
end
local function contains(haystack, needle)
return string.find(normalized(haystack), needle, 1, true) ~= nil
end
local function bindMatches(bind, needle)
if contains(bind.description, needle) or contains(bind.key, needle) or contains(bind.dispatcher, needle)
or contains(bind.action, needle) or contains(bind.mode, needle) then
return true
end
for _, modifier in ipairs(asArray(bind.modifiers)) do
if contains(modifier, needle) then
return true
end
end
return false
end
local function filteredCategories()
local result = {}
local needle = normalized(searchQuery)
for _, category in ipairs(asArray(snapshot.categories)) do
local binds = asArray(category.binds)
if needle == "" or contains(category.name, needle) then
result[#result + 1] = {
id = asString(category.id, asString(category.name)),
name = asString(category.name, tr("panel.uncategorized")),
binds = binds,
}
else
local matching = {}
for _, bind in ipairs(binds) do
if bindMatches(bind, needle) then
matching[#matching + 1] = bind
end
end
if #matching > 0 then
result[#result + 1] = {
id = asString(category.id, asString(category.name)),
name = asString(category.name, tr("panel.uncategorized")),
binds = matching,
}
end
end
end
return result
end
local function filteredHidden()
local result = {}
local needle = normalized(searchQuery)
for _, bind in ipairs(asArray(snapshot.hidden)) do
if needle == "" or bindMatches(bind, needle) or contains(bind.category, needle) then
result[#result + 1] = bind
end
end
return result
end
local function categoryWeight(category)
return #asArray(category.binds) + 2
end
-- Split categories into contiguous, approximately equal columns. Contiguous
-- partitions preserve the reading order from the Lua configuration.
local function partitionCategories(categories, requestedColumns)
if #categories == 0 then
return {}
end
local columnCount = math.max(1, math.min(requestedColumns, #categories))
local columns = {}
local index = 1
local remainingWeight = 0
for _, category in ipairs(categories) do
remainingWeight += categoryWeight(category)
end
for columnIndex = 1, columnCount do
local remainingColumns = columnCount - columnIndex
local target = remainingWeight / (remainingColumns + 1)
local column = {}
local weight = 0
local lastAvailableIndex = #categories - remainingColumns
while index <= lastAvailableIndex do
local nextCategory = categories[index]
local nextWeight = categoryWeight(nextCategory)
if #column > 0 and math.abs(weight - target) <= math.abs(weight + nextWeight - target) then
break
end
column[#column + 1] = nextCategory
weight += nextWeight
index += 1
end
columns[#columns + 1] = column
remainingWeight -= weight
end
return columns
end
local function cardFill()
local color = asString(cfg("card_color"), "surface_variant")
local opacity = math.max(0, math.min(100, tonumber(cfg("card_opacity")) or 35)) / 100
if string.sub(color, 1, 1) == "#" then
local alpha = string.format("%02X", math.floor(opacity * 255 + 0.5))
if #color == 7 then
return color .. alpha
elseif #color == 9 then
return string.sub(color, 1, 7) .. alpha
end
return color
end
return color .. "/" .. string.format("%.2f", opacity)
end
local function modifierStyle(token)
local name = normalized(token)
if name == "super" or name == "meta" or name == "win" or name == "logo" then
return cfg("super_color"), cfg("super_text_color")
elseif name == "ctrl" or name == "control" then
return cfg("ctrl_color"), cfg("ctrl_text_color")
elseif name == "shift" then
return cfg("shift_color"), cfg("shift_text_color")
elseif name == "alt" or name == "mod1" or name == "option" then
return cfg("alt_color"), cfg("alt_text_color")
end
return cfg("key_color"), cfg("key_text_color")
end
-- BEGIN KEYBOARD LAYOUT DATA
local KEYBOARD_100 = {
-- Function row: 16 keys.
{
{ id = "escape", label = "Esc", code = "Esc", units = 1 },
{ spacer = 1 },
{ id = "f1", label = "F1", code = "F1", units = 1 },
{ id = "f2", label = "F2", code = "F2", units = 1 },
{ id = "f3", label = "F3", code = "F3", units = 1 },
{ id = "f4", label = "F4", code = "F4", units = 1 },
{ spacer = 0.5 },
{ id = "f5", label = "F5", code = "F5", units = 1 },
{ id = "f6", label = "F6", code = "F6", units = 1 },
{ id = "f7", label = "F7", code = "F7", units = 1 },
{ id = "f8", label = "F8", code = "F8", units = 1 },
{ spacer = 0.5 },
{ id = "f9", label = "F9", code = "F9", units = 1 },
{ id = "f10", label = "F10", code = "F10", units = 1 },
{ id = "f11", label = "F11", code = "F11", units = 1 },
{ id = "f12", label = "F12", code = "F12", units = 1 },
{ spacer = 0.5 },
{ id = "print_screen", label = "Prt", code = "PrtSc", units = 1 },
{ id = "scroll_lock", label = "Scr", code = "Scroll Lock", units = 1 },
{ id = "pause", label = "Pau", code = "Pause", units = 1 },
{ spacer = 4.5 },
},
-- Number row: 21 keys.
{
{ id = "grave", label = "`", code = "`", units = 1 },
{ id = "digit_1", label = "1", code = "1", units = 1 },
{ id = "digit_2", label = "2", code = "2", units = 1 },
{ id = "digit_3", label = "3", code = "3", units = 1 },
{ id = "digit_4", label = "4", code = "4", units = 1 },
{ id = "digit_5", label = "5", code = "5", units = 1 },
{ id = "digit_6", label = "6", code = "6", units = 1 },
{ id = "digit_7", label = "7", code = "7", units = 1 },
{ id = "digit_8", label = "8", code = "8", units = 1 },
{ id = "digit_9", label = "9", code = "9", units = 1 },
{ id = "digit_0", label = "0", code = "0", units = 1 },
{ id = "minus", label = "-", code = "-", units = 1 },
{ id = "equal", label = "=", code = "=", units = 1 },
{ id = "backspace", label = "Back", code = "Backspace", units = 2 },
{ spacer = 0.5 },
{ id = "insert", label = "Ins", code = "Insert", units = 1 },
{ id = "home", label = "Home", code = "Home", units = 1 },
{ id = "page_up", label = "PgU", code = "PgUp", units = 1 },
{ spacer = 0.5 },
{ id = "num_lock", label = "Num", code = "Num Lock", units = 1 },
{ id = "kp_divide", label = "/", code = "KP_Divide", units = 1 },
{ id = "kp_multiply", label = "*", code = "KP_Multiply", units = 1 },
{ id = "kp_subtract", label = "-", code = "KP_Subtract", units = 1 },
},
-- QWERTY row: 21 keys.
{
{ id = "tab", label = "Tab", code = "Tab", units = 1.5 },
{ id = "key_q", label = "Q", code = "Q", units = 1 },
{ id = "key_w", label = "W", code = "W", units = 1 },
{ id = "key_e", label = "E", code = "E", units = 1 },
{ id = "key_r", label = "R", code = "R", units = 1 },
{ id = "key_t", label = "T", code = "T", units = 1 },
{ id = "key_y", label = "Y", code = "Y", units = 1 },
{ id = "key_u", label = "U", code = "U", units = 1 },
{ id = "key_i", label = "I", code = "I", units = 1 },
{ id = "key_o", label = "O", code = "O", units = 1 },
{ id = "key_p", label = "P", code = "P", units = 1 },
{ id = "bracket_left", label = "[", code = "[", units = 1 },
{ id = "bracket_right", label = "]", code = "]", units = 1 },
{ id = "backslash", label = "\\", code = "\\", units = 1.5 },
{ spacer = 0.5 },
{ id = "delete", label = "Del", code = "Delete", units = 1 },
{ id = "end", label = "End", code = "End", units = 1 },
{ id = "page_down", label = "PgD", code = "PgDn", units = 1 },
{ spacer = 0.5 },
{ id = "kp_7", label = "7", code = "KP_7", units = 1 },
{ id = "kp_8", label = "8", code = "KP_8", units = 1 },
{ id = "kp_9", label = "9", code = "KP_9", units = 1 },
{ id = "kp_add", label = "+", code = "KP_Add", units = 1 },
},
-- Home row: 16 keys. The final numpad spacer continues KP Add from above.
{
{ id = "caps_lock", label = "Caps", code = "Caps Lock", units = 1.75, modifier = true },
{ id = "key_a", label = "A", code = "A", units = 1 },
{ id = "key_s", label = "S", code = "S", units = 1 },
{ id = "key_d", label = "D", code = "D", units = 1 },
{ id = "key_f", label = "F", code = "F", units = 1 },
{ id = "key_g", label = "G", code = "G", units = 1 },
{ id = "key_h", label = "H", code = "H", units = 1 },
{ id = "key_j", label = "J", code = "J", units = 1 },
{ id = "key_k", label = "K", code = "K", units = 1 },
{ id = "key_l", label = "L", code = "L", units = 1 },
{ id = "semicolon", label = ";", code = ";", units = 1 },
{ id = "apostrophe", label = "'", code = "'", units = 1 },
{ id = "enter", label = "Enter", code = "Enter", units = 2.25 },
{ spacer = 4 },
{ id = "kp_4", label = "4", code = "KP_4", units = 1 },
{ id = "kp_5", label = "5", code = "KP_5", units = 1 },
{ id = "kp_6", label = "6", code = "KP_6", units = 1 },
{ id = "kp_add_lower", label = "+", code = "KP_Add", units = 1 },
},
-- Shift row: 17 keys.
{
{ id = "shift_left", label = "Shift", code = "Shift", units = 2.25, modifier = true },
{ id = "key_z", label = "Z", code = "Z", units = 1 },
{ id = "key_x", label = "X", code = "X", units = 1 },
{ id = "key_c", label = "C", code = "C", units = 1 },
{ id = "key_v", label = "V", code = "V", units = 1 },
{ id = "key_b", label = "B", code = "B", units = 1 },
{ id = "key_n", label = "N", code = "N", units = 1 },
{ id = "key_m", label = "M", code = "M", units = 1 },
{ id = "comma", label = ",", code = ",", units = 1 },
{ id = "period", label = ".", code = ".", units = 1 },
{ id = "slash", label = "/", code = "/", units = 1 },
{ id = "shift_right", label = "Shift", code = "Shift", units = 2.75, modifier = true },
{ spacer = 1.5 },
{ id = "arrow_up", label = "↑", code = "Up", units = 1 },
{ spacer = 1 },
{ spacer = 0.5 },
{ id = "kp_1", label = "1", code = "KP_1", units = 1 },
{ id = "kp_2", label = "2", code = "KP_2", units = 1 },
{ id = "kp_3", label = "3", code = "KP_3", units = 1 },
{ id = "kp_enter", label = "Ent", code = "KP_Enter", units = 1 },
},
-- Bottom row: 13 keys. The final numpad spacer continues KP Enter from above.
{
{ id = "ctrl_left", label = "Ctrl", code = "Ctrl", units = 1.25, modifier = true },
{ id = "super_left", label = "Win", code = "Super", units = 1.25, modifier = true },
{ id = "alt_left", label = "Alt", code = "Alt", units = 1.25, modifier = true },
{ id = "space", label = "Space", code = "Space", units = 6.25 },
{ id = "alt_right", label = "Alt", code = "Alt", units = 1.25, modifier = true },
{ id = "super_right", label = "Win", code = "Super", units = 1.25, modifier = true },
{ id = "menu", label = "Menu", code = "Menu", units = 1.25 },
{ id = "ctrl_right", label = "Ctrl", code = "Ctrl", units = 1.25, modifier = true },
{ spacer = 0.5 },
{ id = "arrow_left", label = "←", code = "Left", units = 1 },
{ id = "arrow_down", label = "↓", code = "Down", units = 1 },
{ id = "arrow_right", label = "→", code = "Right", units = 1 },
{ spacer = 0.5 },
{ id = "kp_0", label = "0", code = "KP_0", units = 2 },
{ id = "kp_decimal", label = ".", code = "KP_Decimal", units = 1 },
{ id = "kp_enter_lower", label = "Ent", code = "KP_Enter", units = 1 },
},
}
local function keyboardRowSlice(source, firstIndex, lastIndex)
local row = {}
for index = firstIndex, lastIndex do row[#row + 1] = source[index] end
return row
end
local function keyboardAppend(row, spec)
row[#row + 1] = spec
return row
end
local function keyboardAppendSlice(row, source, firstIndex, lastIndex)
for index = firstIndex, lastIndex do row[#row + 1] = source[index] end
return row
end
local function keyboardSizedKey(spec, units)
return {
id = spec.id, label = spec.label, code = spec.code, units = units,
modifier = spec.modifier, bindable = spec.bindable,
}
end
-- 96% / 1800-compact: the navigation block is folded into the main cluster,
-- while the complete numpad remains directly beside it.
local KEYBOARD_96_FUNCTION = {
KEYBOARD_100[1][1],
KEYBOARD_100[1][3], KEYBOARD_100[1][4], KEYBOARD_100[1][5], KEYBOARD_100[1][6],
KEYBOARD_100[1][8], KEYBOARD_100[1][9], KEYBOARD_100[1][10], KEYBOARD_100[1][11],
KEYBOARD_100[1][13], KEYBOARD_100[1][14], KEYBOARD_100[1][15], KEYBOARD_100[1][16],
KEYBOARD_100[3][16], KEYBOARD_100[2][17], KEYBOARD_100[3][17],
KEYBOARD_100[2][18], KEYBOARD_100[3][18], KEYBOARD_100[1][18],
}
local KEYBOARD_96_SHIFT = keyboardRowSlice(KEYBOARD_100[5], 1, 11)
keyboardAppend(KEYBOARD_96_SHIFT, keyboardSizedKey(KEYBOARD_100[5][12], 1.75))
keyboardAppend(KEYBOARD_96_SHIFT, KEYBOARD_100[5][14])
keyboardAppendSlice(KEYBOARD_96_SHIFT, KEYBOARD_100[5], 17, 20)
local KEYBOARD_96_BOTTOM = {
KEYBOARD_100[6][1], KEYBOARD_100[6][2], KEYBOARD_100[6][3], KEYBOARD_100[6][4],
keyboardSizedKey(KEYBOARD_100[6][5], 1),
keyboardSizedKey(KEYBOARD_100[6][6], 1),
keyboardSizedKey(KEYBOARD_100[6][8], 1),
KEYBOARD_100[6][10], KEYBOARD_100[6][11], KEYBOARD_100[6][12],
keyboardSizedKey(KEYBOARD_100[6][14], 1), KEYBOARD_100[6][15], KEYBOARD_100[6][16],
}
local KEYBOARD_96 = {
KEYBOARD_96_FUNCTION,
keyboardAppendSlice(keyboardRowSlice(KEYBOARD_100[2], 1, 14), KEYBOARD_100[2], 20, 23),
keyboardAppendSlice(keyboardRowSlice(KEYBOARD_100[3], 1, 14), KEYBOARD_100[3], 20, 23),
keyboardAppendSlice(keyboardRowSlice(KEYBOARD_100[4], 1, 13), KEYBOARD_100[4], 15, 18),
KEYBOARD_96_SHIFT,
KEYBOARD_96_BOTTOM,
}
-- 80% / tenkeyless: full function and navigation clusters without a numpad.
local KEYBOARD_80 = {
keyboardRowSlice(KEYBOARD_100[1], 1, 20),
keyboardRowSlice(KEYBOARD_100[2], 1, 18),
keyboardRowSlice(KEYBOARD_100[3], 1, 18),
keyboardAppend(keyboardRowSlice(KEYBOARD_100[4], 1, 13), { spacer = 3.5 }),
keyboardRowSlice(KEYBOARD_100[5], 1, 15),
keyboardRowSlice(KEYBOARD_100[6], 1, 12),
}
local KEYBOARD_COMPACT_BOTTOM = {
KEYBOARD_100[6][1],
KEYBOARD_100[6][2],
KEYBOARD_100[6][3],
KEYBOARD_100[6][4],
keyboardSizedKey(KEYBOARD_100[6][5], 1),
keyboardSizedKey(KEYBOARD_100[6][6], 1),
{ id = "ctrl_right", label = "Ctrl", code = "Ctrl", units = 1, modifier = true },
KEYBOARD_100[6][10],
KEYBOARD_100[6][11],
KEYBOARD_100[6][12],
}
local function compactKeyboardBody()
local shiftRow = keyboardRowSlice(KEYBOARD_100[5], 1, 11)
keyboardAppend(shiftRow, keyboardSizedKey(KEYBOARD_100[5][12], 1.75))
keyboardAppend(shiftRow, KEYBOARD_100[5][14])
keyboardAppend(shiftRow, KEYBOARD_100[3][17])
return {
keyboardAppend(keyboardRowSlice(KEYBOARD_100[2], 1, 14), KEYBOARD_100[2][17]),
keyboardAppend(keyboardRowSlice(KEYBOARD_100[3], 1, 14), KEYBOARD_100[2][18]),
keyboardAppend(keyboardRowSlice(KEYBOARD_100[4], 1, 13), KEYBOARD_100[3][18]),
shiftRow,
keyboardRowSlice(KEYBOARD_COMPACT_BOTTOM, 1, #KEYBOARD_COMPACT_BOTTOM),
}
end
-- 75%: compact navigation and arrows plus a dedicated function row.
local KEYBOARD_75_FUNCTION = {
KEYBOARD_100[1][1],
KEYBOARD_100[1][3], KEYBOARD_100[1][4], KEYBOARD_100[1][5], KEYBOARD_100[1][6],
KEYBOARD_100[1][8], KEYBOARD_100[1][9], KEYBOARD_100[1][10], KEYBOARD_100[1][11],
KEYBOARD_100[1][13], KEYBOARD_100[1][14], KEYBOARD_100[1][15], KEYBOARD_100[1][16],
KEYBOARD_100[1][18], KEYBOARD_100[1][20], KEYBOARD_100[3][16],
}
local KEYBOARD_75 = { KEYBOARD_75_FUNCTION }
for _, row in ipairs(compactKeyboardBody()) do KEYBOARD_75[#KEYBOARD_75 + 1] = row end
-- 65%: the same compact navigation column and arrows, without function keys.
local KEYBOARD_65_NUMBER = { KEYBOARD_100[1][1] }
keyboardAppendSlice(KEYBOARD_65_NUMBER, KEYBOARD_100[2], 2, 14)
keyboardAppend(KEYBOARD_65_NUMBER, KEYBOARD_100[2][17])
local KEYBOARD_65 = compactKeyboardBody()
KEYBOARD_65[1] = KEYBOARD_65_NUMBER
KEYBOARD_65[5] = {
KEYBOARD_100[6][1], KEYBOARD_100[6][2], KEYBOARD_100[6][3], KEYBOARD_100[6][4],
keyboardSizedKey(KEYBOARD_100[6][5], 1),
{ id = "fn", label = "Fn", units = 1, bindable = false },
{ id = "ctrl_right", label = "Ctrl", code = "Ctrl", units = 1, modifier = true },
KEYBOARD_100[6][10], KEYBOARD_100[6][11], KEYBOARD_100[6][12],
}
-- 60%: the ANSI alphanumeric block only.
local KEYBOARD_60 = {
keyboardRowSlice(KEYBOARD_100[2], 1, 14),
keyboardRowSlice(KEYBOARD_100[3], 1, 14),
keyboardRowSlice(KEYBOARD_100[4], 1, 13),
keyboardRowSlice(KEYBOARD_100[5], 1, 12),
keyboardRowSlice(KEYBOARD_100[6], 1, 8),
}
local KEYBOARD_LAYOUT_ORDER = { "100", "96", "80", "75", "65", "60" }
local KEYBOARD_LAYOUTS = {
["100"] = { id = "100", rows = KEYBOARD_100, rowUnits = 23, labelKey = "p100",
features = { functionRow = true, numpad = true, navCluster = true, arrows = true } },
["96"] = { id = "96", rows = KEYBOARD_96, rowUnits = 19, labelKey = "p96",
features = { functionRow = true, numpad = true, navCluster = false, arrows = true } },
["80"] = { id = "80", rows = KEYBOARD_80, rowUnits = 18.5, labelKey = "p80",
features = { functionRow = true, numpad = false, navCluster = true, arrows = true } },
["75"] = { id = "75", rows = KEYBOARD_75, rowUnits = 16, labelKey = "p75",
features = { functionRow = true, numpad = false, navCluster = false, arrows = true } },
["65"] = { id = "65", rows = KEYBOARD_65, rowUnits = 16, labelKey = "p65",
features = { functionRow = false, numpad = false, navCluster = false, arrows = true } },
["60"] = { id = "60", rows = KEYBOARD_60, rowUnits = 15, labelKey = "p60",
features = { functionRow = false, numpad = false, navCluster = false, arrows = false } },
}
-- END KEYBOARD LAYOUT DATA
-- One unit plus one gap is 48 px, divisible by four. Quarter-unit keys can
-- therefore be positioned without fractional rounding drift.
local KEYBOARD_UNIT = 44
local KEYBOARD_GAP = 4
local KEYBOARD_KEY_HEIGHT = 42
local KEYBOARD_CODE_SET = {}
local KEYBOARD_LABELS = {}
for _, layoutId in ipairs(KEYBOARD_LAYOUT_ORDER) do
local layout = KEYBOARD_LAYOUTS[layoutId]
layout.codeSet = {}
for _, keyboardRow in ipairs(layout.rows) do
for _, keySpec in ipairs(keyboardRow) do
if keySpec.bindable ~= false and keySpec.code ~= nil then
local code = canonicalKey(keySpec.code)
KEYBOARD_CODE_SET[code] = true
layout.codeSet[code] = true
KEYBOARD_LABELS[code] = asString(keySpec.label, keySpec.code)
end
end
end
end
local function activeKeyboardLayoutId()
local layoutId = keyboardLayoutId ~= "" and keyboardLayoutId or asString(cfg("keyboard_layout"), "100")
if KEYBOARD_LAYOUTS[layoutId] == nil then return "100" end
return layoutId
end
local function activeKeyboardLayout()
return KEYBOARD_LAYOUTS[activeKeyboardLayoutId()]
end
local function keyboardLayoutSelectedIndex()
local activeId = activeKeyboardLayoutId()
for index, layoutId in ipairs(KEYBOARD_LAYOUT_ORDER) do
if layoutId == activeId then return index - 1 end
end
return 0
end
local function keyboardLayoutOptions()
local options = {}
for _, layoutId in ipairs(KEYBOARD_LAYOUT_ORDER) do
options[#options + 1] = tr("panel.keyboard.layouts." .. KEYBOARD_LAYOUTS[layoutId].labelKey)
end
return options
end
-- Config files use XKB names, while the visual keyboard uses compact labels.
-- These names are accepted by Hyprland Lua, Niri KDL, and MangoWC.
local CONFIG_KEY_NAMES = {
esc = "Escape", prtsc = "Print", scrolllock = "Scroll_Lock", pause = "Pause",
grave = "grave", minus = "minus", equal = "equal", backspace = "BackSpace",
tab = "Tab", bracketleft = "bracketleft", bracketright = "bracketright",
backslash = "backslash", caps_lock = "Caps_Lock", semicolon = "semicolon",
apostrophe = "apostrophe", enter = "Return", comma = "comma", period = "period",
slash = "slash", space = "space", menu = "Menu",
insert = "Insert", home = "Home", pgup = "Prior", delete = "Delete", ["end"] = "End",
pgdn = "Next", left = "Left", right = "Right", up = "Up", down = "Down",
numlock = "Num_Lock", numdivide = "KP_Divide", nummultiply = "KP_Multiply",
numminus = "KP_Subtract", numplus = "KP_Add", numenter = "KP_Enter",
numdecimal = "KP_Decimal",
num0 = "KP_0", num1 = "KP_1", num2 = "KP_2", num3 = "KP_3", num4 = "KP_4",
num5 = "KP_5", num6 = "KP_6", num7 = "KP_7", num8 = "KP_8", num9 = "KP_9",
}
local function creatorHasKey(code)
for _, selectedCode in ipairs(creatorKeys) do
if selectedCode == code then
return true
end
end
return false
end
local function configKeyName(code)
local mapped = CONFIG_KEY_NAMES[code]
if mapped ~= nil then
return mapped
end
if code:match("^[a-z]$") then
return string.upper(code)
end
if code:match("^f%d%d?$") then
return string.upper(code)
end
return code
end
-- A multi-unit key replaces the gaps that would exist between unit-sized
-- keys. Using the same pitch for keys and spacers keeps every 23-unit row at
-- exactly the same physical width, regardless of its number of children.
local function keyboardUnitsWidth(units)
local value = tonumber(units) or 1
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
result = color .. alpha
elseif #color == 9 then
result = string.sub(color, 1, 7) .. alpha
else
result = color
end
else
result = color .. "/" .. string.format("%.2f", opacity)
end
colorWithOpacityCache[cacheKey] = result
return result
end
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,
-- otherwise the function/navigation blocks and the inverted-T arrows no
-- longer share the same unit grid.
return ui.spacer({ width = keyboardUnitsWidth(spec.spacer), flexGrow = 0 })
end
if spec.bindable == false or spec.code == nil then
local width = keyboardUnitsWidth(spec.units or 1)
return ui.row({
key = "keyboard-" .. spec.id,
width = width,
height = KEYBOARD_KEY_HEIGHT,
fill = colorWithOpacity(cfg("key_color"), "surface_variant", 0.28),
border = "outline",
borderWidth = 1,
radius = 7,
align = "center",
justify = "center",
}, {
ui.label({
text = asString(spec.label, ""), color = "on_surface_variant",
fontSize = 8, textAlign = "center", maxLines = 1,
}),
})
end
local code = canonicalKey(spec.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
local fill
local border
local borderWidth = 1
if modifierActive then
local modifierFill = modifierStyle(physicalModifier)
fill = asString(modifierFill, "primary")
border = asString(modifierFill, "primary")
borderWidth = 2
elseif selected then
fill = colorWithOpacity("secondary", "secondary", 0.24)
border = "secondary"
borderWidth = 3
elseif #exactEntries > 0 then
fill = colorWithOpacity(cfg("category_color"), "primary", 0.30)
border = asString(cfg("category_color"), "primary")
borderWidth = 2
elseif #anyEntries > 0 then
fill = colorWithOpacity(cfg("category_color"), "primary", 0.10)
border = asString(cfg("category_color"), "primary")
else
fill = colorWithOpacity(cfg("key_color"), "surface_variant", 0.42)
border = "outline"
end
local displayText = asString(spec.label, "?")
if #exactEntries > 0 then
displayText = displayText .. "\n" .. tostring(#exactEntries)
elseif #anyEntries > 0 then
displayText = displayText .. "\n•"
end
local width = keyboardUnitsWidth(spec.units or 1)
return ui.row({
key = "keyboard-" .. spec.id,
width = width,
height = KEYBOARD_KEY_HEIGHT,
fill = fill,
border = border,
borderWidth = borderWidth,
radius = 7,
paddingH = 4,
paddingV = 3,
align = "center",
justify = "center",
}, {
ui.button({
text = displayText,
width = math.max(18, width - 6),
height = KEYBOARD_KEY_HEIGHT - 6,
fontSize = #asString(spec.label) > 4 and 7 or 8,
variant = "ghost",
selected = selected or modifierActive,
contentAlign = "center",
controlSize = "sm",
tooltip = #exactEntries > 0 and tr("panel.keyboard.occupied") or (
#anyEntries > 0 and tr("panel.keyboard.other_layer") or tr("panel.keyboard.free")
),
onClick = keyCallback(spec.id, spec.code),
}),
})
end
local function keyboardLegendItem(fill, border, label)
return ui.row({ gap = 5, align = "center" }, {
ui.box({ width = 12, height = 12, radius = 3, fill = fill, border = border, borderWidth = 1 }),
ui.label({ text = label, color = "on_surface_variant", fontSize = 10, maxLines = 1 }),
})
end
local function keyboardLayerToolbar()
local buttons = {
ui.label({ text = tr("panel.keyboard.layer"), color = "on_surface_variant", fontSize = 11 }),
}
local callbacks = {
SUPER = "onToggleSuper", CTRL = "onToggleCtrl", SHIFT = "onToggleShift", ALT = "onToggleAlt",
}
local labels = {
SUPER = "Super", CTRL = "Ctrl", SHIFT = "Shift", ALT = "Alt",
}
for _, modifier in ipairs(MODIFIER_ORDER) do
buttons[#buttons + 1] = ui.button({
text = labels[modifier],
selected = activeModifiers[modifier],
variant = activeModifiers[modifier] and "primary" or "ghost",
controlSize = "sm",
onClick = callbacks[modifier],
})
end
buttons[#buttons + 1] = ui.button({
glyph = "restore",
variant = "ghost",
controlSize = "sm",
tooltip = tr("panel.keyboard.clear_modifiers"),
onClick = "onClearModifiers",
})
buttons[#buttons + 1] = ui.spacer({ flexGrow = 1 })
buttons[#buttons + 1] = keyboardLegendItem(
colorWithOpacity(cfg("key_color"), "surface_variant", 0.42), "outline", tr("panel.keyboard.free")
)
buttons[#buttons + 1] = keyboardLegendItem(
colorWithOpacity(cfg("category_color"), "primary", 0.30),
asString(cfg("category_color"), "primary"),
tr("panel.keyboard.occupied")
)
buttons[#buttons + 1] = keyboardLegendItem(
colorWithOpacity(cfg("category_color"), "primary", 0.10),
asString(cfg("category_color"), "primary"),
tr("panel.keyboard.other_layer")
)
return ui.row({ gap = 8, align = "center" }, buttons)
end
local function selectedKeyDetails(index)
if selectedKeyboardKey == nil then
return ui.row({
minHeight = 62,
paddingH = 12,
paddingV = 9,
radius = 10,
fill = colorWithOpacity(cfg("card_color"), "surface_variant", 0.25),
border = "outline",
borderWidth = 1,
align = "center",
justify = "center",
}, {
ui.label({
text = tr("panel.keyboard.select_hint"),
color = "on_surface_variant",
fontSize = 11,
textAlign = "center",
maxLines = 2,
}),
})
end
local signature = activeModifierSignature()
local entries = asArray(index.exact[signature .. "|" .. selectedKeyboardKey])
local chordParts = {}
for _, modifier in ipairs(MODIFIER_ORDER) do
if activeModifiers[modifier] then
chordParts[#chordParts + 1] = modifier == "CTRL" and "Ctrl" or (
modifier == "SUPER" and "Super" or (modifier == "SHIFT" and "Shift" or "Alt")
)
end
end
chordParts[#chordParts + 1] = KEYBOARD_LABELS[selectedKeyboardKey] or selectedKeyboardKey
local chord = table.concat(chordParts, " + ")
local children = {
ui.row({ gap = 8, align = "center" }, {
ui.label({ text = chord, color = "on_surface", fontSize = 13, fontWeight = "bold", flexGrow = 1 }),
ui.label({
text = #entries > 0 and tr("panel.keyboard.occupied") or tr("panel.keyboard.free"),
color = #entries > 0 and asString(cfg("category_color"), "primary") or "on_surface_variant",
fontSize = 11,
fontWeight = "bold",
}),
}),
}
if #entries == 0 then
children[#children + 1] = ui.label({
text = tr("panel.keyboard.free_hint"),
color = "on_surface_variant",
fontSize = 11,
maxLines = 2,
})
else
for entryIndex, entry in ipairs(entries) do
if entryIndex > 5 then
children[#children + 1] = ui.label({
text = tr("panel.keyboard.more_actions", { count = #entries - 5 }),
color = "on_surface_variant",
fontSize = 10,
})
break
end
local bind = entry.bind
local meta = entry.category
local mode = asString(bind.mode)
if mode ~= "" and normalized(mode) ~= "default" then
meta = meta .. " · " .. mode
end
children[#children + 1] = ui.row({
key = "keyboard-detail-" .. asString(bind.id, tostring(entryIndex)),
gap = 8,
paddingH = 8,
paddingV = 4,
radius = 7,
fill = "surface/0.45",
align = "center",
}, {
ui.label({
text = asString(bind.description, tr("panel.no_description")),
color = asString(cfg("description_color"), "on_surface"),
fontSize = 11,
flexGrow = 1,
maxLines = 2,
}),
ui.label({ text = meta, color = "on_surface_variant", fontSize = 9, maxLines = 1 }),
})
end
end
return ui.column({
paddingH = 12,
paddingV = 9,
gap = 5,
radius = 10,
fill = colorWithOpacity(cfg("card_color"), "surface_variant", 0.25),
border = "outline",
borderWidth = 1,
}, children)
end
local function creatorCategoryNames()
if creatorOpen and #creatorCategoryNamesFrozen > 0 then
return creatorCategoryNamesFrozen
end
local names = {}
local seen = {}
for _, category in ipairs(asArray(snapshot.categories)) do
local name = noctalia.string.trim(asString(category.name))
if name ~= "" and not seen[name] then
seen[name] = true
names[#names + 1] = name
end
end
return names
end
local function creatorCategory()
local names = creatorCategoryNames()
if creatorCategoryIndex >= #names then
return noctalia.string.trim(creatorNewCategory)
end
return names[creatorCategoryIndex + 1] or ""
end
local function creatorChord()
local parts = {}
for _, modifier in ipairs(MODIFIER_ORDER) do
if activeModifiers[modifier] then
parts[#parts + 1] = modifier == "SUPER" and "Super" or (
modifier == "CTRL" and "Ctrl" or (modifier == "SHIFT" and "Shift" or "Alt")
)
end
end
for _, code in ipairs(creatorKeys) do
parts[#parts + 1] = KEYBOARD_LABELS[code] or configKeyName(code)
end
return table.concat(parts, " + ")
end
local function creatorKeysDisplay()
local labels = {}
for _, code in ipairs(creatorKeys) do
labels[#labels + 1] = KEYBOARD_LABELS[code] or configKeyName(code)
end
return table.concat(labels, ", ")
end
local function creatorConflict(index)
local signature = activeModifierSignature()
for _, category in ipairs(asArray(snapshot.categories)) do
for _, bind in ipairs(asArray(category.binds)) do
if not (formMode == "edit" and asString(bind.id) == editingBindId)
and modifierSignature(bind.modifiers) == signature
and asString(bind.activation, "press") == creatorActivation then
local rawKeys = type(bind.keys) == "table" and bind.keys or expandedKeys(bind.key)
if #rawKeys == #creatorKeys then
local matches = true
for index, rawKey in ipairs(rawKeys) do
if canonicalKey(rawKey) ~= creatorKeys[index] then matches = false break end
end
if matches then
return { bind = bind, category = asString(category.name, tr("panel.uncategorized")) }
end
end
end
end
end
return nil
end
local function creatorErrorText()
if creatorError == "" then
return ""
end
local key = "panel.creator.errors." .. creatorError
local translated = tr(key)
return translated ~= key and translated or creatorError
end
local function creatorCanSave(index)
local compositor = normalized(snapshot.compositor)
if snapshot.status ~= "ready" or snapshot.compositor ~= creatorContextCompositor
or snapshot.source ~= creatorContextSource or creatorBusy or #creatorKeys == 0
or (editingCapabilities.command ~= false and noctalia.string.trim(creatorCommand) == "")
or creatorCommand:find("{{", 1, true) ~= nil
or noctalia.string.trim(creatorDescription) == "" or creatorCategory() == "" then
return false
end
if compositor ~= "hyprland" and #creatorKeys ~= 1 then
return false
end
if compositor == "niri" and creatorActivation == "release" then
return false
end
return creatorConflict(index) == nil
end
local function commandLibraryCompositorSource()
local compositor = normalized(snapshot.compositor)
if compositor == "hyprland" or compositor == "niri" or compositor == "mangowc" then
return compositor
end
return ""
end
local function commandLibrarySourceLabel(source)
local key = "panel.command_library.sources." .. source
local translated = tr(key)
return translated ~= key and translated or source
end
local function commandLibraryCategoryLabel(category)
local key = "panel.command_library.categories." .. category
local translated = tr(key)
return translated ~= key and translated or category
end
local function commandLibrarySourceIds()
local result = { "", "noctalia" }
local compositorSource = commandLibraryCompositorSource()
if formMode ~= "edit" and compositorSource ~= "" then result[#result + 1] = compositorSource end
return result
end
local function commandLibraryEntryAllowed(entry)
if type(entry) ~= "table" then return false end
if entry.source == "noctalia" then return entry.kind == "shell" end
return formMode ~= "edit" and entry.kind == "native"
and entry.source == commandLibraryCompositorSource()
end
local function commandLibraryCategoryIds()
local sourceIds = commandLibrarySourceIds()
local selectedSource = sourceIds[commandLibrarySourceIndex + 1] or ""
local seen = {}
for _, entry in ipairs(asArray(COMMAND_LIBRARY.entries)) do
if commandLibraryEntryAllowed(entry) and type(entry.category) == "string"
and (selectedSource == "" or entry.source == selectedSource) then
seen[entry.category] = true
end
end
local result = {}
for category, _ in pairs(seen) do result[#result + 1] = category end
table.sort(result, function(left, right)
return commandLibraryCategoryLabel(left):lower() < commandLibraryCategoryLabel(right):lower()
end)
return result
end
local function commandLibraryFilteredEntries()
local sourceIds = commandLibrarySourceIds()
local selectedSource = sourceIds[commandLibrarySourceIndex + 1] or ""
local categoryIds = commandLibraryCategoryIds()
local selectedCategory = categoryIds[commandLibraryCategoryIndex] or ""
local query = normalized(commandLibraryQuery)
local result = {}
for _, entry in ipairs(asArray(COMMAND_LIBRARY.entries)) do
if commandLibraryEntryAllowed(entry)
and (selectedSource == "" or entry.source == selectedSource)
and (selectedCategory == "" or entry.category == selectedCategory) then
local needsInput = asString(entry.template):find("{{", 1, true) ~= nil
local readinessMatches = commandLibraryReadinessIndex == 0
or (commandLibraryReadinessIndex == 1 and not needsInput)
or (commandLibraryReadinessIndex == 2 and needsInput)
local haystack = normalized(table.concat({
asString(entry.id), asString(entry.template), asString(entry.usage),
commandLibrarySourceLabel(asString(entry.source)),
commandLibraryCategoryLabel(asString(entry.category)),
}, " "))
if readinessMatches and (query == "" or haystack:find(query, 1, true) ~= nil) then
result[#result + 1] = entry
end
end
end
return result
end
local function commandLibraryNode()
if not commandLibraryOpen then return ui.row({ visible = false }, {}) end
local sourceIds = commandLibrarySourceIds()
if commandLibrarySourceIndex >= #sourceIds then commandLibrarySourceIndex = 0 end
local sourceOptions = { tr("panel.command_library.all_sources") }
for index = 2, #sourceIds do
sourceOptions[#sourceOptions + 1] = commandLibrarySourceLabel(sourceIds[index])
end
local categoryIds = commandLibraryCategoryIds()
if commandLibraryCategoryIndex > #categoryIds then commandLibraryCategoryIndex = 0 end
local categoryOptions = { tr("panel.command_library.all_categories") }
for _, category in ipairs(categoryIds) do
categoryOptions[#categoryOptions + 1] = commandLibraryCategoryLabel(category)
end
local matches = commandLibraryFilteredEntries()
local resultNodes = {}
local visibleCount = math.min(#matches, 6)
for index = 1, visibleCount do
local entry = matches[index]
local selectedEntry = entry
local useCallback = function()
creatorCommand = asString(selectedEntry.template)
creatorCommandKind = asString(selectedEntry.kind, "shell")
creatorLibraryEntryId = asString(selectedEntry.id)
commandLibraryOpen = false
creatorError = ""
creatorRevision += 1
render()
end
resultNodes[#resultNodes + 1] = ui.row({ gap = 6, align = "center" }, {
ui.glyph({
name = entry.kind == "native" and "binary-tree" or "terminal-2",
size = 15, color = entry.kind == "native" and "primary" or "on_surface_variant",
}),
ui.column({ gap = 1, flexGrow = 1 }, {
ui.label({ text = asString(entry.usage, asString(entry.id)), fontSize = 10, maxLines = 1 }),
ui.label({
text = commandLibrarySourceLabel(asString(entry.source)) .. " · "
.. commandLibraryCategoryLabel(asString(entry.category)),
color = "on_surface_variant", fontSize = 9, maxLines = 1,
}),
}),
asString(entry.template):find("{{", 1, true) ~= nil and ui.label({
text = tr("panel.command_library.requires_input"), color = "tertiary", fontSize = 9,
}) or ui.label({ text = tr("panel.command_library.ready"), color = "secondary", fontSize = 9 }),
ui.button({
text = tr("panel.command_library.use"), variant = "ghost", controlSize = "sm",
onClick = useCallback,
}),
})
end
if #resultNodes == 0 then
resultNodes[1] = ui.label({
text = tr("panel.command_library.no_results"), color = "on_surface_variant", fontSize = 10,
})
end
return ui.column({
gap = 6, paddingH = 8, paddingV = 8, radius = 8,
fill = colorWithOpacity(cfg("card_color"), "surface", 0.35),
border = "outline", borderWidth = 1,
}, {
ui.row({ gap = 6, align = "center" }, {
ui.glyph({ name = "books", size = 16, color = "primary" }),
ui.label({ text = tr("panel.command_library.title"), fontSize = 11, fontWeight = "bold", flexGrow = 1 }),
ui.button({
text = tr("panel.command_library.custom_command"), variant = "ghost", controlSize = "sm",
onClick = "onCommandLibraryUseCustom",
}),
}),
ui.input({
key = "command-library-search-" .. tostring(commandLibraryRevision),
value = commandLibraryQuery, placeholder = tr("panel.command_library.search_placeholder"),
controlSize = "sm", onChange = "onCommandLibraryQueryChanged",
}),
ui.row({ gap = 6, align = "center" }, {
ui.select({
options = sourceOptions, selectedIndex = commandLibrarySourceIndex,
flexGrow = 1, controlSize = "sm", onChange = "onCommandLibrarySourceChanged",
}),
ui.select({
options = categoryOptions, selectedIndex = commandLibraryCategoryIndex,
flexGrow = 1, controlSize = "sm", onChange = "onCommandLibraryCategoryChanged",
}),
ui.select({
options = {
tr("panel.command_library.all_readiness"), tr("panel.command_library.ready"),
tr("panel.command_library.requires_input"),
},
selectedIndex = commandLibraryReadinessIndex, flexGrow = 1, controlSize = "sm",
onChange = "onCommandLibraryReadinessChanged",
}),
}),
ui.label({
text = tr("panel.command_library.showing", { shown = visibleCount, total = #matches }),
color = "on_surface_variant", fontSize = 9,
}),
ui.column({ gap = 3 }, resultNodes),
})
end
local function creatorForm(index)
local names = creatorCategoryNames()
local options = {}
for _, name in ipairs(names) do
options[#options + 1] = name
end
options[#options + 1] = tr("panel.creator.new_category")
if creatorCategoryIndex > #names then
creatorCategoryIndex = #names
end
local isEditing = formMode == "edit"
local showManualCombination = isEditing or viewMode == "list"
local usesNewCategory = creatorCategoryIndex >= #names
local compositor = normalized(snapshot.compositor)
local releaseSupported = compositor ~= "niri" and (not isEditing or editingCapabilities.activation == true)
local conflict = creatorConflict(index)
local chord = creatorChord()
local statusText = creatorErrorText()
if conflict ~= nil and statusText == "" then
statusText = tr("panel.creator.errors.conflict", {
description = asString(conflict.bind.description, tr("panel.no_description")),
})
end
local hintKey = isEditing and "panel.editor.hint" or (viewMode == "list" and "panel.creator.list_hint" or (
compositor == "hyprland" and "panel.creator.hyprland_hint" or (
compositor == "niri" and "panel.creator.niri_hint" or "panel.creator.mangowc_hint"
)))
local categoryControl = ui.select({
key = "creator-category-" .. tostring(creatorRevision), options = options,
selectedIndex = creatorCategoryIndex, width = 250, controlSize = "sm",
onChange = "onCreatorCategoryChanged",
})
return ui.column({
paddingH = 12,
paddingV = 10,
gap = 8,
radius = 10,
fill = colorWithOpacity(cfg("card_color"), "surface_variant", 0.25),
border = "primary",
borderWidth = 1,
}, {
ui.row({ gap = 8, align = "center" }, {
ui.glyph({ name = isEditing and "pencil" or "pencil-plus", size = 18, color = "primary" }),
ui.label({
text = tr(isEditing and "panel.editor.title" or "panel.creator.title"),
fontSize = 13, fontWeight = "bold", flexGrow = 1,
}),
ui.label({
text = chord ~= "" and chord or tr("panel.creator.no_combination"),
color = chord ~= "" and "primary" or "on_surface_variant",
fontSize = 11,
fontWeight = "bold",
maxLines = 1,
}),
}),
ui.label({ text = tr(hintKey), color = "on_surface_variant", fontSize = 10, maxLines = 2 }),
showManualCombination and ui.row({ gap = 6, align = "center" }, {
ui.label({ text = tr("panel.editor.combination"), color = "on_surface_variant", fontSize = 11 }),
ui.button({
text = "Super", selected = activeModifiers.SUPER,
variant = activeModifiers.SUPER and "primary" or "ghost", controlSize = "sm",
onClick = "onToggleSuper",
}),
ui.button({
text = "Ctrl", selected = activeModifiers.CTRL,
variant = activeModifiers.CTRL and "primary" or "ghost", controlSize = "sm",
onClick = "onToggleCtrl",
}),
ui.button({
text = "Shift", selected = activeModifiers.SHIFT,
variant = activeModifiers.SHIFT and "primary" or "ghost", controlSize = "sm",
onClick = "onToggleShift",
}),
ui.button({
text = "Alt", selected = activeModifiers.ALT,
variant = activeModifiers.ALT and "primary" or "ghost", controlSize = "sm",
onClick = "onToggleAlt",
}),
ui.input({
key = "editor-keys-" .. tostring(creatorRevision), value = creatorKeysText,
placeholder = tr("panel.editor.keys_placeholder"), flexGrow = 1, controlSize = "sm",
onChange = "onEditorKeysChanged",
}),
}) or ui.row({ visible = false }, {}),
ui.row({ gap = 8, align = "center" }, {
ui.label({ text = tr("panel.creator.trigger"), color = "on_surface_variant", fontSize = 11 }),
ui.button({
text = tr("panel.creator.press"), selected = creatorActivation == "press",
variant = creatorActivation == "press" and "primary" or "ghost", controlSize = "sm",
onClick = "onCreatorPress",
}),
ui.button({
text = tr("panel.creator.release"), selected = creatorActivation == "release",
variant = creatorActivation == "release" and "primary" or "ghost", controlSize = "sm",
enabled = releaseSupported, tooltip = releaseSupported and "" or tr("panel.creator.release_unavailable"),
onClick = "onCreatorRelease",
}),
ui.spacer({ flexGrow = 1 }),
ui.label({ text = tr("panel.creator.category"), color = "on_surface_variant", fontSize = 11 }),
categoryControl,
ui.input({
key = "creator-new-category-" .. tostring(creatorRevision), value = creatorNewCategory,
placeholder = tr("panel.creator.category_placeholder"), width = 220, controlSize = "sm",
visible = usesNewCategory, onChange = "onCreatorNewCategoryChanged",
}),
}),
ui.row({ gap = 8, align = "center" }, {
ui.input({
key = "creator-description-" .. tostring(creatorRevision), value = creatorDescription,
placeholder = tr("panel.creator.description_placeholder"), flexGrow = 1, controlSize = "sm",
onChange = "onCreatorDescriptionChanged",
}),
ui.input({
key = "creator-command-" .. tostring(creatorRevision),
value = isEditing and editingCapabilities.command ~= true and editingAction or creatorCommand,
placeholder = isEditing and editingCapabilities.command ~= true
and tr("panel.editor.action_placeholder") or tr("panel.creator.command_placeholder"),
flexGrow = 2, controlSize = "sm",
enabled = not isEditing or editingCapabilities.command == true,
onChange = "onCreatorCommandChanged",
}),
ui.button({
text = tr("panel.command_library.open"), glyph = "books",
variant = commandLibraryOpen and "primary" or "ghost", controlSize = "sm",
enabled = not isEditing or editingCapabilities.command == true,
tooltip = tr("panel.command_library.open_hint"),
onClick = "onCommandLibraryToggle",
}),
}),
(not isEditing or editingCapabilities.command == true) and ui.row({ gap = 6, align = "center" }, {
ui.label({
text = tr(creatorCommandKind == "native"
and "panel.command_library.native_action" or "panel.command_library.custom_command"),
color = creatorCommandKind == "native" and "primary" or "on_surface_variant", fontSize = 9,
}),
creatorCommand:find("{{", 1, true) ~= nil and ui.label({
text = tr("panel.command_library.placeholder_hint"), color = "tertiary", fontSize = 9,
maxLines = 2, flexGrow = 1,
}) or ui.spacer({ flexGrow = 1 }),
}) or ui.row({ visible = false }, {}),
commandLibraryNode(),
ui.row({ gap = 8, align = "center" }, {
ui.label({
text = statusText,
color = (conflict ~= nil or creatorError ~= "") and "error" or "on_surface_variant",
fontSize = 10, maxLines = 2, flexGrow = 1,
}),
ui.button({ text = tr("panel.creator.cancel"), variant = "ghost", controlSize = "sm", onClick = "onCreatorCancel" }),
ui.button({
text = creatorBusy and tr("panel.creator.saving")
or tr(isEditing and "panel.editor.save" or "panel.creator.save"),
glyph = "device-floppy", variant = "primary", controlSize = "sm",
enabled = creatorCanSave(index), onClick = "onCreatorSave",
}),
}),
})
end
local function keyboardBody()
local index = keyboardIndex()
local layout = activeKeyboardLayout()
local signature = activeModifierSignature()
local occupied = 0
for code, _ in pairs(layout.codeSet) do
if #asArray(index.exact[signature .. "|" .. code]) > 0 then
occupied = occupied + 1
end
end
local unmapped = 0
for key, entries in pairs(index.any) do
if not layout.codeSet[key] then
unmapped = unmapped + #entries
end
end
local keyboardRows = {}
for rowIndex, row in ipairs(layout.rows) do
local keys = {}
for _, spec in ipairs(row) do
keys[#keys + 1] = keyboardKeyNode(spec, index, signature)
end
keyboardRows[#keyboardRows + 1] = ui.row({
key = "keyboard-row-" .. layout.id .. "-" .. tostring(rowIndex),
width = keyboardUnitsWidth(layout.rowUnits),
gap = KEYBOARD_GAP,
align = "center",
justify = "start",
}, keys)
end
local layer = signature ~= "" and signature:gsub("%+", " + ") or tr("panel.keyboard.no_modifiers")
local summary = tr("panel.keyboard.summary", { layer = layer, count = occupied })
if unmapped > 0 then
summary = summary .. " · " .. tr("panel.keyboard.outside", { count = unmapped })
end
return ui.scroll({ flexGrow = 1 }, {
ui.column({ gap = 9, align = "stretch" }, {
keyboardLayerToolbar(),
ui.label({ text = summary, color = "on_surface_variant", fontSize = 10, textAlign = "center" }),
ui.column({
gap = KEYBOARD_GAP,
padding = 10,
radius = 13,
fill = colorWithOpacity(cfg("card_color"), "surface_variant", 0.18),
border = "outline",
borderWidth = 1,
align = "center",
}, keyboardRows),
creatorOpen and creatorForm(index) or selectedKeyDetails(index),
}),
})
end
selectKeyboardKey = function(code)
local modifier = MODIFIER_ALIASES[normalized(code)]
if modifier ~= nil and activeModifiers[modifier] ~= nil then
activeModifiers[modifier] = not activeModifiers[modifier]
selectedKeyboardKey = nil
else
local canonical = canonicalKey(code)
selectedKeyboardKey = canonical
if creatorOpen then
if creatorHasKey(canonical) then
local remaining = {}
for _, selectedCode in ipairs(creatorKeys) do
if selectedCode ~= canonical then
remaining[#remaining + 1] = selectedCode
end
end
creatorKeys = remaining
elseif normalized(snapshot.compositor) == "hyprland" then
if #creatorKeys < 4 then
creatorKeys[#creatorKeys + 1] = canonical
end
else
creatorKeys = { canonical }
end
creatorKeysText = creatorKeysDisplay()
creatorError = ""
end
end
render()
end
local function keyPill(token, isKey)
local fill
local textColor
if isKey then
fill = cfg("key_color")
textColor = cfg("key_text_color")
else
fill, textColor = modifierStyle(token)
end
return ui.row({
fill = asString(fill, "surface"),
border = "outline",
borderWidth = 1,
radius = 7,
paddingH = 7,
paddingV = 2,
align = "center",
justify = "center",
}, {
ui.label({
text = asString(token, "?"),
color = asString(textColor, "on_surface"),
fontSize = 11,
fontWeight = "bold",
maxLines = 1,
}),
})
end
local function keyWidth(columnCount)
if columnCount <= 1 then
return 330
elseif columnCount == 2 then
return 245
elseif columnCount == 3 then
return 210
end
return 180
end
local DND_BIND_TYPE = "keybind-category"
local function bindHasWritableProvenance(bind)
return not asString(bind.id):match("^range:")
and asString(bind.source) ~= "" and tonumber(bind.start_line) ~= nil
and tonumber(bind.end_line) ~= nil and asString(bind.raw_snippet) ~= ""
and asString(bind.fingerprint) ~= ""
end
local function bindEditable(bind)
local capabilities = type(bind.capabilities) == "table" and bind.capabilities or {}
return capabilities.combo == true and capabilities.description == true
and bindHasWritableProvenance(bind)
end
local function bindCategoryMovable(bind)
local capabilities = type(bind.capabilities) == "table" and bind.capabilities or {}
local bindId = asString(bind.id)
return capabilities.category == true and bindHasWritableProvenance(bind)
and bindId ~= "" and #bindId <= 256
end
local function categoryCanAcceptDrop(categoryName)
local name = noctalia.string.trim(asString(categoryName))
return name ~= "" and #name <= 80 and not name:find("%c")
and not name:find('"', 1, true)
and not name:find("Keymap managed", 1, true)
end
local function categoryRenameable(category)
local binds = asArray(type(category) == "table" and category.binds or nil)
if asString(type(category) == "table" and category.id or "") == "" or #binds == 0 then return false end
for _, bind in ipairs(binds) do
local firstLine = tonumber(bind.start_line)
local lastLine = tonumber(bind.end_line)
if not bindCategoryMovable(bind) or bind.hidden == true
or asString(bind.source):sub(1, 1) ~= "/"
or firstLine == nil or lastLine == nil or firstLine % 1 ~= 0 or lastLine % 1 ~= 0
or firstLine < 1 or lastLine < firstLine then return false end
end
return true
end
local function findSnapshotCategory(categoryId)
for _, category in ipairs(asArray(snapshot.categories)) do
if asString(category.id) == categoryId then return category end
end
return nil
end
local function categoryNameExists(name, excludedId)
local wanted = normalized(name)
if wanted == "" then return false end
for _, category in ipairs(asArray(snapshot.categories)) do
if asString(category.id) ~= excludedId and normalized(category.name) == wanted then return true end
end
return false
end
local function optimisticRenameCategory(categoryId, oldName, newName)
local categories = {}
local renamed = false
for _, category in ipairs(asArray(snapshot.categories)) do
local nextCategory = {}
for key, value in pairs(category) do nextCategory[key] = value end
if asString(category.id) == categoryId and asString(category.name) == oldName then
nextCategory.name = newName
local binds = {}
for _, bind in ipairs(asArray(category.binds)) do
local nextBind = {}
for key, value in pairs(bind) do nextBind[key] = value end
if nextBind.category ~= nil then nextBind.category = newName end
binds[#binds + 1] = nextBind
end
nextCategory.binds = binds
renamed = true
end
categories[#categories + 1] = nextCategory
end
if not renamed then return false end
local nextSnapshot = {}
for key, value in pairs(snapshot) do nextSnapshot[key] = value end
nextSnapshot.categories = categories
snapshot = nextSnapshot
return true
end
local function findSnapshotBind(bindId)
for _, category in ipairs(asArray(snapshot.categories)) do
for _, bind in ipairs(asArray(category.binds)) do
if asString(bind.id) == bindId then
return bind, asString(category.name, tr("panel.uncategorized"))
end
end
end
return nil, ""
end
local function findHiddenBind(bindId)
for _, bind in ipairs(asArray(snapshot.hidden)) do
if bind.hidden == true and asString(bind.id) == bindId then return bind end
end
return nil
end
local function snapshotHasCategory(categoryName)
for _, category in ipairs(asArray(snapshot.categories)) do
if asString(category.name, tr("panel.uncategorized")) == categoryName then
return true
end
end
return false
end
local function optimisticPlaceBind(bindId, targetCategory, anchorId, placement)
local moved = nil
local categories = {}
for _, category in ipairs(asArray(snapshot.categories)) do
local nextCategory = {}
for key, value in pairs(category) do nextCategory[key] = value end
local binds = {}
for _, bind in ipairs(asArray(category.binds)) do
if asString(bind.id) == bindId then moved = bind else binds[#binds + 1] = bind end
end
nextCategory.binds = binds
categories[#categories + 1] = nextCategory
end
if moved == nil then return false end
local inserted = false
for _, category in ipairs(categories) do
if asString(category.name, tr("panel.uncategorized")) == targetCategory then
local binds = category.binds
if asString(anchorId) == "" then
binds[#binds + 1] = moved
inserted = true
else
for index, bind in ipairs(binds) do
if asString(bind.id) == anchorId then
table.insert(binds, placement == "after" and index + 1 or index, moved)
inserted = true
break
end
end
end
break
end
end
if not inserted then return false end
local nextSnapshot = {}
for key, value in pairs(snapshot) do nextSnapshot[key] = value end
nextSnapshot.categories = categories
snapshot = nextSnapshot
return true
end
-- Logical pixels: the declarative UI scales explicit dimensions with the
-- shell UI scale. Keep row actions no taller than the compact key pills so
-- enabling edit mode does not change the height of every keybind row.
local BIND_ROW_ACTION_SIZE = 18
local BIND_ROW_ACTION_GLYPH_SIZE = 12
local function beginBindOperation(bind, operation)
if creatorBusy or not bindEditable(bind) then return end
local bindId = asString(bind.id)
if operation == "delete" and deleteConfirmBindId ~= bindId then
deleteConfirmBindId = bindId
render()
return
end
deleteConfirmBindId = ""
creatorRequestCounter += 1
local requestId = tostring(os.time()) .. "-action-" .. tostring(creatorRequestCounter)
formMode = "edit"
editingOperation = operation
editingBindId = bindId
editingRequestId = requestId
creatorContextCompositor = asString(snapshot.compositor)
creatorContextSource = asString(snapshot.source)
creatorBusy = true
creatorError = ""
creatorOpen = false
noctalia.state.set(UPDATE_REQUEST_KEY, {
request_id = requestId,
target_id = bindId,
operation = operation,
compositor = creatorContextCompositor,
source = creatorContextSource,
})
render()
end
local function beginHiddenOperation(bind, operation)
if creatorBusy or type(bind) ~= "table" or bind.hidden ~= true
or not bindHasWritableProvenance(bind) then return end
local capabilities = type(bind.capabilities) == "table" and bind.capabilities or {}
if operation == "restore" and capabilities.restore ~= true then return end
if operation == "delete" and capabilities.delete ~= true then return end
local bindId = asString(bind.id)
if operation == "delete" and hiddenDeleteConfirmBindId ~= bindId then
hiddenDeleteConfirmBindId = bindId
render()
return
end
hiddenDeleteConfirmBindId = ""
creatorRequestCounter += 1
local requestId = tostring(os.time()) .. "-hidden-" .. tostring(creatorRequestCounter)
formMode = "edit"
editingOperation = operation
editingBindId = bindId
editingRequestId = requestId
creatorContextCompositor = asString(snapshot.compositor)
creatorContextSource = asString(snapshot.source)
creatorBusy = true
creatorError = ""
creatorOpen = false
noctalia.state.set(UPDATE_REQUEST_KEY, {
request_id = requestId,
target_id = bindId,
operation = operation,
hidden = true,
compositor = creatorContextCompositor,
source = creatorContextSource,
})
render()
end
local function clearCategoryRename()
renamingCategoryId = ""
renamingCategoryOriginal = ""
renamingCategoryValue = ""
creatorError = ""
end
local function openCategoryRename(category)
if creatorBusy or not editorMode or not categoryRenameable(category) then return end
creatorOpen = false
formMode = "edit"
editingBindId = ""
editingOperation = "update"
deleteConfirmBindId = ""
hiddenDeleteConfirmBindId = ""
renamingCategoryId = asString(category.id)
renamingCategoryOriginal = asString(category.name, tr("panel.uncategorized"))
renamingCategoryValue = renamingCategoryOriginal
renamingCategoryRevision += 1
creatorError = ""
render()
end
local function openBindEditor(bind, categoryName)
if creatorBusy or not bindEditable(bind) then return end
clearCategoryRename()
formMode = "edit"
editingOperation = "update"
deleteConfirmBindId = ""
editingBindId = asString(bind.id)
editingCategory = categoryName
editingCapabilities = type(bind.capabilities) == "table" and bind.capabilities or {}
editingAction = asString(bind.action, asString(bind.dispatcher))
creatorCategoryNamesFrozen = {}
local categoryNames = creatorCategoryNames()
creatorCategoryNamesFrozen = categoryNames
creatorCategoryIndex = 0
for index, name in ipairs(categoryNames) do
if name == categoryName then creatorCategoryIndex = index - 1 break end
end
creatorNewCategory = ""
creatorKeys = {}
local rawKeys = type(bind.keys) == "table" and bind.keys or expandedKeys(bind.key)
for _, rawKey in ipairs(rawKeys) do creatorKeys[#creatorKeys + 1] = canonicalKey(rawKey) end
creatorKeysText = creatorKeysDisplay()
for _, modifier in ipairs(MODIFIER_ORDER) do activeModifiers[modifier] = false end
for _, rawModifier in ipairs(asArray(bind.modifiers)) do
local modifier = canonicalModifier(rawModifier)
if activeModifiers[modifier] ~= nil then activeModifiers[modifier] = true end
end
creatorActivation = asString(bind.activation, bind.release == true and "release" or "press")
creatorCommand = asString(bind.command)
creatorCommandKind = "shell"
creatorLibraryEntryId = ""
creatorDescription = asString(bind.description)
creatorContextCompositor = asString(snapshot.compositor)
creatorContextSource = asString(snapshot.source)
creatorError = ""
creatorBusy = false
creatorOpen = true
commandLibraryOpen = false
commandLibraryQuery = ""
commandLibrarySourceIndex = 0
commandLibraryCategoryIndex = 0
commandLibraryReadinessIndex = 0
commandLibraryRevision += 1
selectedKeyboardKey = creatorKeys[1]
creatorRevision += 1
viewMode = "list"
render()
end
local function categoryRenameCallback(categoryId)
return function()
local category = findSnapshotCategory(categoryId)
if category ~= nil then openCategoryRename(category) end
end
end
local function bindCallback(bindId, operation)
return function()
local bind, categoryName = findSnapshotBind(bindId)
if bind == nil then return end
if operation == "edit" then
openBindEditor(bind, categoryName)
else
beginBindOperation(bind, operation)
end
end
end
local function hiddenCallback(bindId, operation)
return function()
local bind = findHiddenBind(bindId)
if bind ~= nil then beginHiddenOperation(bind, operation) end
end
end
local function editBindActions(bind, categoryName)
if not editorMode then return {} end
local actions = {}
if bindCategoryMovable(bind) then
actions[#actions + 1] = ui.dragSource({
key = "drag-bind-" .. asString(bind.id),
dragType = DND_BIND_TYPE,
payload = asString(bind.id),
previewAncestor = 1,
liftFromLayout = true,
enabled = not creatorBusy and not creatorOpen and renamingCategoryId == "",
tooltip = tr("panel.editor.drag"),
width = BIND_ROW_ACTION_SIZE,
height = BIND_ROW_ACTION_SIZE,
radius = 4,
align = "center",
justify = "center",
}, {
ui.glyph({
name = "menu-2", size = BIND_ROW_ACTION_GLYPH_SIZE,
color = "on_surface_variant",
}),
})
end
if not bindEditable(bind) then
actions[#actions + 1] = ui.button({
glyph = "lock", variant = "ghost",
width = BIND_ROW_ACTION_SIZE, height = BIND_ROW_ACTION_SIZE,
glyphSize = BIND_ROW_ACTION_GLYPH_SIZE, enabled = false,
tooltip = tr("panel.editor.read_only"),
})
return actions
end
local bindId = asString(bind.id)
local editCallback = bindCallback(bindId, "edit")
local hideCallback = bindCallback(bindId, "hide")
local deleteCallback = bindCallback(bindId, "delete")
actions[#actions + 1] = ui.button({
glyph = "pencil", variant = "ghost",
width = BIND_ROW_ACTION_SIZE, height = BIND_ROW_ACTION_SIZE,
glyphSize = BIND_ROW_ACTION_GLYPH_SIZE,
tooltip = tr("panel.editor.edit"), onClick = editCallback,
})
actions[#actions + 1] = ui.button({
glyph = "eye-off", variant = "ghost",
width = BIND_ROW_ACTION_SIZE, height = BIND_ROW_ACTION_SIZE,
glyphSize = BIND_ROW_ACTION_GLYPH_SIZE,
tooltip = tr("panel.editor.hide"), onClick = hideCallback,
})
if deleteConfirmBindId == asString(bind.id) then
local cancelCallback = "onCancelDeleteBind"
actions[#actions + 1] = ui.button({
glyph = "x", variant = "ghost",
width = BIND_ROW_ACTION_SIZE, height = BIND_ROW_ACTION_SIZE,
glyphSize = BIND_ROW_ACTION_GLYPH_SIZE,
tooltip = tr("panel.editor.cancel_delete"), onClick = cancelCallback,
})
actions[#actions + 1] = ui.button({
glyph = "trash", variant = "destructive",
width = BIND_ROW_ACTION_SIZE, height = BIND_ROW_ACTION_SIZE,
glyphSize = BIND_ROW_ACTION_GLYPH_SIZE,
tooltip = tr("panel.editor.confirm_delete"), onClick = deleteCallback,
})
else
actions[#actions + 1] = ui.button({
glyph = "trash", variant = "ghost",
width = BIND_ROW_ACTION_SIZE, height = BIND_ROW_ACTION_SIZE,
glyphSize = BIND_ROW_ACTION_GLYPH_SIZE,
tooltip = tr("panel.editor.delete"), onClick = deleteCallback,
})
end
return actions
end
local function bindRow(bind, categoryName, columnCount, rowIndex)
local width = keyWidth(columnCount)
local keys = {}
local tokens = {}
local estimatedWidth = 0
for _, modifier in ipairs(asArray(bind.modifiers)) do
tokens[#tokens + 1] = asString(modifier, "?")
estimatedWidth = estimatedWidth + #asString(modifier, "?") * 7 + 18
keys[#keys + 1] = keyPill(modifier, false)
end
local mainKey = asString(bind.key, "?")
tokens[#tokens + 1] = mainKey
estimatedWidth = estimatedWidth + #mainKey * 7 + 18 + math.max(0, #tokens - 1) * 4
keys[#keys + 1] = keyPill(mainKey, true)
local keyArea
if estimatedWidth > width then
keyArea = ui.row({
width = width,
fill = asString(cfg("key_color"), "surface"),
border = "outline",
borderWidth = 1,
radius = 7,
paddingH = 6,
paddingV = 2,
align = "center",
justify = "center",
}, {
ui.label({
text = table.concat(tokens, " + "),
color = asString(cfg("key_text_color"), "on_surface"),
fontSize = 10,
fontWeight = "bold",
textAlign = "center",
maxWidth = width - 12,
maxLines = 2,
}),
})
else
keyArea = ui.row({ width = width, gap = 4, align = "center" }, keys)
end
local description = asString(bind.description, tr("panel.no_description"))
local details = {}
local mode = asString(bind.mode)
if mode ~= "" and normalized(mode) ~= "default" then
details[#details + 1] = ui.label({
text = mode,
color = "secondary",
fontSize = 10,
fontWeight = "bold",
maxLines = 1,
})
end
details[#details + 1] = ui.label({
text = description,
color = asString(cfg("description_color"), "on_surface"),
fontSize = 12,
maxLines = 2,
flexGrow = 1,
})
local children = {
keyArea,
ui.row({ gap = 6, align = "center", flexGrow = 1 }, details),
}
for _, action in ipairs(editBindActions(bind, categoryName)) do
children[#children + 1] = action
end
return ui.row({
key = "bind-" .. asString(bind.id, tostring(rowIndex)),
gap = 10,
paddingH = 6,
paddingV = 4,
radius = 8,
fill = "surface/0.42",
align = "center",
}, children)
end
local function reorderInsertionZone(bind, placement)
if not editorMode or not bindCategoryMovable(bind) then return nil end
return ui.dropZone({
key = "reorder-" .. placement .. "-" .. asString(bind.id),
accepts = { DND_BIND_TYPE },
value = placement .. "|" .. asString(bind.id),
onDrop = "onBindReordered",
height = 3,
radius = 8,
expandOnDrag = true,
hitSlop = 64,
enabled = not creatorBusy and not creatorOpen and renamingCategoryId == "",
}, {})
end
local function hiddenBindRow(bind, columnCount, rowIndex)
local keys = {}
for _, modifier in ipairs(asArray(bind.modifiers)) do keys[#keys + 1] = keyPill(modifier, false) end
local rawKeys = type(bind.keys) == "table" and bind.keys or { asString(bind.key, "?") }
for _, key in ipairs(rawKeys) do keys[#keys + 1] = keyPill(key, true) end
local bindId = asString(bind.id)
local restoreCallback = hiddenCallback(bindId, "restore")
local deleteCallback = hiddenCallback(bindId, "delete")
local actions = {
ui.button({
glyph = "restore", variant = "ghost",
width = BIND_ROW_ACTION_SIZE, height = BIND_ROW_ACTION_SIZE,
glyphSize = BIND_ROW_ACTION_GLYPH_SIZE,
tooltip = tr("panel.editor.restore"), onClick = restoreCallback,
}),
}
if hiddenDeleteConfirmBindId == bindId then
actions[#actions + 1] = ui.button({
glyph = "x", variant = "ghost",
width = BIND_ROW_ACTION_SIZE, height = BIND_ROW_ACTION_SIZE,
glyphSize = BIND_ROW_ACTION_GLYPH_SIZE,
tooltip = tr("panel.editor.cancel_delete"), onClick = "onCancelDeleteHiddenBind",
})
actions[#actions + 1] = ui.button({
glyph = "trash", variant = "destructive",
width = BIND_ROW_ACTION_SIZE, height = BIND_ROW_ACTION_SIZE,
glyphSize = BIND_ROW_ACTION_GLYPH_SIZE,
tooltip = tr("panel.editor.confirm_delete_hidden"), onClick = deleteCallback,
})
else
actions[#actions + 1] = ui.button({
glyph = "trash", variant = "ghost",
width = BIND_ROW_ACTION_SIZE, height = BIND_ROW_ACTION_SIZE,
glyphSize = BIND_ROW_ACTION_GLYPH_SIZE,
tooltip = tr("panel.editor.delete_hidden"), onClick = deleteCallback,
})
end
local details = {
ui.label({
text = asString(bind.description, tr("panel.no_description")),
color = asString(cfg("description_color"), "on_surface"),
fontSize = 12, maxLines = 2, flexGrow = 1,
}),
}
if asString(bind.category) ~= "" then
details[#details + 1] = ui.label({
text = asString(bind.category), color = "secondary", fontSize = 10,
fontWeight = "bold", maxLines = 1,
})
end
local children = {
ui.row({ width = keyWidth(columnCount), gap = 4, align = "center" }, keys),
ui.row({ gap = 6, align = "center", flexGrow = 1 }, details),
}
for _, action in ipairs(actions) do children[#children + 1] = action end
return ui.row({
key = "hidden-bind-" .. bindId .. "-" .. tostring(rowIndex),
gap = 10, paddingH = 6, paddingV = 4, radius = 8,
fill = "surface/0.42", align = "center",
}, children)
end
local function hiddenSection(hidden, columnCount)
if not editorMode or #hidden == 0 then return nil end
local children = {
ui.row({ gap = 8, align = "center" }, {
ui.glyph({ name = "eye-off", size = 16, color = "secondary" }),
ui.label({
text = tr("panel.editor.hidden_title"), color = "secondary",
fontSize = 14, fontWeight = "bold", flexGrow = 1,
}),
ui.label({
text = noctalia.trp("panel.editor.hidden_count", #hidden),
color = "on_surface_variant", fontSize = 11,
}),
}),
ui.label({
text = tr("panel.editor.hidden_hint"), color = "on_surface_variant",
fontSize = 11, maxLines = 2,
}),
}
for index, bind in ipairs(hidden) do
children[#children + 1] = hiddenBindRow(bind, columnCount, index)
end
return ui.column({
key = "hidden-shortcuts", gap = 3, padding = 8, radius = 12,
fill = cardFill(), border = "secondary", borderWidth = 1,
}, children)
end
local function categoryCard(category, columnCount)
local categoryName = asString(category.name, tr("panel.uncategorized"))
local categoryId = asString(category.id, categoryName)
local isRenaming = editorMode and renamingCategoryId == categoryId
local canRename = categoryRenameable(category)
local header
if isRenaming then
local nextName = noctalia.string.trim(renamingCategoryValue)
local canSave = not creatorBusy and categoryCanAcceptDrop(nextName)
and nextName ~= categoryName and not categoryNameExists(nextName, categoryId)
header = ui.row({ gap = 6, align = "center" }, {
ui.glyph({ name = "pencil", size = 15, color = "primary" }),
ui.input({
key = "category-name-" .. tostring(renamingCategoryRevision),
value = renamingCategoryValue,
placeholder = tr("panel.editor.category_name_placeholder"),
flexGrow = 1,
controlSize = "sm",
enabled = not creatorBusy,
focus = true,
onChange = "onCategoryRenameChanged",
onSubmit = "onCategoryRenameSave",
}),
ui.button({
glyph = "x", variant = "ghost", controlSize = "sm",
tooltip = tr("panel.editor.category_rename_cancel"),
enabled = not creatorBusy, onClick = "onCategoryRenameCancel",
}),
ui.button({
glyph = "device-floppy", variant = "primary", controlSize = "sm",
tooltip = tr("panel.editor.category_rename_save"),
enabled = canSave, onClick = "onCategoryRenameSave",
}),
})
else
local headerChildren = {
ui.label({
text = categoryName,
color = asString(cfg("category_color"), "primary"),
fontSize = 14,
fontWeight = "bold",
maxLines = 1,
flexGrow = 1,
}),
ui.label({
text = noctalia.trp("panel.category_count", #asArray(category.binds)),
color = "on_surface_variant",
fontSize = 11,
}),
}
if editorMode then
local renameProps = {
glyph = "pencil", variant = "ghost",
width = BIND_ROW_ACTION_SIZE, height = BIND_ROW_ACTION_SIZE,
glyphSize = BIND_ROW_ACTION_GLYPH_SIZE,
enabled = canRename and not creatorBusy and not creatorOpen and renamingCategoryId == "",
tooltip = canRename and tr("panel.editor.category_rename")
or tr("panel.editor.category_read_only"),
}
if canRename then renameProps.onClick = categoryRenameCallback(categoryId) end
headerChildren[#headerChildren + 1] = ui.button(renameProps)
end
header = ui.row({ gap = 8, align = "center" }, headerChildren)
end
local children = { header }
if isRenaming and creatorError ~= "" then
children[#children + 1] = ui.label({
text = creatorErrorText(), color = "error", fontSize = 10, maxLines = 2,
})
end
local categoryBinds = asArray(category.binds)
for index, bind in ipairs(categoryBinds) do
local insertion = reorderInsertionZone(bind, "before")
if insertion ~= nil then children[#children + 1] = insertion end
children[#children + 1] = bindRow(
bind, categoryName, columnCount, index
)
end
if #categoryBinds > 0 then
local insertion = reorderInsertionZone(categoryBinds[#categoryBinds], "after")
if insertion ~= nil then children[#children + 1] = insertion end
end
local props = {
key = "category-" .. categoryId,
gap = editorMode and 0 or 3,
padding = 8,
radius = 12,
fill = cardFill(),
border = "outline",
borderWidth = 1,
}
if editorMode and categoryCanAcceptDrop(categoryName) then
props.accepts = { DND_BIND_TYPE }
props.value = categoryName
props.onDrop = "onBindDropped"
props.enabled = not creatorBusy and not creatorOpen and renamingCategoryId == ""
return ui.dropZone(props, children)
end
return ui.column(props, children)
end
local function readyBody(categories, requestedColumns)
local hidden = filteredHidden()
if #categories == 0 and (not editorMode or #hidden == 0) then
local searching = normalized(searchQuery) ~= ""
local message = searching and tr("panel.no_results") or tr("panel.empty")
local hint = searching and tr("panel.no_results_hint") or tr("panel.empty_hint")
local children = {
ui.glyph({ name = "keyboard-off", size = 44, color = "on_surface_variant" }),
ui.label({ text = message, fontSize = 16, fontWeight = "bold", color = "on_surface" }),
ui.label({ text = hint, color = "on_surface_variant", textAlign = "center", maxWidth = 560, maxLines = 3 }),
}
if not searching then
children[#children + 1] = ui.button({
text = tr("panel.open_settings_action"), glyph = "settings", variant = "ghost",
onClick = "onOpenSettingsClicked",
})
end
return ui.column({ flexGrow = 1, align = "center", justify = "center", gap = 10 }, children)
end
local columns = partitionCategories(categories, requestedColumns)
local columnNodes = {}
for columnIndex, column in ipairs(columns) do
local cards = {}
for _, category in ipairs(column) do
cards[#cards + 1] = categoryCard(category, #columns)
end
columnNodes[#columnNodes + 1] = ui.column({
key = "column-" .. tostring(columnIndex),
gap = 12,
flexGrow = 1,
align = "stretch",
}, cards)
end
local content = {}
local hiddenNode = hiddenSection(hidden, math.max(1, #columns))
if hiddenNode ~= nil then content[#content + 1] = hiddenNode end
if #columnNodes > 0 then
content[#content + 1] = ui.row({ gap = 12, align = "start" }, columnNodes)
end
return ui.scroll({ flexGrow = 1, gap = 0 }, {
ui.column({ gap = 12, align = "stretch" }, content),
})
end
local function statusBody(status, errorText)
local isError = status == "error"
local errorKey = "panel.errors." .. asString(errorText, "unknown")
local translatedError = tr(errorKey)
if translatedError == errorKey then
translatedError = tr("panel.unknown_error")
end
return ui.column({ flexGrow = 1, align = "center", justify = "center", gap = 12 }, {
ui.glyph({
name = isError and "alert-circle" or "refresh",
size = 44,
color = isError and "error" or "primary",
}),
ui.label({
text = isError and tr("panel.load_failed") or tr("panel.loading"),
fontSize = 16,
fontWeight = "bold",
color = isError and "error" or "on_surface",
}),
ui.label({
text = isError and translatedError or tr("panel.loading_hint"),
color = "on_surface_variant",
textAlign = "center",
maxWidth = 680,
maxLines = 4,
}),
ui.label({
text = tr("panel.path_hint"),
color = "on_surface_variant",
textAlign = "center",
maxWidth = 680,
maxLines = 3,
visible = isError,
}),
ui.button({
text = tr("panel.retry"),
glyph = "refresh",
variant = "primary",
visible = isError,
onClick = "onRefreshClicked",
}),
ui.button({
text = tr("panel.open_settings_action"),
glyph = "settings",
variant = "ghost",
visible = isError,
onClick = "onOpenSettingsClicked",
}),
})
end
local function countVisibleBinds(categories)
local count = 0
for _, category in ipairs(categories) do
count += #asArray(category.binds)
end
return count
end
local function header()
local compositor = asString(snapshot.compositor, tr("panel.detecting"))
local total = tonumber(snapshot.total) or countVisibleBinds(asArray(snapshot.categories))
local subtitle = compositor .. " · " .. noctalia.trp("panel.bind_count", total)
if asString(snapshot.updated_at) ~= "" then
subtitle ..= " · " .. tr("panel.updated", { time = snapshot.updated_at })
end
return ui.row({ gap = 10, align = "center" }, {
ui.glyph({ name = "keyboard", size = 26, color = "primary" }),
ui.column({ gap = 2, flexGrow = 1 }, {
ui.label({ text = tr("title"), fontSize = 18, fontWeight = "bold", color = "on_surface" }),
ui.label({ text = subtitle, fontSize = 11, color = "on_surface_variant", maxLines = 1 }),
}),
ui.button({
glyph = "keyboard",
variant = viewMode == "keyboard" and "primary" or "ghost",
selected = viewMode == "keyboard",
tooltip = tr("panel.keyboard.view"),
onClick = "onShowKeyboard",
}),
ui.button({
glyph = "list",
variant = viewMode == "list" and "primary" or "ghost",
selected = viewMode == "list",
tooltip = tr("panel.list_view"),
onClick = "onShowList",
}),
ui.label({
text = tr("panel.keyboard.layout"),
color = "on_surface_variant",
fontSize = 10,
visible = editorMode,
}),
ui.select({
key = "keyboard-layout-selector",
options = keyboardLayoutOptions(),
selectedIndex = keyboardLayoutSelectedIndex(),
width = 120,
controlSize = "sm",
visible = editorMode,
enabled = not creatorBusy,
onChange = "onKeyboardLayoutChanged",
}),
ui.spacer({ width = 8, flexGrow = 0 }),
ui.button({
text = tr("panel.creator.new_shortcut"),
glyph = "pencil-plus",
variant = creatorOpen and formMode == "create" and "primary" or "ghost",
selected = creatorOpen and formMode == "create",
enabled = snapshot.status == "ready",
tooltip = tr("panel.creator.open"),
onClick = "onCreatorToggle",
}),
ui.button({
text = tr("panel.editor.open"),
glyph = "edit",
variant = editorMode and "primary" or "ghost",
selected = editorMode,
enabled = snapshot.status == "ready",
tooltip = tr("panel.editor.open_hint"),
onClick = "onEditorToggle",
}),
ui.button({
glyph = "refresh",
variant = "ghost",
tooltip = tr("panel.refresh"),
enabled = snapshot.status ~= "loading",
onClick = "onRefreshClicked",
}),
ui.button({
glyph = "folder-open",
variant = "ghost",
tooltip = tr("panel.open_config_folder"),
enabled = sourceDirectory() ~= "" and not creatorBusy,
onClick = "onOpenConfigFolderClicked",
}),
ui.button({
glyph = "settings",
variant = "ghost",
tooltip = tr("panel.open_settings"),
enabled = not creatorBusy,
onClick = "onOpenSettingsClicked",
}),
ui.button({ glyph = "close", variant = "ghost", tooltip = tr("panel.close"), onClick = "onCloseClicked" }),
})
end
local function searchBar(categories)
local children = {
ui.input({
key = "search-" .. tostring(searchRevision),
value = searchQuery,
placeholder = tr("panel.search_placeholder"),
focus = true,
flexGrow = 1,
onChange = "onSearchChanged",
onSubmit = "onSearchChanged",
}),
}
if searchQuery ~= "" then
children[#children + 1] = ui.label({
text = noctalia.trp("panel.search_results", countVisibleBinds(categories)),
color = "on_surface_variant",
fontSize = 11,
})
children[#children + 1] = ui.button({
glyph = "x",
variant = "ghost",
tooltip = tr("panel.clear_search"),
onClick = "onClearSearch",
})
end
return ui.row({ gap = 8, align = "center" }, children)
end
local function warningsBanner()
local warnings = asArray(snapshot.warnings)
if #warnings == 0 then
return nil
end
return ui.row({
gap = 8,
paddingH = 10,
paddingV = 7,
radius = 9,
fill = "warning/0.12",
border = "warning",
borderWidth = 1,
align = "center",
}, {
ui.glyph({ name = "alert-triangle", size = 17, color = "tertiary" }),
ui.label({
text = noctalia.trp("panel.warning_count", #warnings),
color = "on_surface",
fontSize = 11,
maxLines = 2,
flexGrow = 1,
}),
})
end
render = function()
local status = asString(snapshot.status, "idle")
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
body = statusBody(status, snapshot.error)
elseif status == "loading" or status == "idle" then
body = statusBody(status, "")
elseif viewMode == "keyboard" then
body = keyboardBody()
else
body = readyBody(categories, requestedColumns)
end
local children = {
header(),
ui.separator({ orientation = "horizontal", color = "outline", opacity = 0.65 }),
}
if viewMode == "list" then
children[#children + 1] = searchBar(categories)
end
if viewMode == "list" and creatorOpen and status == "ready" then
children[#children + 1] = creatorForm(keyboardIndex())
end
local warning = warningsBanner()
if warning ~= nil and status == "ready" then
children[#children + 1] = warning
end
children[#children + 1] = body
panel.render(ui.column({ flexGrow = 1, gap = 12, align = "stretch" }, children))
end
local function requestRefresh()
local current = tonumber(noctalia.state.get(REFRESH_KEY)) or 0
noctalia.state.set(REFRESH_KEY, current + 1)
end
noctalia.state.watch(SNAPSHOT_KEY, function(value)
if type(value) == "table" and value.status == "loading" and snapshot.status == "ready" then
return
end
if type(value) == "table" then
snapshot = value
else
snapshot = EMPTY_SNAPSHOT
end
render()
end)
noctalia.state.watch(CREATE_RESULT_KEY, function(result)
if formMode ~= "create" or type(result) ~= "table"
or tostring(result.request_id or "") ~= creatorRequestId or not creatorBusy then
return
end
creatorBusy = false
if result.ok == true then
local chord = creatorChord()
creatorOpen = false
creatorKeys = {}
selectedKeyboardKey = nil
noctalia.notify(tr("title"), tr("panel.creator.saved", { chord = chord }))
else
creatorError = asString(result.error, "write_failed")
noctalia.notifyError(tr("title"), creatorErrorText())
end
render()
end)
noctalia.state.watch(UPDATE_RESULT_KEY, function(result)
if formMode ~= "edit" or type(result) ~= "table"
or tostring(result.request_id or "") ~= editingRequestId or not creatorBusy then
return
end
creatorBusy = false
if result.ok == true then
editingSnapshotBefore = nil
creatorOpen = false
editingBindId = ""
selectedKeyboardKey = nil
viewMode = "list"
local resultKey = editingOperation == "hide" and "hidden"
or (editingOperation == "restore" and "restored"
or (editingOperation == "delete" and "deleted"
or (editingOperation == "move" and "moved"
or (editingOperation == "reorder" and "reordered"
or (editingOperation == "rename_category" and "category_renamed" or "updated")))))
noctalia.notify(tr("title"), tr("panel.editor." .. resultKey))
if editingOperation == "rename_category" then clearCategoryRename() end
else
if editingSnapshotBefore ~= nil then snapshot = editingSnapshotBefore end
editingSnapshotBefore = nil
creatorError = asString(result.error, "write_failed")
noctalia.notifyError(tr("title"), creatorErrorText())
end
editingOperation = "update"
deleteConfirmBindId = ""
hiddenDeleteConfirmBindId = ""
render()
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)
snapshot = type(current) == "table" and current or EMPTY_SNAPSHOT
searchQuery = ""
searchRevision += 1
if creatorBusy then
creatorOpen = editingOperation == "update"
viewMode = formMode == "edit" and "list" or "keyboard"
else
creatorOpen = false
creatorError = ""
editorMode = false
formMode = "create"
editingBindId = ""
editingOperation = "update"
deleteConfirmBindId = ""
hiddenDeleteConfirmBindId = ""
clearCategoryRename()
end
if snapshot.status == nil or snapshot.status == "idle" then
requestRefresh()
end
render()
end
function onConfigChanged()
clearHostValueCaches()
render()
end
function update()
render()
end
function onSearchChanged(value)
searchQuery = asString(value)
render()
end
function onClearSearch()
searchQuery = ""
searchRevision += 1
render()
end
function onShowKeyboard()
viewMode = "keyboard"
render()
end
function onShowList()
if creatorBusy then return end
viewMode = "list"
creatorOpen = false
render()
end
function onKeyboardLayoutChanged(index, _label)
if creatorBusy then return end
local selectedIndex = math.floor(tonumber(index) or -1) + 1
local layoutId = KEYBOARD_LAYOUT_ORDER[selectedIndex]
if layoutId == nil or KEYBOARD_LAYOUTS[layoutId] == nil then return end
keyboardLayoutId = layoutId
render()
end
local function resetCreator()
formMode = "create"
editingBindId = ""
editingCategory = ""
editingCapabilities = {}
editingAction = ""
creatorKeys = viewMode == "keyboard" and selectedKeyboardKey ~= nil and { selectedKeyboardKey } or {}
creatorKeysText = creatorKeysDisplay()
creatorActivation = "press"
creatorCommand = ""
creatorCommandKind = "shell"
creatorLibraryEntryId = ""
creatorDescription = ""
creatorCategoryIndex = 0
creatorNewCategory = ""
creatorCategoryNamesFrozen = creatorCategoryNames()
creatorContextCompositor = asString(snapshot.compositor)
creatorContextSource = asString(snapshot.source)
creatorBusy = false
creatorError = ""
commandLibraryOpen = false
commandLibraryQuery = ""
commandLibrarySourceIndex = 0
commandLibraryCategoryIndex = 0
commandLibraryReadinessIndex = 0
commandLibraryRevision += 1
creatorRevision += 1
end
function onCreatorToggle()
if creatorBusy then return end
clearCategoryRename()
if creatorOpen and formMode == "create" then
creatorOpen = false
else
editorMode = false
resetCreator()
creatorOpen = true
end
render()
end
function onCreatorCancel()
if creatorBusy then return end
creatorOpen = false
creatorBusy = false
creatorError = ""
commandLibraryOpen = false
if formMode == "edit" then viewMode = "list" end
render()
end
function onEditorToggle()
if creatorBusy then return end
if editorMode then
editorMode = false
creatorOpen = false
formMode = "create"
editingBindId = ""
deleteConfirmBindId = ""
hiddenDeleteConfirmBindId = ""
clearCategoryRename()
else
editorMode = true
creatorOpen = false
formMode = "edit"
editingBindId = ""
deleteConfirmBindId = ""
hiddenDeleteConfirmBindId = ""
clearCategoryRename()
viewMode = "list"
end
render()
end
function onCategoryRenameChanged(value)
if creatorBusy or renamingCategoryId == "" then return end
renamingCategoryValue = asString(value)
creatorError = ""
render()
end
function onCategoryRenameCancel()
if creatorBusy then return end
clearCategoryRename()
render()
end
function onCategoryRenameSave()
if creatorBusy or renamingCategoryId == "" or snapshot.status ~= "ready" then return end
local category = findSnapshotCategory(renamingCategoryId)
local nextName = noctalia.string.trim(renamingCategoryValue)
if snapshot.compositor == "" or snapshot.source == "" then
creatorError = "stale_context"
elseif category == nil or asString(category.name) ~= renamingCategoryOriginal then
creatorError = "category_changed"
elseif not categoryRenameable(category) then
creatorError = "category_not_editable"
elseif nextName == "" then
creatorError = "category_required"
elseif not categoryCanAcceptDrop(nextName) then
creatorError = "category_invalid"
elseif categoryNameExists(nextName, renamingCategoryId) then
creatorError = "category_exists"
elseif nextName == renamingCategoryOriginal then
clearCategoryRename()
else
creatorRequestCounter += 1
local requestId = tostring(os.time()) .. "-rename-category-" .. tostring(creatorRequestCounter)
formMode = "edit"
editingOperation = "rename_category"
editingBindId = ""
editingRequestId = requestId
creatorContextCompositor = asString(snapshot.compositor)
creatorContextSource = asString(snapshot.source)
creatorBusy = true
creatorError = ""
creatorOpen = false
editingSnapshotBefore = snapshot
if not optimisticRenameCategory(
renamingCategoryId, renamingCategoryOriginal, nextName
) then
editingSnapshotBefore = nil
creatorBusy = false
creatorError = "category_changed"
else
noctalia.state.set(UPDATE_REQUEST_KEY, {
request_id = requestId,
operation = "rename_category",
compositor = creatorContextCompositor,
source = creatorContextSource,
category_id = renamingCategoryId,
old_category = renamingCategoryOriginal,
new_category = nextName,
})
end
end
render()
end
function onCreatorPress()
creatorActivation = "press"
creatorError = ""
render()
end
function onCreatorRelease()
if normalized(snapshot.compositor) ~= "niri" then
creatorActivation = "release"
creatorError = ""
render()
end
end
function onCreatorCategoryChanged(index, _text)
creatorCategoryIndex = tonumber(index) or 0
creatorError = ""
render()
end
function onCreatorNewCategoryChanged(value)
creatorNewCategory = asString(value)
creatorError = ""
render()
end
function onCreatorDescriptionChanged(value)
creatorDescription = asString(value)
creatorError = ""
render()
end
function onCommandLibraryToggle()
if formMode == "edit" and editingCapabilities.command ~= true then return end
commandLibraryOpen = not commandLibraryOpen
creatorError = ""
render()
end
function onCommandLibraryQueryChanged(value)
commandLibraryQuery = asString(value)
render()
end
function onCommandLibrarySourceChanged(index, _text)
commandLibrarySourceIndex = math.max(0, math.floor(tonumber(index) or 0))
commandLibraryCategoryIndex = 0
render()
end
function onCommandLibraryCategoryChanged(index, _text)
commandLibraryCategoryIndex = math.max(0, math.floor(tonumber(index) or 0))
render()
end
function onCommandLibraryReadinessChanged(index, _text)
commandLibraryReadinessIndex = math.max(0, math.min(2, math.floor(tonumber(index) or 0)))
render()
end
function onCommandLibraryUseCustom()
if creatorCommandKind == "native" then
creatorCommand = ""
creatorRevision += 1
end
creatorCommandKind = "shell"
creatorLibraryEntryId = ""
commandLibraryOpen = false
creatorError = ""
render()
end
function onCreatorCommandChanged(value)
creatorCommand = asString(value)
creatorError = ""
render()
end
function onEditorKeysChanged(value)
creatorKeysText = asString(value)
local keys = {}
local seen = {}
for part in (creatorKeysText .. ","):gmatch("(.-),") do
local key = canonicalKey(noctalia.string.trim(part))
if key ~= "" and not seen[key] then
seen[key] = true
keys[#keys + 1] = key
end
end
creatorKeys = keys
selectedKeyboardKey = keys[1]
creatorError = ""
render()
end
function onCreatorSave()
if creatorBusy then
return
end
local index = keyboardIndex()
local compositor = normalized(snapshot.compositor)
local category = creatorCategory()
if snapshot.status ~= "ready" or snapshot.compositor ~= creatorContextCompositor
or snapshot.source ~= creatorContextSource then
creatorError = "stale_context"
elseif #creatorKeys == 0 then
creatorError = "combination_required"
elseif (formMode ~= "edit" or editingCapabilities.command == true)
and noctalia.string.trim(creatorCommand) == "" then
creatorError = "command_required"
elseif creatorCommand:find("{{", 1, true) ~= nil then
creatorError = "library_arguments_required"
elseif noctalia.string.trim(creatorDescription) == "" then
creatorError = "description_required"
elseif category == "" then
creatorError = "category_required"
elseif compositor ~= "hyprland" and #creatorKeys ~= 1 then
creatorError = "single_key_only"
elseif compositor == "niri" and creatorActivation == "release" then
creatorError = "release_unsupported"
elseif creatorConflict(index) ~= nil then
creatorError = "conflict_blocked"
else
local keys = {}
for _, code in ipairs(creatorKeys) do
keys[#keys + 1] = configKeyName(code)
end
local modifiers = {}
for _, modifier in ipairs(MODIFIER_ORDER) do
if activeModifiers[modifier] then
modifiers[#modifiers + 1] = modifier
end
end
creatorRequestCounter += 1
local requestId = tostring(os.time()) .. "-" .. tostring(creatorRequestCounter)
creatorBusy = true
creatorError = ""
local request = {
request_id = requestId,
compositor = creatorContextCompositor,
source = creatorContextSource,
modifiers = modifiers,
keys = keys,
activation = creatorActivation,
command = noctalia.string.trim(creatorCommand),
command_kind = creatorCommandKind,
library_entry_id = creatorLibraryEntryId,
description = noctalia.string.trim(creatorDescription),
category = category,
}
if formMode == "edit" then
editingOperation = "update"
editingRequestId = requestId
request.target_id = editingBindId
noctalia.state.set(UPDATE_REQUEST_KEY, request)
else
creatorRequestId = requestId
noctalia.state.set(CREATE_REQUEST_KEY, request)
end
end
render()
end
local function toggleModifier(modifier)
activeModifiers[modifier] = not activeModifiers[modifier]
selectedKeyboardKey = nil
render()
end
function onToggleSuper() toggleModifier("SUPER") end
function onToggleCtrl() toggleModifier("CTRL") end
function onToggleShift() toggleModifier("SHIFT") end
function onToggleAlt() toggleModifier("ALT") end
function onClearModifiers()
for _, modifier in ipairs(MODIFIER_ORDER) do
activeModifiers[modifier] = false
end
selectedKeyboardKey = nil
render()
end
function onCancelDeleteBind()
deleteConfirmBindId = ""
render()
end
function onCancelDeleteHiddenBind()
hiddenDeleteConfirmBindId = ""
render()
end
function onBindDropped(bindId, targetCategory)
if not editorMode or creatorBusy or snapshot.status ~= "ready" then return end
local targetName = noctalia.string.trim(asString(targetCategory))
if not categoryCanAcceptDrop(targetName) or not snapshotHasCategory(targetName) then return end
local bind, currentCategory = findSnapshotBind(asString(bindId))
if bind == nil or not bindCategoryMovable(bind) or currentCategory == targetName then return end
creatorRequestCounter += 1
local requestId = tostring(os.time()) .. "-move-" .. tostring(creatorRequestCounter)
formMode = "edit"
editingOperation = "move"
editingBindId = asString(bind.id)
editingRequestId = requestId
creatorContextCompositor = asString(snapshot.compositor)
creatorContextSource = asString(snapshot.source)
creatorBusy = true
creatorError = ""
creatorOpen = false
deleteConfirmBindId = ""
editingSnapshotBefore = snapshot
if not optimisticPlaceBind(editingBindId, targetName, "", "after") then
editingSnapshotBefore = nil
creatorBusy = false
return
end
noctalia.state.set(UPDATE_REQUEST_KEY, {
request_id = requestId,
target_id = editingBindId,
operation = "move",
category = targetName,
compositor = creatorContextCompositor,
source = creatorContextSource,
})
render()
end
function onBindReordered(bindId, targetValue)
if not editorMode or creatorBusy or snapshot.status ~= "ready" then return end
local placement, anchorId = asString(targetValue):match("^(%a+)|(.+)$")
if (placement ~= "before" and placement ~= "after") or asString(anchorId) == "" then return end
local bind, bindCategory = findSnapshotBind(asString(bindId))
local anchor, anchorCategory = findSnapshotBind(asString(anchorId))
if bind == nil or anchor == nil or asString(bind.id) == asString(anchor.id)
or not bindCategoryMovable(bind) or not bindCategoryMovable(anchor) then return end
if asString(bind.source) ~= asString(anchor.source) then
if bindCategory ~= anchorCategory then onBindDropped(bindId, anchorCategory) end
return
end
creatorRequestCounter += 1
local requestId = tostring(os.time()) .. "-reorder-" .. tostring(creatorRequestCounter)
formMode = "edit"
editingOperation = "reorder"
editingBindId = asString(bind.id)
editingRequestId = requestId
creatorContextCompositor = asString(snapshot.compositor)
creatorContextSource = asString(snapshot.source)
creatorBusy = true
creatorError = ""
creatorOpen = false
deleteConfirmBindId = ""
hiddenDeleteConfirmBindId = ""
editingSnapshotBefore = snapshot
if not optimisticPlaceBind(editingBindId, anchorCategory, asString(anchor.id), placement) then
editingSnapshotBefore = nil
creatorBusy = false
return
end
noctalia.state.set(UPDATE_REQUEST_KEY, {
request_id = requestId,
target_id = editingBindId,
anchor_id = asString(anchor.id),
operation = "reorder",
placement = placement,
compositor = creatorContextCompositor,
source = creatorContextSource,
})
render()
end
function onIpc(event, payload)
if event == "view-keyboard" then
onShowKeyboard()
elseif event == "view-list" then
onShowList()
elseif event == "keyboard-layout" and KEYBOARD_LAYOUTS[asString(payload)] ~= nil then
keyboardLayoutId = asString(payload)
render()
elseif event == "keyboard-key" and asString(payload) ~= "" then
selectKeyboardKey(payload)
elseif event == "clear-modifiers" then
onClearModifiers()
elseif event == "creator-open" and not creatorOpen then
onCreatorToggle()
elseif event == "creator-cancel" and creatorOpen then
onCreatorCancel()
elseif event == "editor-open" and not editorMode then
onEditorToggle()
elseif event == "editor-bind" and asString(payload) ~= "" then
for _, category in ipairs(asArray(snapshot.categories)) do
for _, bind in ipairs(asArray(category.binds)) do
if asString(bind.id) == asString(payload) then
editorMode = true
openBindEditor(bind, asString(category.name, tr("panel.uncategorized")))
return
end
end
end
end
end
function onRefreshClicked()
requestRefresh()
end
function onOpenConfigFolderClicked()
if creatorBusy then return end
local directory = sourceDirectory()
if directory == "" then
noctalia.notifyError(tr("title"), tr("panel.config_folder_unavailable"))
return
end
local info = noctalia.fileInfo(directory)
if type(info) ~= "table" or info.isDir ~= true then
noctalia.notifyError(tr("title"), tr("panel.config_folder_unavailable"))
return
end
if not noctalia.commandExists("xdg-open") then
noctalia.notifyError(tr("title"), tr("panel.xdg_open_unavailable"))
return
end
local started = noctalia.runAsync("xdg-open " .. shellQuote(directory) .. " >/dev/null 2>&1", function(result)
if result.timedOut == true or tonumber(result.exitCode) ~= 0 then
noctalia.notifyError(tr("title"), tr("panel.config_folder_failed"))
end
end)
if not started then
noctalia.notifyError(tr("title"), tr("panel.config_folder_failed"))
end
end
function onOpenSettingsClicked()
if creatorBusy then return end
local started = noctalia.runAsync("noctalia msg settings-open plugins")
if not started then
noctalia.notifyError(tr("title"), tr("panel.settings_failed"))
end
end
function onCloseClicked()
if creatorBusy then return end
panel.close()
end