feat: add keybind cheatsheet plugin (#64)

* feat: add keybind cheatsheet

* docs: refresh keybind cheatsheet preview

* docs: use official generated keybind thumbnail

* feat(keybind-cheatsheet): preload cached snapshots

Move parser and cache ownership into an event-driven data service so the panel opens from a prepared last-known-good snapshot. Document the plugin's inspiration and independent v5 implementation.
This commit is contained in:
cheerfulScumbag
2026-07-21 19:50:47 -04:00
committed by GitHub
parent 17f7b819d3
commit 2a1efe0468
19 changed files with 2985 additions and 0 deletions
+170
View File
@@ -0,0 +1,170 @@
# Keybind Cheatsheet
Keybind Cheatsheet opens a searchable Noctalia panel containing the active
Mango, Hyprland, or Niri shortcuts. It follows split configuration files,
formats common hardware keys, and keeps custom descriptions and hidden rows.
![Keybind Cheatsheet panel](thumbnail.webp)
## Acknowledgements
Keybind Cheatsheet was inspired by the original
[Keybind Cheatsheet for Noctalia v4](https://github.com/4rmcyt/noctalia-plugins/tree/main/keybind-cheatsheet)
created by [blackbartblues](https://github.com/blackbartblues).
This Noctalia v5 plugin is an independent implementation rather than a direct
port. It has its own user interface, service and cache lifecycle, persistence
model, tests, and integration with the current Noctalia plugin API.
## Plugin
| Field | Value |
| --- | --- |
| ID | `kenn/keybind-cheatsheet` |
| Entries | Data service: `data`; bar widget: `keybinds`; panel: `cheatsheet` |
`kenn` is the plugin's fixed publisher namespace, not your local Linux
username. Copy the plugin IDs in the commands below unchanged. User-specific
configuration paths use the portable `~/.config/...` form.
## Requirements
Install `hyprctl` on `PATH` when using a Hyprland Lua configuration. Mango,
Niri, and classic Hyprland configurations do not spawn external commands.
No clipboard command is required. Color paste uses Noctalia's native clipboard
API.
## Usage
Enable the plugin, then add the `keybinds` widget from Noctalia's bar widget
picker. Clicking its keyboard glyph toggles the cheatsheet.
Open or close the panel without a bar widget:
```sh
noctalia msg panel-toggle kenn/keybind-cheatsheet:cheatsheet
```
Bind that command in the active compositor.
Mango:
```ini
bind=SUPER,F1,spawn,noctalia msg panel-toggle kenn/keybind-cheatsheet:cheatsheet
```
Hyprland classic configuration:
```ini
bind = SUPER, F1, exec, noctalia msg panel-toggle kenn/keybind-cheatsheet:cheatsheet
```
Niri:
```kdl
Mod+F1 { spawn "noctalia" "msg" "panel-toggle" "kenn/keybind-cheatsheet:cheatsheet"; }
```
Type in the search field to filter by key, description, action, or category.
Use the header pencil to enter edit mode. Edit mode keeps hidden bindings
visible with muted content while descriptions and visibility are changed with
the row's pencil and eye buttons; leaving edit mode applies those visibility
choices to the main keymap. The palette button opens key-color controls with
native color picking, clipboard paste, and reset actions.
## Supported configuration
| Compositor | Default | Parsing |
| --- | --- | --- |
| Mango | `~/.config/mango/config.conf` | `bind`, `axisbind`, `mousebind`, `gesturebind`, `switchbind`, `source`, and `source-optional` |
| Hyprland classic | `~/.config/hypr/hyprland.conf` | `bind*` directives, variables, and recursive `source` paths |
| Hyprland Lua | `~/.config/hypr/hyprland.lua` | Live `hyprctl binds -j` data plus category and description scanning through `require()` files |
| Niri | `~/.config/niri/config.kdl` | KDL `binds` blocks, action categorization, and recursive `include` paths |
Includes support `*`, `?`, and bracket glob components. Traversal is limited to
32 levels and 256 files, and repeated paths are visited once to stop cycles.
Category comments use the same forms as the earlier Noctalia plugin:
```ini
# Applications
bind=SUPER,T,spawn,foot #"Terminal"
```
```ini
# 1. Applications
bind = SUPER, T, exec, foot #"Terminal"
```
```kdl
// #"Applications"
Mod+T hotkey-overlay-title="Terminal" { spawn "foot"; }
```
Hyprland Lua category scanning recognizes `-- 1. Applications` headings and
literal `description` or `desc` fields. Concatenated descriptions such as
`"Workspace " .. i` are treated as prefixes.
## Settings
| Setting | Type | Default | Description |
| --- | --- | --- | --- |
| `compositor` | `select` | `auto` | Detect Mango, Hyprland, or Niri, or force one parser. |
| `mango_config` | `file` | `~/.config/mango/config.conf` | Main Mango configuration. |
| `hyprland_config` | `file` | `~/.config/hypr/hyprland.conf` | Main classic Hyprland configuration. |
| `hyprland_lua_config` | `file` | `~/.config/hypr/hyprland.lua` | Lua file scanned for categories. |
| `hyprland_parser` | `select` | `auto` | Select live Lua or classic parsing. |
| `niri_config` | `file` | `~/.config/niri/config.kdl` | Main Niri configuration. |
| `columns` | `int` | `3` | Maximum balanced columns, from 1 to 4. |
| `show_undescribed` | `bool` | `true` | Show bindings that have no description. |
| `show_actions` | `bool` | `false` | Show the compositor action under descriptions. |
| `glyph` | `glyph` | `keyboard` | Bar widget icon. |
Noctalia v5 owns panel dimensions and does not expose runtime auto-height.
This plugin uses a wide 1500 x 760 panel, scrolling, and a responsive cap on
the requested column count.
## IPC
Refresh the snapshot after editing compositor configuration:
```sh
noctalia msg plugin kenn/keybind-cheatsheet:data all refresh
```
The data service refreshes even when the panel is closed. Opening and reopening
the panel only renders the prepared snapshot. The bar entry also accepts
`toggle` and `refresh` when a widget instance exists:
```sh
noctalia msg plugin kenn/keybind-cheatsheet:keybinds focused toggle
noctalia msg plugin kenn/keybind-cheatsheet:keybinds focused refresh
```
For parser development, run the fixture suite inside Noctalia:
```sh
noctalia msg plugin kenn/keybind-cheatsheet:data all self-test
```
The report is logged and written to the plugin's persistent data directory as
`selftest.json`.
## Notes
One event-driven data service loads the durable binding cache and parses the
selected compositor configuration once when Noctalia loads the plugin. It then
remains idle until settings change or a refresh is explicitly requested. It
uses no update interval, filesystem watcher, polling loop, network request, or
persistent subprocess. Hyprland Lua mode runs the fixed command
`hyprctl binds -j` asynchronously.
The last successful parsed snapshot is stored as `bindings-cache.json`. The
panel reads the shared in-memory snapshot and performs no configuration I/O in
`onOpen()`. A failed refresh keeps the previous bindings visible and reports
the error without replacing the durable cache.
Custom descriptions, hidden binding identities, and color overrides are stored
in Noctalia's per-plugin state directory as `preferences.json`. The file
contains no command output or configuration contents.
+892
View File
@@ -0,0 +1,892 @@
--!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 env = getfenv()
local dynamicCallbacks = {}
local callbackIndex = 0
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 function resetCallbacks()
for _, name in ipairs(dynamicCallbacks) do
env[name] = nil
end
dynamicCallbacks = {}
callbackIndex = 0
end
local function callback(prefix, fn)
callbackIndex += 1
local name = "keybindCheatsheet_" .. prefix .. "_" .. callbackIndex
env[name] = fn
table.insert(dynamicCallbacks, name)
return name
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 = callback("visibility", function()
if hidden then
preferences.hidden[binding.id] = nil
else
preferences.hidden[binding.id] = true
end
savePreferences()
render()
end)
local inputChanged = callback("editChange", function(value) editDraft = value end)
local submit = callback("editSubmit", function(value) saveCustomDescription(binding, value) end)
local save = callback("editSave", function() saveCustomDescription(binding, editDraft) end)
local cancel = callback("editCancel", 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 = callback("edit", function()
editingId = identity
editDraft = description
render()
end)
local visibilityChanged = callback("visibility", 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 = callback("chooseColor", function() chooseColor(bucket.id, property) end)
local paste = callback("pasteColor", function() pasteColor(bucket.id, property) end)
local reset = callback("resetColor", 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 = callback("resetAllColors", 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 = callback("refresh", function() refresh() end)
local editMode = callback("editMode", function()
view = view == "edit" and "bindings" or "edit"
editingId = nil
editDraft = ""
render()
end)
local appearance = callback("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 = callback("search", function(value)
query = value
editingId = nil
render()
end)
local clearSearch = callback("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 = callback("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
resetCallbacks()
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 = ""
resetCallbacks()
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,
callbackCount = #dynamicCallbacks,
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,
}
+111
View File
@@ -0,0 +1,111 @@
id = "kenn/keybind-cheatsheet"
name = "Keybind Cheatsheet"
version = "0.2.0"
plugin_api = 4
author = "kenn"
license = "MIT"
dependencies = ["hyprctl"]
tags = ["bar", "panel", "utility", "system", "hyprland", "mangowc", "niri"]
icon = "keyboard"
description = "Searchable keybindings for Mango, Hyprland, and Niri."
[[service]]
id = "data"
entry = "service.luau"
[[setting]]
key = "compositor"
type = "select"
label_key = "settings.compositor.label"
description_key = "settings.compositor.description"
default = "auto"
options = [
{ value = "auto", label_key = "settings.compositor.options.auto" },
{ value = "mango", label_key = "settings.compositor.options.mango" },
{ value = "hyprland", label_key = "settings.compositor.options.hyprland" },
{ value = "niri", label_key = "settings.compositor.options.niri" },
]
[[setting]]
key = "mango_config"
type = "file"
label_key = "settings.mango_config.label"
description_key = "settings.mango_config.description"
default = "~/.config/mango/config.conf"
[[setting]]
key = "hyprland_config"
type = "file"
label_key = "settings.hyprland_config.label"
description_key = "settings.hyprland_config.description"
default = "~/.config/hypr/hyprland.conf"
[[setting]]
key = "hyprland_lua_config"
type = "file"
label_key = "settings.hyprland_lua_config.label"
description_key = "settings.hyprland_lua_config.description"
default = "~/.config/hypr/hyprland.lua"
[[setting]]
key = "hyprland_parser"
type = "select"
label_key = "settings.hyprland_parser.label"
description_key = "settings.hyprland_parser.description"
default = "auto"
options = [
{ value = "auto", label_key = "settings.hyprland_parser.options.auto" },
{ value = "lua", label_key = "settings.hyprland_parser.options.lua" },
{ value = "conf", label_key = "settings.hyprland_parser.options.conf" },
]
[[setting]]
key = "niri_config"
type = "file"
label_key = "settings.niri_config.label"
description_key = "settings.niri_config.description"
default = "~/.config/niri/config.kdl"
[[setting]]
key = "columns"
type = "int"
label_key = "settings.columns.label"
description_key = "settings.columns.description"
default = 3
min = 1
max = 4
step = 1
[[setting]]
key = "show_undescribed"
type = "bool"
label_key = "settings.show_undescribed.label"
description_key = "settings.show_undescribed.description"
default = true
[[setting]]
key = "show_actions"
type = "bool"
label_key = "settings.show_actions.label"
description_key = "settings.show_actions.description"
default = false
[[panel]]
id = "cheatsheet"
entry = "panel.luau"
width = 1500
height = 760
placement = "floating"
position = "center"
open_near_click = true
[[widget]]
id = "keybinds"
entry = "widget.luau"
[[widget.setting]]
key = "glyph"
type = "glyph"
label_key = "settings.glyph.label"
description_key = "settings.glyph.description"
default = "keyboard"
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
{
"mango": {
"count": 9,
"descriptions": ["Terminal", "Close window", "Workspace 1", "Move window", "Focus left", "Lock on close"]
},
"hypr_conf": {
"count": 5,
"descriptions": ["Terminal", "Move window", "Close window", "Workspace 1"]
},
"hypr_lua": {
"count": 4,
"categories": ["Applications", "Workspaces", "Media", "Without Description"]
},
"niri": {
"count": 5,
"descriptions": ["Terminal", "Close window", "Workspace 1"]
}
}
+6
View File
@@ -0,0 +1,6 @@
[
{"locked":false,"mouse":false,"release":false,"repeat":false,"has_description":true,"modmask":64,"submap":"","key":"T","keycode":0,"description":"Terminal","dispatcher":"exec","arg":"foot"},
{"locked":false,"mouse":false,"release":false,"repeat":false,"has_description":true,"modmask":64,"submap":"","key":"1","keycode":0,"description":"Workspace 1","dispatcher":"workspace","arg":"1"},
{"locked":false,"mouse":false,"release":false,"repeat":false,"has_description":true,"modmask":0,"submap":"","key":"XF86AudioMute","keycode":0,"description":"Mute","dispatcher":"exec","arg":"wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle"},
{"locked":false,"mouse":false,"release":false,"repeat":false,"has_description":false,"modmask":64,"submap":"","key":"Q","keycode":0,"description":"","dispatcher":"killactive","arg":""}
]
+9
View File
@@ -0,0 +1,9 @@
$mainMod = SUPER
source = ./parts/*.conf
# 1. Applications
bind = $mainMod, T, exec, foot #"Terminal"
bindm = $mainMod, mouse:272, movewindow #"Move window"
# 2. Window Management
bind = $mainMod SHIFT, Q, killactive, #"Close window"
+9
View File
@@ -0,0 +1,9 @@
-- 1. Applications
Hyprland.config.bind("SUPER, T", launch_terminal, { description = "Terminal" })
-- 2. Workspaces
for i = 1, 9 do
Hyprland.config.bind("SUPER, " .. i, workspace, { description = "Workspace " .. i })
end
require("parts.media")
@@ -0,0 +1,2 @@
-- 3. Media
Hyprland.config.bind(", XF86AudioMute", mute, { desc = "Mute" })
@@ -0,0 +1,3 @@
# 3. Workspaces
binde = $mainMod, 1, workspace, 1 #"Workspace 1"
bind = , XF86AudioMute, exec, wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle
+14
View File
@@ -0,0 +1,14 @@
# Applications
bind=SUPER,T,spawn,foot #"Terminal"
bind=SUPER,B,spawn,firefox #"Browser"
source=./nested.conf
source-optional=./missing.conf
source=./parts/*.conf
# Media
bind=NONE,XF86AudioRaiseVolume,spawn,wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%+
axisbind=SUPER,UP,viewtoleft_have_client
mousebind=SUPER,btn_left,moveresize,curmove #"Move window"
gesturebind=NONE,left,3,focusdir,left #"Focus left"
switchbind=fold,spawn,swaylock #"Lock on close"
+3
View File
@@ -0,0 +1,3 @@
# Window Management
bind=SUPER,Q,killclient #"Close window"
source=./main.conf
@@ -0,0 +1,2 @@
# Workspaces
bind=SUPER,1,view,1 #"Workspace 1"
+12
View File
@@ -0,0 +1,12 @@
include "./parts/*.kdl"
binds {
// #"Applications"
Mod+T hotkey-overlay-title="Terminal" { spawn "foot"; }
Mod+B { spawn "firefox"; }
// #"Window Management"
Mod+Q hotkey-overlay-title="Close window" {
close-window;
}
}
@@ -0,0 +1,5 @@
binds {
// #"Workspaces"
Mod+1 hotkey-overlay-title="Workspace 1" { focus-workspace 1; }
Mod+WheelScrollDown cooldown-ms=150 { focus-workspace-down; }
}
+220
View File
@@ -0,0 +1,220 @@
--!nonstrict
-- Minimal Noctalia host used by the standalone Luau parser tests.
local files = {}
local stateValues = {}
local stateWatchers = {}
local fileExistsOverrides = {}
local configValues = {}
local availableCommands = {}
local asyncRequests = {}
local renderCount = 0
local renderedTree = nil
local readCounts = {}
local writeCounts = {}
local wantsSecondTicks = false
local encodedValues = {}
local encodedIndex = 0
local errorNotifications = 0
local updateInterval = nil
local function copy(value)
if type(value) ~= "table" then return value end
local result = {}
for key, item in pairs(value) do result[copy(key)] = copy(item) end
return result
end
local function normalize(path)
local absolute = path:sub(1, 1) == "/"
local parts = {}
for part in path:gmatch("[^/]+") do
if part == ".." then
if #parts > 0 then table.remove(parts) end
elseif part ~= "." and part ~= "" then
table.insert(parts, part)
end
end
return (absolute and "/" or "") .. table.concat(parts, "/")
end
local function directory(path)
local prefix = path:gsub("/+$", "") .. "/"
local names = {}
for file in pairs(files) do
if file:sub(1, #prefix) == prefix then
local rest = file:sub(#prefix + 1)
local name = rest:match("^([^/]+)")
if name ~= nil then names[name] = true end
end
end
local result = {}
for name in pairs(names) do table.insert(result, name) end
table.sort(result)
return result
end
local function translation(key, substitutions)
local value = key
for name, replacement in pairs(substitutions or {}) do
value = value:gsub("{" .. name .. "}", tostring(replacement))
end
return value
end
local noctalia = {
tr = translation,
getenv = function(name) return name == "HOME" and "/home/test" or nil end,
expandPath = function(path) return normalize(path:gsub("^~", "/home/test")) end,
fileExists = function(path)
path = normalize(path)
if fileExistsOverrides[path] ~= nil then return fileExistsOverrides[path] end
if files[path] ~= nil then return true end
return #directory(path) > 0
end,
fileInfo = function(path)
path = normalize(path)
if files[path] ~= nil then return { size = #files[path], mtime = 0, isDir = false } end
if #directory(path) > 0 then return { size = 0, mtime = 0, isDir = true } end
return nil, "missing"
end,
listDir = function(path)
local values = directory(normalize(path))
if #values > 0 then return values, nil end
return nil, "missing"
end,
readFile = function(path)
path = normalize(path)
readCounts[path] = (readCounts[path] or 0) + 1
local content = files[path]
if content ~= nil then return content, nil end
return nil, "missing"
end,
writeFile = function(path, content)
path = normalize(path)
files[path] = content
writeCounts[path] = (writeCounts[path] or 0) + 1
return true
end,
renameFile = function(from, to)
from = normalize(from)
to = normalize(to)
if files[from] == nil then return false, "missing" end
files[to] = files[from]
files[from] = nil
return true
end,
removeFile = function(path)
files[normalize(path)] = nil
return true
end,
pluginDir = function() return "/plugin" end,
pluginDataDir = function() return "/data" end,
commandExists = function(name) return availableCommands[name] == true end,
getConfig = function(key) return configValues[key] end,
outputs = function() return {} end,
focusedOutputName = function() return nil end,
clipboardText = function() return nil end,
openColorPicker = function() return false end,
notify = function() end,
notifyError = function() errorNotifications += 1 end,
log = function() end,
togglePanel = function() end,
setUpdateInterval = function(value) updateInterval = value end,
runAsync = function(command, callback, timeoutMs)
table.insert(asyncRequests, { command = command, callback = callback, timeoutMs = timeoutMs, completed = false })
return true
end,
string = { trim = function(value) return (value:gsub("^%s+", ""):gsub("%s+$", "")) end },
json = {
decode = function(raw)
local value = encodedValues[raw]
if value == nil then return nil, "invalid mock JSON" end
return copy(value), nil
end,
encode = function(value)
encodedIndex += 1
local token = "__mock_json_" .. tostring(encodedIndex)
encodedValues[token] = copy(value)
return token, nil
end,
},
state = {
get = function(key) return copy(stateValues[key]) end,
set = function(key, value)
stateValues[key] = copy(value)
for _, watcher in ipairs(stateWatchers[key] or {}) do watcher(copy(value)) end
end,
watch = function(key, callback)
stateWatchers[key] = stateWatchers[key] or {}
table.insert(stateWatchers[key], callback)
end,
},
}
local function node(kind)
return function(props, children) return { type = kind, props = props or {}, children = children or {} } end
end
local ui = {}
for _, kind in ipairs({ "column", "row", "box", "label", "glyph", "image", "separator", "spacer", "progress", "button", "graph", "input", "select", "slider", "toggle", "scroll" }) do
ui[kind] = node(kind)
end
return {
noctalia = noctalia,
ui = ui,
panel = {
render = function(_tree)
renderCount += 1
renderedTree = _tree
end,
close = function() end,
setWantsSecondTicks = function(value)
wantsSecondTicks = value == true
end,
},
setFiles = function(value)
files = {}
fileExistsOverrides = {}
for path, content in pairs(value) do files[normalize(path)] = content end
end,
setFileExists = function(path, value)
fileExistsOverrides[normalize(path)] = value
end,
resetRuntime = function()
stateValues = {}
configValues = {}
availableCommands = {}
asyncRequests = {}
renderCount = 0
renderedTree = nil
readCounts = {}
writeCounts = {}
wantsSecondTicks = false
errorNotifications = 0
updateInterval = nil
end,
setFile = function(path, content)
files[normalize(path)] = content
end,
setConfig = function(values) configValues = values or {} end,
setAvailableCommands = function(values) availableCommands = values or {} end,
asyncLaunchCount = function() return #asyncRequests end,
completeAsync = function(index, result)
local request = asyncRequests[index]
if request == nil or request.completed then return false end
request.completed = true
if request.callback ~= nil then request.callback(result) end
return true
end,
renderCount = function() return renderCount end,
renderedTree = function() return renderedTree end,
readCount = function(path) return readCounts[normalize(path)] or 0 end,
writeCount = function(path) return writeCounts[normalize(path)] or 0 end,
fileContent = function(path) return files[normalize(path)] end,
wantsSecondTicks = function() return wantsSecondTicks end,
errorNotificationCount = function() return errorNotifications end,
stateValue = function(key) return copy(stateValues[key]) end,
updateInterval = function() return updateInterval end,
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

+88
View File
@@ -0,0 +1,88 @@
{
"title": "Keybind Cheatsheet",
"widget_tooltip": "Open keybind cheatsheet",
"loading": "Reading keybindings...",
"search_placeholder": "Search keys, descriptions, and actions",
"refresh": "Refresh",
"appearance": "Appearance",
"back": "Back",
"clear": "Clear search",
"edit_bindings": "Edit bindings",
"finish_editing": "Finish editing",
"edit_description": "Edit description",
"hide_binding": "Hide binding",
"show_binding": "Show binding",
"save": "Save",
"cancel": "Cancel",
"reset": "Reset",
"paste": "Paste color",
"choose_color": "Choose color",
"restore_hidden": "Restore hidden bindings",
"reset_colors": "Reset all colors",
"no_results": "No keybindings match this search.",
"without_description": "Without Description",
"other": "Other",
"source_warning": "Some configuration files could not be read.",
"unsupported": "No supported compositor was detected.",
"missing_config": "The configured keybind file could not be read.",
"hyprctl_missing": "hyprctl is required for Hyprland Lua keybindings.",
"hyprctl_failed": "Hyprland did not return its active keybindings.",
"customize_colors": "Key colors",
"background": "Background",
"text": "Text",
"hidden_count": "{count} hidden",
"binding_count": "{count} bindings",
"settings": {
"compositor": {
"label": "Compositor",
"description": "Detect the active compositor or force a parser.",
"options": {
"auto": "Automatic",
"mango": "Mango",
"hyprland": "Hyprland",
"niri": "Niri"
}
},
"mango_config": {
"label": "Mango config",
"description": "Main Mango configuration file. Source directives are followed."
},
"hyprland_config": {
"label": "Hyprland config",
"description": "Main classic Hyprland configuration file."
},
"hyprland_lua_config": {
"label": "Hyprland Lua config",
"description": "Lua configuration used to recover categories for live binds."
},
"hyprland_parser": {
"label": "Hyprland parser",
"description": "Use the live Lua bind list, classic config, or automatic detection.",
"options": {
"auto": "Automatic",
"lua": "Lua / hyprctl",
"conf": "Classic config"
}
},
"niri_config": {
"label": "Niri config",
"description": "Main Niri KDL configuration file. Include directives are followed."
},
"columns": {
"label": "Columns",
"description": "Maximum number of balanced columns in the panel."
},
"show_undescribed": {
"label": "Show undescribed bindings",
"description": "Put bindings without descriptions in a separate section."
},
"show_actions": {
"label": "Show actions",
"description": "Show compositor commands below binding descriptions."
},
"glyph": {
"label": "Glyph",
"description": "Icon shown in the bar."
}
}
}
+26
View File
@@ -0,0 +1,26 @@
--!nonstrict
-- Static, event-only bar entry. Parsing and cache ownership belong to the data
-- service; this widget only toggles the panel or requests a refresh.
local PANEL_ID = "kenn/keybind-cheatsheet:cheatsheet"
local REFRESH_REQUEST_KEY = "keybind-cheatsheet.refresh-request"
local function render()
barWidget.setGlyph(noctalia.getConfig("glyph") or "keyboard")
barWidget.setTooltip(noctalia.tr("widget_tooltip"))
end
function onClick()
noctalia.togglePanel(PANEL_ID)
end
function onIpc(event, _payload)
if event == "toggle" then
noctalia.togglePanel(PANEL_ID)
elseif event == "refresh" then
local generation = tonumber(noctalia.state.get(REFRESH_REQUEST_KEY)) or 0
noctalia.state.set(REFRESH_REQUEST_KEY, generation + 1)
end
end
render()