870 lines
32 KiB
Luau
870 lines
32 KiB
Luau
--!nonstrict
|
|
-- Snapshot-driven keybind viewer for Mango, Hyprland, and Niri.
|
|
--
|
|
-- Parsing, durable cache ownership, and refreshes live in service.luau. This
|
|
-- panel only reads the shared snapshot and renders it.
|
|
|
|
local noctalia = noctalia
|
|
local ui = ui
|
|
local panel = panel
|
|
if noctalia == nil then
|
|
local host = require("./tests/host_mock")
|
|
noctalia = host.noctalia
|
|
ui = host.ui
|
|
panel = host.panel
|
|
end
|
|
|
|
local PANEL_ID = "kenn/keybind-cheatsheet:cheatsheet"
|
|
local SNAPSHOT_KEY = "keybind-cheatsheet.snapshot"
|
|
local REFRESH_REQUEST_KEY = "keybind-cheatsheet.refresh-request"
|
|
local SELF_TEST_REQUEST_KEY = "keybind-cheatsheet.self-test-request"
|
|
local PREFERENCES_FILE = "preferences.json"
|
|
|
|
local bindings = {}
|
|
local parseWarnings = {}
|
|
local currentCompositor = ""
|
|
local loading = false
|
|
local panelOpen = false
|
|
local panelError = nil
|
|
local refreshError = nil
|
|
local snapshot = nil
|
|
local query = ""
|
|
local searchRevision = 0
|
|
local view = "bindings"
|
|
local editingId = nil
|
|
local editDraft = ""
|
|
local preferences = { version = 1, hidden = {}, descriptions = {}, colors = {} }
|
|
local refreshing = false
|
|
|
|
local render
|
|
local refresh
|
|
|
|
local function tr(key, values)
|
|
return noctalia.tr(key, values)
|
|
end
|
|
|
|
local function trim(value)
|
|
local result = (value or ""):gsub("^%s+", ""):gsub("%s+$", "")
|
|
return result
|
|
end
|
|
|
|
local function lower(value)
|
|
return string.lower(value or "")
|
|
end
|
|
|
|
local function startsWith(value, prefix)
|
|
return value:sub(1, #prefix) == prefix
|
|
end
|
|
|
|
local KEY_LABELS = {
|
|
XF86AudioRaiseVolume = "Vol Up",
|
|
XF86AudioLowerVolume = "Vol Down",
|
|
XF86AudioMute = "Mute",
|
|
XF86AudioMicMute = "Mic Mute",
|
|
XF86MonBrightnessUp = "Bright Up",
|
|
XF86MonBrightnessDown = "Bright Down",
|
|
XF86AudioPlay = "Play/Pause",
|
|
XF86AudioPause = "Pause",
|
|
XF86AudioNext = "Next",
|
|
XF86AudioPrev = "Previous",
|
|
Print = "PrtSc",
|
|
Prior = "PgUp",
|
|
Next = "PgDn",
|
|
Return = "Enter",
|
|
Escape = "Esc",
|
|
space = "Space",
|
|
btn_left = "Mouse Left",
|
|
btn_right = "Mouse Right",
|
|
btn_middle = "Mouse Middle",
|
|
btn_side = "Mouse Side",
|
|
btn_extra = "Mouse Extra",
|
|
}
|
|
|
|
local function formatKey(key)
|
|
return KEY_LABELS[key] or key:gsub("^mouse:", "Mouse ")
|
|
end
|
|
|
|
local function niriCategoryFor(action)
|
|
local value = lower(action)
|
|
if startsWith(value, "spawn") then return "Applications" end
|
|
if startsWith(value, "focus-column-") then return "Column Navigation" end
|
|
if startsWith(value, "focus-window-") then return "Window Focus" end
|
|
if startsWith(value, "focus-workspace-") then return "Workspace Navigation" end
|
|
if startsWith(value, "move-column-") then return "Move Columns" end
|
|
if startsWith(value, "move-window-") then return "Move Windows" end
|
|
if startsWith(value, "screenshot") then return "Screenshots" end
|
|
if startsWith(value, "close-window") or startsWith(value, "fullscreen-window") then return "Window Management" end
|
|
if startsWith(value, "power-off-monitors") then return "Power" end
|
|
if startsWith(value, "quit") then return "System" end
|
|
return "Other"
|
|
end
|
|
|
|
|
|
local function preferencesPath()
|
|
local directory, err = noctalia.pluginDataDir()
|
|
if directory == nil then
|
|
noctalia.log("Could not resolve plugin data directory: " .. (err or "unknown error"))
|
|
return nil
|
|
end
|
|
return directory .. "/" .. PREFERENCES_FILE
|
|
end
|
|
|
|
local function normalizePreferences(decoded)
|
|
local result = { version = 1, hidden = {}, descriptions = {}, colors = {} }
|
|
if type(decoded) ~= "table" then
|
|
return result
|
|
end
|
|
for id, hidden in pairs(type(decoded.hidden) == "table" and decoded.hidden or {}) do
|
|
if type(id) == "string" and hidden == true then
|
|
result.hidden[id] = true
|
|
end
|
|
end
|
|
for id, description in pairs(type(decoded.descriptions) == "table" and decoded.descriptions or {}) do
|
|
if type(id) == "string" and type(description) == "string" and trim(description) ~= "" then
|
|
result.descriptions[id] = trim(description)
|
|
end
|
|
end
|
|
for bucket, colors in pairs(type(decoded.colors) == "table" and decoded.colors or {}) do
|
|
if type(bucket) == "string" and type(colors) == "table" then
|
|
result.colors[bucket] = {}
|
|
if type(colors.background) == "string" then result.colors[bucket].background = colors.background end
|
|
if type(colors.text) == "string" then result.colors[bucket].text = colors.text end
|
|
end
|
|
end
|
|
return result
|
|
end
|
|
|
|
local function loadPreferences()
|
|
local path = preferencesPath()
|
|
if path == nil then
|
|
preferences = normalizePreferences(nil)
|
|
return
|
|
end
|
|
local raw = noctalia.readFile(path)
|
|
if raw == nil or raw == "" then
|
|
preferences = normalizePreferences(nil)
|
|
return
|
|
end
|
|
local decoded = noctalia.json.decode(raw)
|
|
preferences = normalizePreferences(decoded)
|
|
end
|
|
|
|
local function savePreferences()
|
|
local path = preferencesPath()
|
|
if path == nil then return end
|
|
local encoded = noctalia.json.encode(preferences, true)
|
|
if encoded == nil then return end
|
|
local ok, err = noctalia.writeFile(path, encoded)
|
|
if not ok then
|
|
noctalia.notifyError(tr("title"), err or "Could not save preferences")
|
|
end
|
|
end
|
|
|
|
local function applySnapshot(value)
|
|
snapshot = type(value) == "table" and value or nil
|
|
if snapshot == nil then
|
|
bindings = {}
|
|
parseWarnings = {}
|
|
currentCompositor = ""
|
|
loading = true
|
|
refreshing = false
|
|
panelError = nil
|
|
refreshError = nil
|
|
return
|
|
end
|
|
|
|
bindings = type(snapshot.bindings) == "table" and snapshot.bindings or {}
|
|
parseWarnings = type(snapshot.warnings) == "table" and snapshot.warnings or {}
|
|
currentCompositor = type(snapshot.compositor) == "string" and snapshot.compositor or ""
|
|
refreshing = snapshot.refreshing == true
|
|
loading = (snapshot.status == "loading" or snapshot.status == "idle") and #bindings == 0
|
|
panelError = snapshot.status == "error" and (snapshot.error or tr("missing_config")) or nil
|
|
refreshError = snapshot.status == "ready" and type(snapshot.error) == "string"
|
|
and snapshot.error ~= "" and snapshot.error or nil
|
|
end
|
|
|
|
refresh = function()
|
|
local current = tonumber(noctalia.state.get(REFRESH_REQUEST_KEY)) or 0
|
|
refreshing = true
|
|
noctalia.state.set(REFRESH_REQUEST_KEY, current + 1)
|
|
render()
|
|
end
|
|
|
|
|
|
local COLOR_BUCKETS = {
|
|
{ id = "super", label = "Super", background = "primary", text = "on_primary", picker = "#6750A4" },
|
|
{ id = "ctrl", label = "Ctrl", background = "secondary", text = "on_secondary", picker = "#625B71" },
|
|
{ id = "shift", label = "Shift", background = "tertiary", text = "on_tertiary", picker = "#7D5260" },
|
|
{ id = "alt", label = "Alt", background = "error", text = "on_error", picker = "#B3261E" },
|
|
{ id = "xf86", label = "Media", background = "secondary/0.25", text = "secondary", picker = "#E8DEF8" },
|
|
{ id = "number", label = "Numbers", background = "tertiary/0.25", text = "tertiary", picker = "#FFD8E4" },
|
|
{ id = "mouse", label = "Mouse", background = "error/0.2", text = "error", picker = "#F9DEDC" },
|
|
{ id = "print", label = "Print", background = "primary/0.25", text = "primary", picker = "#EADDFF" },
|
|
{ id = "default", label = "Other keys", background = "surface_variant", text = "on_surface_variant", picker = "#E7E0EC" },
|
|
{ id = "description", label = "Descriptions", background = "surface", text = "on_surface", picker = "#1D1B20" },
|
|
}
|
|
|
|
local COLOR_BY_ID = {}
|
|
for _, bucket in ipairs(COLOR_BUCKETS) do COLOR_BY_ID[bucket.id] = bucket end
|
|
|
|
local function colorValue(bucketId, property)
|
|
local custom = preferences.colors[bucketId]
|
|
if custom ~= nil and custom[property] ~= nil then
|
|
return custom[property]
|
|
end
|
|
local bucket = COLOR_BY_ID[bucketId] or COLOR_BY_ID.default
|
|
return bucket[property]
|
|
end
|
|
|
|
local function bucketForKey(key)
|
|
local value = lower(key)
|
|
if startsWith(value, "xf86") then return "xf86" end
|
|
if value:match("^%d$") then return "number" end
|
|
if startsWith(value, "mouse") or startsWith(value, "btn_") or value:find("wheel", 1, true) ~= nil then return "mouse" end
|
|
if value == "print" or value == "prior" or value == "next" then return "print" end
|
|
return "default"
|
|
end
|
|
|
|
local function bucketForModifier(modifier)
|
|
local value = lower(modifier)
|
|
if value == "super" or value == "mod4" or value == "logo" then return "super" end
|
|
if value == "ctrl" or value == "control" then return "ctrl" end
|
|
if value == "shift" then return "shift" end
|
|
if value == "alt" or value == "mod1" then return "alt" end
|
|
return "default"
|
|
end
|
|
|
|
local function isHexColor(value)
|
|
if type(value) ~= "string" then return false end
|
|
return value:match("^#%x%x%x%x%x%x$") ~= nil or value:match("^#%x%x%x%x%x%x%x%x$") ~= nil
|
|
end
|
|
|
|
local function setColor(bucketId, property, value)
|
|
preferences.colors[bucketId] = preferences.colors[bucketId] or {}
|
|
preferences.colors[bucketId][property] = value
|
|
savePreferences()
|
|
render()
|
|
end
|
|
|
|
local function chooseColor(bucketId, property)
|
|
local bucket = COLOR_BY_ID[bucketId] or COLOR_BY_ID.default
|
|
local current = colorValue(bucketId, property)
|
|
local initial = isHexColor(current) and current:sub(1, 7) or bucket.picker
|
|
noctalia.openColorPicker(initial, function(color)
|
|
if color ~= nil then
|
|
setColor(bucketId, property, color)
|
|
end
|
|
end)
|
|
end
|
|
|
|
local function pasteColor(bucketId, property)
|
|
local value = trim(noctalia.clipboardText() or "")
|
|
if isHexColor(value) then
|
|
setColor(bucketId, property, string.upper(value))
|
|
else
|
|
noctalia.notifyError(tr("title"), "Clipboard does not contain a #RRGGBB color")
|
|
end
|
|
end
|
|
|
|
local function bindingContentOpacity(hidden)
|
|
return hidden and 0.45 or 1
|
|
end
|
|
|
|
local function keyPill(text, bucketId, key)
|
|
return ui.column({
|
|
key = key,
|
|
fill = colorValue(bucketId, "background"),
|
|
radius = 4,
|
|
paddingH = 7,
|
|
paddingV = 3,
|
|
align = "center",
|
|
}, {
|
|
ui.label({ text = text, color = colorValue(bucketId, "text"), fontSize = 12, fontWeight = "bold", maxLines = 1 }),
|
|
})
|
|
end
|
|
|
|
local function authoredDescription(binding)
|
|
return preferences.descriptions[binding.id] or binding.description or ""
|
|
end
|
|
|
|
local function titleCase(value)
|
|
value = trim(value):gsub("[_%-]+", " ")
|
|
return (value:gsub("(%a)([%w']*)", function(first, rest)
|
|
return string.upper(first) .. lower(rest)
|
|
end))
|
|
end
|
|
|
|
local function friendlySpawn(commandLine)
|
|
local value = lower(trim(commandLine))
|
|
if value:find("noctalia msg panel-toggle launcher", 1, true) ~= nil then return "Application Launcher" end
|
|
if value:find("noctalia msg panel-toggle session", 1, true) ~= nil then return "Power Menu" end
|
|
if value:find("noctalia msg session lock", 1, true) ~= nil then return "Lock Screen" end
|
|
if value:find("playerctl play-pause", 1, true) ~= nil then return "Play / Pause" end
|
|
if value:find("playerctl next", 1, true) ~= nil then return "Next Track" end
|
|
if value:find("playerctl previous", 1, true) ~= nil then return "Previous Track" end
|
|
if value:find("playerctl stop", 1, true) ~= nil then return "Stop Playback" end
|
|
if value:find("wpctl set-volume", 1, true) ~= nil then
|
|
return value:find("+", 1, true) ~= nil and "Volume Up" or "Volume Down"
|
|
end
|
|
if value:find("wpctl set-mute", 1, true) ~= nil then
|
|
return value:find("source", 1, true) ~= nil and "Toggle Microphone Mute" or "Toggle Mute"
|
|
end
|
|
if value:find("brightnessctl", 1, true) ~= nil then
|
|
return value:find("+", 1, true) ~= nil and "Brightness Up" or "Brightness Down"
|
|
end
|
|
|
|
local program = trim(commandLine):match("^([^%s]+)") or "Application"
|
|
program = program:match("([^/]+)$") or program
|
|
local programs = {
|
|
alacritty = "Terminal",
|
|
chromium = "Browser",
|
|
dolphin = "File Manager",
|
|
firefox = "Browser",
|
|
foot = "Terminal",
|
|
kitty = "Terminal",
|
|
nautilus = "File Manager",
|
|
thunar = "File Manager",
|
|
}
|
|
return programs[lower(program)] or ("Launch " .. titleCase(program))
|
|
end
|
|
|
|
local ACTION_LABELS = {
|
|
close_window = "Close Window",
|
|
killactive = "Close Window",
|
|
killclient = "Close Window",
|
|
minimized = "Minimize Window",
|
|
quit = "Exit Compositor",
|
|
reload_config = "Reload Configuration",
|
|
restore_minimized = "Restore Minimized Window",
|
|
switch_proportion_preset = "Cycle Width Preset",
|
|
toggle_named_scratchpad = "Toggle Scratchpad",
|
|
togglefakefullscreen = "Toggle Fake Fullscreen",
|
|
togglefloating = "Toggle Floating",
|
|
togglefullscreen = "Toggle Fullscreen",
|
|
togglegaps = "Toggle Gaps",
|
|
toggleglobal = "Toggle Global Window",
|
|
togglemaximizescreen = "Toggle Maximize",
|
|
toggleoverlay = "Toggle Overlay",
|
|
toggleoverview = "Workspace Overview",
|
|
}
|
|
|
|
local function friendlyAction(binding)
|
|
local action = trim(binding.action)
|
|
local command, arguments = action:match("^(%S+)%s*(.*)$")
|
|
command = lower(command or ""):gsub("%-", "_")
|
|
arguments = trim(arguments or "")
|
|
if command == "spawn" or command == "spawn_shell" or command == "exec" then
|
|
return friendlySpawn(arguments)
|
|
end
|
|
if ACTION_LABELS[command] ~= nil then return ACTION_LABELS[command] end
|
|
|
|
local argument = arguments:match("^([^,%s]+)") or ""
|
|
local direction = argument ~= "" and titleCase(argument) or ""
|
|
if command == "focusdir" or command == "movefocus" then return "Focus " .. direction end
|
|
if command == "exchange_client" or command == "movewindow" then return "Move Window " .. direction end
|
|
if command == "focusmon" then return "Focus Monitor " .. direction end
|
|
if command == "tagmon" then return "Move to Monitor " .. direction end
|
|
if command == "scroller_stack" then return "Move Within Stack " .. direction end
|
|
if command == "view" or command == "workspace" then return "Workspace " .. argument end
|
|
if command == "tag" or command == "movetoworkspace" then return "Send to Workspace " .. argument end
|
|
if command == "viewtoleft" or command == "viewtoleft_have_client" then return "Previous Workspace" end
|
|
if command == "viewtoright" or command == "viewtoright_have_client" then return "Next Workspace" end
|
|
if command == "setlayout" then return titleCase(argument) .. " Layout" end
|
|
if command == "set_proportion" then return "Set Window Proportion " .. argument end
|
|
if command == "setmfact" then return "Set Layout Size " .. argument end
|
|
if command == "moveresize" then
|
|
return arguments:find("curresize", 1, true) ~= nil and "Resize Window with Mouse" or "Move Window with Mouse"
|
|
end
|
|
return titleCase(action)
|
|
end
|
|
|
|
local function effectiveDescription(binding)
|
|
local description = authoredDescription(binding)
|
|
return description ~= "" and description or friendlyAction(binding)
|
|
end
|
|
|
|
local function effectiveCategory(binding)
|
|
if binding.baseCategory ~= nil and binding.baseCategory ~= "" then
|
|
return binding.baseCategory
|
|
end
|
|
if authoredDescription(binding) == "" and binding.compositor ~= "niri" then
|
|
return tr("without_description")
|
|
end
|
|
return binding.compositor == "niri" and niriCategoryFor(binding.action) or tr("other")
|
|
end
|
|
|
|
local function bindingMatches(binding)
|
|
local needle = lower(trim(query))
|
|
if needle == "" then return true end
|
|
local haystack = lower(table.concat({
|
|
table.concat(binding.modifiers, " "),
|
|
binding.key,
|
|
formatKey(binding.key),
|
|
effectiveDescription(binding),
|
|
binding.action,
|
|
effectiveCategory(binding),
|
|
}, " "))
|
|
return haystack:find(needle, 1, true) ~= nil
|
|
end
|
|
|
|
local CATEGORY_PRIORITIES = {
|
|
{ "applications", 10 },
|
|
{ "system", 20 },
|
|
{ "window management", 30 },
|
|
{ "focus", 40 },
|
|
{ "navigation", 40 },
|
|
{ "moving windows", 45 },
|
|
{ "workspace", 50 },
|
|
{ "tags", 50 },
|
|
{ "monitor", 50 },
|
|
{ "media", 60 },
|
|
{ "brightness", 60 },
|
|
{ "layout", 70 },
|
|
{ "mouse", 80 },
|
|
{ "touchpad", 90 },
|
|
{ "gesture", 90 },
|
|
{ "integration", 100 },
|
|
{ lower(tr("without_description")), 900 },
|
|
{ lower(tr("other")), 950 },
|
|
}
|
|
|
|
local function categoryPriority(name)
|
|
local value = lower(name)
|
|
for _, entry in ipairs(CATEGORY_PRIORITIES) do
|
|
if value:find(entry[1], 1, true) ~= nil then return entry[2] end
|
|
end
|
|
return 500
|
|
end
|
|
|
|
local function bindingAvailableInView(hidden, undescribed, managing, showUndescribed)
|
|
if managing then return true end
|
|
return not hidden and (showUndescribed or not undescribed)
|
|
end
|
|
|
|
local function hiddenBindingCount(bindingList, hiddenPreferences)
|
|
local currentIds = {}
|
|
for _, binding in ipairs(bindingList or {}) do
|
|
currentIds[binding.id] = true
|
|
end
|
|
local count = 0
|
|
for id in pairs(currentIds) do
|
|
if hiddenPreferences[id] == true then count += 1 end
|
|
end
|
|
return count
|
|
end
|
|
|
|
local function restoreHiddenBindings(bindingList, hiddenPreferences)
|
|
for _, binding in ipairs(bindingList or {}) do
|
|
hiddenPreferences[binding.id] = nil
|
|
end
|
|
end
|
|
|
|
local function visibleGroups()
|
|
local showUndescribed = noctalia.getConfig("show_undescribed") ~= false
|
|
local managing = view == "edit"
|
|
local groups = {}
|
|
local byName = {}
|
|
for index, binding in ipairs(bindings) do
|
|
local hidden = preferences.hidden[binding.id] == true
|
|
local undescribed = authoredDescription(binding) == "" and binding.compositor ~= "niri"
|
|
if bindingAvailableInView(hidden, undescribed, managing, showUndescribed) and bindingMatches(binding) then
|
|
local category = effectiveCategory(binding)
|
|
local group = byName[category]
|
|
if group == nil then
|
|
group = { name = category, bindings = {}, sourceOrder = #groups + 1, order = 0, weight = 1 }
|
|
byName[category] = group
|
|
table.insert(groups, group)
|
|
end
|
|
binding.displayIndex = index
|
|
table.insert(group.bindings, binding)
|
|
group.weight += 1
|
|
end
|
|
end
|
|
table.sort(groups, function(a, b)
|
|
local left = categoryPriority(a.name)
|
|
local right = categoryPriority(b.name)
|
|
if left ~= right then return left < right end
|
|
return a.sourceOrder < b.sourceOrder
|
|
end)
|
|
for index, group in ipairs(groups) do group.order = index end
|
|
return groups
|
|
end
|
|
|
|
local function columnCount()
|
|
local configured = math.clamp(tonumber(noctalia.getConfig("columns")) or 3, 1, 4)
|
|
local outputWidth = 1920
|
|
local focused = noctalia.focusedOutputName()
|
|
for _, output in ipairs(noctalia.outputs()) do
|
|
if output.name == focused or output.focused then
|
|
outputWidth = output.width / math.max(output.scale or 1, 1)
|
|
break
|
|
end
|
|
end
|
|
local responsive = outputWidth < 760 and 1 or (outputWidth < 1100 and 2 or (outputWidth < 1500 and 3 or 4))
|
|
return math.min(configured, responsive)
|
|
end
|
|
|
|
local function balancedColumns(groups, count)
|
|
local columns = {}
|
|
for index = 1, count do columns[index] = { groups = {}, weight = 0 } end
|
|
for _, group in ipairs(groups) do
|
|
local target = 1
|
|
for index = 2, count do
|
|
if columns[index].weight < columns[target].weight then target = index end
|
|
end
|
|
table.insert(columns[target].groups, group)
|
|
columns[target].weight += group.weight
|
|
end
|
|
for _, column in ipairs(columns) do
|
|
table.sort(column.groups, function(a, b) return a.order < b.order end)
|
|
end
|
|
return columns
|
|
end
|
|
|
|
local function saveCustomDescription(binding, value)
|
|
value = trim(value)
|
|
if value == "" or value == binding.description then
|
|
preferences.descriptions[binding.id] = nil
|
|
else
|
|
preferences.descriptions[binding.id] = value
|
|
end
|
|
editingId = nil
|
|
editDraft = ""
|
|
savePreferences()
|
|
render()
|
|
end
|
|
|
|
local function bindingRow(binding, occurrence)
|
|
local hidden = preferences.hidden[binding.id] == true
|
|
local contentOpacity = bindingContentOpacity(hidden)
|
|
local pills = {}
|
|
for index, modifier in ipairs(binding.modifiers) do
|
|
table.insert(pills, keyPill(modifier == "SUPER" and "Super" or modifier:sub(1, 1) .. lower(modifier:sub(2)), bucketForModifier(modifier), "mod-" .. index))
|
|
end
|
|
table.insert(pills, keyPill(formatKey(binding.key), bucketForKey(binding.key), "key"))
|
|
|
|
local description = effectiveDescription(binding)
|
|
local identity = binding.id .. "#" .. occurrence
|
|
if view == "edit" and editingId == identity then
|
|
local visibilityChanged = function()
|
|
if hidden then
|
|
preferences.hidden[binding.id] = nil
|
|
else
|
|
preferences.hidden[binding.id] = true
|
|
end
|
|
savePreferences()
|
|
render()
|
|
end
|
|
local inputChanged = function(value) editDraft = value end
|
|
local submit = function(value) saveCustomDescription(binding, value) end
|
|
local save = function() saveCustomDescription(binding, editDraft) end
|
|
local cancel = function()
|
|
editingId = nil
|
|
editDraft = ""
|
|
render()
|
|
end
|
|
return ui.row({ key = "edit-" .. identity, gap = 6, align = "center", paddingV = 0 }, {
|
|
ui.row({ minWidth = 188, gap = 3, align = "center", opacity = contentOpacity }, pills),
|
|
ui.input({ key = "description-" .. identity, value = editDraft, placeholder = tr("edit_description"), focus = true, controlSize = "sm", flexGrow = 1, onChange = inputChanged, onSubmit = submit }),
|
|
ui.button({ glyph = "check", width = 22, height = 22, glyphSize = 12, variant = "ghost", controlSize = "sm", tooltip = tr("save"), onClick = save }),
|
|
ui.button({ glyph = "close", width = 22, height = 22, glyphSize = 12, variant = "ghost", controlSize = "sm", tooltip = tr("cancel"), onClick = cancel }),
|
|
ui.button({ glyph = hidden and "eye-off" or "eye", width = 22, height = 22, glyphSize = 13, variant = "ghost", controlSize = "sm", selected = hidden, tooltip = hidden and tr("show_binding") or tr("hide_binding"), onClick = visibilityChanged }),
|
|
})
|
|
end
|
|
|
|
local labels = {
|
|
ui.label({
|
|
text = description,
|
|
color = colorValue("description", "text"),
|
|
fontWeight = "bold",
|
|
fontSize = 13,
|
|
maxLines = 1,
|
|
}),
|
|
}
|
|
if noctalia.getConfig("show_actions") ~= false and description ~= "" and binding.action ~= "" then
|
|
table.insert(labels, ui.label({ text = binding.action, color = "on_surface_variant", fontSize = 11, maxLines = 1 }))
|
|
end
|
|
local row = {
|
|
ui.row({ minWidth = 188, gap = 3, align = "center", opacity = contentOpacity }, pills),
|
|
ui.column({ flexGrow = 1, gap = 0, opacity = contentOpacity }, labels),
|
|
}
|
|
if view == "edit" then
|
|
local edit = function()
|
|
editingId = identity
|
|
editDraft = description
|
|
render()
|
|
end
|
|
local visibilityChanged = function()
|
|
if hidden then
|
|
preferences.hidden[binding.id] = nil
|
|
else
|
|
preferences.hidden[binding.id] = true
|
|
end
|
|
savePreferences()
|
|
render()
|
|
end
|
|
table.insert(row, ui.button({ glyph = "pencil", width = 22, height = 22, glyphSize = 12, variant = "ghost", controlSize = "sm", tooltip = tr("edit_description"), onClick = edit }))
|
|
table.insert(row, ui.button({ glyph = hidden and "eye-off" or "eye", width = 22, height = 22, glyphSize = 13, variant = "ghost", controlSize = "sm", selected = hidden, tooltip = hidden and tr("show_binding") or tr("hide_binding"), onClick = visibilityChanged }))
|
|
end
|
|
return ui.row({ key = identity, gap = 6, align = "center", paddingV = 0 }, row)
|
|
end
|
|
|
|
local function categoryNode(group)
|
|
local children = {
|
|
ui.label({ text = string.upper(group.name), color = "primary", fontSize = 14, fontWeight = "bold" }),
|
|
ui.separator({ color = "outline", thickness = 1, spacing = 1 }),
|
|
}
|
|
local occurrences = {}
|
|
for _, binding in ipairs(group.bindings) do
|
|
occurrences[binding.id] = (occurrences[binding.id] or 0) + 1
|
|
table.insert(children, bindingRow(binding, occurrences[binding.id]))
|
|
end
|
|
return ui.column({ key = "category-" .. group.name, gap = 1, paddingV = 3, align = "stretch" }, children)
|
|
end
|
|
|
|
local function bindingsBody()
|
|
local groups = visibleGroups()
|
|
if #groups == 0 then
|
|
return ui.column({ flexGrow = 1, align = "center", justify = "center" }, {
|
|
ui.glyph({ name = "search-off", size = 32, color = "on_surface_variant" }),
|
|
ui.label({ text = tr("no_results"), color = "on_surface_variant" }),
|
|
})
|
|
end
|
|
local columns = balancedColumns(groups, columnCount())
|
|
local columnNodes = {}
|
|
for index, column in ipairs(columns) do
|
|
local categoryNodes = {}
|
|
for _, group in ipairs(column.groups) do table.insert(categoryNodes, categoryNode(group)) end
|
|
table.insert(columnNodes, ui.column({ key = "column-" .. index, flexGrow = 1, gap = 8, align = "stretch" }, categoryNodes))
|
|
end
|
|
return ui.scroll({ flexGrow = 1, gap = 0 }, {
|
|
ui.row({ key = "binding-columns", gap = 22, align = "stretch" }, columnNodes),
|
|
})
|
|
end
|
|
|
|
local function hiddenCount()
|
|
return hiddenBindingCount(bindings, preferences.hidden)
|
|
end
|
|
|
|
local function colorControl(bucket, property)
|
|
local choose = function() chooseColor(bucket.id, property) end
|
|
local paste = function() pasteColor(bucket.id, property) end
|
|
local reset = function()
|
|
if preferences.colors[bucket.id] ~= nil then
|
|
preferences.colors[bucket.id][property] = nil
|
|
if next(preferences.colors[bucket.id]) == nil then preferences.colors[bucket.id] = nil end
|
|
savePreferences()
|
|
render()
|
|
end
|
|
end
|
|
return ui.row({ gap = 5, align = "center", flexGrow = 1 }, {
|
|
ui.box({ width = 24, height = 24, radius = 5, fill = colorValue(bucket.id, property), border = "outline", borderWidth = 1 }),
|
|
ui.button({ text = property == "background" and tr("background") or tr("text"), variant = "ghost", controlSize = "sm", flexGrow = 1, onClick = choose }),
|
|
ui.button({ glyph = "clipboard", variant = "ghost", controlSize = "sm", tooltip = tr("paste"), onClick = paste }),
|
|
ui.button({ glyph = "restore", variant = "ghost", controlSize = "sm", tooltip = tr("reset"), onClick = reset }),
|
|
})
|
|
end
|
|
|
|
local function appearanceBody()
|
|
local rows = {
|
|
ui.label({ text = tr("customize_colors"), color = "on_surface_variant" }),
|
|
}
|
|
for _, bucket in ipairs(COLOR_BUCKETS) do
|
|
table.insert(rows, ui.column({ key = "color-" .. bucket.id, gap = 5, paddingV = 4 }, {
|
|
ui.label({ text = bucket.label, fontWeight = "medium" }),
|
|
ui.row({ gap = 12, align = "center" }, {
|
|
colorControl(bucket, "background"),
|
|
colorControl(bucket, "text"),
|
|
}),
|
|
}))
|
|
end
|
|
local resetAll = function()
|
|
preferences.colors = {}
|
|
savePreferences()
|
|
render()
|
|
end
|
|
table.insert(rows, ui.separator({ spacing = 6 }))
|
|
table.insert(rows, ui.row({ gap = 8, align = "center" }, {
|
|
ui.button({ glyph = "restore", text = tr("reset_colors"), onClick = resetAll }),
|
|
}))
|
|
return ui.scroll({ flexGrow = 1, gap = 4 }, rows)
|
|
end
|
|
|
|
local function renderHeader()
|
|
local refreshClick = function() refresh() end
|
|
local editMode = function()
|
|
view = view == "edit" and "bindings" or "edit"
|
|
editingId = nil
|
|
editDraft = ""
|
|
render()
|
|
end
|
|
local appearance = function()
|
|
view = view == "appearance" and "bindings" or "appearance"
|
|
editingId = nil
|
|
editDraft = ""
|
|
render()
|
|
end
|
|
local title = tr("title")
|
|
if currentCompositor ~= "" then
|
|
title = (currentCompositor == "mango" and "Mango" or (currentCompositor == "niri" and "Niri" or "Hyprland")) .. " Keymap"
|
|
end
|
|
local children = {
|
|
ui.row({ minWidth = 230, gap = 7, align = "center", flexGrow = 1 }, {
|
|
ui.glyph({ name = "keyboard", size = 16, color = "primary" }),
|
|
ui.label({ text = title, fontSize = 15, fontWeight = "bold", color = "on_surface" }),
|
|
}),
|
|
}
|
|
if (view == "bindings" or view == "edit") and not loading and panelError == nil then
|
|
local searchChanged = function(value)
|
|
query = value
|
|
editingId = nil
|
|
render()
|
|
end
|
|
local clearSearch = function()
|
|
query = ""
|
|
searchRevision += 1
|
|
render()
|
|
end
|
|
table.insert(children, ui.input({ key = "search-" .. searchRevision, value = query, placeholder = tr("search_placeholder"), controlSize = "sm", width = 300, onChange = searchChanged }))
|
|
table.insert(children, ui.button({ glyph = "x", width = 26, variant = "ghost", controlSize = "sm", enabled = query ~= "", tooltip = tr("clear"), onClick = clearSearch }))
|
|
end
|
|
if not loading or #bindings > 0 then
|
|
table.insert(children, ui.label({ text = tr("binding_count", { count = #bindings }), color = "on_surface_variant", fontSize = 12 }))
|
|
end
|
|
if view == "edit" then
|
|
local count = hiddenCount()
|
|
local restoreHidden = function()
|
|
restoreHiddenBindings(bindings, preferences.hidden)
|
|
savePreferences()
|
|
render()
|
|
end
|
|
table.insert(children, ui.label({ text = tr("hidden_count", { count = count }), color = "on_surface_variant", fontSize = 12 }))
|
|
table.insert(children, ui.button({ glyph = "eye", variant = "ghost", controlSize = "sm", enabled = count > 0, tooltip = tr("restore_hidden"), onClick = restoreHidden }))
|
|
end
|
|
table.insert(children, ui.button({ glyph = "refresh", variant = "ghost", tooltip = tr("refresh"), enabled = not refreshing, onClick = refreshClick }))
|
|
table.insert(children, ui.button({ glyph = view == "edit" and "check" or "pencil", variant = "ghost", tooltip = view == "edit" and tr("finish_editing") or tr("edit_bindings"), selected = view == "edit", onClick = editMode }))
|
|
table.insert(children, ui.button({ glyph = view == "appearance" and "check" or "palette", variant = "ghost", tooltip = view == "appearance" and tr("back") or tr("appearance"), selected = view == "appearance", onClick = appearance }))
|
|
return ui.row({ align = "center", justify = "space_between", gap = 7 }, children)
|
|
end
|
|
|
|
render = function()
|
|
if not panelOpen then return end
|
|
local contentState = "bindings"
|
|
if view == "appearance" then
|
|
contentState = "appearance"
|
|
elseif loading then
|
|
contentState = "loading"
|
|
elseif panelError ~= nil then
|
|
contentState = "error"
|
|
elseif view == "edit" then
|
|
contentState = "edit"
|
|
end
|
|
local children = { renderHeader() }
|
|
if view == "appearance" then
|
|
table.insert(children, appearanceBody())
|
|
elseif loading then
|
|
table.insert(children, ui.column({ flexGrow = 1, align = "center", justify = "center", gap = 10 }, {
|
|
ui.glyph({ name = "loader", size = 32, color = "primary" }),
|
|
ui.label({ text = tr("loading"), color = "on_surface_variant" }),
|
|
}))
|
|
elseif panelError ~= nil then
|
|
table.insert(children, ui.column({ flexGrow = 1, align = "center", justify = "center", gap = 10 }, {
|
|
ui.glyph({ name = "alert-triangle", size = 32, color = "error" }),
|
|
ui.label({ text = panelError, color = "error", textAlign = "center", maxWidth = 640 }),
|
|
}))
|
|
else
|
|
if refreshError ~= nil then
|
|
table.insert(children, ui.label({ text = refreshError, color = "error", fontSize = 11, maxLines = 2 }))
|
|
end
|
|
if #parseWarnings > 0 then
|
|
table.insert(children, ui.label({ text = tr("source_warning") .. " " .. table.concat(parseWarnings, " | "), color = "error", fontSize = 11, maxLines = 2 }))
|
|
end
|
|
table.insert(children, bindingsBody())
|
|
end
|
|
panel.render(ui.column({ key = "keybind-cheatsheet-" .. contentState, flexGrow = 1, gap = 8, align = "stretch" }, children))
|
|
end
|
|
|
|
noctalia.state.watch(SNAPSHOT_KEY, function(value)
|
|
applySnapshot(value)
|
|
if panelOpen then render() end
|
|
end)
|
|
|
|
local function releasePanelState(clearModel)
|
|
panelOpen = false
|
|
view = "bindings"
|
|
query = ""
|
|
editingId = nil
|
|
editDraft = ""
|
|
if clearModel then
|
|
bindings = {}
|
|
parseWarnings = {}
|
|
currentCompositor = ""
|
|
loading = false
|
|
refreshing = false
|
|
panelError = nil
|
|
refreshError = nil
|
|
snapshot = nil
|
|
end
|
|
end
|
|
|
|
function onOpen(_context)
|
|
panelOpen = true
|
|
view = "bindings"
|
|
query = ""
|
|
searchRevision += 1
|
|
editingId = nil
|
|
editDraft = ""
|
|
loadPreferences()
|
|
applySnapshot(noctalia.state.get(SNAPSHOT_KEY))
|
|
render()
|
|
end
|
|
|
|
function onClose()
|
|
releasePanelState(false)
|
|
end
|
|
|
|
function onExit(_signal)
|
|
releasePanelState(true)
|
|
end
|
|
|
|
function onConfigChanged()
|
|
if panelOpen then render() end
|
|
end
|
|
|
|
function onIpc(event, _payload)
|
|
if event == "toggle" then
|
|
if panelOpen then panel.close() else noctalia.togglePanel(PANEL_ID) end
|
|
elseif event == "refresh" then
|
|
refresh()
|
|
elseif event == "self-test" then
|
|
local current = tonumber(noctalia.state.get(SELF_TEST_REQUEST_KEY)) or 0
|
|
noctalia.state.set(SELF_TEST_REQUEST_KEY, current + 1)
|
|
end
|
|
end
|
|
|
|
local function lifecycleState()
|
|
return {
|
|
panelOpen = panelOpen,
|
|
loading = loading,
|
|
refreshing = refreshing,
|
|
bindingCount = #bindings,
|
|
snapshotLoaded = snapshot ~= nil,
|
|
editDraft = editDraft,
|
|
}
|
|
end
|
|
|
|
return {
|
|
bindingAvailableInView = bindingAvailableInView,
|
|
bindingContentOpacity = bindingContentOpacity,
|
|
hiddenBindingCount = hiddenBindingCount,
|
|
restoreHiddenBindings = restoreHiddenBindings,
|
|
categoryPriority = categoryPriority,
|
|
friendlyAction = friendlyAction,
|
|
applySnapshot = applySnapshot,
|
|
onOpen = onOpen,
|
|
onClose = onClose,
|
|
onExit = onExit,
|
|
onIpc = onIpc,
|
|
lifecycleState = lifecycleState,
|
|
}
|