3330 lines
114 KiB
Luau
3330 lines
114 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 env = getfenv()
|
|
local dynamicCallbackNames = {}
|
|
local renderCallbackNames = nil
|
|
|
|
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 registerDynamicCallback(name, callback)
|
|
env[name] = callback
|
|
if renderCallbackNames ~= nil then
|
|
renderCallbackNames[name] = true
|
|
end
|
|
return name
|
|
end
|
|
|
|
local function finishDynamicCallbackRender()
|
|
local previous = dynamicCallbackNames
|
|
dynamicCallbackNames = renderCallbackNames or {}
|
|
renderCallbackNames = nil
|
|
for name, _ in pairs(previous) do
|
|
if dynamicCallbackNames[name] ~= true then
|
|
env[name] = nil
|
|
end
|
|
end
|
|
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 name = "onKeyboardKey_" .. id
|
|
local callback = keyCallbackCache[name]
|
|
if callback == nil then
|
|
callback = function() selectKeyboardKey(code) end
|
|
keyCallbackCache[name] = callback
|
|
end
|
|
return registerDynamicCallback(name, callback)
|
|
end
|
|
|
|
local function contains(haystack, needle)
|
|
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 callbackName = "onCommandLibraryUse:" .. asString(entry.id)
|
|
registerDynamicCallback(callbackName, function()
|
|
creatorCommand = asString(entry.template)
|
|
creatorCommandKind = asString(entry.kind, "shell")
|
|
creatorLibraryEntryId = asString(entry.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 = callbackName,
|
|
}),
|
|
})
|
|
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 registerCategoryRenameCallback(categoryId)
|
|
local name = "onCategoryRename:" .. categoryId
|
|
return registerDynamicCallback(name, function()
|
|
local category = findSnapshotCategory(categoryId)
|
|
if category ~= nil then openCategoryRename(category) end
|
|
end)
|
|
end
|
|
|
|
local function registerBindCallback(bindId, operation)
|
|
local name = "onBindAction:" .. operation .. ":" .. bindId
|
|
return registerDynamicCallback(name, 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 registerHiddenCallback(bindId, operation)
|
|
local name = "onHiddenBindAction:" .. operation .. ":" .. bindId
|
|
return registerDynamicCallback(name, 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 = registerBindCallback(bindId, "edit")
|
|
local hideCallback = registerBindCallback(bindId, "hide")
|
|
local deleteCallback = registerBindCallback(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 = registerHiddenCallback(bindId, "restore")
|
|
local deleteCallback = registerHiddenCallback(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 = registerCategoryRenameCallback(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()
|
|
renderCallbackNames = {}
|
|
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))
|
|
finishDynamicCallbackRender()
|
|
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
|