From 2a1efe0468c8108c7c0f3d93650fbb3641e2b96e Mon Sep 17 00:00:00 2001 From: cheerfulScumbag <164391367+cheerfulScumbag@users.noreply.github.com> Date: Wed, 22 Jul 2026 00:50:47 +0100 Subject: [PATCH] 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. --- keybind-cheatsheet/README.md | 170 ++ keybind-cheatsheet/panel.luau | 892 +++++++++++ keybind-cheatsheet/plugin.toml | 111 ++ keybind-cheatsheet/service.luau | 1395 +++++++++++++++++ keybind-cheatsheet/tests/expected.json | 18 + .../tests/fixtures/hypr/binds.json | 6 + .../tests/fixtures/hypr/hyprland.conf | 9 + .../tests/fixtures/hypr/hyprland.lua | 9 + .../tests/fixtures/hypr/parts/media.lua | 2 + .../tests/fixtures/hypr/parts/workspaces.conf | 3 + .../tests/fixtures/mango/main.conf | 14 + .../tests/fixtures/mango/nested.conf | 3 + .../fixtures/mango/parts/workspaces.conf | 2 + .../tests/fixtures/niri/config.kdl | 12 + .../tests/fixtures/niri/parts/workspaces.kdl | 5 + keybind-cheatsheet/tests/host_mock.luau | 220 +++ keybind-cheatsheet/thumbnail.webp | Bin 0 -> 39316 bytes keybind-cheatsheet/translations/en.json | 88 ++ keybind-cheatsheet/widget.luau | 26 + 19 files changed, 2985 insertions(+) create mode 100644 keybind-cheatsheet/README.md create mode 100644 keybind-cheatsheet/panel.luau create mode 100644 keybind-cheatsheet/plugin.toml create mode 100644 keybind-cheatsheet/service.luau create mode 100644 keybind-cheatsheet/tests/expected.json create mode 100644 keybind-cheatsheet/tests/fixtures/hypr/binds.json create mode 100644 keybind-cheatsheet/tests/fixtures/hypr/hyprland.conf create mode 100644 keybind-cheatsheet/tests/fixtures/hypr/hyprland.lua create mode 100644 keybind-cheatsheet/tests/fixtures/hypr/parts/media.lua create mode 100644 keybind-cheatsheet/tests/fixtures/hypr/parts/workspaces.conf create mode 100644 keybind-cheatsheet/tests/fixtures/mango/main.conf create mode 100644 keybind-cheatsheet/tests/fixtures/mango/nested.conf create mode 100644 keybind-cheatsheet/tests/fixtures/mango/parts/workspaces.conf create mode 100644 keybind-cheatsheet/tests/fixtures/niri/config.kdl create mode 100644 keybind-cheatsheet/tests/fixtures/niri/parts/workspaces.kdl create mode 100644 keybind-cheatsheet/tests/host_mock.luau create mode 100644 keybind-cheatsheet/thumbnail.webp create mode 100644 keybind-cheatsheet/translations/en.json create mode 100644 keybind-cheatsheet/widget.luau diff --git a/keybind-cheatsheet/README.md b/keybind-cheatsheet/README.md new file mode 100644 index 0000000..f9e4575 --- /dev/null +++ b/keybind-cheatsheet/README.md @@ -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. diff --git a/keybind-cheatsheet/panel.luau b/keybind-cheatsheet/panel.luau new file mode 100644 index 0000000..6a9440a --- /dev/null +++ b/keybind-cheatsheet/panel.luau @@ -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, +} diff --git a/keybind-cheatsheet/plugin.toml b/keybind-cheatsheet/plugin.toml new file mode 100644 index 0000000..9c4875e --- /dev/null +++ b/keybind-cheatsheet/plugin.toml @@ -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" diff --git a/keybind-cheatsheet/service.luau b/keybind-cheatsheet/service.luau new file mode 100644 index 0000000..a490342 --- /dev/null +++ b/keybind-cheatsheet/service.luau @@ -0,0 +1,1395 @@ +--!nonstrict +-- Cache-first keybind data service for Mango, Hyprland, and Niri. +-- +-- The active configuration is parsed once during script startup and only +-- again after a relevant settings change or explicit refresh request. There +-- is no update interval, filesystem watcher, polling loop, or persistent +-- subprocess. + +local noctalia = noctalia +if noctalia == nil then + local host = require("./tests/host_mock") + noctalia = host.noctalia +end + +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 MAX_PARSE_DEPTH = 32 +local MAX_PARSE_FILES = 256 +local BINDINGS_CACHE_FILE = "bindings-cache.json" +local CACHE_SCHEMA = 1 + +local refreshing = false +local refreshQueued = false +local refreshGeneration = 0 +local lastGood = nil + +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 configString(key, fallback) + local value = noctalia.getConfig(key) + if type(value) ~= "string" or trim(value) == "" then + return fallback + end + return trim(value) +end + +local function pathDirname(path) + local clean = (path or ""):gsub("/+$", "") + local parent = clean:match("^(.*)/[^/]*$") + if parent == nil or parent == "" then + return clean:sub(1, 1) == "/" and "/" or "." + end + return parent +end + +local function pathJoin(base, child) + if child == nil or child == "" then + return base + end + if child:sub(1, 1) == "/" then + return child + end + if base == "/" then + return "/" .. child + end + return (base:gsub("/+$", "")) .. "/" .. child +end + +local function normalizePath(path) + local absolute = path:sub(1, 1) == "/" + local parts = {} + for part in path:gmatch("[^/]+") do + if part == ".." then + if #parts > 0 and parts[#parts] ~= ".." then + table.remove(parts) + elseif not absolute then + table.insert(parts, part) + end + elseif part ~= "." and part ~= "" then + table.insert(parts, part) + end + end + local result = table.concat(parts, "/") + if absolute then + result = "/" .. result + end + if result == "" then + return absolute and "/" or "." + end + return result +end + +local function expandEnvironment(path) + local expanded = path:gsub("%${([%w_]+)}", function(name) + return noctalia.getenv(name) or "${" .. name .. "}" + end) + expanded = expanded:gsub("%$HOME", noctalia.getenv("HOME") or "~") + return noctalia.expandPath(expanded) +end + +local function unquote(value) + local result = trim(value) + if #result >= 2 then + local first = result:sub(1, 1) + local last = result:sub(-1) + if (first == "\"" and last == "\"") or (first == "'" and last == "'") then + result = result:sub(2, -2) + end + end + return result +end + +local function resolvePath(value, includingFile) + local path = expandEnvironment(unquote(value)) + if path:sub(1, 1) ~= "/" then + path = pathJoin(pathDirname(includingFile), path) + end + return normalizePath(path) +end + +local function hasGlob(path) + return path:find("*", 1, true) ~= nil + or path:find("?", 1, true) ~= nil + or path:find("[", 1, true) ~= nil +end + +local LUA_PATTERN_MAGIC = { + ["^"] = true, + ["$"] = true, + ["("] = true, + [")"] = true, + ["%"] = true, + ["."] = true, + ["+"] = true, + ["-"] = true, + ["]"] = true, +} + +local function globPattern(segment) + local result = { "^" } + local index = 1 + while index <= #segment do + local char = segment:sub(index, index) + if char == "*" then + table.insert(result, ".*") + elseif char == "?" then + table.insert(result, ".") + elseif char == "[" then + local closing = segment:find("]", index + 1, true) + if closing ~= nil then + table.insert(result, segment:sub(index, closing)) + index = closing + else + table.insert(result, "%[") + end + elseif LUA_PATTERN_MAGIC[char] then + table.insert(result, "%" .. char) + else + table.insert(result, char) + end + index += 1 + end + table.insert(result, "$") + return table.concat(result) +end + +local function expandGlob(path, context) + if not hasGlob(path) then + -- Read explicit paths directly. A separate existence check can race with + -- atomic Home Manager symlink replacement and suppress a readable file. + return { path } + end + + local candidates = { path:sub(1, 1) == "/" and "/" or "." } + for segment in path:gmatch("[^/]+") do + local nextCandidates = {} + if hasGlob(segment) then + local pattern = globPattern(segment) + for _, base in ipairs(candidates) do + local entries = noctalia.listDir(base) + if entries ~= nil then + table.sort(entries) + for _, name in ipairs(entries) do + if name:match(pattern) then + table.insert(nextCandidates, normalizePath(pathJoin(base, name))) + end + end + end + end + else + for _, base in ipairs(candidates) do + table.insert(nextCandidates, normalizePath(pathJoin(base, segment))) + end + end + candidates = nextCandidates + end + + local files = {} + for _, candidate in ipairs(candidates) do + local info = noctalia.fileInfo(candidate) + if info ~= nil and not info.isDir then + table.insert(files, candidate) + end + end + table.sort(files) + if context ~= nil and hasGlob(path) then + local snapshot = {} + for _, file in ipairs(files) do table.insert(snapshot, file) end + table.insert(context.globs, { pattern = path, files = snapshot }) + end + return files +end + +local function extractTrailingDescription(line) + local ending = line:sub(-1) + local startIndex, description + if ending == "\"" then + startIndex, _, description = line:find("#%s*\"([^\"]*)\"%s*$") + elseif ending == "'" then + startIndex, _, description = line:find("#%s*'([^']*)'%s*$") + end + if startIndex == nil then + return line, "" + end + return trim(line:sub(1, startIndex - 1)), trim(description) +end + +local function splitCsv(value, maximumParts) + if value:find("\"", 1, true) == nil and value:find("'", 1, true) == nil + and value:find("\\", 1, true) == nil then + local parts = {} + local startIndex = 1 + while maximumParts == nil or #parts < maximumParts - 1 do + local comma = value:find(",", startIndex, true) + if comma == nil then break end + table.insert(parts, trim(value:sub(startIndex, comma - 1))) + startIndex = comma + 1 + end + table.insert(parts, trim(value:sub(startIndex))) + return parts + end + + local parts = {} + local current = {} + local quote = nil + local escaped = false + local index = 1 + while index <= #value do + local char = value:sub(index, index) + if escaped then + table.insert(current, char) + escaped = false + elseif char == "\\" and quote ~= nil then + table.insert(current, char) + escaped = true + elseif quote ~= nil then + table.insert(current, char) + if char == quote then + quote = nil + end + elseif char == "\"" or char == "'" then + quote = char + table.insert(current, char) + elseif char == "," and (maximumParts == nil or #parts < maximumParts - 1) then + table.insert(parts, trim(table.concat(current))) + current = {} + else + table.insert(current, char) + end + index += 1 + end + table.insert(parts, trim(table.concat(current))) + return parts +end + +local MODIFIER_ORDER = { "SUPER", "CTRL", "SHIFT", "ALT", "MOD2", "MOD3", "MOD5" } +local KNOWN_MODIFIERS = { SUPER = true, CTRL = true, SHIFT = true, ALT = true, MOD2 = true, MOD3 = true, MOD5 = true } +local MODIFIER_ALIASES = { + LOGO = "SUPER", + WIN = "SUPER", + MOD4 = "SUPER", + CONTROL = "CTRL", + MOD1 = "ALT", +} + +local function expandHyprVariables(value, variables) + local previous = value + for _ = 1, 8 do + local expanded = previous:gsub("%$([%w_]+)", function(name) + return variables[name] or "$" .. name + end) + if expanded == previous then + break + end + previous = expanded + end + return previous +end + +local function normalizeModifiers(value, variables) + local raw = expandHyprVariables(value or "", variables or {}) + raw = raw:gsub("%+", " "):gsub("|", " "):gsub(",", " ") + local present = {} + local unknown = {} + for token in raw:gmatch("[^%s]+") do + local upper = string.upper(token) + upper = MODIFIER_ALIASES[upper] or upper + if upper ~= "NONE" and upper ~= "" then + if KNOWN_MODIFIERS[upper] then + present[upper] = true + elseif not present[upper] then + present[upper] = true + table.insert(unknown, upper) + end + end + end + local result = {} + for _, name in ipairs(MODIFIER_ORDER) do + if present[name] then + table.insert(result, name) + end + end + for _, name in ipairs(unknown) do + table.insert(result, name) + end + return result +end + +local function bindingIdentity(binding) + return table.concat({ + binding.compositor or "", + binding.bindingType or "", + table.concat(binding.modifiers or {}, "+"), + binding.key or "", + binding.action or "", + binding.submap or "", + }, "|") +end + +local function addBinding(target, binding) + binding.description = trim(binding.description) + binding.category = trim(binding.category) + binding.baseCategory = binding.category + binding.action = trim(binding.action) + binding.key = trim(binding.key) + binding.modifiers = binding.modifiers or {} + binding.id = bindingIdentity(binding) + table.insert(target, binding) +end + +local CATEGORY_SMALL_WORDS = { + ["and"] = true, + ["for"] = true, + of = true, + the = true, + to = true, +} + +local function mangoCategoryFromComment(line) + if not startsWith(line, "#") or startsWith(line, "#\"") or startsWith(line, "#'") then + return nil + end + local heading = trim(line:gsub("^#+%s*", "")) + if heading == "" or heading:match("^[%-%=_*]+$") ~= nil then return nil end + + local explicit = heading:match("^[Cc]ategory%s*:%s*(.+)$") + if explicit ~= nil then return trim(explicit) end + heading = trim(heading:gsub("^%d+%.%s*", "")) + + if #heading > 64 or lower(heading):match("^bind%s*=") ~= nil then return nil end + if heading:find("=", 1, true) ~= nil or heading:find(":", 1, true) ~= nil + or heading:find(" + ", 1, true) ~= nil then return nil end + + local wordCount = 0 + for token in heading:gmatch("%S+") do + wordCount += 1 + local word = token:gsub("^[^%a]+", ""):gsub("[^%a]+$", "") + local lowered = lower(word) + if word ~= "" and not CATEGORY_SMALL_WORDS[lowered] + and word:sub(1, 1) ~= string.upper(word:sub(1, 1)) then + return nil + end + end + if wordCount == 0 or wordCount > 7 then return nil end + return heading +end + +local function parseMangoContent(content, sourceFile, context) + local hasBindings = content:find("bind", 1, true) ~= nil + local hasSources = content:find("source", 1, true) ~= nil + if not hasBindings and not hasSources then return {} end + + local category = "" + local includes = {} + local lineNumber = 0 + for rawLine in (content .. "\n"):gmatch("(.-)\r?\n") do + lineNumber += 1 + local line = trim(rawLine) + if line ~= "" then + if startsWith(line, "source") then + local optionalPath = line:match("^source%-optional%s*=%s*(.-)%s*$") + local sourcePath = line:match("^source%s*=%s*(.-)%s*$") + if optionalPath ~= nil then + table.insert(includes, { path = optionalPath, optional = true }) + elseif sourcePath ~= nil then + table.insert(includes, { path = expandHyprVariables(sourcePath, context.variables), optional = false }) + end + elseif startsWith(line, "#") then + if hasBindings then + local heading = mangoCategoryFromComment(line) + if heading ~= nil then category = heading end + end + elseif hasBindings and line:find("bind", 1, true) ~= nil then + local clean, description = extractTrailingDescription(line) + local directive, body = clean:match("^([%a]*bind)%s*=%s*(.*)$") + if directive ~= nil then + directive = lower(directive) + local maximum = directive == "gesturebind" and 5 or (directive == "switchbind" and 3 or 4) + local fields = splitCsv(body, maximum) + local modifiers = {} + local key = "" + local command = "" + local parameters = "" + if directive == "switchbind" then + key = fields[1] or "" + command = fields[2] or "" + parameters = fields[3] or "" + elseif directive == "gesturebind" then + modifiers = normalizeModifiers(fields[1], {}) + key = (fields[3] or "") .. "-finger " .. (fields[2] or "") + command = fields[4] or "" + parameters = fields[5] or "" + else + modifiers = normalizeModifiers(fields[1], {}) + key = fields[2] or "" + command = fields[3] or "" + parameters = fields[4] or "" + end + if key ~= "" and command ~= "" then + addBinding(context.bindings, { + compositor = "mango", + bindingType = directive, + modifiers = modifiers, + key = key, + action = trim(command .. " " .. parameters), + description = description, + category = category, + sourceFile = sourceFile, + sourceLine = lineNumber, + }) + end + end + end + end + end + return includes +end + +local function parseHyprContent(content, sourceFile, context) + local category = "" + local includes = {} + local lineNumber = 0 + for rawLine in (content .. "\n"):gmatch("(.-)\r?\n") do + lineNumber += 1 + local line = trim(rawLine) + if line ~= "" then + local variable, value = line:match("^%$([%w_]+)%s*=%s*(.-)%s*$") + local sourcePath = line:match("^source%s*=%s*(.-)%s*$") + local numberedCategory = line:match("^#%s*%d+%.%s*(.-)%s*$") + if variable ~= nil then + context.variables[variable] = value + elseif sourcePath ~= nil then + table.insert(includes, { path = expandHyprVariables(sourcePath, context.variables), optional = false }) + elseif numberedCategory ~= nil and numberedCategory ~= "" then + category = numberedCategory + else + local clean, description = extractTrailingDescription(line) + local directive, body = clean:match("^(bind[%a]*)%s*=%s*(.*)$") + if directive ~= nil then + local fields = splitCsv(body, 4) + local key = fields[2] or "" + local dispatcher = fields[3] or "" + if key ~= "" and dispatcher ~= "" then + addBinding(context.bindings, { + compositor = "hyprland", + bindingType = lower(directive), + modifiers = normalizeModifiers(fields[1], context.variables), + key = key, + action = trim(dispatcher .. " " .. (fields[4] or "")), + description = description, + category = category, + sourceFile = sourceFile, + sourceLine = lineNumber, + }) + end + end + end + end + end + return includes +end + +local function niriTokens(content) + local tokens = {} + local index = 1 + local line = 1 + while index <= #content do + local char = content:sub(index, index) + local nextChar = content:sub(index + 1, index + 1) + if char == "\n" then + line += 1 + index += 1 + elseif char:match("%s") then + index += 1 + elseif char == "/" and nextChar == "/" then + local ending = content:find("\n", index + 2, true) or (#content + 1) + table.insert(tokens, { type = "comment", value = content:sub(index + 2, ending - 1), line = line }) + index = ending + elseif char == "/" and nextChar == "*" then + local ending = content:find("*/", index + 2, true) or (#content - 1) + local value = content:sub(index + 2, ending - 1) + table.insert(tokens, { type = "comment", value = value, line = line }) + local _, newlines = value:gsub("\n", "") + line += newlines + index = ending + 2 + elseif char == "\"" then + local startLine = line + local value = {} + index += 1 + local escaped = false + while index <= #content do + local stringChar = content:sub(index, index) + if escaped then + local replacements = { n = "\n", r = "\r", t = "\t" } + table.insert(value, replacements[stringChar] or stringChar) + escaped = false + elseif stringChar == "\\" then + escaped = true + elseif stringChar == "\"" then + index += 1 + break + else + if stringChar == "\n" then + line += 1 + end + table.insert(value, stringChar) + end + index += 1 + end + table.insert(tokens, { type = "string", value = table.concat(value), line = startLine }) + elseif char == "{" or char == "}" or char == ";" then + table.insert(tokens, { type = char, value = char, line = line }) + index += 1 + else + local start = index + while index <= #content do + char = content:sub(index, index) + nextChar = content:sub(index + 1, index + 1) + if char:match("%s") or char == "\"" or char == "{" or char == "}" or char == ";" + or (char == "/" and (nextChar == "/" or nextChar == "*")) then + break + end + index += 1 + end + table.insert(tokens, { type = "word", value = content:sub(start, index - 1), line = line }) + end + end + return tokens +end + +local function niriActionText(tokens) + local values = {} + for _, token in ipairs(tokens) do + if token.type == "string" then + table.insert(values, token.value) + elseif token.type == "word" then + table.insert(values, token.value) + elseif token.type == ";" then + if #values > 0 then + values[#values] = values[#values] .. ";" + end + end + end + return trim(table.concat(values, " ")) +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 niriHeaderDescription(header) + for index, token in ipairs(header) do + if token.type == "word" then + if token.value == "hotkey-overlay-title=" and header[index + 1] ~= nil and header[index + 1].type == "string" then + return header[index + 1].value + end + local quoted = token.value:match('^hotkey%-overlay%-title="(.*)"$') + if quoted ~= nil then + return quoted + end + end + end + return "" +end + +local function parseNiriContent(content, sourceFile, context) + local includes = {} + for rawLine in (content .. "\n"):gmatch("(.-)\r?\n") do + local includePath = rawLine:match('^%s*include%s+"([^"]+)"') + if includePath ~= nil then + table.insert(includes, { path = includePath, optional = false }) + end + end + + local tokens = niriTokens(content) + local index = 1 + while index <= #tokens do + if tokens[index].type == "word" and tokens[index].value == "binds" + and tokens[index + 1] ~= nil and tokens[index + 1].type == "{" then + index += 2 + local category = "" + while index <= #tokens and tokens[index].type ~= "}" do + local token = tokens[index] + if token.type == "comment" then + local heading = token.value:match('#%s*"([^"]+)"') or token.value:match("#%s*'([^']+)'") + if heading ~= nil then + category = trim(heading) + end + index += 1 + elseif token.type == "word" then + local hotkey = token.value + local sourceLine = token.line + local header = {} + index += 1 + while index <= #tokens and tokens[index].type ~= "{" and tokens[index].type ~= "}" do + table.insert(header, tokens[index]) + index += 1 + end + if index <= #tokens and tokens[index].type == "{" then + local depth = 1 + local actionTokens = {} + index += 1 + while index <= #tokens and depth > 0 do + if tokens[index].type == "{" then + depth += 1 + table.insert(actionTokens, tokens[index]) + elseif tokens[index].type == "}" then + depth -= 1 + if depth > 0 then + table.insert(actionTokens, tokens[index]) + end + else + table.insert(actionTokens, tokens[index]) + end + index += 1 + end + local action = niriActionText(actionTokens) + local keyParts = {} + for part in hotkey:gmatch("[^+]+") do + table.insert(keyParts, part) + end + local key = table.remove(keyParts) or hotkey + local description = niriHeaderDescription(header) + addBinding(context.bindings, { + compositor = "niri", + bindingType = "bind", + modifiers = normalizeModifiers(table.concat(keyParts, " "), {}), + key = key, + action = action, + description = description, + category = category ~= "" and category or niriCategoryFor(action), + sourceFile = sourceFile, + sourceLine = sourceLine, + }) + else + index += 1 + end + else + index += 1 + end + end + end + index += 1 + end + return includes +end + +local function walkConfig(rootPath, parser) + local context = { + bindings = {}, + variables = {}, + warnings = {}, + visited = {}, + fileCount = 0, + rootRead = false, + sources = {}, + globs = {}, + } + + local visit + visit = function(pattern, depth, optional, isRoot) + if depth > MAX_PARSE_DEPTH then + table.insert(context.warnings, "Include depth exceeded at " .. pattern) + return + end + if context.fileCount >= MAX_PARSE_FILES then + table.insert(context.warnings, "File limit reached at " .. pattern) + return + end + + local files = expandGlob(pattern, context) + if #files == 0 then + if not optional then + table.insert(context.warnings, "Could not read " .. pattern) + end + return + end + for _, file in ipairs(files) do + file = normalizePath(file) + if not context.visited[file] and context.fileCount < MAX_PARSE_FILES then + context.visited[file] = true + context.fileCount += 1 + local content, err = noctalia.readFile(file) + if content == nil then + if not optional then + table.insert(context.warnings, err or ("Could not read " .. file)) + end + else + local info = noctalia.fileInfo(file) + if info ~= nil then + table.insert(context.sources, { path = file, size = info.size, mtime = info.mtime }) + end + if isRoot then + context.rootRead = true + end + local includes = parser(content, file, context) or {} + for _, include in ipairs(includes) do + local resolved = resolvePath(include.path, file) + visit(resolved, depth + 1, include.optional == true, false) + end + end + end + end + end + + local expandedRoot = normalizePath(expandEnvironment(rootPath)) + visit(expandedRoot, 0, false, true) + return context +end + +local function readConfig(rootPath, parser) + local context = walkConfig(rootPath, parser) + if not context.rootRead then + context = walkConfig(rootPath, parser) + end + if not context.rootRead then + noctalia.log("Keybind cheatsheet could not read " .. expandEnvironment(rootPath) + .. ": " .. table.concat(context.warnings, "; ")) + end + return context +end + +local function scanHyprLuaContent(content, sourceFile, context) + local category = "" + local includes = {} + for rawLine in (content .. "\n"):gmatch("(.-)\r?\n") do + local heading = rawLine:match("^%s*%-%-%s*%d+%.%s*(.-)%s*$") + if heading ~= nil and heading ~= "" then + category = heading + end + + local code = rawLine:gsub("%-%-.*$", "") + local module = code:match('require%s*%(%s*"([^"]+)"') + or code:match("require%s*%(%s*'([^']+)'") + or code:match('require%s+"([^"]+)"') + or code:match("require%s+'([^']+)'") + if module ~= nil then + local modulePath = module:gsub("%.", "/") .. ".lua" + table.insert(includes, { path = pathJoin(context.luaRoot, modulePath), optional = false }) + end + + local description = code:match('description%s*=%s*"([^"]*)"') + or code:match("description%s*=%s*'([^']*)'") + or code:match('desc%s*=%s*"([^"]*)"') + or code:match("desc%s*=%s*'([^']*)'") + if description ~= nil and description ~= "" then + local kind = code:find("%.%.") ~= nil and "prefix" or "exact" + table.insert(context.rules, { kind = kind, value = description, category = category ~= "" and category or "Other" }) + end + end + return includes +end + +local function scanHyprLua(rootPath) + local context = { + bindings = {}, + variables = {}, + warnings = {}, + visited = {}, + fileCount = 0, + rootRead = false, + rules = {}, + luaRoot = pathDirname(normalizePath(expandEnvironment(rootPath))), + sources = {}, + globs = {}, + } + + local visit + visit = function(path, depth, isRoot) + if depth > MAX_PARSE_DEPTH or context.fileCount >= MAX_PARSE_FILES then + table.insert(context.warnings, "Lua include limit reached at " .. path) + return + end + path = normalizePath(path) + if context.visited[path] then + return + end + context.visited[path] = true + context.fileCount += 1 + local content, err = noctalia.readFile(path) + if content == nil then + table.insert(context.warnings, err or ("Could not read " .. path)) + return + end + local info = noctalia.fileInfo(path) + if info ~= nil then + table.insert(context.sources, { path = path, size = info.size, mtime = info.mtime }) + end + if isRoot then context.rootRead = true end + local includes = scanHyprLuaContent(content, path, context) + for _, include in ipairs(includes) do + visit(normalizePath(include.path), depth + 1, false) + end + end + + visit(normalizePath(expandEnvironment(rootPath)), 0, true) + return context +end + +local function categoryFromHyprRules(description, rules) + for _, rule in ipairs(rules) do + if rule.kind == "exact" and description == rule.value then + return rule.category + end + end + for _, rule in ipairs(rules) do + if rule.kind == "prefix" and startsWith(description, rule.value) then + return rule.category + end + end + return "Other" +end + +local function modifiersFromMask(mask) + local result = {} + local bits = { + { 1, "SHIFT" }, + { 4, "CTRL" }, + { 8, "ALT" }, + { 16, "MOD2" }, + { 32, "MOD3" }, + { 64, "SUPER" }, + { 128, "MOD5" }, + } + mask = tonumber(mask) or 0 + for _, entry in ipairs(bits) do + if math.floor(mask / entry[1]) % 2 == 1 then + table.insert(result, entry[2]) + end + end + return result +end + +local function parseHyprJson(raw, rules) + local decoded, err = noctalia.json.decode(raw) + if type(decoded) ~= "table" then + return nil, err or "Invalid hyprctl JSON" + end + local result = {} + for _, entry in ipairs(decoded) do + if type(entry) == "table" then + local description = entry.has_description == false and "" or (entry.description or "") + local key = entry.key or "" + if key == "" and tonumber(entry.keycode) ~= nil and tonumber(entry.keycode) ~= 0 then + key = "code:" .. tostring(entry.keycode) + end + local dispatcher = entry.dispatcher or "" + local argument = entry.arg or "" + if key ~= "" and dispatcher ~= "" then + addBinding(result, { + compositor = "hyprland", + bindingType = entry.mouse == true and "bindm" or "bind", + modifiers = modifiersFromMask(entry.modmask), + key = key, + action = trim(dispatcher .. " " .. argument), + description = description, + category = description ~= "" and categoryFromHyprRules(description, rules) or "", + sourceFile = "hyprctl binds -j", + sourceLine = 0, + submap = entry.submap or "", + }) + end + end + end + return result, nil +end + +local function detectCompositor() + local forced = configString("compositor", "auto") + if forced ~= "auto" then + return forced + end + if noctalia.getenv("MANGO_INSTANCE_SIGNATURE") ~= nil then return "mango" end + if noctalia.getenv("HYPRLAND_INSTANCE_SIGNATURE") ~= nil then return "hyprland" end + if noctalia.getenv("NIRI_SOCKET") ~= nil then return "niri" end + + local desktop = lower((noctalia.getenv("XDG_CURRENT_DESKTOP") or "") .. ":" .. (noctalia.getenv("XDG_SESSION_DESKTOP") or "")) + if desktop:find("mango", 1, true) ~= nil then return "mango" end + if desktop:find("hyprland", 1, true) ~= nil then return "hyprland" end + if desktop:find("niri", 1, true) ~= nil then return "niri" end + + if noctalia.fileExists(noctalia.expandPath("~/.config/mango/config.conf")) then return "mango" end + if noctalia.fileExists(noctalia.expandPath("~/.config/hypr/hyprland.lua")) + or noctalia.fileExists(noctalia.expandPath("~/.config/hypr/hyprland.conf")) then return "hyprland" end + if noctalia.fileExists(noctalia.expandPath("~/.config/niri/config.kdl")) then return "niri" end + return nil +end + +local function currentRequest() + local compositor = detectCompositor() + if compositor == nil then return nil end + if compositor == "mango" then + return { + compositor = compositor, + parser = "mango", + root = normalizePath(expandEnvironment(configString("mango_config", "~/.config/mango/config.conf"))), + } + end + if compositor == "niri" then + return { + compositor = compositor, + parser = "niri", + root = normalizePath(expandEnvironment(configString("niri_config", "~/.config/niri/config.kdl"))), + } + end + if compositor == "hyprland" then + local mode = configString("hyprland_parser", "auto") + local luaPath = configString("hyprland_lua_config", "~/.config/hypr/hyprland.lua") + local useLua = mode == "lua" or (mode == "auto" and noctalia.fileExists(noctalia.expandPath(luaPath))) + local root = useLua and luaPath or configString("hyprland_config", "~/.config/hypr/hyprland.conf") + return { + compositor = compositor, + parser = useLua and "hypr-lua" or "hypr-conf", + root = normalizePath(expandEnvironment(root)), + } + end + return nil +end + +local function requestsMatch(left, right) + return type(left) == "table" and type(right) == "table" + and left.compositor == right.compositor + and left.parser == right.parser + and left.root == right.root +end + +local function normalizedCache(decoded) + if type(decoded) ~= "table" or decoded.schema ~= CACHE_SCHEMA or type(decoded.request) ~= "table" + or type(decoded.bindings) ~= "table" or type(decoded.sources) ~= "table" + or type(decoded.globs) ~= "table" then + return nil + end + + local cachedBindings = {} + for _, item in ipairs(decoded.bindings) do + if type(item) ~= "table" or type(item.key) ~= "string" or type(item.action) ~= "string" + or type(item.modifiers) ~= "table" then + return nil + end + local modifiers = {} + for _, modifier in ipairs(item.modifiers) do + if type(modifier) ~= "string" then return nil end + table.insert(modifiers, modifier) + end + addBinding(cachedBindings, { + compositor = type(item.compositor) == "string" and item.compositor or decoded.request.compositor, + bindingType = type(item.bindingType) == "string" and item.bindingType or "bind", + modifiers = modifiers, + key = item.key, + action = item.action, + description = type(item.description) == "string" and item.description or "", + category = type(item.category) == "string" and item.category or "", + sourceFile = type(item.sourceFile) == "string" and item.sourceFile or "", + sourceLine = tonumber(item.sourceLine) or 0, + submap = type(item.submap) == "string" and item.submap or "", + }) + end + + local warnings = {} + for _, warning in ipairs(type(decoded.warnings) == "table" and decoded.warnings or {}) do + if type(warning) == "string" then table.insert(warnings, warning) end + end + return { + schema = CACHE_SCHEMA, + request = { + compositor = decoded.request.compositor, + parser = decoded.request.parser, + root = decoded.request.root, + }, + bindings = cachedBindings, + warnings = warnings, + sources = decoded.sources, + globs = decoded.globs, + } +end + +local function cachePath() + 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 .. "/" .. BINDINGS_CACHE_FILE +end + +local function readBindingCache() + local path = cachePath() + if path == nil then return nil end + local raw = noctalia.readFile(path) + if raw == nil or raw == "" then return nil end + local decoded = noctalia.json.decode(raw) + return normalizedCache(decoded) +end + +local function cachedBindingValue(binding) + return { + compositor = binding.compositor, + bindingType = binding.bindingType, + modifiers = binding.modifiers, + key = binding.key, + action = binding.action, + description = binding.description, + category = binding.baseCategory or binding.category, + sourceFile = binding.sourceFile, + sourceLine = binding.sourceLine, + submap = binding.submap, + } +end + +local function saveBindingCache(cache) + local path = cachePath() + if path == nil then return end + local serializedBindings = {} + for _, binding in ipairs(cache.bindings) do + table.insert(serializedBindings, cachedBindingValue(binding)) + end + local encoded, encodeError = noctalia.json.encode({ + schema = CACHE_SCHEMA, + request = cache.request, + bindings = serializedBindings, + warnings = cache.warnings, + sources = cache.sources, + globs = cache.globs, + }, false) + if encoded == nil then + noctalia.log("Could not encode keybind cache: " .. (encodeError or "unknown error")) + return + end + + local temporary = path .. ".tmp" + local written, writeError = noctalia.writeFile(temporary, encoded) + if not written then + noctalia.log("Could not write keybind cache: " .. (writeError or "unknown error")) + return + end + local renamed, renameError = noctalia.renameFile(temporary, path) + if not renamed then + noctalia.removeFile(temporary) + noctalia.log("Could not commit keybind cache: " .. (renameError or "unknown error")) + end +end + +local refresh + +local function snapshot(status, request, cache, err, isRefreshing) + return { + schema = CACHE_SCHEMA, + status = status, + compositor = request ~= nil and request.compositor or "", + parser = request ~= nil and request.parser or "", + source = request ~= nil and request.root or "", + request = request, + bindings = cache ~= nil and cache.bindings or {}, + warnings = cache ~= nil and cache.warnings or {}, + error = err or "", + refreshing = isRefreshing == true, + } +end + +local function publish(status, request, cache, err, isRefreshing) + noctalia.state.set(SNAPSHOT_KEY, snapshot(status, request, cache, err, isRefreshing)) +end + +local function matchingLastGood(request) + if requestsMatch(lastGood ~= nil and lastGood.request or nil, request) then + return lastGood + end + return nil +end + +local function finishRefresh(generation, request, result, warnings, err, sourceSnapshot) + if generation ~= refreshGeneration then return end + + refreshing = false + if err == nil then + lastGood = { + schema = CACHE_SCHEMA, + request = request, + bindings = result or {}, + warnings = warnings or {}, + sources = sourceSnapshot ~= nil and sourceSnapshot.sources or {}, + globs = sourceSnapshot ~= nil and sourceSnapshot.globs or {}, + } + saveBindingCache(lastGood) + publish("ready", request, lastGood, "", false) + else + local previous = matchingLastGood(request) + if previous ~= nil then + publish("ready", request, previous, err, false) + else + publish("error", request, nil, err, false) + end + end + + if refreshQueued then + refreshQueued = false + refresh(request) + end +end + +local function configReadError(context) + if context.rootRead then return nil end + return tr("missing_config") +end + +local function refreshHyprLua(generation, request) + local scan = scanHyprLua(request.root) + if not noctalia.commandExists("hyprctl") then + finishRefresh(generation, request, {}, scan.warnings, tr("hyprctl_missing"), scan) + return + end + + local accepted = noctalia.runAsync("hyprctl binds -j", function(result) + if generation ~= refreshGeneration then return end + if result.exitCode ~= 0 or result.timedOut then + local message = trim(result.stderr) + finishRefresh( + generation, + request, + {}, + scan.warnings, + message ~= "" and message or tr("hyprctl_failed"), + scan + ) + return + end + local parsed, err = parseHyprJson(result.stdout, scan.rules) + finishRefresh(generation, request, parsed or {}, scan.warnings, err, scan) + end, 10000) + + if not accepted then + finishRefresh(generation, request, {}, scan.warnings, tr("hyprctl_failed"), scan) + end +end + +local function performRefresh(generation, request) + if request == nil then + finishRefresh(generation, { compositor = "", parser = "", root = "" }, {}, {}, tr("unsupported"), nil) + elseif request.parser == "mango" then + local context = readConfig(request.root, parseMangoContent) + finishRefresh(generation, request, context.bindings, context.warnings, configReadError(context), context) + elseif request.parser == "niri" then + local context = readConfig(request.root, parseNiriContent) + finishRefresh(generation, request, context.bindings, context.warnings, configReadError(context), context) + elseif request.parser == "hypr-lua" then + refreshHyprLua(generation, request) + elseif request.parser == "hypr-conf" then + local context = readConfig(request.root, parseHyprContent) + finishRefresh(generation, request, context.bindings, context.warnings, configReadError(context), context) + else + finishRefresh(generation, request, {}, {}, tr("unsupported"), nil) + end +end + +refresh = function(request) + if refreshing then + refreshQueued = true + return + end + + request = request or currentRequest() + refreshGeneration += 1 + local generation = refreshGeneration + refreshing = true + local previous = matchingLastGood(request) + if previous == nil then + publish("loading", request, nil, "", true) + end + performRefresh(generation, request) +end + +local function loadCachedSnapshot(request) + local cached = readBindingCache() + if requestsMatch(cached ~= nil and cached.request or nil, request) then + lastGood = cached + publish("ready", request, cached, "", true) + else + lastGood = nil + publish("loading", request, nil, "", true) + end +end + +local function bootstrap() + local request = currentRequest() + loadCachedSnapshot(request) + refresh(request) +end + +local function lifecycleState() + local current = noctalia.state.get(SNAPSHOT_KEY) + return { + refreshing = refreshing, + refreshQueued = refreshQueued, + bindingCount = type(current) == "table" and type(current.bindings) == "table" and #current.bindings or 0, + cacheLoaded = lastGood ~= nil, + } +end + +local function resetForTests() + refreshGeneration += 1 + refreshing = false + refreshQueued = false + lastGood = nil + bootstrap() +end + +local function containsAll(values, required) + local found = {} + for _, value in ipairs(values) do found[value] = true end + for _, value in ipairs(required or {}) do + if not found[value] then return false, value end + end + return true, nil +end + +local function runSelfTest() + local pluginDir = noctalia.pluginDir() or "." + local fixtureRoot = pluginDir .. "/tests/fixtures" + local expectedRaw = noctalia.readFile(pluginDir .. "/tests/expected.json") + local expected = expectedRaw ~= nil and noctalia.json.decode(expectedRaw) or nil + local report = { passed = true, cases = {} } + if type(expected) ~= "table" then + report.passed = false + report.error = "Could not read tests/expected.json" + else + local cases = { + mango = function() + return walkConfig(fixtureRoot .. "/mango/main.conf", parseMangoContent).bindings + end, + hypr_conf = function() + return walkConfig(fixtureRoot .. "/hypr/hyprland.conf", parseHyprContent).bindings + end, + niri = function() + return walkConfig(fixtureRoot .. "/niri/config.kdl", parseNiriContent).bindings + end, + hypr_lua = function() + local scan = scanHyprLua(fixtureRoot .. "/hypr/hyprland.lua") + local raw = noctalia.readFile(fixtureRoot .. "/hypr/binds.json") or "[]" + return parseHyprJson(raw, scan.rules) or {} + end, + } + for name, execute in pairs(cases) do + local parsed = execute() + local expectedCase = expected[name] + local descriptions = {} + local categories = {} + for _, binding in ipairs(parsed) do + table.insert(descriptions, binding.description) + table.insert( + categories, + binding.description == "" and tr("without_description") + or (binding.category ~= "" and binding.category or tr("other")) + ) + end + local descriptionsOk, missingDescription = containsAll(descriptions, expectedCase.descriptions) + local categoriesOk, missingCategory = containsAll(categories, expectedCase.categories) + local passed = #parsed == expectedCase.count and descriptionsOk and categoriesOk + report.cases[name] = { + passed = passed, + expectedCount = expectedCase.count, + actualCount = #parsed, + missingDescription = missingDescription, + missingCategory = missingCategory, + } + if not passed then report.passed = false end + end + end + local encoded = noctalia.json.encode(report, true) or "{}" + local dataDir = noctalia.pluginDataDir() + if dataDir ~= nil then noctalia.writeFile(dataDir .. "/selftest.json", encoded) end + noctalia.log("Keybind cheatsheet self-test: " .. encoded) + if report.passed then + noctalia.notify(tr("title"), "Parser self-test passed") + else + noctalia.notifyError(tr("title"), "Parser self-test failed; see the Noctalia log") + end +end + +function onConfigChanged() + local request = currentRequest() + local current = noctalia.state.get(SNAPSHOT_KEY) + if requestsMatch(type(current) == "table" and current.request or nil, request) then + return + end + refreshGeneration += 1 + refreshing = false + refreshQueued = false + loadCachedSnapshot(request) + refresh(request) +end + +function onIpc(event, _payload) + if event == "refresh" then + refresh() + elseif event == "self-test" then + runSelfTest() + end +end + +function onExit(_signal) + refreshGeneration += 1 + refreshing = false + refreshQueued = false +end + +noctalia.state.watch(REFRESH_REQUEST_KEY, function(_request) + refresh() +end) + +noctalia.state.watch(SELF_TEST_REQUEST_KEY, function(_request) + runSelfTest() +end) + +bootstrap() + +return { + parseMangoContent = parseMangoContent, + mangoCategoryFromComment = mangoCategoryFromComment, + parseHyprContent = parseHyprContent, + parseNiriContent = parseNiriContent, + parseHyprJson = parseHyprJson, + scanHyprLua = scanHyprLua, + walkConfig = walkConfig, + configReadError = configReadError, + currentRequest = currentRequest, + requestsMatch = requestsMatch, + normalizedCache = normalizedCache, + refresh = refresh, + bootstrap = bootstrap, + resetForTests = resetForTests, + lifecycleState = lifecycleState, + onConfigChanged = onConfigChanged, + onIpc = onIpc, + onExit = onExit, + runSelfTest = runSelfTest, + snapshotKey = SNAPSHOT_KEY, + refreshRequestKey = REFRESH_REQUEST_KEY, +} diff --git a/keybind-cheatsheet/tests/expected.json b/keybind-cheatsheet/tests/expected.json new file mode 100644 index 0000000..3e3b1a4 --- /dev/null +++ b/keybind-cheatsheet/tests/expected.json @@ -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"] + } +} diff --git a/keybind-cheatsheet/tests/fixtures/hypr/binds.json b/keybind-cheatsheet/tests/fixtures/hypr/binds.json new file mode 100644 index 0000000..a848a13 --- /dev/null +++ b/keybind-cheatsheet/tests/fixtures/hypr/binds.json @@ -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":""} +] diff --git a/keybind-cheatsheet/tests/fixtures/hypr/hyprland.conf b/keybind-cheatsheet/tests/fixtures/hypr/hyprland.conf new file mode 100644 index 0000000..d74e64f --- /dev/null +++ b/keybind-cheatsheet/tests/fixtures/hypr/hyprland.conf @@ -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" diff --git a/keybind-cheatsheet/tests/fixtures/hypr/hyprland.lua b/keybind-cheatsheet/tests/fixtures/hypr/hyprland.lua new file mode 100644 index 0000000..490a3be --- /dev/null +++ b/keybind-cheatsheet/tests/fixtures/hypr/hyprland.lua @@ -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") diff --git a/keybind-cheatsheet/tests/fixtures/hypr/parts/media.lua b/keybind-cheatsheet/tests/fixtures/hypr/parts/media.lua new file mode 100644 index 0000000..367a4e1 --- /dev/null +++ b/keybind-cheatsheet/tests/fixtures/hypr/parts/media.lua @@ -0,0 +1,2 @@ +-- 3. Media +Hyprland.config.bind(", XF86AudioMute", mute, { desc = "Mute" }) diff --git a/keybind-cheatsheet/tests/fixtures/hypr/parts/workspaces.conf b/keybind-cheatsheet/tests/fixtures/hypr/parts/workspaces.conf new file mode 100644 index 0000000..c0da9d5 --- /dev/null +++ b/keybind-cheatsheet/tests/fixtures/hypr/parts/workspaces.conf @@ -0,0 +1,3 @@ +# 3. Workspaces +binde = $mainMod, 1, workspace, 1 #"Workspace 1" +bind = , XF86AudioMute, exec, wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle diff --git a/keybind-cheatsheet/tests/fixtures/mango/main.conf b/keybind-cheatsheet/tests/fixtures/mango/main.conf new file mode 100644 index 0000000..c471e98 --- /dev/null +++ b/keybind-cheatsheet/tests/fixtures/mango/main.conf @@ -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" diff --git a/keybind-cheatsheet/tests/fixtures/mango/nested.conf b/keybind-cheatsheet/tests/fixtures/mango/nested.conf new file mode 100644 index 0000000..3270c1e --- /dev/null +++ b/keybind-cheatsheet/tests/fixtures/mango/nested.conf @@ -0,0 +1,3 @@ +# Window Management +bind=SUPER,Q,killclient #"Close window" +source=./main.conf diff --git a/keybind-cheatsheet/tests/fixtures/mango/parts/workspaces.conf b/keybind-cheatsheet/tests/fixtures/mango/parts/workspaces.conf new file mode 100644 index 0000000..f198396 --- /dev/null +++ b/keybind-cheatsheet/tests/fixtures/mango/parts/workspaces.conf @@ -0,0 +1,2 @@ +# Workspaces +bind=SUPER,1,view,1 #"Workspace 1" diff --git a/keybind-cheatsheet/tests/fixtures/niri/config.kdl b/keybind-cheatsheet/tests/fixtures/niri/config.kdl new file mode 100644 index 0000000..5d32170 --- /dev/null +++ b/keybind-cheatsheet/tests/fixtures/niri/config.kdl @@ -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; + } +} diff --git a/keybind-cheatsheet/tests/fixtures/niri/parts/workspaces.kdl b/keybind-cheatsheet/tests/fixtures/niri/parts/workspaces.kdl new file mode 100644 index 0000000..0a74d29 --- /dev/null +++ b/keybind-cheatsheet/tests/fixtures/niri/parts/workspaces.kdl @@ -0,0 +1,5 @@ +binds { + // #"Workspaces" + Mod+1 hotkey-overlay-title="Workspace 1" { focus-workspace 1; } + Mod+WheelScrollDown cooldown-ms=150 { focus-workspace-down; } +} diff --git a/keybind-cheatsheet/tests/host_mock.luau b/keybind-cheatsheet/tests/host_mock.luau new file mode 100644 index 0000000..57a32b0 --- /dev/null +++ b/keybind-cheatsheet/tests/host_mock.luau @@ -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, +} diff --git a/keybind-cheatsheet/thumbnail.webp b/keybind-cheatsheet/thumbnail.webp new file mode 100644 index 0000000000000000000000000000000000000000..0dbffc38556429b127e04e62f4222d8c898cf9c5 GIT binary patch literal 39316 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