diff --git a/keymap/README.md b/keymap/README.md new file mode 100644 index 0000000..6861cec --- /dev/null +++ b/keymap/README.md @@ -0,0 +1,358 @@ +# Keymap + +Keymap is a searchable, theme-aware shortcut viewer and editor for Noctalia. +It supports Hyprland's native Lua configuration, Niri, and MangoWC, follows +split configuration files, and preserves user-defined categories. + +![Keymap shortcut editor](screenshots/editor.webp) + +## Features + +- Reads the active compositor automatically, with a manual override for custom + sessions and test setups. +- Searches shortcut descriptions, combinations, categories, actions, commands, + and MangoWC key modes. +- Renders ANSI 100%, 96%, 80% (TKL), 75%, 65%, and 60% keyboards. +- Shows whether a physical key is free, occupied on the selected modifier + layer, or used by another combination. +- Creates press and release shortcuts where the compositor supports them. +- Browses Noctalia IPC commands and native actions for the active compositor, + with source, category, readiness, and text filters. +- Includes a searchable library of Noctalia commands and native compositor + actions, while retaining free-form custom shell commands. +- Edits supported combinations, descriptions, commands, trigger modes, and + categories directly above the shortcut list. +- Reorders shortcuts and moves them between categories with drag and drop. +- Renames categories and can hide, restore, or permanently delete shortcuts. +- Uses Noctalia theme roles by default and accepts custom colors for cards, + headings, modifier pills, keys, and text. +- Validates and reloads configuration changes, with guarded rollback on + failure. + +![100% keyboard view](screenshots/keyboard-100.webp) + +## Plugin + +| Type | ID | +| --- | --- | +| Bar widget | `widget` | +| Panel | `panel` | +| Hyprland service | `service` | +| Niri service | `niri-service` | +| MangoWC service | `mangowc-service` | +| Configuration writer | `writer-service` | + +The complete plugin ID is `blackbartblues/keymap`. + +## Requirements + +- Noctalia v5 with plugin API 5. +- Hyprland with its native Lua configuration API, Niri, or MangoWC. +- The command-line tools for the selected compositor, plus `xdg-open` for the + optional configuration-folder action: Hyprland uses `hyprctl` and + `Hyprland`; Niri uses `niri`; MangoWC uses `mango` and `mmsg`. + +Classic Hyprland `.conf` keybinds are intentionally unsupported. Hyprland is +moving to its native Lua configuration API, which is the only Hyprland format +Keymap reads and writes. + +For read-only use, Niri and MangoWC are parsed without spawning their IPC +tools. The validator and reload commands are used only after an explicit edit. +The folder button uses `xdg-open` to open the directory containing the active +configuration file. + +## Usage + +Add the `widget` entry to a Noctalia bar and click it, or open the panel through +IPC: + +```sh +noctalia msg panel-toggle blackbartblues/keymap:panel +``` + +The header provides keyboard and list views, the shortcut creator, the +existing-shortcut editor, refresh, configuration-folder, settings, and close +actions. + +The settings button opens `Settings -> Plugins`. Select the gear beside +Keymap to edit its compositor, paths, keyboard size, columns, and colors. + +## Configuration discovery + +An existing path entered in Keymap settings always wins. If that path does not +exist, Keymap searches safe, compositor-specific locations: + +- Hyprland: `$XDG_CONFIG_HOME/hypr/hyprland.lua`, `init.lua`, and + `/etc/xdg/hypr/hyprland.lua`, followed by a scored scan of top-level `.lua` + files in the Hyprland configuration directory. +- Niri: `$NIRI_CONFIG`, `$XDG_CONFIG_HOME/niri/config.kdl`, followed by a + scored scan of top-level `.kdl` files in the Niri configuration directory. +- MangoWC: `$XDG_CONFIG_HOME/mango/config.conf`, `/etc/mango/config.conf`, + followed by a scored scan of top-level `.conf` files in the MangoWC + configuration directory. + +The generated `keymap.lua`, `keymap.kdl`, and `keymap.conf` files and files +whose names contain `backup` are excluded from automatic discovery. If no +usable shortcut file is found, the panel points to settings so the correct path +can be entered manually. + +## Browsing shortcuts + +In keyboard view, enable an exact Super, Ctrl, Shift, and Alt layer. Occupied +keys open the shortcuts assigned to that combination; unoccupied keys can be +sent directly to the creator. Change the physical layout from the keyboard +size selector while editing shortcuts, or set its default in plugin settings. + +In list view, type into the search box to filter the complete category tree. +Sequential shortcuts such as workspaces 1 through 9 can optionally be folded +into a single row. + +The example configurations below demonstrate the same feature set without +reading or modifying a personal setup: + +![Niri example shortcuts](screenshots/niri-list.webp) + +![MangoWC example shortcuts](screenshots/mangowc-list.webp) + +## Creating shortcuts + +Select **New shortcut**, choose modifiers and a key, select an existing or new +category, then enter a description and command. The keyboard view can fill the +combination by clicking a physical key. + +Select **Commands** beside the command field to open the known-command +library. It contains every command exposed by Noctalia's IPC help plus the +native Hyprland Lua, Niri KDL, or MangoWC actions for the active compositor. +Filter by source, category, whether an action still needs arguments, or free +text. Selecting an entry fills the command field; replace every +`{{placeholder}}` with a compositor-valid value before saving. **Custom +command** returns to an unrestricted shell command, so the library never +removes the option to type a command manually. + +Native actions are offered while creating a shortcut. In the editor, the +library offers Noctalia shell commands only: converting an existing shell bind +to a different native syntax cannot be rewritten safely across all supported +source forms. + +Hyprland supports multi-key sequences such as `Super + C + V`. Niri and +MangoWC accept one ordinary key in this writer. Niri exposes press activation; +Hyprland and MangoWC expose press and release activation. + +![Shortcut creator](screenshots/creator.webp) + +### Command library + +Select **Command library** beside the command field to browse 362 known +commands and native actions: + +| Source | Entries | Verified from | +| --- | ---: | --- | +| Noctalia | 98 | Runtime `noctalia msg --help` output | +| Hyprland | 51 | Native `hl.dsp.*` dispatcher bindings from Hyprland 0.56.0 | +| Niri | 135 | Configurable KDL actions from Niri 26.04 | +| MangoWC | 78 | Current parser dispatchers and official keybinding documentation | + +Search by action, syntax, source, or category. Source, category, and readiness +filters can narrow the results. **Ready** entries can be inserted directly; +**Needs input** entries contain visible `{{argument}}` placeholders that must +be replaced before saving. Selecting **Custom command** returns to a normal +shell command without restricting it to the catalog. + +Noctalia entries are saved as shell commands. When creating a shortcut, +compositor entries are emitted as native Hyprland Lua, Niri KDL, or MangoWC +actions rather than wrappers around an IPC command. The writer checks the +selected entry ID, compositor, source, and completed template again before it +touches a file. Existing native actions remain preserved but are not converted +to another native catalog action by the editor. + +![Known command library](screenshots/command-library.webp) + +The first created shortcut adds one marked include to the configured root and +creates a sibling managed file: + +| Compositor | Managed file | Marked include | +| --- | --- | --- | +| Hyprland | `keymap.lua` | `require("keymap")` | +| Niri | `keymap.kdl` | `include "keymap.kdl"` | +| MangoWC | `keymap.conf` | `source=./keymap.conf` | + +## Editing and organizing + +Select **Edit shortcuts** to expose actions on writable rows. The pencil opens +the inline editor above the category cards. The eye-slash action hides a +shortcut without losing its original text, and the trash action permanently +deletes it after confirmation. Hidden shortcuts remain available in the +editor's recovery section, where they can be restored or deleted. + +Drag a row handle to another position in the same category or into another +category. The same move can be performed with the Category field in the inline +editor. Select the pencil in a category heading to rename that category. + +Native compositor actions remain intact. Fields that cannot be rewritten +safely are disabled instead of being guessed. Generated, ranged, or otherwise +read-only entries are visibly locked. + +Before every create, update, move, reorder, category rename, hide, restore, or +delete operation, Keymap verifies that the source still matches the parsed +snapshot and refuses symbolic-link targets. Writes use a temporary sibling and +atomic rename. The candidate is then checked with the compositor's native +validator and reloaded: + +| Compositor | Validator | Reload | +| --- | --- | --- | +| Hyprland | `Hyprland --verify-config -c ` | `hyprctl reload` | +| Niri | `niri validate -c ` | `niri msg action load-config-file` | +| MangoWC | `mango -c -p` | `mmsg dispatch reload_config` | + +If validation or reload fails, Keymap attempts to restore each file it changed. +After a reload failure, it also attempts to reload the restored configuration. +Rollback or recovery-reload failures are reported explicitly. A source that +changes after parsing is never overwritten; the operation stops and asks the +user to refresh instead. + +## Categories and source formats + +### Hyprland Lua + +Place numbered headings before groups of native Lua bindings: + +```lua +-- 1. Applications +hl.bind("SUPER + RETURN", hl.dsp.exec_cmd("foot"), { description = "Open terminal" }) +``` + +Local modules loaded with `require` are scanned recursively. Literal +descriptions and literal prefixes such as `description = "Workspace " .. i` +are matched against the live bind registry. The live registry remains +authoritative; the Lua files supply source locations, editable snippets, and +category order. + +Hyprland versions whose `hyprctl binds -j` output cannot be decoded are handled +automatically through the complete plain-text `hyprctl binds` fallback. + +### Niri + +Category comments live inside `binds {}` blocks: + +```kdl +binds { + // #"Applications" + Mod+Return hotkey-overlay-title="Open terminal" { spawn-sh "foot"; } +} +``` + +Positional `include` nodes, optional includes, later overrides, disabled `/-` +nodes, custom overlay titles, and native actions are supported. Because Niri +does not expose an effective bind registry through IPC, its configuration tree +is the source of truth. + +### MangoWC + +Category comments and optional descriptions use the following form: + +```ini +# Applications +bind=SUPER,Return,spawn_shell,foot #"Open terminal" +``` + +Keymap supports `bind` with `l/s/r/p` flags, `axisbind`, `mousebind`, +`gesturebind`, `switchbind`, `keymode`, `source`, and `source-optional`. +MangoWC's configuration tree is the source of truth. + +Complete, non-loaded examples are included in the repository: + +- [`examples/hyprland.lua`](examples/hyprland.lua) — 40 shortcuts and a config + that passes `Hyprland --verify-config`. +- [`examples/niri.kdl`](examples/niri.kdl) — 39 shortcuts and a config that + passes `niri validate`. +- [`examples/mangowc.conf`](examples/mangowc.conf) — 40 shortcuts. + +They cover applications, window management, workspaces, screenshots, Noctalia, +media controls, utilities, release triggers, wheel bindings, and compositor +native actions. They are documentation and test fixtures; Keymap never loads +them automatically. + +## Settings + +| Setting | Default | Purpose | +| --- | --- | --- | +| `compositor` | `auto` | Detect the session or force Hyprland, Niri, or MangoWC. | +| `hyprland_config` | `~/.config/hypr/hyprland.lua` | Hyprland native Lua root. | +| `niri_config` | `~/.config/niri/config.kdl` | Niri KDL root. | +| `mangowc_config` | `~/.config/mango/config.conf` | MangoWC config root. | +| `merge_sequential` | `true` | Fold related numbered shortcuts into one row. | +| `show_undescribed` | `true` | Show Hyprland binds without descriptions. | +| `keyboard_layout` | `100` | Default 100%, 96%, 80%, 75%, 65%, or 60% view. | +| `columns` | `3` | One to four balanced category columns. | +| `card_color` / `card_opacity` | `surface_variant` / `35` | Card background role or custom color and opacity. | +| `category_color` | `primary` | Category heading role or custom color. | +| `description_color` | `on_surface` | Description role or custom color. | +| modifier color pairs | Noctalia theme roles | Background and text for Super, Ctrl, Shift, and Alt. | +| `key_color` / `key_text_color` | `surface` / `on_surface` | Ordinary key background and text. | +| widget `glyph` / `show_label` | `keyboard` / `false` | Bar appearance. | + +![Keymap settings](screenshots/settings.webp) + +Theme-role values follow Noctalia palette changes automatically. Every color +setting also accepts a custom color. + +## IPC + +Request an immediate refresh from the service for the active compositor: + +```sh +# Hyprland +noctalia msg plugin blackbartblues/keymap:service all refresh + +# Niri +noctalia msg plugin blackbartblues/keymap:niri-service all refresh + +# MangoWC +noctalia msg plugin blackbartblues/keymap:mangowc-service all refresh +``` + +The panel accepts `view-keyboard`, `view-list`, `creator-open`, +`creator-cancel`, `editor-open`, `editor-bind `, `clear-modifiers`, +`keyboard-key `, and `keyboard-layout ` events. For example: + +```sh +noctalia msg plugin blackbartblues/keymap:panel all keyboard-layout 75 +``` + +Valid layout payloads are `100`, `96`, `80`, `75`, `65`, and `60`. + +## Safety and limits + +- Keymap makes no network requests and never executes commands stored inside + shortcuts; it only passes explicitly saved configuration text to the active + compositor. +- Configuration traversal is cycle-safe and limited to 64 files of up to + 512 KiB each. Root files accepted for writing are limited to 2 MiB. +- Required missing Niri includes and MangoWC sources stop parsing rather than + silently presenting an incomplete list. Optional sources and non-fatal + parser issues are shown as warnings. +- All interface prose and settings metadata use Noctalia's translation API. + Physical key legends and standard modifier names remain technical labels. + The shipped English catalog is the source language for Weblate. + +## Tests + +From the `keymap` directory: + +```sh +for test_file in tests/*.lua; do lua "$test_file"; done +python tests/command_library_test.py +python tests/i18n_test.py +Hyprland --verify-config -c examples/hyprland.lua +niri validate -c examples/niri.kdl +``` + +The suite covers category markers, hidden-block recovery, Hyprland command +parsing and text fallback, all keyboard layouts, create/update/write rollback, +the three example configurations, command-library integrity and native action +creation, short DnD identifiers, automatic path discovery, and translation-key +coverage. + +## License + +MIT. diff --git a/keymap/command_library.json b/keymap/command_library.json new file mode 100644 index 0000000..b5cf95d --- /dev/null +++ b/keymap/command_library.json @@ -0,0 +1,2915 @@ +{ + "schema": 1, + "sources": { + "noctalia": { + "revision": "noctalia-core d07ed348f4948a2afd093a6a1ad683ed81616436, runtime msg --help" + }, + "hyprland": { + "revision": "v0.56.0, commit 36b2e0cfe0c6094dbc47bd42a437431315bb3087, LuaBindingsDispatchers.cpp" + }, + "niri": { + "revision": "v26.04, commit 8ed0da44d974c32c6877d2f4630c314da0717ecb, niri-config/src/binds.rs" + }, + "mangowc": { + "revision": "mangowm/mango commit b5efdd24e76c3c9aa2300e626fe013fcd3617cca, parse_config.h and docs/bindings/keys.md" + } + }, + "entries": [ + { + "id": "hyprland/exec_cmd", + "source": "hyprland", + "category": "applications", + "kind": "native", + "template": "hl.dsp.exec_cmd({{command}})", + "usage": "hl.dsp.exec_cmd(…)" + }, + { + "id": "hyprland/exec_raw", + "source": "hyprland", + "category": "applications", + "kind": "native", + "template": "hl.dsp.exec_raw({{command}})", + "usage": "hl.dsp.exec_raw(…)" + }, + { + "id": "hyprland/cursor.move", + "source": "hyprland", + "category": "cursor", + "kind": "native", + "template": "hl.dsp.cursor.move({ x = {{x}}, y = {{y}} })", + "usage": "hl.dsp.cursor.move(…)" + }, + { + "id": "hyprland/cursor.move_to_corner", + "source": "hyprland", + "category": "cursor", + "kind": "native", + "template": "hl.dsp.cursor.move_to_corner({ corner = {{corner}} })", + "usage": "hl.dsp.cursor.move_to_corner(…)" + }, + { + "id": "hyprland/dpms", + "source": "hyprland", + "category": "display", + "kind": "native", + "template": "hl.dsp.dpms()", + "usage": "hl.dsp.dpms()" + }, + { + "id": "hyprland/force_renderer_reload", + "source": "hyprland", + "category": "display", + "kind": "native", + "template": "hl.dsp.force_renderer_reload()", + "usage": "hl.dsp.force_renderer_reload()" + }, + { + "id": "hyprland/focus", + "source": "hyprland", + "category": "focus", + "kind": "native", + "template": "hl.dsp.focus({ direction = {{direction}} })", + "usage": "hl.dsp.focus(…)" + }, + { + "id": "hyprland/group.active", + "source": "hyprland", + "category": "groups", + "kind": "native", + "template": "hl.dsp.group.active({ index = {{index}} })", + "usage": "hl.dsp.group.active(…)" + }, + { + "id": "hyprland/group.lock", + "source": "hyprland", + "category": "groups", + "kind": "native", + "template": "hl.dsp.group.lock()", + "usage": "hl.dsp.group.lock()" + }, + { + "id": "hyprland/group.lock_active", + "source": "hyprland", + "category": "groups", + "kind": "native", + "template": "hl.dsp.group.lock_active()", + "usage": "hl.dsp.group.lock_active()" + }, + { + "id": "hyprland/group.move_window", + "source": "hyprland", + "category": "groups", + "kind": "native", + "template": "hl.dsp.group.move_window()", + "usage": "hl.dsp.group.move_window()" + }, + { + "id": "hyprland/group.next", + "source": "hyprland", + "category": "groups", + "kind": "native", + "template": "hl.dsp.group.next()", + "usage": "hl.dsp.group.next()" + }, + { + "id": "hyprland/group.prev", + "source": "hyprland", + "category": "groups", + "kind": "native", + "template": "hl.dsp.group.prev()", + "usage": "hl.dsp.group.prev()" + }, + { + "id": "hyprland/group.toggle", + "source": "hyprland", + "category": "groups", + "kind": "native", + "template": "hl.dsp.group.toggle()", + "usage": "hl.dsp.group.toggle()" + }, + { + "id": "hyprland/pass", + "source": "hyprland", + "category": "input", + "kind": "native", + "template": "hl.dsp.pass({ window = {{window-selector}} })", + "usage": "hl.dsp.pass(…)" + }, + { + "id": "hyprland/release_input_capture", + "source": "hyprland", + "category": "input", + "kind": "native", + "template": "hl.dsp.release_input_capture()", + "usage": "hl.dsp.release_input_capture()" + }, + { + "id": "hyprland/send_key_state", + "source": "hyprland", + "category": "input", + "kind": "native", + "template": "hl.dsp.send_key_state({ mods = {{modifiers}}, key = {{key}}, state = {{state}} })", + "usage": "hl.dsp.send_key_state(…)" + }, + { + "id": "hyprland/send_shortcut", + "source": "hyprland", + "category": "input", + "kind": "native", + "template": "hl.dsp.send_shortcut({ mods = {{modifiers}}, key = {{key}} })", + "usage": "hl.dsp.send_shortcut(…)" + }, + { + "id": "hyprland/layout", + "source": "hyprland", + "category": "layout", + "kind": "native", + "template": "hl.dsp.layout({{message}})", + "usage": "hl.dsp.layout(…)" + }, + { + "id": "hyprland/event", + "source": "hyprland", + "category": "system", + "kind": "native", + "template": "hl.dsp.event({{event}})", + "usage": "hl.dsp.event(…)" + }, + { + "id": "hyprland/exit", + "source": "hyprland", + "category": "system", + "kind": "native", + "template": "hl.dsp.exit()", + "usage": "hl.dsp.exit()" + }, + { + "id": "hyprland/force_idle", + "source": "hyprland", + "category": "system", + "kind": "native", + "template": "hl.dsp.force_idle({{seconds}})", + "usage": "hl.dsp.force_idle(…)" + }, + { + "id": "hyprland/global", + "source": "hyprland", + "category": "system", + "kind": "native", + "template": "hl.dsp.global({{global-shortcut}})", + "usage": "hl.dsp.global(…)" + }, + { + "id": "hyprland/no_op", + "source": "hyprland", + "category": "system", + "kind": "native", + "template": "hl.dsp.no_op()", + "usage": "hl.dsp.no_op()" + }, + { + "id": "hyprland/submap", + "source": "hyprland", + "category": "system", + "kind": "native", + "template": "hl.dsp.submap({{name}})", + "usage": "hl.dsp.submap(…)" + }, + { + "id": "hyprland/window.alter_zorder", + "source": "hyprland", + "category": "windows", + "kind": "native", + "template": "hl.dsp.window.alter_zorder({ mode = {{mode}} })", + "usage": "hl.dsp.window.alter_zorder(…)" + }, + { + "id": "hyprland/window.bring_to_top", + "source": "hyprland", + "category": "windows", + "kind": "native", + "template": "hl.dsp.window.bring_to_top()", + "usage": "hl.dsp.window.bring_to_top()" + }, + { + "id": "hyprland/window.center", + "source": "hyprland", + "category": "windows", + "kind": "native", + "template": "hl.dsp.window.center()", + "usage": "hl.dsp.window.center()" + }, + { + "id": "hyprland/window.clear_tags", + "source": "hyprland", + "category": "windows", + "kind": "native", + "template": "hl.dsp.window.clear_tags()", + "usage": "hl.dsp.window.clear_tags()" + }, + { + "id": "hyprland/window.close", + "source": "hyprland", + "category": "windows", + "kind": "native", + "template": "hl.dsp.window.close()", + "usage": "hl.dsp.window.close()" + }, + { + "id": "hyprland/window.cycle_next", + "source": "hyprland", + "category": "windows", + "kind": "native", + "template": "hl.dsp.window.cycle_next()", + "usage": "hl.dsp.window.cycle_next()" + }, + { + "id": "hyprland/window.deny_from_group", + "source": "hyprland", + "category": "windows", + "kind": "native", + "template": "hl.dsp.window.deny_from_group()", + "usage": "hl.dsp.window.deny_from_group()" + }, + { + "id": "hyprland/window.drag", + "source": "hyprland", + "category": "windows", + "kind": "native", + "template": "hl.dsp.window.drag()", + "usage": "hl.dsp.window.drag()" + }, + { + "id": "hyprland/window.float", + "source": "hyprland", + "category": "windows", + "kind": "native", + "template": "hl.dsp.window.float()", + "usage": "hl.dsp.window.float()" + }, + { + "id": "hyprland/window.fullscreen", + "source": "hyprland", + "category": "windows", + "kind": "native", + "template": "hl.dsp.window.fullscreen()", + "usage": "hl.dsp.window.fullscreen()" + }, + { + "id": "hyprland/window.fullscreen_state", + "source": "hyprland", + "category": "windows", + "kind": "native", + "template": "hl.dsp.window.fullscreen_state({ internal = {{internal-state}}, client = {{client-state}} })", + "usage": "hl.dsp.window.fullscreen_state(…)" + }, + { + "id": "hyprland/window.kill", + "source": "hyprland", + "category": "windows", + "kind": "native", + "template": "hl.dsp.window.kill()", + "usage": "hl.dsp.window.kill()" + }, + { + "id": "hyprland/window.move", + "source": "hyprland", + "category": "windows", + "kind": "native", + "template": "hl.dsp.window.move({ direction = {{direction}} })", + "usage": "hl.dsp.window.move(…)" + }, + { + "id": "hyprland/window.pin", + "source": "hyprland", + "category": "windows", + "kind": "native", + "template": "hl.dsp.window.pin()", + "usage": "hl.dsp.window.pin()" + }, + { + "id": "hyprland/window.pseudo", + "source": "hyprland", + "category": "windows", + "kind": "native", + "template": "hl.dsp.window.pseudo()", + "usage": "hl.dsp.window.pseudo()" + }, + { + "id": "hyprland/window.resize", + "source": "hyprland", + "category": "windows", + "kind": "native", + "template": "hl.dsp.window.resize()", + "usage": "hl.dsp.window.resize()" + }, + { + "id": "hyprland/window.set_prop", + "source": "hyprland", + "category": "windows", + "kind": "native", + "template": "hl.dsp.window.set_prop({ prop = {{property}}, value = {{value}} })", + "usage": "hl.dsp.window.set_prop(…)" + }, + { + "id": "hyprland/window.signal", + "source": "hyprland", + "category": "windows", + "kind": "native", + "template": "hl.dsp.window.signal({ signal = {{signal}} })", + "usage": "hl.dsp.window.signal(…)" + }, + { + "id": "hyprland/window.swap", + "source": "hyprland", + "category": "windows", + "kind": "native", + "template": "hl.dsp.window.swap({ direction = {{direction}} })", + "usage": "hl.dsp.window.swap(…)" + }, + { + "id": "hyprland/window.tag", + "source": "hyprland", + "category": "windows", + "kind": "native", + "template": "hl.dsp.window.tag({ tag = {{tag}} })", + "usage": "hl.dsp.window.tag(…)" + }, + { + "id": "hyprland/window.toggle_swallow", + "source": "hyprland", + "category": "windows", + "kind": "native", + "template": "hl.dsp.window.toggle_swallow()", + "usage": "hl.dsp.window.toggle_swallow()" + }, + { + "id": "hyprland/workspace.change_id", + "source": "hyprland", + "category": "workspaces", + "kind": "native", + "template": "hl.dsp.workspace.change_id({ workspace = {{workspace}}, id = {{id}} })", + "usage": "hl.dsp.workspace.change_id(…)" + }, + { + "id": "hyprland/workspace.move", + "source": "hyprland", + "category": "workspaces", + "kind": "native", + "template": "hl.dsp.workspace.move({ workspace = {{workspace}}, monitor = {{monitor}} })", + "usage": "hl.dsp.workspace.move(…)" + }, + { + "id": "hyprland/workspace.rename", + "source": "hyprland", + "category": "workspaces", + "kind": "native", + "template": "hl.dsp.workspace.rename({ workspace = {{workspace}}, name = {{name}} })", + "usage": "hl.dsp.workspace.rename(…)" + }, + { + "id": "hyprland/workspace.swap_monitors", + "source": "hyprland", + "category": "workspaces", + "kind": "native", + "template": "hl.dsp.workspace.swap_monitors({ monitor1 = {{monitor-1}}, monitor2 = {{monitor-2}} })", + "usage": "hl.dsp.workspace.swap_monitors(…)" + }, + { + "id": "hyprland/workspace.toggle_special", + "source": "hyprland", + "category": "workspaces", + "kind": "native", + "template": "hl.dsp.workspace.toggle_special({{name}})", + "usage": "hl.dsp.workspace.toggle_special(…)" + }, + { + "id": "mangowc/spawn", + "source": "mangowc", + "category": "applications", + "kind": "native", + "template": "spawn,{{parameters}}", + "usage": "spawn cmd" + }, + { + "id": "mangowc/spawn_on_empty", + "source": "mangowc", + "category": "applications", + "kind": "native", + "template": "spawn_on_empty,{{parameters}}", + "usage": "spawn_on_empty cmd,tagnumber" + }, + { + "id": "mangowc/spawn_shell", + "source": "mangowc", + "category": "applications", + "kind": "native", + "template": "spawn_shell,{{parameters}}", + "usage": "spawn_shell cmd" + }, + { + "id": "mangowc/dwindle_split_horizontal", + "source": "mangowc", + "category": "layout", + "kind": "native", + "template": "dwindle_split_horizontal", + "usage": "dwindle_split_horizontal" + }, + { + "id": "mangowc/dwindle_split_vertical", + "source": "mangowc", + "category": "layout", + "kind": "native", + "template": "dwindle_split_vertical", + "usage": "dwindle_split_vertical" + }, + { + "id": "mangowc/dwindle_toggle_split_direction", + "source": "mangowc", + "category": "layout", + "kind": "native", + "template": "dwindle_toggle_split_direction", + "usage": "dwindle_toggle_split_direction" + }, + { + "id": "mangowc/incgaps", + "source": "mangowc", + "category": "layout", + "kind": "native", + "template": "incgaps,{{parameters}}", + "usage": "incgaps +/-value" + }, + { + "id": "mangowc/incnmaster", + "source": "mangowc", + "category": "layout", + "kind": "native", + "template": "incnmaster,{{parameters}}", + "usage": "incnmaster +1/-1" + }, + { + "id": "mangowc/scroller_stack", + "source": "mangowc", + "category": "layout", + "kind": "native", + "template": "scroller_stack,{{parameters}}", + "usage": "scroller_stack left/right/up/down" + }, + { + "id": "mangowc/set_proportion", + "source": "mangowc", + "category": "layout", + "kind": "native", + "template": "set_proportion,{{parameters}}", + "usage": "set_proportion float" + }, + { + "id": "mangowc/setlayout", + "source": "mangowc", + "category": "layout", + "kind": "native", + "template": "setlayout,{{parameters}}", + "usage": "setlayout name" + }, + { + "id": "mangowc/setmfact", + "source": "mangowc", + "category": "layout", + "kind": "native", + "template": "setmfact,{{parameters}}", + "usage": "setmfact +0.05" + }, + { + "id": "mangowc/switch_layout", + "source": "mangowc", + "category": "layout", + "kind": "native", + "template": "switch_layout", + "usage": "switch_layout" + }, + { + "id": "mangowc/switch_proportion_preset", + "source": "mangowc", + "category": "layout", + "kind": "native", + "template": "switch_proportion_preset", + "usage": "switch_proportion_preset" + }, + { + "id": "mangowc/togglegaps", + "source": "mangowc", + "category": "layout", + "kind": "native", + "template": "togglegaps", + "usage": "togglegaps" + }, + { + "id": "mangowc/chvt", + "source": "mangowc", + "category": "system", + "kind": "native", + "template": "chvt", + "usage": "chvt" + }, + { + "id": "mangowc/create_virtual_output", + "source": "mangowc", + "category": "system", + "kind": "native", + "template": "create_virtual_output", + "usage": "create_virtual_output" + }, + { + "id": "mangowc/destroy_all_virtual_output", + "source": "mangowc", + "category": "system", + "kind": "native", + "template": "destroy_all_virtual_output", + "usage": "destroy_all_virtual_output" + }, + { + "id": "mangowc/disable_monitor", + "source": "mangowc", + "category": "system", + "kind": "native", + "template": "disable_monitor,{{parameters}}", + "usage": "disable_monitor monitor_spec" + }, + { + "id": "mangowc/enable_monitor", + "source": "mangowc", + "category": "system", + "kind": "native", + "template": "enable_monitor,{{parameters}}", + "usage": "enable_monitor monitor_spec" + }, + { + "id": "mangowc/load_config_file", + "source": "mangowc", + "category": "system", + "kind": "native", + "template": "load_config_file,{{parameters}}", + "usage": "load_config_file file path" + }, + { + "id": "mangowc/moveresize", + "source": "mangowc", + "category": "system", + "kind": "native", + "template": "moveresize", + "usage": "moveresize" + }, + { + "id": "mangowc/quit", + "source": "mangowc", + "category": "system", + "kind": "native", + "template": "quit", + "usage": "quit" + }, + { + "id": "mangowc/reload_config", + "source": "mangowc", + "category": "system", + "kind": "native", + "template": "reload_config", + "usage": "reload_config" + }, + { + "id": "mangowc/setkeymode", + "source": "mangowc", + "category": "system", + "kind": "native", + "template": "setkeymode,{{parameters}}", + "usage": "setkeymode mode" + }, + { + "id": "mangowc/setoption", + "source": "mangowc", + "category": "system", + "kind": "native", + "template": "setoption,{{parameters}}", + "usage": "setoption key,value" + }, + { + "id": "mangowc/sleep_monitor", + "source": "mangowc", + "category": "system", + "kind": "native", + "template": "sleep_monitor,{{parameters}}", + "usage": "sleep_monitor monitor_spec" + }, + { + "id": "mangowc/sleep_toggle_monitor", + "source": "mangowc", + "category": "system", + "kind": "native", + "template": "sleep_toggle_monitor,{{parameters}}", + "usage": "sleep_toggle_monitor monitor_spec" + }, + { + "id": "mangowc/switch_keyboard_layout", + "source": "mangowc", + "category": "system", + "kind": "native", + "template": "switch_keyboard_layout", + "usage": "switch_keyboard_layout [index]" + }, + { + "id": "mangowc/toggle_monitor", + "source": "mangowc", + "category": "system", + "kind": "native", + "template": "toggle_monitor,{{parameters}}", + "usage": "toggle_monitor monitor_spec" + }, + { + "id": "mangowc/toggle_trackpad_enable", + "source": "mangowc", + "category": "system", + "kind": "native", + "template": "toggle_trackpad_enable", + "usage": "toggle_trackpad_enable" + }, + { + "id": "mangowc/togglejump", + "source": "mangowc", + "category": "system", + "kind": "native", + "template": "togglejump", + "usage": "togglejump" + }, + { + "id": "mangowc/toggleoverlay", + "source": "mangowc", + "category": "system", + "kind": "native", + "template": "toggleoverlay", + "usage": "toggleoverlay" + }, + { + "id": "mangowc/toggleoverview", + "source": "mangowc", + "category": "system", + "kind": "native", + "template": "toggleoverview", + "usage": "toggleoverview" + }, + { + "id": "mangowc/wakeup_monitor", + "source": "mangowc", + "category": "system", + "kind": "native", + "template": "wakeup_monitor,{{parameters}}", + "usage": "wakeup_monitor monitor_spec" + }, + { + "id": "mangowc/centerwin", + "source": "mangowc", + "category": "windows", + "kind": "native", + "template": "centerwin", + "usage": "centerwin" + }, + { + "id": "mangowc/exchange_client", + "source": "mangowc", + "category": "windows", + "kind": "native", + "template": "exchange_client,{{parameters}}", + "usage": "exchange_client left/right/up/down" + }, + { + "id": "mangowc/exchange_stack_client", + "source": "mangowc", + "category": "windows", + "kind": "native", + "template": "exchange_stack_client,{{parameters}}", + "usage": "exchange_stack_client next/prev" + }, + { + "id": "mangowc/focusdir", + "source": "mangowc", + "category": "windows", + "kind": "native", + "template": "focusdir,{{parameters}}", + "usage": "focusdir left/right/up/down" + }, + { + "id": "mangowc/focusid", + "source": "mangowc", + "category": "windows", + "kind": "native", + "template": "focusid", + "usage": "focusid" + }, + { + "id": "mangowc/focuslast", + "source": "mangowc", + "category": "windows", + "kind": "native", + "template": "focuslast", + "usage": "focuslast" + }, + { + "id": "mangowc/focusstack", + "source": "mangowc", + "category": "windows", + "kind": "native", + "template": "focusstack,{{parameters}}", + "usage": "focusstack next/prev" + }, + { + "id": "mangowc/groupfocus", + "source": "mangowc", + "category": "windows", + "kind": "native", + "template": "groupfocus,{{parameters}}", + "usage": "groupfocus prev/next" + }, + { + "id": "mangowc/groupjoin", + "source": "mangowc", + "category": "windows", + "kind": "native", + "template": "groupjoin,{{parameters}}", + "usage": "groupjoin left/right/up/down" + }, + { + "id": "mangowc/groupleave", + "source": "mangowc", + "category": "windows", + "kind": "native", + "template": "groupleave", + "usage": "groupleave" + }, + { + "id": "mangowc/killclient", + "source": "mangowc", + "category": "windows", + "kind": "native", + "template": "killclient,{{parameters}}", + "usage": "killclient force" + }, + { + "id": "mangowc/minimized", + "source": "mangowc", + "category": "windows", + "kind": "native", + "template": "minimized", + "usage": "minimized" + }, + { + "id": "mangowc/movewin", + "source": "mangowc", + "category": "windows", + "kind": "native", + "template": "movewin,{{parameters}}", + "usage": "movewin (x,y)" + }, + { + "id": "mangowc/resizewin", + "source": "mangowc", + "category": "windows", + "kind": "native", + "template": "resizewin,{{parameters}}", + "usage": "resizewin (width,height)" + }, + { + "id": "mangowc/restore_minimized", + "source": "mangowc", + "category": "windows", + "kind": "native", + "template": "restore_minimized", + "usage": "restore_minimized" + }, + { + "id": "mangowc/smartmovewin", + "source": "mangowc", + "category": "windows", + "kind": "native", + "template": "smartmovewin,{{parameters}}", + "usage": "smartmovewin left/right/up/down" + }, + { + "id": "mangowc/smartresizewin", + "source": "mangowc", + "category": "windows", + "kind": "native", + "template": "smartresizewin,{{parameters}}", + "usage": "smartresizewin left/right/up/down" + }, + { + "id": "mangowc/toggle_all_floating", + "source": "mangowc", + "category": "windows", + "kind": "native", + "template": "toggle_all_floating", + "usage": "toggle_all_floating" + }, + { + "id": "mangowc/toggle_named_scratchpad", + "source": "mangowc", + "category": "windows", + "kind": "native", + "template": "toggle_named_scratchpad,{{parameters}}", + "usage": "toggle_named_scratchpad appid,title,cmd" + }, + { + "id": "mangowc/toggle_render_border", + "source": "mangowc", + "category": "windows", + "kind": "native", + "template": "toggle_render_border", + "usage": "toggle_render_border" + }, + { + "id": "mangowc/toggle_scratchpad", + "source": "mangowc", + "category": "windows", + "kind": "native", + "template": "toggle_scratchpad", + "usage": "toggle_scratchpad" + }, + { + "id": "mangowc/togglefakefullscreen", + "source": "mangowc", + "category": "windows", + "kind": "native", + "template": "togglefakefullscreen", + "usage": "togglefakefullscreen" + }, + { + "id": "mangowc/togglefloating", + "source": "mangowc", + "category": "windows", + "kind": "native", + "template": "togglefloating", + "usage": "togglefloating" + }, + { + "id": "mangowc/togglefullscreen", + "source": "mangowc", + "category": "windows", + "kind": "native", + "template": "togglefullscreen", + "usage": "togglefullscreen" + }, + { + "id": "mangowc/toggleglobal", + "source": "mangowc", + "category": "windows", + "kind": "native", + "template": "toggleglobal", + "usage": "toggleglobal" + }, + { + "id": "mangowc/togglemaximizescreen", + "source": "mangowc", + "category": "windows", + "kind": "native", + "template": "togglemaximizescreen", + "usage": "togglemaximizescreen" + }, + { + "id": "mangowc/zoom", + "source": "mangowc", + "category": "windows", + "kind": "native", + "template": "zoom", + "usage": "zoom" + }, + { + "id": "mangowc/comboview", + "source": "mangowc", + "category": "workspaces", + "kind": "native", + "template": "comboview,{{parameters}}", + "usage": "comboview 1-9" + }, + { + "id": "mangowc/focusmon", + "source": "mangowc", + "category": "workspaces", + "kind": "native", + "template": "focusmon,{{parameters}}", + "usage": "focusmon left/right/up/down/monitor_spec" + }, + { + "id": "mangowc/tag", + "source": "mangowc", + "category": "workspaces", + "kind": "native", + "template": "tag,{{parameters}}", + "usage": "tag 1-9 [,synctag]" + }, + { + "id": "mangowc/tagcrossmon", + "source": "mangowc", + "category": "workspaces", + "kind": "native", + "template": "tagcrossmon,{{parameters}}", + "usage": "tagcrossmon tag,monitor_spec" + }, + { + "id": "mangowc/tagmon", + "source": "mangowc", + "category": "workspaces", + "kind": "native", + "template": "tagmon,{{parameters}}", + "usage": "tagmon left/right/up/down/monitor_spec,[keeptag]" + }, + { + "id": "mangowc/tagsilent", + "source": "mangowc", + "category": "workspaces", + "kind": "native", + "template": "tagsilent,{{parameters}}", + "usage": "tagsilent 1-9" + }, + { + "id": "mangowc/tagtoleft", + "source": "mangowc", + "category": "workspaces", + "kind": "native", + "template": "tagtoleft", + "usage": "tagtoleft [synctag]" + }, + { + "id": "mangowc/tagtoright", + "source": "mangowc", + "category": "workspaces", + "kind": "native", + "template": "tagtoright", + "usage": "tagtoright [synctag]" + }, + { + "id": "mangowc/toggletag", + "source": "mangowc", + "category": "workspaces", + "kind": "native", + "template": "toggletag,{{parameters}}", + "usage": "toggletag 0-9" + }, + { + "id": "mangowc/toggleview", + "source": "mangowc", + "category": "workspaces", + "kind": "native", + "template": "toggleview,{{parameters}}", + "usage": "toggleview 1-9" + }, + { + "id": "mangowc/view", + "source": "mangowc", + "category": "workspaces", + "kind": "native", + "template": "view,{{parameters}}", + "usage": "view -1/0/1-9` or `mask [,synctag]" + }, + { + "id": "mangowc/viewcrossmon", + "source": "mangowc", + "category": "workspaces", + "kind": "native", + "template": "viewcrossmon,{{parameters}}", + "usage": "viewcrossmon tag,monitor_spec" + }, + { + "id": "mangowc/viewtoleft", + "source": "mangowc", + "category": "workspaces", + "kind": "native", + "template": "viewtoleft", + "usage": "viewtoleft [synctag]" + }, + { + "id": "mangowc/viewtoleft_have_client", + "source": "mangowc", + "category": "workspaces", + "kind": "native", + "template": "viewtoleft_have_client", + "usage": "viewtoleft_have_client [synctag]" + }, + { + "id": "mangowc/viewtoright", + "source": "mangowc", + "category": "workspaces", + "kind": "native", + "template": "viewtoright", + "usage": "viewtoright [synctag]" + }, + { + "id": "mangowc/viewtoright_have_client", + "source": "mangowc", + "category": "workspaces", + "kind": "native", + "template": "viewtoright_have_client", + "usage": "viewtoright_have_client [synctag]" + }, + { + "id": "niri/spawn", + "source": "niri", + "category": "applications", + "kind": "native", + "template": "spawn {{arguments}}", + "usage": "spawn " + }, + { + "id": "niri/spawn-sh", + "source": "niri", + "category": "applications", + "kind": "native", + "template": "spawn-sh {{command}}", + "usage": "spawn-sh " + }, + { + "id": "niri/clear-dynamic-cast-target", + "source": "niri", + "category": "casting", + "kind": "native", + "template": "clear-dynamic-cast-target", + "usage": "clear-dynamic-cast-target" + }, + { + "id": "niri/focus-column", + "source": "niri", + "category": "focus", + "kind": "native", + "template": "focus-column {{argument}}", + "usage": "focus-column " + }, + { + "id": "niri/focus-column-first", + "source": "niri", + "category": "focus", + "kind": "native", + "template": "focus-column-first", + "usage": "focus-column-first" + }, + { + "id": "niri/focus-column-last", + "source": "niri", + "category": "focus", + "kind": "native", + "template": "focus-column-last", + "usage": "focus-column-last" + }, + { + "id": "niri/focus-column-left", + "source": "niri", + "category": "focus", + "kind": "native", + "template": "focus-column-left", + "usage": "focus-column-left" + }, + { + "id": "niri/focus-column-left-or-last", + "source": "niri", + "category": "focus", + "kind": "native", + "template": "focus-column-left-or-last", + "usage": "focus-column-left-or-last" + }, + { + "id": "niri/focus-column-or-monitor-left", + "source": "niri", + "category": "focus", + "kind": "native", + "template": "focus-column-or-monitor-left", + "usage": "focus-column-or-monitor-left" + }, + { + "id": "niri/focus-column-or-monitor-right", + "source": "niri", + "category": "focus", + "kind": "native", + "template": "focus-column-or-monitor-right", + "usage": "focus-column-or-monitor-right" + }, + { + "id": "niri/focus-column-right", + "source": "niri", + "category": "focus", + "kind": "native", + "template": "focus-column-right", + "usage": "focus-column-right" + }, + { + "id": "niri/focus-column-right-or-first", + "source": "niri", + "category": "focus", + "kind": "native", + "template": "focus-column-right-or-first", + "usage": "focus-column-right-or-first" + }, + { + "id": "niri/focus-floating", + "source": "niri", + "category": "focus", + "kind": "native", + "template": "focus-floating", + "usage": "focus-floating" + }, + { + "id": "niri/focus-monitor", + "source": "niri", + "category": "focus", + "kind": "native", + "template": "focus-monitor {{argument}}", + "usage": "focus-monitor " + }, + { + "id": "niri/focus-monitor-down", + "source": "niri", + "category": "focus", + "kind": "native", + "template": "focus-monitor-down", + "usage": "focus-monitor-down" + }, + { + "id": "niri/focus-monitor-left", + "source": "niri", + "category": "focus", + "kind": "native", + "template": "focus-monitor-left", + "usage": "focus-monitor-left" + }, + { + "id": "niri/focus-monitor-next", + "source": "niri", + "category": "focus", + "kind": "native", + "template": "focus-monitor-next", + "usage": "focus-monitor-next" + }, + { + "id": "niri/focus-monitor-previous", + "source": "niri", + "category": "focus", + "kind": "native", + "template": "focus-monitor-previous", + "usage": "focus-monitor-previous" + }, + { + "id": "niri/focus-monitor-right", + "source": "niri", + "category": "focus", + "kind": "native", + "template": "focus-monitor-right", + "usage": "focus-monitor-right" + }, + { + "id": "niri/focus-monitor-up", + "source": "niri", + "category": "focus", + "kind": "native", + "template": "focus-monitor-up", + "usage": "focus-monitor-up" + }, + { + "id": "niri/focus-tiling", + "source": "niri", + "category": "focus", + "kind": "native", + "template": "focus-tiling", + "usage": "focus-tiling" + }, + { + "id": "niri/focus-window-bottom", + "source": "niri", + "category": "focus", + "kind": "native", + "template": "focus-window-bottom", + "usage": "focus-window-bottom" + }, + { + "id": "niri/focus-window-down", + "source": "niri", + "category": "focus", + "kind": "native", + "template": "focus-window-down", + "usage": "focus-window-down" + }, + { + "id": "niri/focus-window-down-or-column-left", + "source": "niri", + "category": "focus", + "kind": "native", + "template": "focus-window-down-or-column-left", + "usage": "focus-window-down-or-column-left" + }, + { + "id": "niri/focus-window-down-or-column-right", + "source": "niri", + "category": "focus", + "kind": "native", + "template": "focus-window-down-or-column-right", + "usage": "focus-window-down-or-column-right" + }, + { + "id": "niri/focus-window-down-or-top", + "source": "niri", + "category": "focus", + "kind": "native", + "template": "focus-window-down-or-top", + "usage": "focus-window-down-or-top" + }, + { + "id": "niri/focus-window-in-column", + "source": "niri", + "category": "focus", + "kind": "native", + "template": "focus-window-in-column {{argument}}", + "usage": "focus-window-in-column " + }, + { + "id": "niri/focus-window-or-monitor-down", + "source": "niri", + "category": "focus", + "kind": "native", + "template": "focus-window-or-monitor-down", + "usage": "focus-window-or-monitor-down" + }, + { + "id": "niri/focus-window-or-monitor-up", + "source": "niri", + "category": "focus", + "kind": "native", + "template": "focus-window-or-monitor-up", + "usage": "focus-window-or-monitor-up" + }, + { + "id": "niri/focus-window-or-workspace-down", + "source": "niri", + "category": "focus", + "kind": "native", + "template": "focus-window-or-workspace-down", + "usage": "focus-window-or-workspace-down" + }, + { + "id": "niri/focus-window-or-workspace-up", + "source": "niri", + "category": "focus", + "kind": "native", + "template": "focus-window-or-workspace-up", + "usage": "focus-window-or-workspace-up" + }, + { + "id": "niri/focus-window-previous", + "source": "niri", + "category": "focus", + "kind": "native", + "template": "focus-window-previous", + "usage": "focus-window-previous" + }, + { + "id": "niri/focus-window-top", + "source": "niri", + "category": "focus", + "kind": "native", + "template": "focus-window-top", + "usage": "focus-window-top" + }, + { + "id": "niri/focus-window-up", + "source": "niri", + "category": "focus", + "kind": "native", + "template": "focus-window-up", + "usage": "focus-window-up" + }, + { + "id": "niri/focus-window-up-or-bottom", + "source": "niri", + "category": "focus", + "kind": "native", + "template": "focus-window-up-or-bottom", + "usage": "focus-window-up-or-bottom" + }, + { + "id": "niri/focus-window-up-or-column-left", + "source": "niri", + "category": "focus", + "kind": "native", + "template": "focus-window-up-or-column-left", + "usage": "focus-window-up-or-column-left" + }, + { + "id": "niri/focus-window-up-or-column-right", + "source": "niri", + "category": "focus", + "kind": "native", + "template": "focus-window-up-or-column-right", + "usage": "focus-window-up-or-column-right" + }, + { + "id": "niri/focus-workspace", + "source": "niri", + "category": "focus", + "kind": "native", + "template": "focus-workspace {{workspace}}", + "usage": "focus-workspace " + }, + { + "id": "niri/focus-workspace-down", + "source": "niri", + "category": "focus", + "kind": "native", + "template": "focus-workspace-down", + "usage": "focus-workspace-down" + }, + { + "id": "niri/focus-workspace-previous", + "source": "niri", + "category": "focus", + "kind": "native", + "template": "focus-workspace-previous", + "usage": "focus-workspace-previous" + }, + { + "id": "niri/focus-workspace-up", + "source": "niri", + "category": "focus", + "kind": "native", + "template": "focus-workspace-up", + "usage": "focus-workspace-up" + }, + { + "id": "niri/toggle-keyboard-shortcuts-inhibit", + "source": "niri", + "category": "input", + "kind": "native", + "template": "toggle-keyboard-shortcuts-inhibit", + "usage": "toggle-keyboard-shortcuts-inhibit" + }, + { + "id": "niri/close-overview", + "source": "niri", + "category": "layout", + "kind": "native", + "template": "close-overview", + "usage": "close-overview" + }, + { + "id": "niri/open-overview", + "source": "niri", + "category": "layout", + "kind": "native", + "template": "open-overview", + "usage": "open-overview" + }, + { + "id": "niri/switch-layout", + "source": "niri", + "category": "layout", + "kind": "native", + "template": "switch-layout {{layout}}", + "usage": "switch-layout " + }, + { + "id": "niri/toggle-overview", + "source": "niri", + "category": "layout", + "kind": "native", + "template": "toggle-overview", + "usage": "toggle-overview" + }, + { + "id": "niri/power-off-monitors", + "source": "niri", + "category": "monitors", + "kind": "native", + "template": "power-off-monitors", + "usage": "power-off-monitors" + }, + { + "id": "niri/power-on-monitors", + "source": "niri", + "category": "monitors", + "kind": "native", + "template": "power-on-monitors", + "usage": "power-on-monitors" + }, + { + "id": "niri/set-dynamic-cast-monitor", + "source": "niri", + "category": "monitors", + "kind": "native", + "template": "set-dynamic-cast-monitor", + "usage": "set-dynamic-cast-monitor" + }, + { + "id": "niri/quit", + "source": "niri", + "category": "power", + "kind": "native", + "template": "quit", + "usage": "quit" + }, + { + "id": "niri/suspend", + "source": "niri", + "category": "power", + "kind": "native", + "template": "suspend", + "usage": "suspend" + }, + { + "id": "niri/do-screen-transition", + "source": "niri", + "category": "screenshots", + "kind": "native", + "template": "do-screen-transition", + "usage": "do-screen-transition" + }, + { + "id": "niri/screenshot", + "source": "niri", + "category": "screenshots", + "kind": "native", + "template": "screenshot", + "usage": "screenshot" + }, + { + "id": "niri/screenshot-screen", + "source": "niri", + "category": "screenshots", + "kind": "native", + "template": "screenshot-screen", + "usage": "screenshot-screen" + }, + { + "id": "niri/screenshot-window", + "source": "niri", + "category": "screenshots", + "kind": "native", + "template": "screenshot-window", + "usage": "screenshot-window" + }, + { + "id": "niri/debug-toggle-damage", + "source": "niri", + "category": "system", + "kind": "native", + "template": "debug-toggle-damage", + "usage": "debug-toggle-damage" + }, + { + "id": "niri/debug-toggle-opaque-regions", + "source": "niri", + "category": "system", + "kind": "native", + "template": "debug-toggle-opaque-regions", + "usage": "debug-toggle-opaque-regions" + }, + { + "id": "niri/show-hotkey-overlay", + "source": "niri", + "category": "system", + "kind": "native", + "template": "show-hotkey-overlay", + "usage": "show-hotkey-overlay" + }, + { + "id": "niri/toggle-debug-tint", + "source": "niri", + "category": "system", + "kind": "native", + "template": "toggle-debug-tint", + "usage": "toggle-debug-tint" + }, + { + "id": "niri/center-column", + "source": "niri", + "category": "windows", + "kind": "native", + "template": "center-column", + "usage": "center-column" + }, + { + "id": "niri/center-visible-columns", + "source": "niri", + "category": "windows", + "kind": "native", + "template": "center-visible-columns", + "usage": "center-visible-columns" + }, + { + "id": "niri/center-window", + "source": "niri", + "category": "windows", + "kind": "native", + "template": "center-window", + "usage": "center-window" + }, + { + "id": "niri/close-window", + "source": "niri", + "category": "windows", + "kind": "native", + "template": "close-window", + "usage": "close-window" + }, + { + "id": "niri/consume-or-expel-window-left", + "source": "niri", + "category": "windows", + "kind": "native", + "template": "consume-or-expel-window-left", + "usage": "consume-or-expel-window-left" + }, + { + "id": "niri/consume-or-expel-window-right", + "source": "niri", + "category": "windows", + "kind": "native", + "template": "consume-or-expel-window-right", + "usage": "consume-or-expel-window-right" + }, + { + "id": "niri/consume-window-into-column", + "source": "niri", + "category": "windows", + "kind": "native", + "template": "consume-window-into-column", + "usage": "consume-window-into-column" + }, + { + "id": "niri/expand-column-to-available-width", + "source": "niri", + "category": "windows", + "kind": "native", + "template": "expand-column-to-available-width", + "usage": "expand-column-to-available-width" + }, + { + "id": "niri/expel-window-from-column", + "source": "niri", + "category": "windows", + "kind": "native", + "template": "expel-window-from-column", + "usage": "expel-window-from-column" + }, + { + "id": "niri/fullscreen-window", + "source": "niri", + "category": "windows", + "kind": "native", + "template": "fullscreen-window", + "usage": "fullscreen-window" + }, + { + "id": "niri/maximize-column", + "source": "niri", + "category": "windows", + "kind": "native", + "template": "maximize-column", + "usage": "maximize-column" + }, + { + "id": "niri/maximize-window-to-edges", + "source": "niri", + "category": "windows", + "kind": "native", + "template": "maximize-window-to-edges", + "usage": "maximize-window-to-edges" + }, + { + "id": "niri/move-column-left", + "source": "niri", + "category": "windows", + "kind": "native", + "template": "move-column-left", + "usage": "move-column-left" + }, + { + "id": "niri/move-column-left-or-to-monitor-left", + "source": "niri", + "category": "windows", + "kind": "native", + "template": "move-column-left-or-to-monitor-left", + "usage": "move-column-left-or-to-monitor-left" + }, + { + "id": "niri/move-column-right", + "source": "niri", + "category": "windows", + "kind": "native", + "template": "move-column-right", + "usage": "move-column-right" + }, + { + "id": "niri/move-column-right-or-to-monitor-right", + "source": "niri", + "category": "windows", + "kind": "native", + "template": "move-column-right-or-to-monitor-right", + "usage": "move-column-right-or-to-monitor-right" + }, + { + "id": "niri/move-column-to-first", + "source": "niri", + "category": "windows", + "kind": "native", + "template": "move-column-to-first", + "usage": "move-column-to-first" + }, + { + "id": "niri/move-column-to-index", + "source": "niri", + "category": "windows", + "kind": "native", + "template": "move-column-to-index {{argument}}", + "usage": "move-column-to-index " + }, + { + "id": "niri/move-column-to-last", + "source": "niri", + "category": "windows", + "kind": "native", + "template": "move-column-to-last", + "usage": "move-column-to-last" + }, + { + "id": "niri/move-column-to-monitor", + "source": "niri", + "category": "windows", + "kind": "native", + "template": "move-column-to-monitor {{argument}}", + "usage": "move-column-to-monitor " + }, + { + "id": "niri/move-column-to-monitor-down", + "source": "niri", + "category": "windows", + "kind": "native", + "template": "move-column-to-monitor-down", + "usage": "move-column-to-monitor-down" + }, + { + "id": "niri/move-column-to-monitor-left", + "source": "niri", + "category": "windows", + "kind": "native", + "template": "move-column-to-monitor-left", + "usage": "move-column-to-monitor-left" + }, + { + "id": "niri/move-column-to-monitor-next", + "source": "niri", + "category": "windows", + "kind": "native", + "template": "move-column-to-monitor-next", + "usage": "move-column-to-monitor-next" + }, + { + "id": "niri/move-column-to-monitor-previous", + "source": "niri", + "category": "windows", + "kind": "native", + "template": "move-column-to-monitor-previous", + "usage": "move-column-to-monitor-previous" + }, + { + "id": "niri/move-column-to-monitor-right", + "source": "niri", + "category": "windows", + "kind": "native", + "template": "move-column-to-monitor-right", + "usage": "move-column-to-monitor-right" + }, + { + "id": "niri/move-column-to-monitor-up", + "source": "niri", + "category": "windows", + "kind": "native", + "template": "move-column-to-monitor-up", + "usage": "move-column-to-monitor-up" + }, + { + "id": "niri/move-column-to-workspace", + "source": "niri", + "category": "windows", + "kind": "native", + "template": "move-column-to-workspace {{workspace}}", + "usage": "move-column-to-workspace " + }, + { + "id": "niri/move-column-to-workspace-down", + "source": "niri", + "category": "windows", + "kind": "native", + "template": "move-column-to-workspace-down", + "usage": "move-column-to-workspace-down" + }, + { + "id": "niri/move-column-to-workspace-up", + "source": "niri", + "category": "windows", + "kind": "native", + "template": "move-column-to-workspace-up", + "usage": "move-column-to-workspace-up" + }, + { + "id": "niri/move-window-down", + "source": "niri", + "category": "windows", + "kind": "native", + "template": "move-window-down", + "usage": "move-window-down" + }, + { + "id": "niri/move-window-down-or-to-workspace-down", + "source": "niri", + "category": "windows", + "kind": "native", + "template": "move-window-down-or-to-workspace-down", + "usage": "move-window-down-or-to-workspace-down" + }, + { + "id": "niri/move-window-to-floating", + "source": "niri", + "category": "windows", + "kind": "native", + "template": "move-window-to-floating", + "usage": "move-window-to-floating" + }, + { + "id": "niri/move-window-to-monitor", + "source": "niri", + "category": "windows", + "kind": "native", + "template": "move-window-to-monitor {{argument}}", + "usage": "move-window-to-monitor " + }, + { + "id": "niri/move-window-to-monitor-down", + "source": "niri", + "category": "windows", + "kind": "native", + "template": "move-window-to-monitor-down", + "usage": "move-window-to-monitor-down" + }, + { + "id": "niri/move-window-to-monitor-left", + "source": "niri", + "category": "windows", + "kind": "native", + "template": "move-window-to-monitor-left", + "usage": "move-window-to-monitor-left" + }, + { + "id": "niri/move-window-to-monitor-next", + "source": "niri", + "category": "windows", + "kind": "native", + "template": "move-window-to-monitor-next", + "usage": "move-window-to-monitor-next" + }, + { + "id": "niri/move-window-to-monitor-previous", + "source": "niri", + "category": "windows", + "kind": "native", + "template": "move-window-to-monitor-previous", + "usage": "move-window-to-monitor-previous" + }, + { + "id": "niri/move-window-to-monitor-right", + "source": "niri", + "category": "windows", + "kind": "native", + "template": "move-window-to-monitor-right", + "usage": "move-window-to-monitor-right" + }, + { + "id": "niri/move-window-to-monitor-up", + "source": "niri", + "category": "windows", + "kind": "native", + "template": "move-window-to-monitor-up", + "usage": "move-window-to-monitor-up" + }, + { + "id": "niri/move-window-to-tiling", + "source": "niri", + "category": "windows", + "kind": "native", + "template": "move-window-to-tiling", + "usage": "move-window-to-tiling" + }, + { + "id": "niri/move-window-to-workspace", + "source": "niri", + "category": "windows", + "kind": "native", + "template": "move-window-to-workspace {{workspace}}", + "usage": "move-window-to-workspace " + }, + { + "id": "niri/move-window-to-workspace-down", + "source": "niri", + "category": "windows", + "kind": "native", + "template": "move-window-to-workspace-down", + "usage": "move-window-to-workspace-down" + }, + { + "id": "niri/move-window-to-workspace-up", + "source": "niri", + "category": "windows", + "kind": "native", + "template": "move-window-to-workspace-up", + "usage": "move-window-to-workspace-up" + }, + { + "id": "niri/move-window-up", + "source": "niri", + "category": "windows", + "kind": "native", + "template": "move-window-up", + "usage": "move-window-up" + }, + { + "id": "niri/move-window-up-or-to-workspace-up", + "source": "niri", + "category": "windows", + "kind": "native", + "template": "move-window-up-or-to-workspace-up", + "usage": "move-window-up-or-to-workspace-up" + }, + { + "id": "niri/reset-window-height", + "source": "niri", + "category": "windows", + "kind": "native", + "template": "reset-window-height", + "usage": "reset-window-height" + }, + { + "id": "niri/set-column-display", + "source": "niri", + "category": "windows", + "kind": "native", + "template": "set-column-display {{argument}}", + "usage": "set-column-display " + }, + { + "id": "niri/set-column-width", + "source": "niri", + "category": "windows", + "kind": "native", + "template": "set-column-width {{size}}", + "usage": "set-column-width " + }, + { + "id": "niri/set-dynamic-cast-window", + "source": "niri", + "category": "windows", + "kind": "native", + "template": "set-dynamic-cast-window", + "usage": "set-dynamic-cast-window" + }, + { + "id": "niri/set-window-height", + "source": "niri", + "category": "windows", + "kind": "native", + "template": "set-window-height {{size}}", + "usage": "set-window-height " + }, + { + "id": "niri/set-window-width", + "source": "niri", + "category": "windows", + "kind": "native", + "template": "set-window-width {{size}}", + "usage": "set-window-width " + }, + { + "id": "niri/swap-window-left", + "source": "niri", + "category": "windows", + "kind": "native", + "template": "swap-window-left", + "usage": "swap-window-left" + }, + { + "id": "niri/swap-window-right", + "source": "niri", + "category": "windows", + "kind": "native", + "template": "swap-window-right", + "usage": "swap-window-right" + }, + { + "id": "niri/switch-focus-between-floating-and-tiling", + "source": "niri", + "category": "windows", + "kind": "native", + "template": "switch-focus-between-floating-and-tiling", + "usage": "switch-focus-between-floating-and-tiling" + }, + { + "id": "niri/switch-preset-column-width", + "source": "niri", + "category": "windows", + "kind": "native", + "template": "switch-preset-column-width", + "usage": "switch-preset-column-width" + }, + { + "id": "niri/switch-preset-column-width-back", + "source": "niri", + "category": "windows", + "kind": "native", + "template": "switch-preset-column-width-back", + "usage": "switch-preset-column-width-back" + }, + { + "id": "niri/switch-preset-window-height", + "source": "niri", + "category": "windows", + "kind": "native", + "template": "switch-preset-window-height", + "usage": "switch-preset-window-height" + }, + { + "id": "niri/switch-preset-window-height-back", + "source": "niri", + "category": "windows", + "kind": "native", + "template": "switch-preset-window-height-back", + "usage": "switch-preset-window-height-back" + }, + { + "id": "niri/switch-preset-window-width", + "source": "niri", + "category": "windows", + "kind": "native", + "template": "switch-preset-window-width", + "usage": "switch-preset-window-width" + }, + { + "id": "niri/switch-preset-window-width-back", + "source": "niri", + "category": "windows", + "kind": "native", + "template": "switch-preset-window-width-back", + "usage": "switch-preset-window-width-back" + }, + { + "id": "niri/toggle-column-tabbed-display", + "source": "niri", + "category": "windows", + "kind": "native", + "template": "toggle-column-tabbed-display", + "usage": "toggle-column-tabbed-display" + }, + { + "id": "niri/toggle-window-floating", + "source": "niri", + "category": "windows", + "kind": "native", + "template": "toggle-window-floating", + "usage": "toggle-window-floating" + }, + { + "id": "niri/toggle-window-rule-opacity", + "source": "niri", + "category": "windows", + "kind": "native", + "template": "toggle-window-rule-opacity", + "usage": "toggle-window-rule-opacity" + }, + { + "id": "niri/toggle-windowed-fullscreen", + "source": "niri", + "category": "windows", + "kind": "native", + "template": "toggle-windowed-fullscreen", + "usage": "toggle-windowed-fullscreen" + }, + { + "id": "niri/move-workspace-down", + "source": "niri", + "category": "workspaces", + "kind": "native", + "template": "move-workspace-down", + "usage": "move-workspace-down" + }, + { + "id": "niri/move-workspace-to-index", + "source": "niri", + "category": "workspaces", + "kind": "native", + "template": "move-workspace-to-index {{argument}}", + "usage": "move-workspace-to-index " + }, + { + "id": "niri/move-workspace-to-monitor", + "source": "niri", + "category": "workspaces", + "kind": "native", + "template": "move-workspace-to-monitor {{argument}}", + "usage": "move-workspace-to-monitor " + }, + { + "id": "niri/move-workspace-to-monitor-down", + "source": "niri", + "category": "workspaces", + "kind": "native", + "template": "move-workspace-to-monitor-down", + "usage": "move-workspace-to-monitor-down" + }, + { + "id": "niri/move-workspace-to-monitor-left", + "source": "niri", + "category": "workspaces", + "kind": "native", + "template": "move-workspace-to-monitor-left", + "usage": "move-workspace-to-monitor-left" + }, + { + "id": "niri/move-workspace-to-monitor-next", + "source": "niri", + "category": "workspaces", + "kind": "native", + "template": "move-workspace-to-monitor-next", + "usage": "move-workspace-to-monitor-next" + }, + { + "id": "niri/move-workspace-to-monitor-previous", + "source": "niri", + "category": "workspaces", + "kind": "native", + "template": "move-workspace-to-monitor-previous", + "usage": "move-workspace-to-monitor-previous" + }, + { + "id": "niri/move-workspace-to-monitor-right", + "source": "niri", + "category": "workspaces", + "kind": "native", + "template": "move-workspace-to-monitor-right", + "usage": "move-workspace-to-monitor-right" + }, + { + "id": "niri/move-workspace-to-monitor-up", + "source": "niri", + "category": "workspaces", + "kind": "native", + "template": "move-workspace-to-monitor-up", + "usage": "move-workspace-to-monitor-up" + }, + { + "id": "niri/move-workspace-up", + "source": "niri", + "category": "workspaces", + "kind": "native", + "template": "move-workspace-up", + "usage": "move-workspace-up" + }, + { + "id": "niri/set-workspace-name", + "source": "niri", + "category": "workspaces", + "kind": "native", + "template": "set-workspace-name {{argument}}", + "usage": "set-workspace-name " + }, + { + "id": "niri/unset-workspace-name", + "source": "niri", + "category": "workspaces", + "kind": "native", + "template": "unset-workspace-name", + "usage": "unset-workspace-name" + }, + { + "id": "noctalia/color-scheme-get", + "source": "noctalia", + "category": "appearance", + "kind": "shell", + "template": "noctalia msg color-scheme-get", + "usage": "color-scheme-get" + }, + { + "id": "noctalia/color-scheme-set", + "source": "noctalia", + "category": "appearance", + "kind": "shell", + "template": "noctalia msg color-scheme-set {{source}} {{name}}", + "usage": "color-scheme-set " + }, + { + "id": "noctalia/templates-apply", + "source": "noctalia", + "category": "appearance", + "kind": "shell", + "template": "noctalia msg templates-apply", + "usage": "templates-apply" + }, + { + "id": "noctalia/theme-mode-get", + "source": "noctalia", + "category": "appearance", + "kind": "shell", + "template": "noctalia msg theme-mode-get", + "usage": "theme-mode-get" + }, + { + "id": "noctalia/theme-mode-set", + "source": "noctalia", + "category": "appearance", + "kind": "shell", + "template": "noctalia msg theme-mode-set {{dark-light-auto}}", + "usage": "theme-mode-set " + }, + { + "id": "noctalia/theme-mode-toggle", + "source": "noctalia", + "category": "appearance", + "kind": "shell", + "template": "noctalia msg theme-mode-toggle", + "usage": "theme-mode-toggle" + }, + { + "id": "noctalia/wallpaper-get", + "source": "noctalia", + "category": "appearance", + "kind": "shell", + "template": "noctalia msg wallpaper-get {{connector}}", + "usage": "wallpaper-get []" + }, + { + "id": "noctalia/wallpaper-next", + "source": "noctalia", + "category": "appearance", + "kind": "shell", + "template": "noctalia msg wallpaper-next {{connector}}", + "usage": "wallpaper-next []" + }, + { + "id": "noctalia/wallpaper-previous", + "source": "noctalia", + "category": "appearance", + "kind": "shell", + "template": "noctalia msg wallpaper-previous {{connector}}", + "usage": "wallpaper-previous []" + }, + { + "id": "noctalia/wallpaper-random", + "source": "noctalia", + "category": "appearance", + "kind": "shell", + "template": "noctalia msg wallpaper-random {{connector}}", + "usage": "wallpaper-random []" + }, + { + "id": "noctalia/wallpaper-set", + "source": "noctalia", + "category": "appearance", + "kind": "shell", + "template": "noctalia msg wallpaper-set {{connector}} {{path}}", + "usage": "wallpaper-set [] " + }, + { + "id": "noctalia/effects-profile-set", + "source": "noctalia", + "category": "audio", + "kind": "shell", + "template": "noctalia msg effects-profile-set {{output-input}} {{profile}}", + "usage": "effects-profile-set " + }, + { + "id": "noctalia/mic-mute", + "source": "noctalia", + "category": "audio", + "kind": "shell", + "template": "noctalia msg mic-mute", + "usage": "mic-mute" + }, + { + "id": "noctalia/mic-volume-down", + "source": "noctalia", + "category": "audio", + "kind": "shell", + "template": "noctalia msg mic-volume-down", + "usage": "mic-volume-down [step]" + }, + { + "id": "noctalia/mic-volume-set", + "source": "noctalia", + "category": "audio", + "kind": "shell", + "template": "noctalia msg mic-volume-set {{value}}", + "usage": "mic-volume-set " + }, + { + "id": "noctalia/mic-volume-up", + "source": "noctalia", + "category": "audio", + "kind": "shell", + "template": "noctalia msg mic-volume-up", + "usage": "mic-volume-up [step]" + }, + { + "id": "noctalia/volume-down", + "source": "noctalia", + "category": "audio", + "kind": "shell", + "template": "noctalia msg volume-down", + "usage": "volume-down [step]" + }, + { + "id": "noctalia/volume-mute", + "source": "noctalia", + "category": "audio", + "kind": "shell", + "template": "noctalia msg volume-mute", + "usage": "volume-mute" + }, + { + "id": "noctalia/volume-set", + "source": "noctalia", + "category": "audio", + "kind": "shell", + "template": "noctalia msg volume-set {{value}}", + "usage": "volume-set " + }, + { + "id": "noctalia/volume-up", + "source": "noctalia", + "category": "audio", + "kind": "shell", + "template": "noctalia msg volume-up", + "usage": "volume-up [step]" + }, + { + "id": "noctalia/bar-auto-hide-set", + "source": "noctalia", + "category": "bars", + "kind": "shell", + "template": "noctalia msg bar-auto-hide-set {{on-off-true-false-1-0}}", + "usage": "bar-auto-hide-set [bar-name] [monitor-selector]" + }, + { + "id": "noctalia/bar-hide", + "source": "noctalia", + "category": "bars", + "kind": "shell", + "template": "noctalia msg bar-hide", + "usage": "bar-hide [bar-name] [monitor-selector]" + }, + { + "id": "noctalia/bar-layer-set", + "source": "noctalia", + "category": "bars", + "kind": "shell", + "template": "noctalia msg bar-layer-set {{top-overlay}}", + "usage": "bar-layer-set [bar-name] [monitor-selector]" + }, + { + "id": "noctalia/bar-reserve-toggle", + "source": "noctalia", + "category": "bars", + "kind": "shell", + "template": "noctalia msg bar-reserve-toggle", + "usage": "bar-reserve-toggle [bar-name] [monitor-selector]" + }, + { + "id": "noctalia/bar-show", + "source": "noctalia", + "category": "bars", + "kind": "shell", + "template": "noctalia msg bar-show", + "usage": "bar-show [bar-name] [monitor-selector]" + }, + { + "id": "noctalia/bar-toggle", + "source": "noctalia", + "category": "bars", + "kind": "shell", + "template": "noctalia msg bar-toggle", + "usage": "bar-toggle [bar-name] [monitor-selector]" + }, + { + "id": "noctalia/clipboard-clear", + "source": "noctalia", + "category": "clipboard", + "kind": "shell", + "template": "noctalia msg clipboard-clear", + "usage": "clipboard-clear" + }, + { + "id": "noctalia/bluetooth-disable", + "source": "noctalia", + "category": "connectivity", + "kind": "shell", + "template": "noctalia msg bluetooth-disable", + "usage": "bluetooth-disable" + }, + { + "id": "noctalia/bluetooth-enable", + "source": "noctalia", + "category": "connectivity", + "kind": "shell", + "template": "noctalia msg bluetooth-enable", + "usage": "bluetooth-enable" + }, + { + "id": "noctalia/bluetooth-status", + "source": "noctalia", + "category": "connectivity", + "kind": "shell", + "template": "noctalia msg bluetooth-status", + "usage": "bluetooth-status" + }, + { + "id": "noctalia/bluetooth-toggle", + "source": "noctalia", + "category": "connectivity", + "kind": "shell", + "template": "noctalia msg bluetooth-toggle", + "usage": "bluetooth-toggle" + }, + { + "id": "noctalia/wifi-disable", + "source": "noctalia", + "category": "connectivity", + "kind": "shell", + "template": "noctalia msg wifi-disable", + "usage": "wifi-disable" + }, + { + "id": "noctalia/wifi-enable", + "source": "noctalia", + "category": "connectivity", + "kind": "shell", + "template": "noctalia msg wifi-enable", + "usage": "wifi-enable" + }, + { + "id": "noctalia/wifi-status", + "source": "noctalia", + "category": "connectivity", + "kind": "shell", + "template": "noctalia msg wifi-status", + "usage": "wifi-status" + }, + { + "id": "noctalia/wifi-toggle", + "source": "noctalia", + "category": "connectivity", + "kind": "shell", + "template": "noctalia msg wifi-toggle", + "usage": "wifi-toggle" + }, + { + "id": "noctalia/brightness-down", + "source": "noctalia", + "category": "display", + "kind": "shell", + "template": "noctalia msg brightness-down", + "usage": "brightness-down [current|*|all|monitor-selector] [step]" + }, + { + "id": "noctalia/brightness-list-backlight-devices", + "source": "noctalia", + "category": "display", + "kind": "shell", + "template": "noctalia msg brightness-list-backlight-devices", + "usage": "brightness-list-backlight-devices" + }, + { + "id": "noctalia/brightness-osd", + "source": "noctalia", + "category": "display", + "kind": "shell", + "template": "noctalia msg brightness-osd {{value}}", + "usage": "brightness-osd " + }, + { + "id": "noctalia/brightness-set", + "source": "noctalia", + "category": "display", + "kind": "shell", + "template": "noctalia msg brightness-set {{value}}", + "usage": "brightness-set | brightness-set " + }, + { + "id": "noctalia/brightness-up", + "source": "noctalia", + "category": "display", + "kind": "shell", + "template": "noctalia msg brightness-up", + "usage": "brightness-up [current|*|all|monitor-selector] [step]" + }, + { + "id": "noctalia/dpms-off", + "source": "noctalia", + "category": "display", + "kind": "shell", + "template": "noctalia msg dpms-off", + "usage": "dpms-off" + }, + { + "id": "noctalia/dpms-on", + "source": "noctalia", + "category": "display", + "kind": "shell", + "template": "noctalia msg dpms-on", + "usage": "dpms-on" + }, + { + "id": "noctalia/keyboard-backlight-down", + "source": "noctalia", + "category": "display", + "kind": "shell", + "template": "noctalia msg keyboard-backlight-down", + "usage": "keyboard-backlight-down" + }, + { + "id": "noctalia/keyboard-backlight-osd", + "source": "noctalia", + "category": "display", + "kind": "shell", + "template": "noctalia msg keyboard-backlight-osd {{value}}", + "usage": "keyboard-backlight-osd " + }, + { + "id": "noctalia/keyboard-backlight-set", + "source": "noctalia", + "category": "display", + "kind": "shell", + "template": "noctalia msg keyboard-backlight-set {{value}}", + "usage": "keyboard-backlight-set " + }, + { + "id": "noctalia/keyboard-backlight-toggle", + "source": "noctalia", + "category": "display", + "kind": "shell", + "template": "noctalia msg keyboard-backlight-toggle", + "usage": "keyboard-backlight-toggle" + }, + { + "id": "noctalia/keyboard-backlight-up", + "source": "noctalia", + "category": "display", + "kind": "shell", + "template": "noctalia msg keyboard-backlight-up", + "usage": "keyboard-backlight-up" + }, + { + "id": "noctalia/media", + "source": "noctalia", + "category": "media", + "kind": "shell", + "template": "noctalia msg media {{next-previous-toggle-play-pause-stop-next-player-previous-player}}", + "usage": "media " + }, + { + "id": "noctalia/notification-clear-active", + "source": "noctalia", + "category": "notifications", + "kind": "shell", + "template": "noctalia msg notification-clear-active", + "usage": "notification-clear-active" + }, + { + "id": "noctalia/notification-clear-history", + "source": "noctalia", + "category": "notifications", + "kind": "shell", + "template": "noctalia msg notification-clear-history", + "usage": "notification-clear-history" + }, + { + "id": "noctalia/notification-dnd-set", + "source": "noctalia", + "category": "notifications", + "kind": "shell", + "template": "noctalia msg notification-dnd-set {{on-off-true-false-1-0}}", + "usage": "notification-dnd-set " + }, + { + "id": "noctalia/notification-dnd-status", + "source": "noctalia", + "category": "notifications", + "kind": "shell", + "template": "noctalia msg notification-dnd-status", + "usage": "notification-dnd-status" + }, + { + "id": "noctalia/notification-dnd-toggle", + "source": "noctalia", + "category": "notifications", + "kind": "shell", + "template": "noctalia msg notification-dnd-toggle", + "usage": "notification-dnd-toggle" + }, + { + "id": "noctalia/notification-invoke-latest", + "source": "noctalia", + "category": "notifications", + "kind": "shell", + "template": "noctalia msg notification-invoke-latest", + "usage": "notification-invoke-latest" + }, + { + "id": "noctalia/notification-show", + "source": "noctalia", + "category": "notifications", + "kind": "shell", + "template": "noctalia msg notification-show {{summary-body-json}}", + "usage": "notification-show " + }, + { + "id": "noctalia/dock-hide", + "source": "noctalia", + "category": "panels", + "kind": "shell", + "template": "noctalia msg dock-hide", + "usage": "dock-hide" + }, + { + "id": "noctalia/dock-reload", + "source": "noctalia", + "category": "panels", + "kind": "shell", + "template": "noctalia msg dock-reload", + "usage": "dock-reload" + }, + { + "id": "noctalia/dock-show", + "source": "noctalia", + "category": "panels", + "kind": "shell", + "template": "noctalia msg dock-show", + "usage": "dock-show" + }, + { + "id": "noctalia/dock-toggle", + "source": "noctalia", + "category": "panels", + "kind": "shell", + "template": "noctalia msg dock-toggle", + "usage": "dock-toggle" + }, + { + "id": "noctalia/panel-close", + "source": "noctalia", + "category": "panels", + "kind": "shell", + "template": "noctalia msg panel-close", + "usage": "panel-close [id]" + }, + { + "id": "noctalia/panel-open", + "source": "noctalia", + "category": "panels", + "kind": "shell", + "template": "noctalia msg panel-open {{id}}", + "usage": "panel-open [context]" + }, + { + "id": "noctalia/panel-toggle", + "source": "noctalia", + "category": "panels", + "kind": "shell", + "template": "noctalia msg panel-toggle {{id}}", + "usage": "panel-toggle [context]" + }, + { + "id": "noctalia/settings-close", + "source": "noctalia", + "category": "panels", + "kind": "shell", + "template": "noctalia msg settings-close", + "usage": "settings-close" + }, + { + "id": "noctalia/settings-open", + "source": "noctalia", + "category": "panels", + "kind": "shell", + "template": "noctalia msg settings-open", + "usage": "settings-open [context]" + }, + { + "id": "noctalia/settings-toggle", + "source": "noctalia", + "category": "panels", + "kind": "shell", + "template": "noctalia msg settings-toggle", + "usage": "settings-toggle [context]" + }, + { + "id": "noctalia/window-switcher", + "source": "noctalia", + "category": "panels", + "kind": "shell", + "template": "noctalia msg window-switcher", + "usage": "window-switcher [close]" + }, + { + "id": "noctalia/plugin", + "source": "noctalia", + "category": "plugins", + "kind": "shell", + "template": "noctalia msg plugin {{author-plugin-entry}} {{target-bar-name}} {{event}}", + "usage": "plugin [payload]" + }, + { + "id": "noctalia/plugins", + "source": "noctalia", + "category": "plugins", + "kind": "shell", + "template": "noctalia msg plugins {{list-enable-disable-update-source}}", + "usage": "plugins ..." + }, + { + "id": "noctalia/caffeine-disable", + "source": "noctalia", + "category": "power", + "kind": "shell", + "template": "noctalia msg caffeine-disable", + "usage": "caffeine-disable" + }, + { + "id": "noctalia/caffeine-enable", + "source": "noctalia", + "category": "power", + "kind": "shell", + "template": "noctalia msg caffeine-enable", + "usage": "caffeine-enable" + }, + { + "id": "noctalia/caffeine-toggle", + "source": "noctalia", + "category": "power", + "kind": "shell", + "template": "noctalia msg caffeine-toggle", + "usage": "caffeine-toggle" + }, + { + "id": "noctalia/nightlight-disable", + "source": "noctalia", + "category": "power", + "kind": "shell", + "template": "noctalia msg nightlight-disable", + "usage": "nightlight-disable" + }, + { + "id": "noctalia/nightlight-enable", + "source": "noctalia", + "category": "power", + "kind": "shell", + "template": "noctalia msg nightlight-enable", + "usage": "nightlight-enable" + }, + { + "id": "noctalia/nightlight-force-toggle", + "source": "noctalia", + "category": "power", + "kind": "shell", + "template": "noctalia msg nightlight-force-toggle", + "usage": "nightlight-force-toggle" + }, + { + "id": "noctalia/nightlight-toggle", + "source": "noctalia", + "category": "power", + "kind": "shell", + "template": "noctalia msg nightlight-toggle", + "usage": "nightlight-toggle" + }, + { + "id": "noctalia/power-cycle", + "source": "noctalia", + "category": "power", + "kind": "shell", + "template": "noctalia msg power-cycle", + "usage": "power-cycle" + }, + { + "id": "noctalia/power-set", + "source": "noctalia", + "category": "power", + "kind": "shell", + "template": "noctalia msg power-set {{profile}}", + "usage": "power-set " + }, + { + "id": "noctalia/screenshot-fullscreen", + "source": "noctalia", + "category": "screenshots", + "kind": "shell", + "template": "noctalia msg screenshot-fullscreen", + "usage": "screenshot-fullscreen [pick|monitor|all]" + }, + { + "id": "noctalia/screenshot-region", + "source": "noctalia", + "category": "screenshots", + "kind": "shell", + "template": "noctalia msg screenshot-region", + "usage": "screenshot-region" + }, + { + "id": "noctalia/session", + "source": "noctalia", + "category": "session", + "kind": "shell", + "template": "noctalia msg session {{lock-suspend-lock-and-suspend-logout-reboot-shutdown}}", + "usage": "session " + }, + { + "id": "noctalia/config-reload", + "source": "noctalia", + "category": "system", + "kind": "shell", + "template": "noctalia msg config-reload", + "usage": "config-reload" + }, + { + "id": "noctalia/log-level-set", + "source": "noctalia", + "category": "system", + "kind": "shell", + "template": "noctalia msg log-level-set {{debug-info-warn-error}}", + "usage": "log-level-set " + }, + { + "id": "noctalia/log-level-status", + "source": "noctalia", + "category": "system", + "kind": "shell", + "template": "noctalia msg log-level-status", + "usage": "log-level-status" + }, + { + "id": "noctalia/status", + "source": "noctalia", + "category": "system", + "kind": "shell", + "template": "noctalia msg status", + "usage": "status" + }, + { + "id": "noctalia/desktop-widgets-edit", + "source": "noctalia", + "category": "widgets", + "kind": "shell", + "template": "noctalia msg desktop-widgets-edit", + "usage": "desktop-widgets-edit" + }, + { + "id": "noctalia/desktop-widgets-exit", + "source": "noctalia", + "category": "widgets", + "kind": "shell", + "template": "noctalia msg desktop-widgets-exit", + "usage": "desktop-widgets-exit" + }, + { + "id": "noctalia/desktop-widgets-hide", + "source": "noctalia", + "category": "widgets", + "kind": "shell", + "template": "noctalia msg desktop-widgets-hide", + "usage": "desktop-widgets-hide" + }, + { + "id": "noctalia/desktop-widgets-show", + "source": "noctalia", + "category": "widgets", + "kind": "shell", + "template": "noctalia msg desktop-widgets-show", + "usage": "desktop-widgets-show" + }, + { + "id": "noctalia/desktop-widgets-toggle", + "source": "noctalia", + "category": "widgets", + "kind": "shell", + "template": "noctalia msg desktop-widgets-toggle", + "usage": "desktop-widgets-toggle" + }, + { + "id": "noctalia/desktop-widgets-toggle-edit", + "source": "noctalia", + "category": "widgets", + "kind": "shell", + "template": "noctalia msg desktop-widgets-toggle-edit", + "usage": "desktop-widgets-toggle-edit" + }, + { + "id": "noctalia/lockscreen-widgets-edit", + "source": "noctalia", + "category": "widgets", + "kind": "shell", + "template": "noctalia msg lockscreen-widgets-edit", + "usage": "lockscreen-widgets-edit" + }, + { + "id": "noctalia/lockscreen-widgets-exit", + "source": "noctalia", + "category": "widgets", + "kind": "shell", + "template": "noctalia msg lockscreen-widgets-exit", + "usage": "lockscreen-widgets-exit" + }, + { + "id": "noctalia/lockscreen-widgets-toggle-edit", + "source": "noctalia", + "category": "widgets", + "kind": "shell", + "template": "noctalia msg lockscreen-widgets-toggle-edit", + "usage": "lockscreen-widgets-toggle-edit" + }, + { + "id": "noctalia/workspace-alert-add", + "source": "noctalia", + "category": "workspaces", + "kind": "shell", + "template": "noctalia msg workspace-alert-add {{workspace}}", + "usage": "workspace-alert-add " + }, + { + "id": "noctalia/workspace-alert-add-window", + "source": "noctalia", + "category": "workspaces", + "kind": "shell", + "template": "noctalia msg workspace-alert-add-window {{window-id}}", + "usage": "workspace-alert-add-window " + }, + { + "id": "noctalia/workspace-alert-clear", + "source": "noctalia", + "category": "workspaces", + "kind": "shell", + "template": "noctalia msg workspace-alert-clear {{workspace}}", + "usage": "workspace-alert-clear " + }, + { + "id": "noctalia/workspace-alert-clear-all", + "source": "noctalia", + "category": "workspaces", + "kind": "shell", + "template": "noctalia msg workspace-alert-clear-all", + "usage": "workspace-alert-clear-all" + }, + { + "id": "noctalia/workspace-alert-status", + "source": "noctalia", + "category": "workspaces", + "kind": "shell", + "template": "noctalia msg workspace-alert-status", + "usage": "workspace-alert-status" + } + ] +} diff --git a/keymap/examples/hyprland.lua b/keymap/examples/hyprland.lua new file mode 100644 index 0000000..6a924ee --- /dev/null +++ b/keymap/examples/hyprland.lua @@ -0,0 +1,56 @@ +-- Keymap example for Hyprland's native Lua configuration API. +-- This file is documentation and test data; it is not loaded automatically. + +-- 1. Applications +hl.bind("SUPER + RETURN", hl.dsp.exec_cmd("foot"), { description = "Open terminal" }) +hl.bind("SUPER + B", hl.dsp.exec_cmd("firefox"), { description = "Open browser" }) +hl.bind("SUPER + E", hl.dsp.exec_cmd("xdg-open ."), { description = "Open files" }) +hl.bind("SUPER + SPACE", hl.dsp.exec_cmd("noctalia msg panel-toggle launcher"), { description = "Open launcher" }) +hl.bind("SUPER + M", hl.dsp.exec_cmd("thunderbird"), { description = "Open mail" }) +hl.bind("SUPER + A", hl.dsp.exec_cmd("gnome-calculator"), { description = "Open calculator" }) + +-- 2. Windows +hl.bind("SUPER + Q", hl.dsp.window.close(), { description = "Close window" }) +hl.bind("SUPER + F", hl.dsp.window.fullscreen({ mode = "fullscreen", action = "toggle" }), { description = "Toggle fullscreen" }) +hl.bind("SUPER + SHIFT + F", hl.dsp.window.float({ action = "toggle" }), { description = "Toggle floating" }) +hl.bind("SUPER + LEFT", hl.dsp.focus({ direction = "left" }), { description = "Focus left" }) +hl.bind("SUPER + RIGHT", hl.dsp.focus({ direction = "right" }), { description = "Focus right" }) +hl.bind("SUPER + UP", hl.dsp.focus({ direction = "up" }), { description = "Focus up" }) +hl.bind("SUPER + DOWN", hl.dsp.focus({ direction = "down" }), { description = "Focus down" }) +hl.bind("SUPER + SHIFT + LEFT", hl.dsp.window.move({ direction = "left" }), { description = "Move window left" }) +hl.bind("SUPER + SHIFT + RIGHT", hl.dsp.window.move({ direction = "right" }), { description = "Move window right" }) +hl.bind("SUPER + TAB", hl.dsp.window.cycle_next(), { description = "Focus next window" }) + +-- 3. Workspaces +hl.bind("SUPER + 1", hl.dsp.focus({ workspace = 1 }), { description = "Workspace 1" }) +hl.bind("SUPER + 2", hl.dsp.focus({ workspace = 2 }), { description = "Workspace 2" }) +hl.bind("SUPER + 3", hl.dsp.focus({ workspace = 3 }), { description = "Workspace 3" }) +hl.bind("SUPER + 4", hl.dsp.focus({ workspace = 4 }), { description = "Workspace 4" }) +hl.bind("SUPER + SHIFT + 1", hl.dsp.window.move({ workspace = 1, follow = false }), { description = "Move to workspace 1" }) +hl.bind("SUPER + SHIFT + 2", hl.dsp.window.move({ workspace = 2, follow = false }), { description = "Move to workspace 2" }) +hl.bind("SUPER + mouse_down", hl.dsp.focus({ workspace = "e+1" }), { description = "Next workspace" }) +hl.bind("SUPER + mouse_up", hl.dsp.focus({ workspace = "e-1" }), { description = "Previous workspace" }) + +-- 4. Screenshots +hl.bind("Print", hl.dsp.exec_cmd("grimblast copy output"), { description = "Capture monitor" }) +hl.bind("SHIFT + Print", hl.dsp.exec_cmd("grimblast copy area"), { description = "Capture region" }) +hl.bind("CTRL + Print", hl.dsp.exec_cmd("grimblast copy active"), { description = "Capture window" }) + +-- 5. Noctalia +hl.bind("SUPER + K", hl.dsp.exec_cmd("noctalia msg panel-toggle blackbartblues/keymap:panel"), { description = "Open Keymap" }) +hl.bind("SUPER + V", hl.dsp.exec_cmd("noctalia msg panel-toggle clipboard"), { description = "Clipboard history" }) +hl.bind("SUPER + N", hl.dsp.exec_cmd("noctalia msg panel-toggle notifications"), { description = "Notifications" }) +hl.bind("SUPER + P", hl.dsp.exec_cmd("noctalia msg panel-toggle session-menu"), { description = "Session menu" }) + +-- 6. Media +hl.bind("XF86AudioPlay", hl.dsp.exec_cmd("playerctl play-pause"), { locked = true, description = "Play or pause" }) +hl.bind("XF86AudioNext", hl.dsp.exec_cmd("playerctl next"), { locked = true, description = "Next track" }) +hl.bind("XF86AudioPrev", hl.dsp.exec_cmd("playerctl previous"), { locked = true, description = "Previous track" }) +hl.bind("XF86AudioMute", hl.dsp.exec_cmd("wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle"), { locked = true, description = "Mute audio" }) +hl.bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd("wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%+"), { locked = true, repeating = true, description = "Volume up" }) +hl.bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-"), { locked = true, repeating = true, description = "Volume down" }) + +-- 7. Utilities +hl.bind("SUPER + C + V", hl.dsp.exec_cmd("wl-paste | wl-copy"), { release = true, description = "Normalize clipboard" }) +hl.bind("SUPER + SHIFT + C", hl.dsp.exec_cmd("hyprpicker -a"), { description = "Pick a color" }) +hl.bind("SUPER + L", hl.dsp.exec_cmd("noctalia msg session lock"), { description = "Lock screen" }) diff --git a/keymap/examples/mangowc.conf b/keymap/examples/mangowc.conf new file mode 100644 index 0000000..e00f874 --- /dev/null +++ b/keymap/examples/mangowc.conf @@ -0,0 +1,56 @@ +# Keymap example for MangoWC. +# This file is documentation and test data; it is not loaded automatically. + +# Applications +bind=SUPER,Return,spawn_shell,foot #"Open terminal" +bind=SUPER,B,spawn_shell,firefox #"Open browser" +bind=SUPER,E,spawn_shell,xdg-open . #"Open files" +bind=SUPER,Space,spawn_shell,noctalia msg panel-toggle launcher #"Open launcher" +bind=SUPER,M,spawn_shell,thunderbird #"Open mail" +bind=SUPER,A,spawn_shell,gnome-calculator #"Open calculator" + +# Windows +bind=SUPER,Q,killclient, #"Close window" +bind=SUPER,F,togglefullscreen, #"Toggle fullscreen" +bind=SUPER+SHIFT,F,togglefloating, #"Toggle floating" +bind=SUPER,Left,focusdir,left #"Focus left" +bind=SUPER,Right,focusdir,right #"Focus right" +bind=SUPER,Up,focusdir,up #"Focus up" +bind=SUPER,Down,focusdir,down #"Focus down" +bind=SUPER+SHIFT,Left,smartmovewin,left #"Move window left" +bind=SUPER+SHIFT,Right,smartmovewin,right #"Move window right" +bind=SUPER,Tab,focusstack,next #"Focus next window" + +# Workspaces +bind=SUPER,1,view,1 #"Workspace 1" +bind=SUPER,2,view,2 #"Workspace 2" +bind=SUPER,3,view,3 #"Workspace 3" +bind=SUPER,4,view,4 #"Workspace 4" +bind=SUPER+SHIFT,1,tag,1 #"Move to workspace 1" +bind=SUPER+SHIFT,2,tag,2 #"Move to workspace 2" +axisbind=SUPER,DOWN,viewtoleft,0 #"Next workspace" +axisbind=SUPER,UP,viewtoright,0 #"Previous workspace" + +# Screenshots +bind=NONE,Print,spawn_shell,grim ~/Pictures/screenshot.png #"Capture monitor" +bind=SHIFT,Print,spawn_shell,grim -g "$(slurp)" ~/Pictures/region.png #"Capture region" +bind=CTRL,Print,spawn_shell,grim -g "$(slurp -w)" ~/Pictures/window.png #"Capture window" + +# Noctalia +bind=SUPER,K,spawn_shell,noctalia msg panel-toggle blackbartblues/keymap:panel #"Open Keymap" +bind=SUPER,V,spawn_shell,noctalia msg panel-toggle clipboard #"Clipboard history" +bind=SUPER,N,spawn_shell,noctalia msg panel-toggle notifications #"Notifications" +bind=SUPER,P,spawn_shell,noctalia msg panel-toggle session-menu #"Session menu" + +# Media +bindl=NONE,XF86AudioPlay,spawn_shell,playerctl play-pause #"Play or pause" +bindl=NONE,XF86AudioNext,spawn_shell,playerctl next #"Next track" +bindl=NONE,XF86AudioPrev,spawn_shell,playerctl previous #"Previous track" +bindl=NONE,XF86AudioMute,spawn_shell,wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle #"Mute audio" +bindl=NONE,XF86AudioRaiseVolume,spawn_shell,wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%+ #"Volume up" +bindl=NONE,XF86AudioLowerVolume,spawn_shell,wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%- #"Volume down" + +# Utilities +bindr=SUPER+SHIFT,V,spawn_shell,wl-paste | wl-copy #"Normalize clipboard" +bind=SUPER+SHIFT,C,spawn_shell,hyprpicker -a #"Pick a color" +bind=SUPER,L,spawn_shell,noctalia msg session lock #"Lock screen" diff --git a/keymap/examples/niri.kdl b/keymap/examples/niri.kdl new file mode 100644 index 0000000..bb602f0 --- /dev/null +++ b/keymap/examples/niri.kdl @@ -0,0 +1,57 @@ +// Keymap example for Niri. +// This file is documentation and test data; it is not loaded automatically. + +binds { + // #"Applications" + Mod+Return hotkey-overlay-title="Open terminal" { spawn-sh "foot"; } + Mod+B hotkey-overlay-title="Open browser" { spawn-sh "firefox"; } + Mod+E hotkey-overlay-title="Open files" { spawn-sh "xdg-open ."; } + Mod+Space hotkey-overlay-title="Open launcher" { spawn-sh "noctalia msg panel-toggle launcher"; } + Mod+M hotkey-overlay-title="Open mail" { spawn-sh "thunderbird"; } + Mod+A hotkey-overlay-title="Open calculator" { spawn-sh "gnome-calculator"; } + + // #"Windows" + Mod+Q hotkey-overlay-title="Close window" { close-window; } + Mod+F hotkey-overlay-title="Toggle fullscreen" { fullscreen-window; } + Mod+Shift+F hotkey-overlay-title="Toggle floating" { toggle-window-floating; } + Mod+Left hotkey-overlay-title="Focus left" { focus-column-left; } + Mod+Right hotkey-overlay-title="Focus right" { focus-column-right; } + Mod+Up hotkey-overlay-title="Focus up" { focus-window-up; } + Mod+Down hotkey-overlay-title="Focus down" { focus-window-down; } + Mod+Shift+Left hotkey-overlay-title="Move window left" { move-column-left; } + Mod+Shift+Right hotkey-overlay-title="Move window right" { move-column-right; } + Mod+Tab hotkey-overlay-title="Focus next window" { focus-window-or-workspace-down; } + + // #"Workspaces" + Mod+1 hotkey-overlay-title="Workspace 1" { focus-workspace 1; } + Mod+2 hotkey-overlay-title="Workspace 2" { focus-workspace 2; } + Mod+3 hotkey-overlay-title="Workspace 3" { focus-workspace 3; } + Mod+4 hotkey-overlay-title="Workspace 4" { focus-workspace 4; } + Mod+Shift+1 hotkey-overlay-title="Move to workspace 1" { move-column-to-workspace 1; } + Mod+Shift+2 hotkey-overlay-title="Move to workspace 2" { move-column-to-workspace 2; } + Mod+WheelScrollDown hotkey-overlay-title="Next workspace" { focus-workspace-down; } + Mod+WheelScrollUp hotkey-overlay-title="Previous workspace" { focus-workspace-up; } + + // #"Screenshots" + Print hotkey-overlay-title="Capture monitor" { screenshot-screen; } + Shift+Print hotkey-overlay-title="Capture region" { screenshot; } + Ctrl+Print hotkey-overlay-title="Capture window" { screenshot-window; } + + // #"Noctalia" + Mod+K hotkey-overlay-title="Open Keymap" { spawn-sh "noctalia msg panel-toggle blackbartblues/keymap:panel"; } + Mod+V hotkey-overlay-title="Clipboard history" { spawn-sh "noctalia msg panel-toggle clipboard"; } + Mod+N hotkey-overlay-title="Notifications" { spawn-sh "noctalia msg panel-toggle notifications"; } + Mod+P hotkey-overlay-title="Session menu" { spawn-sh "noctalia msg panel-toggle session-menu"; } + + // #"Media" + XF86AudioPlay hotkey-overlay-title="Play or pause" { spawn-sh "playerctl play-pause"; } + XF86AudioNext hotkey-overlay-title="Next track" { spawn-sh "playerctl next"; } + XF86AudioPrev hotkey-overlay-title="Previous track" { spawn-sh "playerctl previous"; } + XF86AudioMute hotkey-overlay-title="Mute audio" { spawn-sh "wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle"; } + XF86AudioRaiseVolume hotkey-overlay-title="Volume up" { spawn-sh "wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%+"; } + XF86AudioLowerVolume hotkey-overlay-title="Volume down" { spawn-sh "wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-"; } + + // #"Utilities" + Mod+Shift+C hotkey-overlay-title="Pick a color" { spawn-sh "hyprpicker -a"; } + Mod+L hotkey-overlay-title="Lock screen" { spawn-sh "noctalia msg session lock"; } +} diff --git a/keymap/mangowc_service.luau b/keymap/mangowc_service.luau new file mode 100644 index 0000000..867619b --- /dev/null +++ b/keymap/mangowc_service.luau @@ -0,0 +1,967 @@ +--!nonstrict +-- Keymap service for MangoWC / mangowm. +-- +-- Mango's IPC does not expose the loaded keybinding registry. The recursively +-- included configuration tree is therefore the authoritative available source. + +local SNAPSHOT_KEY = "keymap.snapshot" +local REFRESH_REQUEST_KEY = "keymap.refresh_request" +local DEFAULT_CONFIG = "~/.config/mango/config.conf" +local SYSTEM_CONFIG = "/etc/mango/config.conf" +local MAX_FILES = 64 +local MAX_SOURCE_BYTES = 512 * 1024 +local MAX_HIDDEN_BYTES = 2 * 1024 * 1024 + +local refreshing = false +local refreshQueued = false + +local function config(key, fallback) + local value = noctalia.getConfig(key) + if value == nil then + return fallback + end + return value +end + +local function trim(value) + if type(value) ~= "string" then + return "" + end + return value:match("^%s*(.-)%s*$") or "" +end + +local function envSet(name) + local value = noctalia.getenv(name) + return type(value) == "string" and value ~= "" +end + +local function environment(name) + local value = noctalia.getenv(name) + return type(value) == "string" and value or "" +end + +local function desktopHas(value, wanted) + local normalized = tostring(value or ""):lower() + return normalized:find(wanted, 1, true) ~= nil +end + +-- Shared detection order used by all compositor-specific services. +local function detectedCompositor() + if envSet("NIRI_SOCKET") then + return "niri" + end + if envSet("HYPRLAND_INSTANCE_SIGNATURE") then + return "hyprland" + end + if envSet("MANGO_INSTANCE_SIGNATURE") then + return "mangowc" + end + + local current = noctalia.getenv("XDG_CURRENT_DESKTOP") + local session = noctalia.getenv("XDG_SESSION_DESKTOP") + local desktopSession = noctalia.getenv("DESKTOP_SESSION") + if desktopHas(current, "niri") or desktopHas(session, "niri") or desktopHas(desktopSession, "niri") then + return "niri" + end + if desktopHas(current, "hyprland") or desktopHas(session, "hyprland") or desktopHas(desktopSession, "hyprland") then + return "hyprland" + end + if desktopHas(current, "mango") or desktopHas(session, "mango") + or desktopHas(desktopSession, "mango") or desktopHas(current, "mangowc") + or desktopHas(session, "mangowc") or desktopHas(desktopSession, "mangowc") then + return "mangowc" + end + return "" +end + +local function selectedCompositor() + local selected = tostring(config("compositor", "auto")):lower() + if selected == "hyprland" or selected == "niri" or selected == "mangowc" then + return selected + end + return detectedCompositor() +end + +local function isActive() + return selectedCompositor() == "mangowc" +end + +local function dirname(path) + local directory = path:match("^(.*)/[^/]*$") + return directory ~= nil and directory ~= "" and directory or "." +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 + parts[#parts + 1] = part + end + elseif part ~= "." and part ~= "" then + parts[#parts + 1] = part + end + end + local normalized = table.concat(parts, "/") + if absolute then + return "/" .. normalized + end + return normalized ~= "" and normalized or "." +end + +local function expandPath(path) + local expanded = noctalia.expandPath(path) + if type(expanded) == "string" and expanded ~= "" then + return normalizePath(expanded) + end + return normalizePath(path) +end + +local function configPath() + local configured = trim(config("mangowc_config", DEFAULT_CONFIG)) + if configured == "" then + configured = DEFAULT_CONFIG + end + local path = expandPath(configured) + if noctalia.fileExists(path) then return path end + + local xdg = environment("XDG_CONFIG_HOME") + local mangoDir = normalizePath((xdg ~= "" and xdg or (environment("HOME") .. "/.config")) .. "/mango") + local candidates = { normalizePath(mangoDir .. "/config.conf"), SYSTEM_CONFIG } + for _, candidate in ipairs(candidates) do + if noctalia.fileExists(candidate) then return candidate end + end + + local entries = noctalia.listDir(mangoDir) + if type(entries) ~= "table" then return path end + table.sort(entries) + local bestPath, bestScore = nil, -1 + for _, name in ipairs(entries) do + if type(name) == "string" and name:match("%.conf$") and name ~= "keymap.conf" + and not name:lower():find("backup", 1, true) then + local candidate = normalizePath(mangoDir .. "/" .. name) + local source = noctalia.readFile(candidate) + if type(source) == "string" and #source <= MAX_SOURCE_BYTES then + local _, bindCount = source:gsub("bind[%w%-]*%s*=", "") + local _, sourceCount = source:gsub("source[%w%-]*%s*=", "") + local score = bindCount * 10 + sourceCount * 2 + if name:lower():find("bind", 1, true) then score = score + 5 end + if score > bestScore then bestPath, bestScore = candidate, score end + end + end + end + return bestScore > 0 and bestPath or path +end + +local function stripOuterQuotes(value) + local text = trim(value) + local quote = text:sub(1, 1) + if #text >= 2 and (quote == '"' or quote == "'") and text:sub(-1) == quote then + return text:sub(2, -2) + end + return text +end + +local function resolveSource(rootDirectory, source) + local path = stripOuterQuotes(source) + if path == "" then + return nil + end + if path:find("*", 1, true) or path:find("?", 1, true) or path:find("[", 1, true) then + return nil, "mangowc_source_glob_not_supported:" .. path + end + path = noctalia.expandPath(path) or path + if path:sub(1, 1) == "/" then + return normalizePath(path) + end + path = path:gsub("^%./", "") + return normalizePath(rootDirectory .. "/" .. path) +end + +local function slug(value) + local id = value:lower():gsub("[^%a%d]+", "-"):gsub("^-+", ""):gsub("-+$", "") + return id ~= "" and id or "category" +end + +local function titleCase(value) + local text = tostring(value or ""):gsub("_", " "):gsub("%-", " ") + text = text:gsub("(%l)(%u)", "%1 %2") + return (text:gsub("%f[%a]%a", string.upper)) +end + +local function splitCsv(value) + local parts = {} + local start = 1 + while true do + local comma = value:find(",", start, true) + if comma == nil then + parts[#parts + 1] = trim(value:sub(start)) + break + end + parts[#parts + 1] = trim(value:sub(start, comma - 1)) + start = comma + 1 + end + return parts +end + +local function joinTail(parts, first) + local output = {} + for index = first, #parts do + output[#output + 1] = parts[index] + end + return trim(table.concat(output, ",")) +end + +-- Locate an ordinary inline comment while preserving the plugin convention +-- #"description" and quoted hashes in shell commands. +local function findUnquotedComment(line) + local inSingle = false + local inDouble = false + local escaped = false + for index = 1, #line do + local char = line:sub(index, index) + if escaped then + escaped = false + elseif char == "\\" and (inSingle or inDouble) then + escaped = true + elseif char == "'" and not inDouble then + inSingle = not inSingle + elseif char == '"' and not inSingle then + inDouble = not inDouble + elseif char == "#" and not inSingle and not inDouble and line:sub(index + 1, index + 1) ~= '"' then + return index + end + end + return nil +end + +local function extractDescription(line) + local description = line:match('#"(.*)"%s*$') + local marker = line:match('()#".*"%s*$') + if description == nil or marker == nil then + return line, nil + end + description = description:gsub('\\"', '"'):gsub("\\\\", "\\") + return trim(line:sub(1, marker - 1)), description +end + +local function extractCategory(line) + local rest = trim(line) + if rest:sub(1, 1) ~= "#" then + return nil + end + local managed = trim(rest:match("^#%s*Keymap category:%s*(.+)$") or "") + if managed ~= "" then return managed end + rest = trim(rest:gsub("^#+", "", 1)) + if rest == "" or #rest > 100 or rest:match("^[%w%-]+%s*=") then + return nil + end + if rest:match("^[─━═=%-_*#/\\%s]+$") or rest:match("^[%(%[]") then + return nil + end + if rest:match("^%-%>") or rest:match("^=%>") then + return nil + end + local firstWord = rest:match("^(%a+)") + if firstWord ~= nil then + local upper = firstWord:upper() + if upper == "TODO" or upper == "FIXME" or upper == "NOTE" or upper == "HACK" + or upper == "XXX" or upper == "BUG" or upper == "WIP" then + return nil + end + end + rest = trim(rest:gsub("[─━═=%-_*][─━═=%-_*][─━═=%-_*]+%s*$", "")) + if rest == "" then + return nil + end + return trim(rest:match("^%d+%.%s*(.+)$") or rest) +end + +local KEY_NAMES = { + RETURN = "Enter", ESCAPE = "Esc", SPACE = "Space", PRINT = "PrtSc", + PRIOR = "PgUp", NEXT = "PgDn", EQUAL = "=", MINUS = "-", PLUS = "+", + COMMA = ",", PERIOD = ".", SEMICOLON = ";", APOSTROPHE = "'", GRAVE = "`", + SLASH = "/", BACKSLASH = "\\", BRACKETLEFT = "[", BRACKETRIGHT = "]", + XF86AUDIORAISEVOLUME = "Vol Up", XF86AUDIOLOWERVOLUME = "Vol Down", + XF86AUDIOMUTE = "Mute", XF86AUDIOMICMUTE = "Mic Mute", XF86AUDIOPLAY = "Play", + XF86AUDIOPAUSE = "Pause", XF86AUDIONEXT = "Next", XF86AUDIOPREV = "Prev", + XF86AUDIOSTOP = "Stop", XF86MONBRIGHTNESSUP = "Bright Up", + XF86MONBRIGHTNESSDOWN = "Bright Down", +} + +local AXIS_NAMES = { + UP = "Scroll Up", DOWN = "Scroll Down", LEFT = "Scroll Left", RIGHT = "Scroll Right", +} + +local BUTTON_NAMES = { + BTN_LEFT = "Left Click", BTN_RIGHT = "Right Click", BTN_MIDDLE = "Middle Click", + BTN_SIDE = "Mouse Side", BTN_EXTRA = "Mouse Extra", BTN_FORWARD = "Mouse Forward", + BTN_BACK = "Mouse Back", BTN_TASK = "Mouse Task", ["CODE:272"] = "Left Click", + ["CODE:273"] = "Right Click", ["CODE:274"] = "Middle Click", +} + +local MODIFIER_NAMES = { + SUPER = "Super", SUPER_L = "Super", SUPER_R = "Super", LOGO = "Super", + CTRL = "Ctrl", CONTROL = "Ctrl", CTRL_L = "Ctrl", CTRL_R = "Ctrl", + SHIFT = "Shift", SHIFT_L = "Shift", SHIFT_R = "Shift", + ALT = "Alt", ALT_L = "Alt", ALT_R = "Alt", MOD1 = "Alt", + HYPER = "Hyper", HYPER_L = "Hyper", HYPER_R = "Hyper", +} + +local MODIFIER_CODES = { + ["37"] = "Ctrl", ["50"] = "Shift", ["62"] = "Shift", ["64"] = "Alt", + ["105"] = "Ctrl", ["108"] = "Alt", ["133"] = "Super", ["134"] = "Super", +} + +local function parseModifiers(value) + local modifiers = {} + local seen = {} + for token in (value .. "+"):gmatch("(.-)%+") do + local upper = trim(token):upper() + local name = MODIFIER_NAMES[upper] + if name == nil then + name = MODIFIER_CODES[upper:match("^CODE:(%d+)$") or ""] + end + if name ~= nil and not seen[name] then + seen[name] = true + modifiers[#modifiers + 1] = name + elseif upper ~= "" and upper ~= "NONE" and name == nil then + local code = upper:match("^CODE:(%d+)$") + local rawName = code ~= nil and ("Code " .. code) or titleCase(upper:lower()) + if not seen[rawName] then + seen[rawName] = true + modifiers[#modifiers + 1] = rawName + end + end + end + return modifiers +end + +local function formatKey(value) + local key = trim(value) + return KEY_NAMES[key:upper()] or key +end + +local NO_ARG_ACTIONS = { + killclient = "actions.mangowc.killclient", togglefullscreen = "actions.mangowc.togglefullscreen", + togglefakefullscreen = "actions.mangowc.togglefakefullscreen", togglemaximizescreen = "actions.mangowc.togglemaximizescreen", + togglefloating = "actions.mangowc.togglefloating", toggle_all_floating = "actions.mangowc.toggle_all_floating", + toggleglobal = "actions.mangowc.toggleglobal", toggleoverview = "actions.mangowc.toggleoverview", + togglejump = "actions.mangowc.togglejump", toggleoverlay = "actions.mangowc.toggleoverlay", + toggle_scratchpad = "actions.mangowc.toggle_scratchpad", minimized = "actions.mangowc.minimized", + restore_minimized = "actions.mangowc.restore_minimized", reload_config = "actions.mangowc.reload_config", + quit = "actions.mangowc.quit", switch_proportion_preset = "actions.mangowc.switch_proportion_preset", + switch_keyboard_layout = "actions.mangowc.switch_keyboard_layout", zoom = "actions.mangowc.zoom", + restart = "actions.mangowc.restart", incnmaster = "actions.mangowc.incnmaster", + switch_layout = "actions.mangowc.switch_layout", togglegaps = "actions.mangowc.togglegaps", + dwindle_toggle_split_direction = "actions.mangowc.dwindle_toggle_split_direction", +} + +local DIRECTION_ACTIONS = { + focusdir = "actions.mangowc.focusdir", exchange_client = "actions.mangowc.exchange_client", + focusmon = "actions.mangowc.focusmon", tagmon = "actions.mangowc.tagmon", + groupjoin = "actions.mangowc.groupjoin", groupfocus = "actions.mangowc.groupfocus", + smartmovewin = "actions.mangowc.smartmovewin", smartresizewin = "actions.mangowc.smartresizewin", + scroller_stack = "actions.mangowc.scroller_stack", +} + +local function formatAction(action, args) + local normalized = trim(action):lower() + local argument = trim(args) + if NO_ARG_ACTIONS[normalized] ~= nil and (argument == "" or argument == "0") then + return noctalia.tr(NO_ARG_ACTIONS[normalized]) + end + if DIRECTION_ACTIONS[normalized] ~= nil then + local label = noctalia.tr(DIRECTION_ACTIONS[normalized]) + return argument ~= "" and noctalia.tr("actions.with_argument", { action = label, argument = titleCase(argument) }) or label + end + if normalized == "spawn" or normalized == "spawn_shell" or normalized == "spawn_on_empty" then + return argument ~= "" and noctalia.tr("actions.run", { command = argument }) or noctalia.tr("actions.run_command") + end + if normalized == "view" then + local tag = argument:match("^([^,]+)") or argument + return tag ~= "" and noctalia.tr("actions.view_tag_number", { tag = tag }) or noctalia.tr("actions.view_tag") + end + if normalized == "tag" or normalized == "tagsilent" then + local tag = argument:match("^([^,]+)") or argument + return tag ~= "" and noctalia.tr("actions.move_to_tag_number", { tag = tag }) or noctalia.tr("actions.move_to_tag") + end + if normalized == "setlayout" then + return argument ~= "" and noctalia.tr("actions.layout_value", { layout = argument }) or noctalia.tr("actions.set_layout") + end + if normalized == "setkeymode" then + return argument ~= "" and noctalia.tr("actions.key_mode_value", { mode = argument }) or noctalia.tr("actions.set_key_mode") + end + return noctalia.tr("actions.native", { + action = normalized .. (argument ~= "" and (" " .. argument) or ""), + }) +end + +local function parseFlags(suffix) + local flags = {} + for index = 1, #suffix do + local flag = suffix:sub(index, index):lower() + if flag == "l" then flags.locked = true + elseif flag == "s" then flags.keysym = true + elseif flag == "r" then flags.release = true + elseif flag == "p" then flags.pass = true end + end + return flags +end + +local function addCategory(context, name) + local category = context.byCategory[name] + if category ~= nil then + return category + end + local baseId = slug(name) + local id = baseId + local suffix = 2 + while context.categoryIds[id] do + id = baseId .. "-" .. tostring(suffix) + suffix = suffix + 1 + end + context.categoryIds[id] = true + category = { id = id, name = name, binds = {} } + context.byCategory[name] = category + context.categories[#context.categories + 1] = category + return category +end + +local fingerprint + +local function stableBindId(context, fields) + local identity = table.concat(fields, "|") + local count = (context.bindIds[identity] or 0) + 1 + context.bindIds[identity] = count + return "mango:" .. fingerprint(identity) .. (count > 1 and (":" .. tostring(count)) or "") +end + +-- FNV-1a over the exact source line. The editor recalculates this before a +-- write so a bind is never replaced after the file changed behind its back. +-- Multiplication is split into 16-bit words to retain exact 32-bit arithmetic +-- in runtimes where Lua numbers are doubles. +local function xorLowByte(value, byte) + local low = value % 256 + local result = 0 + local place = 1 + for _ = 1, 8 do + if low % 2 ~= byte % 2 then + result = result + place + end + low = math.floor(low / 2) + byte = math.floor(byte / 2) + place = place * 2 + end + return value - (value % 256) + result +end + +fingerprint = function(rawSnippet) + local hash = 2166136261 + for index = 1, #rawSnippet do + hash = xorLowByte(hash, rawSnippet:byte(index)) + local low = hash % 65536 + local high = math.floor(hash / 65536) + local lowProduct = low * 403 + local resultLow = lowProduct % 65536 + local resultHigh = ( + math.floor(lowProduct / 65536) + low * 256 + high * 403 + ) % 65536 + hash = resultHigh * 65536 + resultLow + end + return string.format("%08x", hash) +end + +local function sourceLines(source) + local lines = {} + for line in (source .. "\n"):gmatch("([^\n]*)\n") do lines[#lines + 1] = line end + return lines +end + +local function hexDecode(value) + if value == "" or #value % 2 ~= 0 or value:find("[^0-9a-f]") ~= nil then return nil end + local output = {} + for index = 1, #value, 2 do output[#output + 1] = string.char(tonumber(value:sub(index, index + 1), 16)) end + return table.concat(output) +end + +local function hiddenBlockAt(lines, startLine) + local namespace = "^([\t ]*)# Keymap hidden " + if lines[startLine]:match(namespace) == nil then return nil, startLine, false end + local indent, blockId, originalFingerprint = lines[startLine]:match( + namespace .. "v1 begin ([0-9a-f]+) ([0-9a-f]+)$" + ) + if indent == nil or #blockId ~= 8 or #originalFingerprint ~= 8 then return nil, startLine, true end + local marker = indent .. "# Keymap hidden v1" + local escaped = marker:gsub("(%W)", "%%%1") + local encoded, cursor = {}, startLine + 1 + while cursor <= #lines do + local chunk = lines[cursor]:match("^" .. escaped .. " data ([0-9a-f]+)$") + if chunk ~= nil then + if #chunk > 96 or #chunk % 2 ~= 0 then return nil, cursor, true end + encoded[#encoded + 1] = chunk + cursor = cursor + 1 + else + local endId = lines[cursor]:match("^" .. escaped .. " end ([0-9a-f]+)$") + if endId == nil then return nil, math.max(startLine, cursor - 1), true end + local original = hexDecode(table.concat(encoded)) + if #encoded == 0 or endId ~= blockId or original == nil or original == "" + or #original > MAX_HIDDEN_BYTES or fingerprint(original) ~= originalFingerprint + or fingerprint("MangoWC\0" .. original) ~= blockId then return nil, cursor, true end + return { block_id = blockId, original = original, original_fingerprint = originalFingerprint, + raw_snippet = table.concat(lines, "\n", startLine, cursor) }, cursor, true + end + end + return nil, #lines, true +end + +local function editCapabilities(kind, action) + local isKeyboard = kind == "bind" + return { + combo = isKeyboard, + category = isKeyboard, + description = isKeyboard, + command = isKeyboard and trim(action):lower() == "spawn_shell", + activation = isKeyboard, + } +end + +local function disabledEditCapabilities() + return { + combo = false, category = false, description = false, + command = false, activation = false, + } +end + +local function addBind( + context, categoryName, kind, suffix, parts, description, + source, lineNumber, rawSnippet, startLine +) + local modifiers = {} + local key = "" + local action = "" + local args = "" + if kind == "bind" then + if #parts < 3 then return false end + modifiers, key, action, args = parseModifiers(parts[1]), formatKey(parts[2]), parts[3], joinTail(parts, 4) + elseif kind == "axisbind" then + if #parts < 3 then return false end + modifiers = parseModifiers(parts[1]) + key, action, args = AXIS_NAMES[parts[2]:upper()] or titleCase(parts[2]), parts[3], joinTail(parts, 4) + elseif kind == "mousebind" then + if #parts < 3 then return false end + modifiers = parseModifiers(parts[1]) + key, action, args = BUTTON_NAMES[parts[2]:upper()] or titleCase(parts[2]), parts[3], joinTail(parts, 4) + elseif kind == "gesturebind" then + if #parts < 4 then return false end + modifiers = parseModifiers(parts[1]) + key, action, args = tostring(parts[3]) .. "-finger " .. titleCase(parts[2]), parts[4], joinTail(parts, 5) + elseif kind == "switchbind" then + if #parts < 2 then return false end + local state = parts[1]:lower() + key = state == "fold" and "Lid Closed" or (state == "unfold" and "Lid Open" or titleCase(parts[1])) + action, args = parts[2], joinTail(parts, 3) + else + return false + end + + local fields = { context.keymode, kind, table.concat(modifiers, "+"), key, suffix, action, args } + local category = addCategory(context, categoryName) + local activation = parseFlags(suffix).release == true and "release" or "press" + category.binds[#category.binds + 1] = { + id = stableBindId(context, fields), modifiers = modifiers, key = key, + description = description or formatAction(action, args), dispatcher = trim(action), + command = args, action = formatAction(action, args), + mode = context.keymode, kind = kind, flags = parseFlags(suffix), activation = activation, + source = source, line = startLine or lineNumber, + start_line = startLine or lineNumber, end_line = lineNumber, + raw_snippet = rawSnippet, fingerprint = fingerprint(rawSnippet), + managed = source:match("([^/]+)$") == "keymap.conf", + capabilities = editCapabilities(kind, action), + _rawKey = kind == "bind" and trim(parts[2]) or key, + } + context.total = context.total + 1 + return true +end + +local function hiddenTarget(block, path, startLine, endLine, inheritedCategory, keymode) + local bindLine = "" + for raw in (block.original .. "\n"):gmatch("([^\n]*)\n") do + if trim(raw):match("^bind[lsrp]*%s*=") or trim(raw):match("^axisbind%s*=") + or trim(raw):match("^mousebind%s*=") or trim(raw):match("^gesturebind%s*=") + or trim(raw):match("^switchbind%s*=") then bindLine = raw end + end + local effective, description = extractDescription(trim(bindLine)) + local directive, value = effective:match("^([%w%-]+)%s*=%s*(.*)$") + directive = tostring(directive or ""):lower() + local suffix = directive:match("^bind([lsrp]*)$") + local kind = suffix ~= nil and "bind" or directive + if kind ~= "bind" and kind ~= "axisbind" and kind ~= "mousebind" + and kind ~= "gesturebind" and kind ~= "switchbind" then kind, suffix = "bind", "" end + local markedCategory = block.original:match( + "^%s*#%s*Keymap bind%-category:%s*([^\n]-)%s*\n" + ) + local temporary = { + defaultCategory = inheritedCategory or "", filesRead = 0, files = {}, visited = {}, warnings = {}, + categories = {}, byCategory = {}, categoryIds = {}, bindIds = {}, total = 0, + keymode = keymode or "default", + } + addBind(temporary, markedCategory or inheritedCategory or temporary.defaultCategory, kind, suffix or "", + splitCsv(value or ""), description, path, endLine, block.original, startLine) + local bind = temporary.categories[1] and temporary.categories[1].binds[1] or {} + bind.hidden = true + bind.id = "hidden:mango:" .. fingerprint(path .. "\0" .. tostring(startLine) .. "\0" .. block.block_id) + bind.source, bind.line, bind.start_line, bind.end_line = path, startLine, startLine, endLine + bind.raw_snippet, bind.fingerprint = block.raw_snippet, fingerprint(block.raw_snippet) + bind.original_fingerprint = block.original_fingerprint + bind.category = markedCategory or inheritedCategory or temporary.defaultCategory + bind.capabilities = { restore = true, delete = true } + return bind +end + +local function parseFile(context, path, optional, depth) + if context.filesRead >= MAX_FILES then + context.warnings[#context.warnings + 1] = "mangowc_file_limit_reached" + return + end + if context.visited[path] then + return + end + context.visited[path] = true + local source = noctalia.readFile(path) + if type(source) ~= "string" then + if not optional then + local prefix = depth == 0 and "mangowc_config_unreadable:" or "mangowc_required_source_unreadable:" + context.warnings[#context.warnings + 1] = prefix .. path + if depth > 0 then + context.fatalError = "mangowc_required_source_missing" + end + end + return + end + context.filesRead = context.filesRead + 1 + context.files[#context.files + 1] = path + if #source > MAX_SOURCE_BYTES then + source = source:sub(1, MAX_SOURCE_BYTES) + context.warnings[#context.warnings + 1] = "mangowc_source_truncated:" .. path + end + + local currentCategory = nil + local pendingBindCategory = nil + local pendingMarkerLine = nil + local pendingMarkerRaw = nil + local lineNumber = 0 + local hiddenLines = sourceLines(source) + local hiddenCategory = nil + local hiddenKeymode = context.keymode + local hiddenCursor = 1 + local hiddenConsumed = {} + while hiddenCursor <= #hiddenLines do + local block, blockEnd, candidate = hiddenBlockAt(hiddenLines, hiddenCursor) + if candidate then + for consumed = hiddenCursor, blockEnd do hiddenConsumed[consumed] = true end + if block == nil then + context.warnings[#context.warnings + 1] = "hidden_block_invalid:" .. path .. ":" .. tostring(hiddenCursor) + else + context.hidden[#context.hidden + 1] = hiddenTarget( + block, path, hiddenCursor, blockEnd, + hiddenCategory or context.defaultCategory, hiddenKeymode + ) + end + hiddenCursor = blockEnd + 1 + else + local raw = hiddenLines[hiddenCursor] + local category = extractCategory(raw) + if category ~= nil then hiddenCategory = category end + local effective = trim(raw:sub(1, (findUnquotedComment(raw) or (#raw + 1)) - 1)) + local directive, value = effective:match("^([%w%-]+)%s*=%s*(.*)$") + if directive ~= nil and directive:lower() == "keymode" then + hiddenKeymode = trim(value) ~= "" and trim(value) or "default" + end + hiddenCursor = hiddenCursor + 1 + end + end + for rawLine in (source .. "\n"):gmatch("([^\n]*)\n") do + lineNumber = lineNumber + 1 + if not hiddenConsumed[lineNumber] then + local commentAt = findUnquotedComment(rawLine) + local effective = trim(commentAt ~= nil and rawLine:sub(1, commentAt - 1) or rawLine) + if effective == "" then + local bindCategory = rawLine:match( + "^%s*#%s*Keymap bind%-category:%s*(.-)%s*$" + ) + if bindCategory ~= nil and bindCategory ~= "" then + pendingBindCategory = bindCategory + pendingMarkerLine = lineNumber + pendingMarkerRaw = rawLine + else + local category = extractCategory(rawLine) + if category ~= nil then currentCategory = category end + end + else + local cleanLine, description = extractDescription(effective) + local directive, value = cleanLine:match("^([%w%-]+)%s*=%s*(.*)$") + if directive ~= nil then + directive = directive:lower() + if directive == "source" or directive == "source-optional" then + local included, warning = resolveSource(dirname(path), value) + if warning ~= nil then + context.warnings[#context.warnings + 1] = warning + elseif included ~= nil then + parseFile(context, included, directive == "source-optional", depth + 1) + end + elseif directive == "keymode" then + context.keymode = trim(value) ~= "" and trim(value) or "default" + else + local suffix = directive:match("^bind([lsrp]*)$") + local kind = suffix ~= nil and "bind" or nil + if kind == nil and (directive == "axisbind" or directive == "mousebind" + or directive == "gesturebind" or directive == "switchbind") then + kind, suffix = directive, "" + end + if kind ~= nil then + local rawSnippet = pendingMarkerRaw ~= nil + and (pendingMarkerRaw .. "\n" .. rawLine) or rawLine + if not addBind( + context, pendingBindCategory or currentCategory or context.defaultCategory, + kind, suffix, splitCsv(value), description, path, lineNumber, + rawSnippet, pendingMarkerLine + ) then + context.warnings[#context.warnings + 1] = "mangowc_invalid_bind:" + .. path .. ":" .. tostring(lineNumber) + end + end + end + end + pendingBindCategory = nil + pendingMarkerLine = nil + pendingMarkerRaw = nil + end + end + end +end + +local function cleanBind(bind) + return { + id = bind.id, modifiers = bind.modifiers, key = bind.key, + description = bind.description, dispatcher = bind.dispatcher, + mode = bind.mode, kind = bind.kind, flags = bind.flags, + activation = type(bind.flags) == "table" and bind.flags.release == true and "release" or "press", + command = bind.command, action = bind.action, managed = bind.managed == true, + source = bind.source, line = bind.line, + start_line = bind.start_line, end_line = bind.end_line, + raw_snippet = bind.raw_snippet, fingerprint = bind.fingerprint, + capabilities = bind.capabilities, + } +end + +local function descriptionTemplate(description) + local template, replacements = description:gsub("%f[%d]%d+%f[%D]", "%%N%%") + return template, replacements > 0 +end + +local function mergedBind(run) + local first = run[1].bind + local last = run[#run].bind + local range = tostring(run[1].number) .. "-" .. tostring(run[#run].number) + local startLine = first.start_line or first.line + local endLine = first.end_line or first.line + for _, item in ipairs(run) do + local itemStart = item.bind.start_line or item.bind.line + local itemEnd = item.bind.end_line or item.bind.line + if itemStart ~= nil and (startLine == nil or itemStart < startLine) then startLine = itemStart end + if itemEnd ~= nil and (endLine == nil or itemEnd > endLine) then endLine = itemEnd end + end + return { + id = "range:" .. first.id .. ":" .. last.id, + modifiers = first.modifiers, + key = range, + description = first.description:gsub("%f[%d]%d+%f[%D]", range, 1), + dispatcher = first.dispatcher, + mode = first.mode, + kind = first.kind, + flags = first.flags, + activation = type(first.flags) == "table" and first.flags.release == true and "release" or "press", + command = first.command, managed = first.managed == true, + source = first.source, + line = first.line, + start_line = startLine, end_line = endLine, + raw_snippet = "", fingerprint = "", + -- A displayed numeric range can represent interleaved, non-contiguous + -- source lines, so it deliberately cannot be edited as a single bind. + capabilities = disabledEditCapabilities(), + } +end + +-- Merge interleaved numeric runs (for example view 1, tag 1, view 2, +-- tag 2...). Mode is part of the signature so keymodes never bleed together. +local function mergeSequential(binds) + if #binds < 3 then + local output = {} + for _, bind in ipairs(binds) do output[#output + 1] = cleanBind(bind) end + return output + end + + local groups = {} + for index, bind in ipairs(binds) do + local rawKey = tostring(bind._rawKey or "") + local number = rawKey:match("^%d+$") and tonumber(rawKey) or nil + local template, hasNumber = descriptionTemplate(bind.description) + if number ~= nil and hasNumber then + local signature = table.concat({ + tostring(bind.mode or "default"), + table.concat(bind.modifiers or {}, "+"), + tostring(bind.dispatcher or ""), + template, + }, "|") + groups[signature] = groups[signature] or {} + groups[signature][#groups[signature] + 1] = { index = index, number = number, bind = bind } + end + end + + local replacements = {} + local skipped = {} + for _, group in pairs(groups) do + table.sort(group, function(a, b) + return a.number == b.number and a.index < b.index or a.number < b.number + end) + local runStart = 1 + for cursor = 2, #group + 1 do + local continues = cursor <= #group and group[cursor].number == group[cursor - 1].number + 1 + if not continues then + if cursor - runStart >= 3 then + local run = {} + local insertionIndex = group[runStart].index + for itemIndex = runStart, cursor - 1 do + local item = group[itemIndex] + run[#run + 1] = item + if item.index < insertionIndex then insertionIndex = item.index end + end + replacements[insertionIndex] = mergedBind(run) + for _, item in ipairs(run) do + if item.index ~= insertionIndex then skipped[item.index] = true end + end + end + runStart = cursor + end + end + end + + local output = {} + for index, bind in ipairs(binds) do + if replacements[index] ~= nil then + output[#output + 1] = replacements[index] + elseif not skipped[index] then + output[#output + 1] = cleanBind(bind) + end + end + return output +end + +local function parseConfig(source) + local context = { + defaultCategory = noctalia.tr("category.other"), + filesRead = 0, files = {}, visited = {}, warnings = {}, categories = {}, + byCategory = {}, categoryIds = {}, bindIds = {}, total = 0, hidden = {}, + keymode = "default", fatalError = nil, + } + parseFile(context, source, false, 0) + if config("merge_sequential", true) == true then + for _, category in ipairs(context.categories) do + category.binds = mergeSequential(category.binds) + end + else + for _, category in ipairs(context.categories) do + local clean = {} + for _, bind in ipairs(category.binds) do clean[#clean + 1] = cleanBind(bind) end + category.binds = clean + end + end + return context +end + +local function snapshot(status, source, errorCode, categories, total, warnings, updatedAt, hidden) + return { + status = status, error = errorCode or "", compositor = "MangoWC", source = source, + updated_at = updatedAt or "", total = total or 0, categories = categories or {}, + warnings = warnings or {}, + hidden = hidden or {}, + } +end + +local function publishError(source, errorCode, warnings) + noctalia.state.set( + SNAPSHOT_KEY, + snapshot("error", source, errorCode, {}, 0, warnings, os.date("%H:%M:%S")) + ) +end + +local function finishRefresh() + refreshing = false + if refreshQueued then + refreshQueued = false + refresh() + end +end + +function refresh() + if not isActive() then + return + end + if refreshing then + refreshQueued = true + return + end + refreshing = true + + local source = configPath() + local previous = noctalia.state.get(SNAPSHOT_KEY) + local sameCompositor = type(previous) == "table" and previous.compositor == "MangoWC" + local previousCategories = sameCompositor and previous.categories or {} + local previousTotal = sameCompositor and previous.total or 0 + local previousUpdatedAt = sameCompositor and previous.updated_at or "" + local previousHidden = sameCompositor and previous.hidden or {} + noctalia.state.set( + SNAPSHOT_KEY, + snapshot("loading", source, "", previousCategories, previousTotal, {}, previousUpdatedAt, previousHidden) + ) + + local parsed = parseConfig(source) + if #parsed.files == 0 then + publishError(source, "mangowc_config_unreadable", parsed.warnings) + finishRefresh() + return + end + if parsed.fatalError ~= nil then + publishError(source, parsed.fatalError, parsed.warnings) + finishRefresh() + return + end + if parsed.total == 0 and #parsed.hidden == 0 then + publishError(source, "mangowc_no_binds", parsed.warnings) + finishRefresh() + return + end + noctalia.state.set( + SNAPSHOT_KEY, + snapshot("ready", source, "", parsed.categories, parsed.total, parsed.warnings, os.date("%H:%M:%S"), parsed.hidden) + ) + finishRefresh() +end + +function onIpc(event, _payload) + if event == "refresh" then refresh() end +end + +function onConfigChanged() + refresh() +end + +noctalia.state.watch(REFRESH_REQUEST_KEY, function(_request) + refresh() +end) + +refresh() diff --git a/keymap/niri_service.luau b/keymap/niri_service.luau new file mode 100644 index 0000000..1c860d9 --- /dev/null +++ b/keymap/niri_service.luau @@ -0,0 +1,959 @@ +--!nonstrict +-- Niri data service for Keymap. +-- Niri IPC does not expose keybindings, so this service reads the active KDL +-- config and positional include files. It deliberately performs no subprocesses. + +local SNAPSHOT_KEY = "keymap.snapshot" +local REFRESH_REQUEST_KEY = "keymap.refresh_request" +local MAX_FILES = 64 +local MAX_SOURCE_BYTES = 512 * 1024 +local MAX_HIDDEN_BYTES = 2 * 1024 * 1024 + +local refreshing = false +local refreshQueued = false + +local function config(key, fallback) + local value = noctalia.getConfig(key) + if value == nil then + return fallback + end + return value +end + +local function trim(value) + if type(value) ~= "string" then + return "" + end + return value:match("^%s*(.-)%s*$") or "" +end + +local function env(name) + return trim(noctalia.getenv(name)) +end + +-- Shared compositor priority used by all three parser services. +local function detectedCompositor() + local selected = trim(config("compositor", "auto")):lower() + if selected ~= "" and selected ~= "auto" then + if selected == "mango" then + return "mangowc" + end + return selected + end + if env("NIRI_SOCKET") ~= "" then + return "niri" + end + if env("HYPRLAND_INSTANCE_SIGNATURE") ~= "" then + return "hyprland" + end + if env("MANGO_INSTANCE_SIGNATURE") ~= "" then + return "mangowc" + end + local desktop = (env("XDG_CURRENT_DESKTOP") .. ":" .. env("XDG_SESSION_DESKTOP") + .. ":" .. env("DESKTOP_SESSION")):lower() + if desktop:find("niri", 1, true) ~= nil then + return "niri" + end + if desktop:find("hyprland", 1, true) ~= nil then + return "hyprland" + end + if desktop:find("mangowc", 1, true) ~= nil or desktop:find("mango", 1, true) ~= nil then + return "mangowc" + end + return "unknown" +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 + parts[#parts + 1] = part + end + elseif part ~= "." and part ~= "" then + parts[#parts + 1] = part + end + end + local normalized = table.concat(parts, "/") + if absolute then + return "/" .. normalized + end + return normalized ~= "" and normalized or "." +end + +local function dirname(path) + local directory = path:match("^(.*)/[^/]*$") + return directory ~= nil and directory ~= "" and directory or "." +end + +local function expandHome(path) + if path == "~" then + return env("HOME") + end + if path:sub(1, 2) == "~/" then + return env("HOME") .. path:sub(2) + end + return path +end + +local function sourcePath() + local configured = trim(config("niri_config", "")) + if configured == "" then + configured = "~/.config/niri/config.kdl" + end + local expanded = normalizePath(expandHome(configured)) + if noctalia.fileExists(expanded) then return expanded end + + local xdg = env("XDG_CONFIG_HOME") + local niriDir = normalizePath((xdg ~= "" and xdg or (env("HOME") .. "/.config")) .. "/niri") + local candidates = { + normalizePath(expandHome(env("NIRI_CONFIG"))), + normalizePath(niriDir .. "/config.kdl"), + } + for _, path in ipairs(candidates) do + if path ~= "." and noctalia.fileExists(path) then return path end + end + + local entries = noctalia.listDir(niriDir) + if type(entries) ~= "table" then return expanded end + table.sort(entries) + local bestPath, bestScore = nil, -1 + for _, name in ipairs(entries) do + if type(name) == "string" and name:match("%.kdl$") and name ~= "keymap.kdl" + and not name:lower():find("backup", 1, true) then + local path = normalizePath(niriDir .. "/" .. name) + local source = noctalia.readFile(path) + if type(source) == "string" and #source <= MAX_SOURCE_BYTES then + local _, bindBlocks = source:gsub("%f[%a]binds%s*{", "") + local _, includes = source:gsub("%f[%a]include%s+", "") + local score = bindBlocks * 20 + includes * 2 + if name:lower():find("bind", 1, true) then score = score + 5 end + if score > bestScore then bestPath, bestScore = path, score end + end + end + end + return bestScore > 0 and bestPath or expanded +end + +local function resolveInclude(currentFile, path) + local expanded = expandHome(path) + if expanded:sub(1, 1) == "/" then + return normalizePath(expanded) + end + return normalizePath(dirname(currentFile) .. "/" .. expanded) +end + +local ESCAPES = { + n = "\n", + r = "\r", + t = "\t", + ["\\"] = "\\", + ['"'] = '"', + ["'"] = "'", +} + +local function quotedLiteral(text, startAt) + local start = startAt or 1 + while start <= #text and text:sub(start, start):match("%s") do + start = start + 1 + end + local quote = text:sub(start, start) + if quote ~= '"' and quote ~= "'" then + return nil, nil + end + local output = {} + local escaped = false + for index = start + 1, #text do + local char = text:sub(index, index) + if escaped then + output[#output + 1] = ESCAPES[char] or char + escaped = false + elseif char == "\\" then + escaped = true + elseif char == quote then + return table.concat(output), index + 1 + else + output[#output + 1] = char + end + end + return nil, nil +end + +-- Returns executable code and a trailing // comment. Comment markers inside +-- strings are preserved. KDL block comments remain stateful across lines. +local function stripComments(line, inBlockComment) + local code = {} + local comment = nil + local quote = nil + local escaped = false + local index = 1 + while index <= #line do + local char = line:sub(index, index) + local pair = line:sub(index, index + 1) + if inBlockComment then + if pair == "*/" then + inBlockComment = false + index = index + 2 + else + index = index + 1 + end + elseif quote ~= nil then + code[#code + 1] = char + if escaped then + escaped = false + elseif char == "\\" then + escaped = true + elseif char == quote then + quote = nil + end + index = index + 1 + elseif char == '"' or char == "'" then + quote = char + code[#code + 1] = char + index = index + 1 + elseif pair == "//" then + comment = line:sub(index + 2) + break + elseif pair == "/*" then + inBlockComment = true + index = index + 2 + else + code[#code + 1] = char + index = index + 1 + end + end + return table.concat(code), comment, inBlockComment +end + +local function braceDelta(text) + local delta = 0 + local quote = nil + local escaped = false + for index = 1, #text do + local char = text:sub(index, index) + if quote ~= nil then + if escaped then + escaped = false + elseif char == "\\" then + escaped = true + elseif char == quote then + quote = nil + end + elseif char == '"' or char == "'" then + quote = char + elseif char == "{" then + delta = delta + 1 + elseif char == "}" then + delta = delta - 1 + end + end + return delta +end + +local function firstOpenBrace(text) + local quote = nil + local escaped = false + for index = 1, #text do + local char = text:sub(index, index) + if quote ~= nil then + if escaped then + escaped = false + elseif char == "\\" then + escaped = true + elseif char == quote then + quote = nil + end + elseif char == '"' or char == "'" then + quote = char + elseif char == "{" then + return index + end + end + return nil +end + +local function contentBeforeOuterClose(text) + local depth = 1 + local quote = nil + local escaped = false + for index = 1, #text do + local char = text:sub(index, index) + if quote ~= nil then + if escaped then + escaped = false + elseif char == "\\" then + escaped = true + elseif char == quote then + quote = nil + end + elseif char == '"' or char == "'" then + quote = char + elseif char == "{" then + depth = depth + 1 + elseif char == "}" then + depth = depth - 1 + if depth == 0 then + return text:sub(1, index - 1) + end + end + end + return text +end + +local function includeNode(code) + local rest = code:match("^%s*include%s+(.+)$") + if rest == nil then + return nil, false + end + local optional = rest:match("%f[%w]optional%s*=%s*true%f[^%w]") ~= nil + local quoteAt = rest:find('"', 1, true) or rest:find("'", 1, true) + if quoteAt == nil then + return nil, optional + end + return quotedLiteral(rest, quoteAt), optional +end + +local MODIFIER_ALIASES = { + Control = "Ctrl", Ctrl = "Ctrl", Win = "Super", Super = "Super", + Mod = "Mod", Alt = "Alt", Shift = "Shift", + ISO_Level3_Shift = "Mod5", Mod5 = "Mod5", + ISO_Level5_Shift = "ISO_Level5_Shift", +} + +local KEY_NAMES = { + RETURN = "Enter", SPACE = "Space", ESCAPE = "Esc", PRINT = "PrtSc", + PRIOR = "PgUp", NEXT = "PgDn", + WHEELSCROLLUP = "Scroll Up", WHEELSCROLLDOWN = "Scroll Down", + WHEELSCROLLLEFT = "Scroll Left", WHEELSCROLLRIGHT = "Scroll Right", + TOUCHPADSCROLLUP = "Touchpad Up", TOUCHPADSCROLLDOWN = "Touchpad Down", + TOUCHPADSCROLLLEFT = "Touchpad Left", TOUCHPADSCROLLRIGHT = "Touchpad Right", + MOUSELEFT = "Left Click", MOUSERIGHT = "Right Click", MOUSEMIDDLE = "Middle Click", + MOUSEFORWARD = "Mouse Forward", MOUSEBACK = "Mouse Back", + XF86AUDIORAISEVOLUME = "Vol Up", XF86AUDIOLOWERVOLUME = "Vol Down", + XF86AUDIOMUTE = "Mute", XF86AUDIOMICMUTE = "Mic Mute", + XF86AUDIOPLAY = "Play", XF86AUDIOPAUSE = "Pause", XF86AUDIONEXT = "Next", + XF86AUDIOPREV = "Prev", XF86AUDIOSTOP = "Stop", + XF86MONBRIGHTNESSUP = "Bright Up", XF86MONBRIGHTNESSDOWN = "Bright Down", +} + +local function splitCombo(combo) + local modifiers = {} + local mainKey = "" + local signature = {} + for part in combo:gmatch("[^+]+") do + local item = trim(part) + local modifier = MODIFIER_ALIASES[item] + if modifier ~= nil then + modifiers[#modifiers + 1] = modifier + signature[#signature + 1] = modifier:lower() + elseif item ~= "" then + mainKey = item + signature[#signature + 1] = item:lower() + end + end + return modifiers, KEY_NAMES[mainKey:upper()] or mainKey, mainKey, table.concat(signature, "+") +end + +local function bindHeader(prefix) + local header = trim(prefix) + if header:sub(1, 1) == '"' or header:sub(1, 1) == "'" then + local combo, nextAt = quotedLiteral(header, 1) + return combo, combo ~= nil and trim(header:sub(nextAt)) or nil + end + local combo, attributes = header:match("^(%S+)%s*(.-)%s*$") + return combo, attributes +end + +local function titleAttribute(attributes) + local _, valueAt = (attributes or ""):find("hotkey%-overlay%-title%s*=%s*") + if valueAt == nil then + return nil + end + local suffix = attributes:sub(valueAt + 1) + if suffix:match("^%s*null%f[^%w_]") then + return nil + end + local value = quotedLiteral(suffix, 1) + if value == nil then + return nil + end + value = value:gsub("<[^>]*>", "") + value = value:gsub("&", "&"):gsub("<", "<"):gsub(">", ">") + return trim(value) +end + +local ACTION_CATEGORIES = { + spawn = "category.niri.applications", ["spawn-sh"] = "category.niri.applications", + ["focus-column"] = "category.niri.column_navigation", ["focus-window"] = "category.niri.window_focus", + ["focus-workspace"] = "category.niri.workspace_navigation", + ["move-column-to-workspace"] = "category.niri.workspace_management", + ["move-window-to-workspace"] = "category.niri.workspace_management", + ["move-column"] = "category.niri.move_columns", ["move-window"] = "category.niri.move_windows", + ["consume-window"] = "category.niri.window_management", ["expel-window"] = "category.niri.window_management", + ["close-window"] = "category.niri.window_management", ["fullscreen-window"] = "category.niri.window_management", + ["maximize-column"] = "category.niri.column_management", ["set-column-width"] = "category.niri.column_width", + ["switch-preset-column-width"] = "category.niri.column_width", ["reset-window-height"] = "category.niri.window_size", + screenshot = "category.niri.screenshots", ["power-off-monitors"] = "category.niri.power", + ["power-on-monitors"] = "category.niri.power", quit = "category.niri.system", + ["toggle-animation"] = "category.niri.animations", +} + +local function actionVerb(action) + return action:match("^%s*([%w_-]+)") or "" +end + +local function actionDescription(action, verb) + if verb == "spawn" or verb == "spawn-sh" then + local command = quotedLiteral(action:sub(#verb + 1), 1) + if command ~= nil and command ~= "" then + return noctalia.tr("actions.run", { command = command }) + end + end + return noctalia.tr("actions.native", { action = trim(action:gsub(";%s*$", "")) }) +end + +local function actionCategory(action, verb) + local lower = action:lower() + if lower:find("noctalia", 1, true) ~= nil + and (lower:find(" msg ", 1, true) ~= nil or lower:find(" ipc ", 1, true) ~= nil) + then + return noctalia.tr("category.noctalia") + end + local best = nil + local bestLength = -1 + for prefix, category in pairs(ACTION_CATEGORIES) do + if verb:sub(1, #prefix) == prefix and #prefix > bestLength then + best = category + bestLength = #prefix + end + end + return noctalia.tr(best or "category.other") +end + +local function slug(value) + local id = value:lower():gsub("[^%a%d]+", "-"):gsub("^-+", ""):gsub("-+$", "") + return id ~= "" and id or "category" +end + +local function xorByte(left, right) + local result = 0 + local place = 1 + for _ = 1, 8 do + local leftBit = left % 2 + local rightBit = right % 2 + if leftBit ~= rightBit then result = result + place end + left = math.floor(left / 2) + right = math.floor(right / 2) + place = place * 2 + end + return result +end + +-- FNV-1a 32-bit without bit libraries. Splitting the prime (0x01000193) into +-- 2^24 + 403 keeps every intermediate below the exact integer limit of a +-- Lua/Luau number. +local function stableFingerprint(value) + local hash = 2166136261 + for index = 1, #value do + local low = hash % 256 + hash = hash - low + xorByte(low, value:byte(index)) + hash = (hash * 403 + (hash % 256) * 16777216) % 4294967296 + end + return string.format("%08x", hash) +end + +local function sourceLines(source) + local lines = {} + for line in (source .. "\n"):gmatch("([^\n]*)\n") do lines[#lines + 1] = line end + return lines +end + +local function hexDecode(value) + if value == "" or #value % 2 ~= 0 or value:find("[^0-9a-f]") ~= nil then return nil end + local output = {} + for index = 1, #value, 2 do output[#output + 1] = string.char(tonumber(value:sub(index, index + 1), 16)) end + return table.concat(output) +end + +local function hiddenBlockAt(lines, startLine) + local namespace = "^([\t ]*)// Keymap hidden " + if lines[startLine]:match(namespace) == nil then return nil, startLine, false end + local indent, blockId, originalFingerprint = lines[startLine]:match( + namespace .. "v1 begin ([0-9a-f]+) ([0-9a-f]+)$" + ) + if indent == nil or #blockId ~= 8 or #originalFingerprint ~= 8 then return nil, startLine, true end + local marker = indent .. "// Keymap hidden v1" + local escaped = marker:gsub("(%W)", "%%%1") + local encoded, cursor = {}, startLine + 1 + while cursor <= #lines do + local chunk = lines[cursor]:match("^" .. escaped .. " data ([0-9a-f]+)$") + if chunk ~= nil then + if #chunk > 96 or #chunk % 2 ~= 0 then return nil, cursor, true end + encoded[#encoded + 1] = chunk + cursor = cursor + 1 + else + local endId = lines[cursor]:match("^" .. escaped .. " end ([0-9a-f]+)$") + if endId == nil then return nil, math.max(startLine, cursor - 1), true end + local original = hexDecode(table.concat(encoded)) + if #encoded == 0 or endId ~= blockId or original == nil or original == "" + or #original > MAX_HIDDEN_BYTES or stableFingerprint(original) ~= originalFingerprint + or stableFingerprint("Niri\0" .. original) ~= blockId then return nil, cursor, true end + return { block_id = blockId, original = original, original_fingerprint = originalFingerprint, + raw_snippet = table.concat(lines, "\n", startLine, cursor) }, cursor, true + end + end + return nil, #lines, true +end + +local function hiddenTarget(block, path, startLine, endLine, inheritedCategory) + local original = block.original + local markedCategory = original:match( + "^%s*//%s*Keymap bind%-category:%s*([^\n]-)%s*\n" + ) + local codeLines, inBlock = {}, false + for raw in (original .. "\n"):gmatch("([^\n]*)\n") do + local code + code, _, inBlock = stripComments(raw, inBlock) + if trim(code) ~= "" then codeLines[#codeLines + 1] = code end + end + local code = table.concat(codeLines, "\n") + local openAt = firstOpenBrace(code) + local combo, attributes, action = "", "", "" + if openAt ~= nil then + combo, attributes = bindHeader(code:sub(1, openAt - 1)) + action = contentBeforeOuterClose(code:sub(openAt + 1)) + end + combo, attributes = combo or "", attributes or "" + local modifiers, key, _, signature = splitCombo(combo) + local normalizedAction = trim(action):gsub(";%s*$", "") + local verb = actionVerb(normalizedAction) + local description = titleAttribute(attributes) + if description == nil or description == "" then description = actionDescription(normalizedAction, verb) end + local command = verb == "spawn-sh" and (quotedLiteral(normalizedAction:sub(#verb + 1), 1) or "") or "" + return { + hidden = true, id = "hidden:niri:" .. stableFingerprint(path .. "\0" .. tostring(startLine) .. "\0" .. block.block_id), + source = path, line = startLine, start_line = startLine, end_line = endLine, + raw_snippet = block.raw_snippet, fingerprint = stableFingerprint(block.raw_snippet), + original_fingerprint = block.original_fingerprint, modifiers = modifiers, key = key, + description = description, dispatcher = verb, command = command, action = normalizedAction, + activation = "press", category = markedCategory or inheritedCategory or actionCategory(normalizedAction, verb), + capabilities = { restore = true, delete = true }, _signature = signature, + } +end + +local function cleanBind(bind) + return { + id = bind.id, modifiers = bind.modifiers, key = bind.key, + description = bind.description, dispatcher = bind.dispatcher, + activation = "press", command = bind.command, action = bind.action, + source = bind.source, start_line = bind.start_line, end_line = bind.end_line, + raw_snippet = bind.raw_snippet, fingerprint = bind.fingerprint, + managed = bind.managed == true, capabilities = bind.capabilities, + } +end + +local function mergeSequential(binds) + local groups = {} + for index, bind in ipairs(binds) do + local number = bind._rawKey:match("^%d+$") and tonumber(bind._rawKey) or nil + local template, replacements = bind.description:gsub("%f[%d]%d+%f[%D]", "%%N%%") + if number ~= nil and replacements > 0 then + local signature = table.concat(bind.modifiers, "+") .. "|" .. bind.dispatcher .. "|" .. template + groups[signature] = groups[signature] or {} + groups[signature][#groups[signature] + 1] = { index = index, number = number, bind = bind } + end + end + local replacements = {} + local skipped = {} + for _, group in pairs(groups) do + table.sort(group, function(a, b) + return a.number == b.number and a.index < b.index or a.number < b.number + end) + local runStart = 1 + for cursor = 2, #group + 1 do + local continues = cursor <= #group and group[cursor].number == group[cursor - 1].number + 1 + if not continues then + if cursor - runStart >= 3 then + local first = group[runStart] + local last = group[cursor - 1] + local range = tostring(first.number) .. "-" .. tostring(last.number) + local merged = cleanBind(first.bind) + merged.id = "range:" .. first.bind.id .. ":" .. last.bind.id + merged.key = range + merged.description = first.bind.description:gsub("%f[%d]%d+%f[%D]", range, 1) + replacements[first.index] = merged + for item = runStart + 1, cursor - 1 do + skipped[group[item].index] = true + end + end + runStart = cursor + end + end + end + local output = {} + for index, bind in ipairs(binds) do + -- An editable range cannot safely carry the provenance of one real bind. + -- Preserve every effective record individually once provenance is present. + if bind.fingerprint ~= nil then + output[#output + 1] = cleanBind(bind) + elseif replacements[index] ~= nil then + output[#output + 1] = replacements[index] + elseif not skipped[index] then + output[#output + 1] = cleanBind(bind) + end + end + return output +end + +local function buildCategories(records) + local shouldMerge = config("merge_sequential", true) == true + local byName = {} + local order = {} + local seen = {} + local total = 0 + for _, bind in ipairs(records) do + if not bind._overridden then + if not seen[bind._category] then + seen[bind._category] = true + order[#order + 1] = bind._category + end + byName[bind._category] = byName[bind._category] or {} + byName[bind._category][#byName[bind._category] + 1] = bind + total = total + 1 + end + end + local categories = {} + local usedIds = {} + for _, name in ipairs(order) do + local baseId = slug(name) + local id = baseId + local suffix = 2 + while usedIds[id] do + id = baseId .. "-" .. tostring(suffix) + suffix = suffix + 1 + end + usedIds[id] = true + local binds = byName[name] + local output = {} + if shouldMerge then + output = mergeSequential(binds) + else + for _, bind in ipairs(binds) do + output[#output + 1] = cleanBind(bind) + end + end + categories[#categories + 1] = { id = id, name = name, binds = output } + end + return categories, total +end + +local function parseBind(combo, attributes, action, category, records, activeByCombo, provenance) + local modifiers, key, rawKey, signature = splitCombo(combo) + local normalizedAction = trim(action):gsub(";%s*$", "") + local verb = actionVerb(normalizedAction) + if rawKey == "" or verb == "" then + return + end + local previous = activeByCombo[signature] + if previous ~= nil then + previous._overridden = true + end + local description = titleAttribute(attributes) + if description == nil or description == "" then + description = actionDescription(normalizedAction, verb) + end + local command = "" + if verb == "spawn-sh" then + command = quotedLiteral(normalizedAction:sub(#verb + 1), 1) or "" + end + local rawSnippet = provenance.raw_snippet or "" + local bind = { + id = "niri:" .. stableFingerprint(signature .. "\0" .. verb .. "\0" .. normalizedAction), + modifiers = modifiers, key = key, description = description, dispatcher = verb, + activation = "press", command = command, action = normalizedAction, + source = provenance.source, start_line = provenance.start_line, + end_line = provenance.end_line, raw_snippet = rawSnippet, + fingerprint = stableFingerprint(rawSnippet), + managed = provenance.source:match("([^/]+)$") == "keymap.kdl", + capabilities = { + combo = true, category = true, description = true, + command = verb == "spawn-sh", activation = false, + }, + _rawKey = rawKey, _category = category or actionCategory(normalizedAction, verb), + } + records[#records + 1] = bind + activeByCombo[signature] = bind +end + +local function parseConfig(root) + local records = {} + local hidden = {} + local activeByCombo = {} + local warnings = {} + local warningSeen = {} + local visiting = {} + local filesRead = 0 + local fatalError = nil + + local function warn(value) + if not warningSeen[value] then + warningSeen[value] = true + warnings[#warnings + 1] = value + end + end + + local parseFile + parseFile = function(path, optional) + if visiting[path] then + warn("niri_include_cycle:" .. path) + return + end + if filesRead >= MAX_FILES then + warn("niri_file_limit_reached") + return + end + local source = noctalia.readFile(path) + if type(source) ~= "string" then + warn((optional and "niri_optional_include_missing:" or "niri_include_unreadable:") .. path) + if not optional then + fatalError = "niri_required_include_missing" + end + return + end + filesRead = filesRead + 1 + visiting[path] = true + if #source > MAX_SOURCE_BYTES then + source = source:sub(1, MAX_SOURCE_BYTES) + warn("niri_source_truncated:" .. path) + end + + local inBlockComment = false + local topDepth = 0 + local inBinds = false + local bindsDepth = 0 + local currentCategory = nil + local pendingBindCategory = nil + local pendingMarkerLine = nil + local pendingMarkerRaw = nil + local currentBind = nil + local slashdashDepth = nil + local lineNumber = 0 + local hiddenLines = sourceLines(source) + local hiddenCategory = nil + local hiddenCursor = 1 + while hiddenCursor <= #hiddenLines do + local block, blockEnd, candidate = hiddenBlockAt(hiddenLines, hiddenCursor) + if candidate then + if block == nil then + warn("hidden_block_invalid:" .. path .. ":" .. tostring(hiddenCursor)) + else + hidden[#hidden + 1] = hiddenTarget(block, path, hiddenCursor, blockEnd, hiddenCategory) + end + hiddenCursor = blockEnd + 1 + else + local _, comment = stripComments(hiddenLines[hiddenCursor], false) + local label = type(comment) == "string" and comment:match('^%s*#?%s*"([^\"]+)"%s*$') or nil + if label ~= nil and label ~= "" then hiddenCategory = label end + hiddenCursor = hiddenCursor + 1 + end + end + + local function finishBind() + local action = contentBeforeOuterClose(table.concat(currentBind.parts, "\n")) + parseBind( + currentBind.combo, currentBind.attributes, action, currentBind.category, + records, activeByCombo, + { + source = path, start_line = currentBind.start_line, + end_line = lineNumber, raw_snippet = table.concat(currentBind.raw_lines, "\n"), + } + ) + currentBind = nil + end + + for rawLine in (source .. "\n"):gmatch("([^\n]*)\n") do + lineNumber = lineNumber + 1 + local code, comment + code, comment, inBlockComment = stripComments(rawLine, inBlockComment) + local stripped = trim(code) + if slashdashDepth ~= nil then + slashdashDepth = slashdashDepth + braceDelta(code) + if slashdashDepth <= 0 then slashdashDepth = nil end + elseif stripped:sub(1, 2) == "/-" then + local delta = braceDelta(stripped:sub(3)) + if delta > 0 then slashdashDepth = delta end + elseif inBinds then + local delta = braceDelta(code) + if currentBind ~= nil then + currentBind.parts[#currentBind.parts + 1] = code + currentBind.raw_lines[#currentBind.raw_lines + 1] = rawLine + currentBind.depth = currentBind.depth + delta + bindsDepth = bindsDepth + delta + if currentBind.depth <= 0 then finishBind() end + else + local bindCategory = type(comment) == "string" and comment:match( + "^%s*Keymap bind%-category:%s*(.-)%s*$" + ) or nil + if bindCategory ~= nil and bindCategory ~= "" then + pendingBindCategory = bindCategory + pendingMarkerLine = lineNumber + pendingMarkerRaw = rawLine + else + local label = type(comment) == "string" and comment:match('^%s*#?%s*"([^\"]+)"%s*$') or nil + if label ~= nil and label ~= "" then currentCategory = label end + end + if stripped ~= "" and not stripped:match("^}") then + local openAt = firstOpenBrace(code) + if openAt ~= nil then + local combo, attributes = bindHeader(code:sub(1, openAt - 1)) + if combo ~= nil then + local actionStart = code:sub(openAt + 1) + currentBind = { + combo = combo, attributes = attributes, + category = pendingBindCategory or currentCategory, + parts = { actionStart }, depth = 1 + braceDelta(actionStart), + start_line = pendingMarkerLine or lineNumber, + raw_lines = pendingMarkerRaw ~= nil and { pendingMarkerRaw, rawLine } or { rawLine }, + } + pendingBindCategory = nil + pendingMarkerLine = nil + pendingMarkerRaw = nil + if currentBind.depth <= 0 then finishBind() end + else + pendingBindCategory = nil + pendingMarkerLine = nil + pendingMarkerRaw = nil + end + end + end + bindsDepth = bindsDepth + delta + end + if bindsDepth <= 0 then + inBinds = false + bindsDepth = 0 + currentCategory = nil + pendingBindCategory = nil + pendingMarkerLine = nil + pendingMarkerRaw = nil + currentBind = nil + end + else + if topDepth == 0 then + local includePath, optionalInclude = includeNode(code) + if includePath ~= nil then + parseFile(resolveInclude(path, includePath), optionalInclude) + end + if code:match("^%s*binds%s*{") then + inBinds = true + bindsDepth = braceDelta(code) + currentCategory = nil + end + end + if not inBinds then topDepth = topDepth + braceDelta(code) end + end + end + visiting[path] = nil + end + + parseFile(root, false) + local categories, total = buildCategories(records) + return categories, total, hidden, warnings, fatalError +end + +local function snapshot(status, source, errorCode, categories, total, warnings, updatedAt, hidden) + return { + status = status, + error = errorCode or "", + compositor = "Niri", + source = source, + updated_at = updatedAt or "", + total = total or 0, + categories = categories or {}, + warnings = warnings or {}, + hidden = hidden or {}, + } +end + +local function finishRefresh() + refreshing = false + if refreshQueued then + refreshQueued = false + refresh() + end +end + +function refresh() + -- Each compositor service receives the same events. Only the selected or + -- auto-detected service may publish, preventing snapshot races. + if detectedCompositor() ~= "niri" then + return + end + if refreshing then + refreshQueued = true + return + end + refreshing = true + local source = sourcePath() + local previous = noctalia.state.get(SNAPSHOT_KEY) + local keepPrevious = type(previous) == "table" and previous.compositor == "Niri" + local previousCategories = keepPrevious and previous.categories or {} + local previousTotal = keepPrevious and previous.total or 0 + local previousUpdatedAt = keepPrevious and previous.updated_at or "" + local previousHidden = keepPrevious and previous.hidden or {} + noctalia.state.set( + SNAPSHOT_KEY, + snapshot("loading", source, "", previousCategories, previousTotal, {}, previousUpdatedAt, previousHidden) + ) + + if type(noctalia.readFile(source)) ~= "string" then + noctalia.state.set( + SNAPSHOT_KEY, + snapshot("error", source, "niri_config_unreadable", {}, 0, {}, os.date("%H:%M:%S")) + ) + finishRefresh() + return + end + + local categories, total, hidden, warnings, fatalError = parseConfig(source) + if fatalError ~= nil then + noctalia.state.set( + SNAPSHOT_KEY, + snapshot("error", source, fatalError, {}, 0, warnings, os.date("%H:%M:%S")) + ) + elseif total == 0 and #hidden == 0 then + noctalia.state.set( + SNAPSHOT_KEY, + snapshot("error", source, "niri_no_binds", {}, 0, warnings, os.date("%H:%M:%S")) + ) + else + noctalia.state.set( + SNAPSHOT_KEY, + snapshot("ready", source, "", categories, total, warnings, os.date("%H:%M:%S"), hidden) + ) + end + finishRefresh() +end + +function onIpc(event, _payload) + if event == "refresh" then + refresh() + end +end + +function onConfigChanged() + refresh() +end + +-- Manual lifecycle: refreshes are driven by initial load, config changes, IPC, +-- and the shared refresh request. The host's periodic update hook is a no-op. +function update() +end + +noctalia.state.watch(REFRESH_REQUEST_KEY, function(_request) + refresh() +end) + +refresh() diff --git a/keymap/panel.luau b/keymap/panel.luau new file mode 100644 index 0000000..837264e --- /dev/null +++ b/keymap/panel.luau @@ -0,0 +1,3281 @@ +--!nonstrict + +-- Service/UI state contract +-- ------------------------- +-- `keymap.snapshot` is replaced atomically by the service: +-- { +-- status = "idle" | "loading" | "ready" | "error", +-- error = string, +-- compositor = "Hyprland", +-- source = string?, +-- updated_at = string?, +-- total = number, +-- warnings = { string }, +-- categories = { +-- { +-- id = string, +-- name = string, +-- binds = { +-- { +-- id = string, +-- modifiers = { "SUPER", "CTRL", ... }, +-- key = string, +-- description = string, +-- dispatcher = string?, +-- activation = "press" | "release", +-- }, +-- }, +-- }, +-- }, +-- } +-- The panel increments `keymap.refresh_request` to request a new +-- snapshot. It never mutates the service-owned snapshot. + +local SNAPSHOT_KEY = "keymap.snapshot" +local REFRESH_KEY = "keymap.refresh_request" +local CREATE_REQUEST_KEY = "keymap.create_request" +local CREATE_RESULT_KEY = "keymap.create_result" +local UPDATE_REQUEST_KEY = "keymap.update_request" +local UPDATE_RESULT_KEY = "keymap.update_result" + +local EMPTY_SNAPSHOT = { + status = "idle", + error = "", + compositor = "", + total = 0, + categories = {}, + hidden = {}, +} + +local snapshot = EMPTY_SNAPSHOT +local searchQuery = "" +local searchRevision = 0 +local viewMode = "keyboard" +local keyboardLayoutId = "" +local selectedKeyboardKey = nil +local activeModifiers = { SUPER = false, CTRL = false, SHIFT = false, ALT = false } +local creatorOpen = false +local creatorKeys = {} +local creatorKeysText = "" +local creatorActivation = "press" +local creatorCommand = "" +local creatorCommandKind = "shell" +local creatorLibraryEntryId = "" +local creatorDescription = "" +local creatorCategoryIndex = 0 +local creatorNewCategory = "" +local creatorCategoryNamesFrozen = {} +local creatorContextCompositor = "" +local creatorContextSource = "" +local creatorRevision = 0 +local creatorRequestId = "" +local creatorRequestCounter = 0 +local creatorBusy = false +local creatorError = "" +local commandLibraryOpen = false +local commandLibraryQuery = "" +local commandLibrarySourceIndex = 0 +local commandLibraryCategoryIndex = 0 +local commandLibraryReadinessIndex = 0 +local commandLibraryRevision = 0 +local formMode = "create" +local editorMode = false +local editingBindId = "" +local editingCategory = "" +local editingCapabilities = {} +local editingAction = "" +local editingRequestId = "" +local editingOperation = "update" +local editingSnapshotBefore = nil +local deleteConfirmBindId = "" +local hiddenDeleteConfirmBindId = "" +local renamingCategoryId = "" +local renamingCategoryOriginal = "" +local renamingCategoryValue = "" +local renamingCategoryRevision = 0 +local render +local selectKeyboardKey +local env = getfenv() +local dynamicCallbackNames = {} +local renderCallbackNames = nil + +local COMMAND_LIBRARY = { entries = {} } +do + local encoded = noctalia.readFile("command_library.json") + if type(encoded) == "string" and type(noctalia.json) == "table" + and type(noctalia.json.decode) == "function" then + local ok, decoded = pcall(noctalia.json.decode, encoded) + if ok and type(decoded) == "table" and decoded.schema == 1 + and type(decoded.entries) == "table" then + COMMAND_LIBRARY = decoded + end + end +end + +local function tr(key, args) + return noctalia.tr(key, args) +end + +local function cfg(key) + return noctalia.getConfig(key) +end + +local function asString(value, fallback) + if type(value) == "string" and value ~= "" then + return value + end + return fallback or "" +end + +local function asArray(value) + if type(value) == "table" then + return value + end + return {} +end + +local function registerDynamicCallback(name, callback) + env[name] = callback + if renderCallbackNames ~= nil then + renderCallbackNames[name] = true + end + return name +end + +local function finishDynamicCallbackRender() + local previous = dynamicCallbackNames + dynamicCallbackNames = renderCallbackNames or {} + renderCallbackNames = nil + for name, _ in pairs(previous) do + if dynamicCallbackNames[name] ~= true then + env[name] = nil + end + end +end + +local function shellQuote(value) + return "'" .. asString(value):gsub("'", "'\\''") .. "'" +end + +local function sourceDirectory() + local source = asString(snapshot.source) + if source == "" then return "" end + local expanded = noctalia.expandPath(source) + local directory = expanded:match("^(.*)/[^/]*$") + if directory == nil then return "." end + if directory == "" then return "/" end + return directory +end + +local function normalized(value) + return string.lower(noctalia.string.trim(asString(value))) +end + +local MODIFIER_ORDER = { "SUPER", "CTRL", "SHIFT", "ALT" } +local MODIFIER_ALIASES = { + super = "SUPER", meta = "SUPER", win = "SUPER", logo = "SUPER", mod = "SUPER", mod4 = "SUPER", + ctrl = "CTRL", control = "CTRL", + shift = "SHIFT", + alt = "ALT", option = "ALT", mod1 = "ALT", +} + +local KEY_ALIASES = { + escape = "esc", esc = "esc", + ["return"] = "enter", enter = "enter", kp_enter = "numenter", kpenter = "numenter", + space = "space", spacebar = "space", + print = "prtsc", printscreen = "prtsc", sys_req = "prtsc", prtsc = "prtsc", + scroll_lock = "scrolllock", scrolllock = "scrolllock", + pause = "pause", ["break"] = "pause", + insert = "insert", ins = "insert", delete = "delete", del = "delete", + home = "home", ["end"] = "end", prior = "pgup", page_up = "pgup", pageup = "pgup", pgup = "pgup", + next = "pgdn", page_down = "pgdn", pagedown = "pgdn", pgdn = "pgdn", + left = "left", arrowleft = "left", right = "right", arrowright = "right", + up = "up", arrowup = "up", down = "down", arrowdown = "down", + grave = "grave", asciitilde = "grave", minus = "minus", underscore = "minus", + equal = "equal", plus = "equal", + bracketleft = "bracketleft", braceleft = "bracketleft", + bracketright = "bracketright", braceright = "bracketright", + backslash = "backslash", bar = "backslash", + semicolon = "semicolon", colon = "semicolon", apostrophe = "apostrophe", quotedbl = "apostrophe", + comma = "comma", less = "comma", period = "period", greater = "period", slash = "slash", question = "slash", + num_lock = "numlock", numlock = "numlock", kp_divide = "numdivide", kpdivide = "numdivide", + kp_multiply = "nummultiply", kpmultiply = "nummultiply", kp_subtract = "numminus", kpsubtract = "numminus", + kp_add = "numplus", kpadd = "numplus", kp_decimal = "numdecimal", kpdecimal = "numdecimal", + num_plus = "numplus", ["num_+"] = "numplus", num_minus = "numminus", ["num_-"] = "numminus", + num_multiply = "nummultiply", ["num_*"] = "nummultiply", + num_divide = "numdivide", ["num_/"] = "numdivide", + kp_insert = "num0", kpinsert = "num0", kp_end = "num1", kpend = "num1", + kp_down = "num2", kpdown = "num2", kp_next = "num3", kpnext = "num3", + kp_left = "num4", kpleft = "num4", kp_begin = "num5", kpbegin = "num5", + kp_right = "num6", kpright = "num6", kp_home = "num7", kphome = "num7", + kp_up = "num8", kpup = "num8", kp_prior = "num9", kpprior = "num9", + kp_delete = "numdecimal", kpdelete = "numdecimal", +} + +local SYMBOL_ALIASES = { + ["~"] = "grave", ["!"] = "1", ["@"] = "2", ["#"] = "3", + ["$"] = "4", ["%"] = "5", ["^"] = "6", ["&"] = "7", ["*"] = "8", + ["("] = "9", [")"] = "0", ["-"] = "minus", ["_"] = "minus", ["="] = "equal", ["+"] = "equal", + ["["] = "bracketleft", ["{"] = "bracketleft", ["]"] = "bracketright", ["}"] = "bracketright", + ["\\"] = "backslash", ["|"] = "backslash", [";"] = "semicolon", [":"] = "semicolon", + ["'"] = "apostrophe", ["\""] = "apostrophe", [","] = "comma", ["<"] = "comma", + ["."] = "period", [">"] = "period", ["/"] = "slash", ["?"] = "slash", +} +SYMBOL_ALIASES[string.char(96)] = "grave" + +local function canonicalModifier(value) + return MODIFIER_ALIASES[normalized(value)] or string.upper(asString(value)) +end + +local function canonicalKey(value) + local raw = normalized(value) + if SYMBOL_ALIASES[raw] ~= nil then + return SYMBOL_ALIASES[raw] + end + local key = raw:gsub("%s+", "_"):gsub("%-", "_") + local rangeStart, rangeEnd = key:match("^(%d)_(%d)$") + if rangeStart ~= nil then + return rangeStart .. "-" .. rangeEnd + end + if KEY_ALIASES[key] ~= nil then + return KEY_ALIASES[key] + end + local numpad = key:match("^kp_?(%d)$") or key:match("^num_?(%d)$") + if numpad ~= nil then + return "num" .. numpad + end + if key:match("^f%d%d?$") or key:match("^[a-z]$") or key:match("^%d$") then + return key + end + return key +end + +local function modifierSignature(modifiers) + local enabled = {} + local extras = {} + for _, modifier in ipairs(asArray(modifiers)) do + local canonical = canonicalModifier(modifier) + if activeModifiers[canonical] ~= nil then + enabled[canonical] = true + else + extras[#extras + 1] = canonical + end + end + local ordered = {} + for _, modifier in ipairs(MODIFIER_ORDER) do + if enabled[modifier] then + ordered[#ordered + 1] = modifier + end + end + table.sort(extras) + for _, modifier in ipairs(extras) do + ordered[#ordered + 1] = modifier + end + return table.concat(ordered, "+") +end + +local function activeModifierSignature() + local ordered = {} + for _, modifier in ipairs(MODIFIER_ORDER) do + if activeModifiers[modifier] then + ordered[#ordered + 1] = modifier + end + end + return table.concat(ordered, "+") +end + +local function expandedKeys(value) + local canonical = canonicalKey(value) + local first, last = canonical:match("^(%d)%-(%d)$") + if first ~= nil and tonumber(first) <= tonumber(last) then + local result = {} + for number = tonumber(first), tonumber(last) do + result[#result + 1] = tostring(number) + end + return result + end + return { canonical } +end + +local function keyboardIndex() + local exact = {} + local any = {} + for _, category in ipairs(asArray(snapshot.categories)) do + for _, bind in ipairs(asArray(category.binds)) do + local signature = modifierSignature(bind.modifiers) + local indexedKeys = type(bind.keys) == "table" and bind.keys or expandedKeys(bind.key) + for _, rawKey in ipairs(indexedKeys) do + local key = canonicalKey(rawKey) + local entry = { + bind = bind, + category = asString(category.name, tr("panel.uncategorized")), + } + local chord = signature .. "|" .. key + exact[chord] = exact[chord] or {} + exact[chord][#exact[chord] + 1] = entry + any[key] = any[key] or {} + any[key][#any[key] + 1] = entry + end + end + end + return { exact = exact, any = any } +end + +local function keyCallback(id, code) + local name = "onKeyboardKey_" .. id + return registerDynamicCallback(name, function() + selectKeyboardKey(code) + end) +end + +local function contains(haystack, needle) + return string.find(normalized(haystack), needle, 1, true) ~= nil +end + +local function bindMatches(bind, needle) + if contains(bind.description, needle) or contains(bind.key, needle) or contains(bind.dispatcher, needle) + or contains(bind.action, needle) or contains(bind.mode, needle) then + return true + end + for _, modifier in ipairs(asArray(bind.modifiers)) do + if contains(modifier, needle) then + return true + end + end + return false +end + +local function filteredCategories() + local result = {} + local needle = normalized(searchQuery) + + for _, category in ipairs(asArray(snapshot.categories)) do + local binds = asArray(category.binds) + if needle == "" or contains(category.name, needle) then + result[#result + 1] = { + id = asString(category.id, asString(category.name)), + name = asString(category.name, tr("panel.uncategorized")), + binds = binds, + } + else + local matching = {} + for _, bind in ipairs(binds) do + if bindMatches(bind, needle) then + matching[#matching + 1] = bind + end + end + if #matching > 0 then + result[#result + 1] = { + id = asString(category.id, asString(category.name)), + name = asString(category.name, tr("panel.uncategorized")), + binds = matching, + } + end + end + end + + return result +end + +local function filteredHidden() + local result = {} + local needle = normalized(searchQuery) + for _, bind in ipairs(asArray(snapshot.hidden)) do + if needle == "" or bindMatches(bind, needle) or contains(bind.category, needle) then + result[#result + 1] = bind + end + end + return result +end + +local function categoryWeight(category) + return #asArray(category.binds) + 2 +end + +-- Split categories into contiguous, approximately equal columns. Contiguous +-- partitions preserve the reading order from the Lua configuration. +local function partitionCategories(categories, requestedColumns) + if #categories == 0 then + return {} + end + + local columnCount = math.max(1, math.min(requestedColumns, #categories)) + local columns = {} + local index = 1 + local remainingWeight = 0 + for _, category in ipairs(categories) do + remainingWeight += categoryWeight(category) + end + + for columnIndex = 1, columnCount do + local remainingColumns = columnCount - columnIndex + local target = remainingWeight / (remainingColumns + 1) + local column = {} + local weight = 0 + local lastAvailableIndex = #categories - remainingColumns + + while index <= lastAvailableIndex do + local nextCategory = categories[index] + local nextWeight = categoryWeight(nextCategory) + if #column > 0 and math.abs(weight - target) <= math.abs(weight + nextWeight - target) then + break + end + column[#column + 1] = nextCategory + weight += nextWeight + index += 1 + end + + columns[#columns + 1] = column + remainingWeight -= weight + end + + return columns +end + +local function cardFill() + local color = asString(cfg("card_color"), "surface_variant") + local opacity = math.max(0, math.min(100, tonumber(cfg("card_opacity")) or 35)) / 100 + if string.sub(color, 1, 1) == "#" then + local alpha = string.format("%02X", math.floor(opacity * 255 + 0.5)) + if #color == 7 then + return color .. alpha + elseif #color == 9 then + return string.sub(color, 1, 7) .. alpha + end + return color + end + return color .. "/" .. string.format("%.2f", opacity) +end + +local function modifierStyle(token) + local name = normalized(token) + if name == "super" or name == "meta" or name == "win" or name == "logo" then + return cfg("super_color"), cfg("super_text_color") + elseif name == "ctrl" or name == "control" then + return cfg("ctrl_color"), cfg("ctrl_text_color") + elseif name == "shift" then + return cfg("shift_color"), cfg("shift_text_color") + elseif name == "alt" or name == "mod1" or name == "option" then + return cfg("alt_color"), cfg("alt_text_color") + end + return cfg("key_color"), cfg("key_text_color") +end + +-- BEGIN KEYBOARD LAYOUT DATA +local KEYBOARD_100 = { + -- Function row: 16 keys. + { + { id = "escape", label = "Esc", code = "Esc", units = 1 }, + { spacer = 1 }, + { id = "f1", label = "F1", code = "F1", units = 1 }, + { id = "f2", label = "F2", code = "F2", units = 1 }, + { id = "f3", label = "F3", code = "F3", units = 1 }, + { id = "f4", label = "F4", code = "F4", units = 1 }, + { spacer = 0.5 }, + { id = "f5", label = "F5", code = "F5", units = 1 }, + { id = "f6", label = "F6", code = "F6", units = 1 }, + { id = "f7", label = "F7", code = "F7", units = 1 }, + { id = "f8", label = "F8", code = "F8", units = 1 }, + { spacer = 0.5 }, + { id = "f9", label = "F9", code = "F9", units = 1 }, + { id = "f10", label = "F10", code = "F10", units = 1 }, + { id = "f11", label = "F11", code = "F11", units = 1 }, + { id = "f12", label = "F12", code = "F12", units = 1 }, + { spacer = 0.5 }, + { id = "print_screen", label = "Prt", code = "PrtSc", units = 1 }, + { id = "scroll_lock", label = "Scr", code = "Scroll Lock", units = 1 }, + { id = "pause", label = "Pau", code = "Pause", units = 1 }, + { spacer = 4.5 }, + }, + + -- Number row: 21 keys. + { + { id = "grave", label = "`", code = "`", units = 1 }, + { id = "digit_1", label = "1", code = "1", units = 1 }, + { id = "digit_2", label = "2", code = "2", units = 1 }, + { id = "digit_3", label = "3", code = "3", units = 1 }, + { id = "digit_4", label = "4", code = "4", units = 1 }, + { id = "digit_5", label = "5", code = "5", units = 1 }, + { id = "digit_6", label = "6", code = "6", units = 1 }, + { id = "digit_7", label = "7", code = "7", units = 1 }, + { id = "digit_8", label = "8", code = "8", units = 1 }, + { id = "digit_9", label = "9", code = "9", units = 1 }, + { id = "digit_0", label = "0", code = "0", units = 1 }, + { id = "minus", label = "-", code = "-", units = 1 }, + { id = "equal", label = "=", code = "=", units = 1 }, + { id = "backspace", label = "Back", code = "Backspace", units = 2 }, + { spacer = 0.5 }, + { id = "insert", label = "Ins", code = "Insert", units = 1 }, + { id = "home", label = "Home", code = "Home", units = 1 }, + { id = "page_up", label = "PgU", code = "PgUp", units = 1 }, + { spacer = 0.5 }, + { id = "num_lock", label = "Num", code = "Num Lock", units = 1 }, + { id = "kp_divide", label = "/", code = "KP_Divide", units = 1 }, + { id = "kp_multiply", label = "*", code = "KP_Multiply", units = 1 }, + { id = "kp_subtract", label = "-", code = "KP_Subtract", units = 1 }, + }, + + -- QWERTY row: 21 keys. + { + { id = "tab", label = "Tab", code = "Tab", units = 1.5 }, + { id = "key_q", label = "Q", code = "Q", units = 1 }, + { id = "key_w", label = "W", code = "W", units = 1 }, + { id = "key_e", label = "E", code = "E", units = 1 }, + { id = "key_r", label = "R", code = "R", units = 1 }, + { id = "key_t", label = "T", code = "T", units = 1 }, + { id = "key_y", label = "Y", code = "Y", units = 1 }, + { id = "key_u", label = "U", code = "U", units = 1 }, + { id = "key_i", label = "I", code = "I", units = 1 }, + { id = "key_o", label = "O", code = "O", units = 1 }, + { id = "key_p", label = "P", code = "P", units = 1 }, + { id = "bracket_left", label = "[", code = "[", units = 1 }, + { id = "bracket_right", label = "]", code = "]", units = 1 }, + { id = "backslash", label = "\\", code = "\\", units = 1.5 }, + { spacer = 0.5 }, + { id = "delete", label = "Del", code = "Delete", units = 1 }, + { id = "end", label = "End", code = "End", units = 1 }, + { id = "page_down", label = "PgD", code = "PgDn", units = 1 }, + { spacer = 0.5 }, + { id = "kp_7", label = "7", code = "KP_7", units = 1 }, + { id = "kp_8", label = "8", code = "KP_8", units = 1 }, + { id = "kp_9", label = "9", code = "KP_9", units = 1 }, + { id = "kp_add", label = "+", code = "KP_Add", units = 1 }, + }, + + -- Home row: 16 keys. The final numpad spacer continues KP Add from above. + { + { id = "caps_lock", label = "Caps", code = "Caps Lock", units = 1.75, modifier = true }, + { id = "key_a", label = "A", code = "A", units = 1 }, + { id = "key_s", label = "S", code = "S", units = 1 }, + { id = "key_d", label = "D", code = "D", units = 1 }, + { id = "key_f", label = "F", code = "F", units = 1 }, + { id = "key_g", label = "G", code = "G", units = 1 }, + { id = "key_h", label = "H", code = "H", units = 1 }, + { id = "key_j", label = "J", code = "J", units = 1 }, + { id = "key_k", label = "K", code = "K", units = 1 }, + { id = "key_l", label = "L", code = "L", units = 1 }, + { id = "semicolon", label = ";", code = ";", units = 1 }, + { id = "apostrophe", label = "'", code = "'", units = 1 }, + { id = "enter", label = "Enter", code = "Enter", units = 2.25 }, + { spacer = 4 }, + { id = "kp_4", label = "4", code = "KP_4", units = 1 }, + { id = "kp_5", label = "5", code = "KP_5", units = 1 }, + { id = "kp_6", label = "6", code = "KP_6", units = 1 }, + { id = "kp_add_lower", label = "+", code = "KP_Add", units = 1 }, + }, + + -- Shift row: 17 keys. + { + { id = "shift_left", label = "Shift", code = "Shift", units = 2.25, modifier = true }, + { id = "key_z", label = "Z", code = "Z", units = 1 }, + { id = "key_x", label = "X", code = "X", units = 1 }, + { id = "key_c", label = "C", code = "C", units = 1 }, + { id = "key_v", label = "V", code = "V", units = 1 }, + { id = "key_b", label = "B", code = "B", units = 1 }, + { id = "key_n", label = "N", code = "N", units = 1 }, + { id = "key_m", label = "M", code = "M", units = 1 }, + { id = "comma", label = ",", code = ",", units = 1 }, + { id = "period", label = ".", code = ".", units = 1 }, + { id = "slash", label = "/", code = "/", units = 1 }, + { id = "shift_right", label = "Shift", code = "Shift", units = 2.75, modifier = true }, + { spacer = 1.5 }, + { id = "arrow_up", label = "↑", code = "Up", units = 1 }, + { spacer = 1 }, + { spacer = 0.5 }, + { id = "kp_1", label = "1", code = "KP_1", units = 1 }, + { id = "kp_2", label = "2", code = "KP_2", units = 1 }, + { id = "kp_3", label = "3", code = "KP_3", units = 1 }, + { id = "kp_enter", label = "Ent", code = "KP_Enter", units = 1 }, + }, + + -- Bottom row: 13 keys. The final numpad spacer continues KP Enter from above. + { + { id = "ctrl_left", label = "Ctrl", code = "Ctrl", units = 1.25, modifier = true }, + { id = "super_left", label = "Win", code = "Super", units = 1.25, modifier = true }, + { id = "alt_left", label = "Alt", code = "Alt", units = 1.25, modifier = true }, + { id = "space", label = "Space", code = "Space", units = 6.25 }, + { id = "alt_right", label = "Alt", code = "Alt", units = 1.25, modifier = true }, + { id = "super_right", label = "Win", code = "Super", units = 1.25, modifier = true }, + { id = "menu", label = "Menu", code = "Menu", units = 1.25 }, + { id = "ctrl_right", label = "Ctrl", code = "Ctrl", units = 1.25, modifier = true }, + { spacer = 0.5 }, + { id = "arrow_left", label = "←", code = "Left", units = 1 }, + { id = "arrow_down", label = "↓", code = "Down", units = 1 }, + { id = "arrow_right", label = "→", code = "Right", units = 1 }, + { spacer = 0.5 }, + { id = "kp_0", label = "0", code = "KP_0", units = 2 }, + { id = "kp_decimal", label = ".", code = "KP_Decimal", units = 1 }, + { id = "kp_enter_lower", label = "Ent", code = "KP_Enter", units = 1 }, + }, +} + +local function keyboardRowSlice(source, firstIndex, lastIndex) + local row = {} + for index = firstIndex, lastIndex do row[#row + 1] = source[index] end + return row +end + +local function keyboardAppend(row, spec) + row[#row + 1] = spec + return row +end + +local function keyboardAppendSlice(row, source, firstIndex, lastIndex) + for index = firstIndex, lastIndex do row[#row + 1] = source[index] end + return row +end + +local function keyboardSizedKey(spec, units) + return { + id = spec.id, label = spec.label, code = spec.code, units = units, + modifier = spec.modifier, bindable = spec.bindable, + } +end + +-- 96% / 1800-compact: the navigation block is folded into the main cluster, +-- while the complete numpad remains directly beside it. +local KEYBOARD_96_FUNCTION = { + KEYBOARD_100[1][1], + KEYBOARD_100[1][3], KEYBOARD_100[1][4], KEYBOARD_100[1][5], KEYBOARD_100[1][6], + KEYBOARD_100[1][8], KEYBOARD_100[1][9], KEYBOARD_100[1][10], KEYBOARD_100[1][11], + KEYBOARD_100[1][13], KEYBOARD_100[1][14], KEYBOARD_100[1][15], KEYBOARD_100[1][16], + KEYBOARD_100[3][16], KEYBOARD_100[2][17], KEYBOARD_100[3][17], + KEYBOARD_100[2][18], KEYBOARD_100[3][18], KEYBOARD_100[1][18], +} +local KEYBOARD_96_SHIFT = keyboardRowSlice(KEYBOARD_100[5], 1, 11) +keyboardAppend(KEYBOARD_96_SHIFT, keyboardSizedKey(KEYBOARD_100[5][12], 1.75)) +keyboardAppend(KEYBOARD_96_SHIFT, KEYBOARD_100[5][14]) +keyboardAppendSlice(KEYBOARD_96_SHIFT, KEYBOARD_100[5], 17, 20) +local KEYBOARD_96_BOTTOM = { + KEYBOARD_100[6][1], KEYBOARD_100[6][2], KEYBOARD_100[6][3], KEYBOARD_100[6][4], + keyboardSizedKey(KEYBOARD_100[6][5], 1), + keyboardSizedKey(KEYBOARD_100[6][6], 1), + keyboardSizedKey(KEYBOARD_100[6][8], 1), + KEYBOARD_100[6][10], KEYBOARD_100[6][11], KEYBOARD_100[6][12], + keyboardSizedKey(KEYBOARD_100[6][14], 1), KEYBOARD_100[6][15], KEYBOARD_100[6][16], +} +local KEYBOARD_96 = { + KEYBOARD_96_FUNCTION, + keyboardAppendSlice(keyboardRowSlice(KEYBOARD_100[2], 1, 14), KEYBOARD_100[2], 20, 23), + keyboardAppendSlice(keyboardRowSlice(KEYBOARD_100[3], 1, 14), KEYBOARD_100[3], 20, 23), + keyboardAppendSlice(keyboardRowSlice(KEYBOARD_100[4], 1, 13), KEYBOARD_100[4], 15, 18), + KEYBOARD_96_SHIFT, + KEYBOARD_96_BOTTOM, +} + +-- 80% / tenkeyless: full function and navigation clusters without a numpad. +local KEYBOARD_80 = { + keyboardRowSlice(KEYBOARD_100[1], 1, 20), + keyboardRowSlice(KEYBOARD_100[2], 1, 18), + keyboardRowSlice(KEYBOARD_100[3], 1, 18), + keyboardAppend(keyboardRowSlice(KEYBOARD_100[4], 1, 13), { spacer = 3.5 }), + keyboardRowSlice(KEYBOARD_100[5], 1, 15), + keyboardRowSlice(KEYBOARD_100[6], 1, 12), +} + +local KEYBOARD_COMPACT_BOTTOM = { + KEYBOARD_100[6][1], + KEYBOARD_100[6][2], + KEYBOARD_100[6][3], + KEYBOARD_100[6][4], + keyboardSizedKey(KEYBOARD_100[6][5], 1), + keyboardSizedKey(KEYBOARD_100[6][6], 1), + { id = "ctrl_right", label = "Ctrl", code = "Ctrl", units = 1, modifier = true }, + KEYBOARD_100[6][10], + KEYBOARD_100[6][11], + KEYBOARD_100[6][12], +} + +local function compactKeyboardBody() + local shiftRow = keyboardRowSlice(KEYBOARD_100[5], 1, 11) + keyboardAppend(shiftRow, keyboardSizedKey(KEYBOARD_100[5][12], 1.75)) + keyboardAppend(shiftRow, KEYBOARD_100[5][14]) + keyboardAppend(shiftRow, KEYBOARD_100[3][17]) + return { + keyboardAppend(keyboardRowSlice(KEYBOARD_100[2], 1, 14), KEYBOARD_100[2][17]), + keyboardAppend(keyboardRowSlice(KEYBOARD_100[3], 1, 14), KEYBOARD_100[2][18]), + keyboardAppend(keyboardRowSlice(KEYBOARD_100[4], 1, 13), KEYBOARD_100[3][18]), + shiftRow, + keyboardRowSlice(KEYBOARD_COMPACT_BOTTOM, 1, #KEYBOARD_COMPACT_BOTTOM), + } +end + +-- 75%: compact navigation and arrows plus a dedicated function row. +local KEYBOARD_75_FUNCTION = { + KEYBOARD_100[1][1], + KEYBOARD_100[1][3], KEYBOARD_100[1][4], KEYBOARD_100[1][5], KEYBOARD_100[1][6], + KEYBOARD_100[1][8], KEYBOARD_100[1][9], KEYBOARD_100[1][10], KEYBOARD_100[1][11], + KEYBOARD_100[1][13], KEYBOARD_100[1][14], KEYBOARD_100[1][15], KEYBOARD_100[1][16], + KEYBOARD_100[1][18], KEYBOARD_100[1][20], KEYBOARD_100[3][16], +} +local KEYBOARD_75 = { KEYBOARD_75_FUNCTION } +for _, row in ipairs(compactKeyboardBody()) do KEYBOARD_75[#KEYBOARD_75 + 1] = row end + +-- 65%: the same compact navigation column and arrows, without function keys. +local KEYBOARD_65_NUMBER = { KEYBOARD_100[1][1] } +keyboardAppendSlice(KEYBOARD_65_NUMBER, KEYBOARD_100[2], 2, 14) +keyboardAppend(KEYBOARD_65_NUMBER, KEYBOARD_100[2][17]) +local KEYBOARD_65 = compactKeyboardBody() +KEYBOARD_65[1] = KEYBOARD_65_NUMBER +KEYBOARD_65[5] = { + KEYBOARD_100[6][1], KEYBOARD_100[6][2], KEYBOARD_100[6][3], KEYBOARD_100[6][4], + keyboardSizedKey(KEYBOARD_100[6][5], 1), + { id = "fn", label = "Fn", units = 1, bindable = false }, + { id = "ctrl_right", label = "Ctrl", code = "Ctrl", units = 1, modifier = true }, + KEYBOARD_100[6][10], KEYBOARD_100[6][11], KEYBOARD_100[6][12], +} + +-- 60%: the ANSI alphanumeric block only. +local KEYBOARD_60 = { + keyboardRowSlice(KEYBOARD_100[2], 1, 14), + keyboardRowSlice(KEYBOARD_100[3], 1, 14), + keyboardRowSlice(KEYBOARD_100[4], 1, 13), + keyboardRowSlice(KEYBOARD_100[5], 1, 12), + keyboardRowSlice(KEYBOARD_100[6], 1, 8), +} + +local KEYBOARD_LAYOUT_ORDER = { "100", "96", "80", "75", "65", "60" } +local KEYBOARD_LAYOUTS = { + ["100"] = { id = "100", rows = KEYBOARD_100, rowUnits = 23, labelKey = "p100", + features = { functionRow = true, numpad = true, navCluster = true, arrows = true } }, + ["96"] = { id = "96", rows = KEYBOARD_96, rowUnits = 19, labelKey = "p96", + features = { functionRow = true, numpad = true, navCluster = false, arrows = true } }, + ["80"] = { id = "80", rows = KEYBOARD_80, rowUnits = 18.5, labelKey = "p80", + features = { functionRow = true, numpad = false, navCluster = true, arrows = true } }, + ["75"] = { id = "75", rows = KEYBOARD_75, rowUnits = 16, labelKey = "p75", + features = { functionRow = true, numpad = false, navCluster = false, arrows = true } }, + ["65"] = { id = "65", rows = KEYBOARD_65, rowUnits = 16, labelKey = "p65", + features = { functionRow = false, numpad = false, navCluster = false, arrows = true } }, + ["60"] = { id = "60", rows = KEYBOARD_60, rowUnits = 15, labelKey = "p60", + features = { functionRow = false, numpad = false, navCluster = false, arrows = false } }, +} +-- END KEYBOARD LAYOUT DATA + +-- One unit plus one gap is 48 px, divisible by four. Quarter-unit keys can +-- therefore be positioned without fractional rounding drift. +local KEYBOARD_UNIT = 44 +local KEYBOARD_GAP = 4 +local KEYBOARD_KEY_HEIGHT = 42 +local KEYBOARD_CODE_SET = {} +local KEYBOARD_LABELS = {} +for _, layoutId in ipairs(KEYBOARD_LAYOUT_ORDER) do + local layout = KEYBOARD_LAYOUTS[layoutId] + layout.codeSet = {} + for _, keyboardRow in ipairs(layout.rows) do + for _, keySpec in ipairs(keyboardRow) do + if keySpec.bindable ~= false and keySpec.code ~= nil then + local code = canonicalKey(keySpec.code) + KEYBOARD_CODE_SET[code] = true + layout.codeSet[code] = true + KEYBOARD_LABELS[code] = asString(keySpec.label, keySpec.code) + end + end + end +end + +local function activeKeyboardLayoutId() + local layoutId = keyboardLayoutId ~= "" and keyboardLayoutId or asString(cfg("keyboard_layout"), "100") + if KEYBOARD_LAYOUTS[layoutId] == nil then return "100" end + return layoutId +end + +local function activeKeyboardLayout() + return KEYBOARD_LAYOUTS[activeKeyboardLayoutId()] +end + +local function keyboardLayoutSelectedIndex() + local activeId = activeKeyboardLayoutId() + for index, layoutId in ipairs(KEYBOARD_LAYOUT_ORDER) do + if layoutId == activeId then return index - 1 end + end + return 0 +end + +local function keyboardLayoutOptions() + local options = {} + for _, layoutId in ipairs(KEYBOARD_LAYOUT_ORDER) do + options[#options + 1] = tr("panel.keyboard.layouts." .. KEYBOARD_LAYOUTS[layoutId].labelKey) + end + return options +end + +-- Config files use XKB names, while the visual keyboard uses compact labels. +-- These names are accepted by Hyprland Lua, Niri KDL, and MangoWC. +local CONFIG_KEY_NAMES = { + esc = "Escape", prtsc = "Print", scrolllock = "Scroll_Lock", pause = "Pause", + grave = "grave", minus = "minus", equal = "equal", backspace = "BackSpace", + tab = "Tab", bracketleft = "bracketleft", bracketright = "bracketright", + backslash = "backslash", caps_lock = "Caps_Lock", semicolon = "semicolon", + apostrophe = "apostrophe", enter = "Return", comma = "comma", period = "period", + slash = "slash", space = "space", menu = "Menu", + insert = "Insert", home = "Home", pgup = "Prior", delete = "Delete", ["end"] = "End", + pgdn = "Next", left = "Left", right = "Right", up = "Up", down = "Down", + numlock = "Num_Lock", numdivide = "KP_Divide", nummultiply = "KP_Multiply", + numminus = "KP_Subtract", numplus = "KP_Add", numenter = "KP_Enter", + numdecimal = "KP_Decimal", + num0 = "KP_0", num1 = "KP_1", num2 = "KP_2", num3 = "KP_3", num4 = "KP_4", + num5 = "KP_5", num6 = "KP_6", num7 = "KP_7", num8 = "KP_8", num9 = "KP_9", +} + +local function creatorHasKey(code) + for _, selectedCode in ipairs(creatorKeys) do + if selectedCode == code then + return true + end + end + return false +end + +local function configKeyName(code) + local mapped = CONFIG_KEY_NAMES[code] + if mapped ~= nil then + return mapped + end + if code:match("^[a-z]$") then + return string.upper(code) + end + if code:match("^f%d%d?$") then + return string.upper(code) + end + return code +end + +-- A multi-unit key replaces the gaps that would exist between unit-sized +-- keys. Using the same pitch for keys and spacers keeps every 23-unit row at +-- exactly the same physical width, regardless of its number of children. +local function keyboardUnitsWidth(units) + local value = tonumber(units) or 1 + return math.max(1, math.floor(value * KEYBOARD_UNIT + (value - 1) * KEYBOARD_GAP + 0.5)) +end + +local function colorWithOpacity(value, fallback, opacity) + local color = asString(value, fallback) + if string.sub(color, 1, 1) == "#" then + local alpha = string.format("%02X", math.floor(math.max(0, math.min(1, opacity)) * 255 + 0.5)) + if #color == 7 then + return color .. alpha + elseif #color == 9 then + return string.sub(color, 1, 7) .. alpha + end + return color + end + return color .. "/" .. string.format("%.2f", opacity) +end + +local function keyboardKeyNode(spec, index) + if spec.spacer ~= nil then + -- `ui.spacer` is flexible: inside a fixed-width row it absorbs remaining + -- space and moves every key that follows it. Keyboard gaps must be rigid, + -- otherwise the function/navigation blocks and the inverted-T arrows no + -- longer share the same unit grid. + return ui.spacer({ width = keyboardUnitsWidth(spec.spacer), flexGrow = 0 }) + end + + if spec.bindable == false or spec.code == nil then + local width = keyboardUnitsWidth(spec.units or 1) + return ui.row({ + key = "keyboard-" .. spec.id, + width = width, + height = KEYBOARD_KEY_HEIGHT, + fill = colorWithOpacity(cfg("key_color"), "surface_variant", 0.28), + border = "outline", + borderWidth = 1, + radius = 7, + align = "center", + justify = "center", + }, { + ui.label({ + text = asString(spec.label, ""), color = "on_surface_variant", + fontSize = 8, textAlign = "center", maxLines = 1, + }), + }) + end + + local code = canonicalKey(spec.code) + local signature = activeModifierSignature() + local exactEntries = asArray(index.exact[signature .. "|" .. code]) + local anyEntries = asArray(index.any[code]) + local physicalModifier = MODIFIER_ALIASES[normalized(spec.code)] + local modifierActive = physicalModifier ~= nil and activeModifiers[physicalModifier] == true + local selected = creatorOpen and creatorHasKey(code) or selectedKeyboardKey == code + + local fill + local border + local borderWidth = 1 + if modifierActive then + local modifierFill = modifierStyle(physicalModifier) + fill = asString(modifierFill, "primary") + border = asString(modifierFill, "primary") + borderWidth = 2 + elseif selected then + fill = colorWithOpacity("secondary", "secondary", 0.24) + border = "secondary" + borderWidth = 3 + elseif #exactEntries > 0 then + fill = colorWithOpacity(cfg("category_color"), "primary", 0.30) + border = asString(cfg("category_color"), "primary") + borderWidth = 2 + elseif #anyEntries > 0 then + fill = colorWithOpacity(cfg("category_color"), "primary", 0.10) + border = asString(cfg("category_color"), "primary") + else + fill = colorWithOpacity(cfg("key_color"), "surface_variant", 0.42) + border = "outline" + end + + local displayText = asString(spec.label, "?") + if #exactEntries > 0 then + displayText = displayText .. "\n" .. tostring(#exactEntries) + elseif #anyEntries > 0 then + displayText = displayText .. "\n•" + end + + local width = keyboardUnitsWidth(spec.units or 1) + return ui.row({ + key = "keyboard-" .. spec.id, + width = width, + height = KEYBOARD_KEY_HEIGHT, + fill = fill, + border = border, + borderWidth = borderWidth, + radius = 7, + paddingH = 4, + paddingV = 3, + align = "center", + justify = "center", + }, { + ui.button({ + text = displayText, + width = math.max(18, width - 6), + height = KEYBOARD_KEY_HEIGHT - 6, + fontSize = #asString(spec.label) > 4 and 7 or 8, + variant = "ghost", + selected = selected or modifierActive, + contentAlign = "center", + controlSize = "sm", + tooltip = #exactEntries > 0 and tr("panel.keyboard.occupied") or ( + #anyEntries > 0 and tr("panel.keyboard.other_layer") or tr("panel.keyboard.free") + ), + onClick = keyCallback(spec.id, spec.code), + }), + }) +end + +local function keyboardLegendItem(fill, border, label) + return ui.row({ gap = 5, align = "center" }, { + ui.box({ width = 12, height = 12, radius = 3, fill = fill, border = border, borderWidth = 1 }), + ui.label({ text = label, color = "on_surface_variant", fontSize = 10, maxLines = 1 }), + }) +end + +local function keyboardLayerToolbar() + local buttons = { + ui.label({ text = tr("panel.keyboard.layer"), color = "on_surface_variant", fontSize = 11 }), + } + local callbacks = { + SUPER = "onToggleSuper", CTRL = "onToggleCtrl", SHIFT = "onToggleShift", ALT = "onToggleAlt", + } + local labels = { + SUPER = "Super", CTRL = "Ctrl", SHIFT = "Shift", ALT = "Alt", + } + for _, modifier in ipairs(MODIFIER_ORDER) do + buttons[#buttons + 1] = ui.button({ + text = labels[modifier], + selected = activeModifiers[modifier], + variant = activeModifiers[modifier] and "primary" or "ghost", + controlSize = "sm", + onClick = callbacks[modifier], + }) + end + buttons[#buttons + 1] = ui.button({ + glyph = "restore", + variant = "ghost", + controlSize = "sm", + tooltip = tr("panel.keyboard.clear_modifiers"), + onClick = "onClearModifiers", + }) + buttons[#buttons + 1] = ui.spacer({ flexGrow = 1 }) + buttons[#buttons + 1] = keyboardLegendItem( + colorWithOpacity(cfg("key_color"), "surface_variant", 0.42), "outline", tr("panel.keyboard.free") + ) + buttons[#buttons + 1] = keyboardLegendItem( + colorWithOpacity(cfg("category_color"), "primary", 0.30), + asString(cfg("category_color"), "primary"), + tr("panel.keyboard.occupied") + ) + buttons[#buttons + 1] = keyboardLegendItem( + colorWithOpacity(cfg("category_color"), "primary", 0.10), + asString(cfg("category_color"), "primary"), + tr("panel.keyboard.other_layer") + ) + return ui.row({ gap = 8, align = "center" }, buttons) +end + +local function selectedKeyDetails(index) + if selectedKeyboardKey == nil then + return ui.row({ + minHeight = 62, + paddingH = 12, + paddingV = 9, + radius = 10, + fill = colorWithOpacity(cfg("card_color"), "surface_variant", 0.25), + border = "outline", + borderWidth = 1, + align = "center", + justify = "center", + }, { + ui.label({ + text = tr("panel.keyboard.select_hint"), + color = "on_surface_variant", + fontSize = 11, + textAlign = "center", + maxLines = 2, + }), + }) + end + + local signature = activeModifierSignature() + local entries = asArray(index.exact[signature .. "|" .. selectedKeyboardKey]) + local chordParts = {} + for _, modifier in ipairs(MODIFIER_ORDER) do + if activeModifiers[modifier] then + chordParts[#chordParts + 1] = modifier == "CTRL" and "Ctrl" or ( + modifier == "SUPER" and "Super" or (modifier == "SHIFT" and "Shift" or "Alt") + ) + end + end + chordParts[#chordParts + 1] = KEYBOARD_LABELS[selectedKeyboardKey] or selectedKeyboardKey + local chord = table.concat(chordParts, " + ") + local children = { + ui.row({ gap = 8, align = "center" }, { + ui.label({ text = chord, color = "on_surface", fontSize = 13, fontWeight = "bold", flexGrow = 1 }), + ui.label({ + text = #entries > 0 and tr("panel.keyboard.occupied") or tr("panel.keyboard.free"), + color = #entries > 0 and asString(cfg("category_color"), "primary") or "on_surface_variant", + fontSize = 11, + fontWeight = "bold", + }), + }), + } + if #entries == 0 then + children[#children + 1] = ui.label({ + text = tr("panel.keyboard.free_hint"), + color = "on_surface_variant", + fontSize = 11, + maxLines = 2, + }) + else + for entryIndex, entry in ipairs(entries) do + if entryIndex > 5 then + children[#children + 1] = ui.label({ + text = tr("panel.keyboard.more_actions", { count = #entries - 5 }), + color = "on_surface_variant", + fontSize = 10, + }) + break + end + local bind = entry.bind + local meta = entry.category + local mode = asString(bind.mode) + if mode ~= "" and normalized(mode) ~= "default" then + meta = meta .. " · " .. mode + end + children[#children + 1] = ui.row({ + key = "keyboard-detail-" .. asString(bind.id, tostring(entryIndex)), + gap = 8, + paddingH = 8, + paddingV = 4, + radius = 7, + fill = "surface/0.45", + align = "center", + }, { + ui.label({ + text = asString(bind.description, tr("panel.no_description")), + color = asString(cfg("description_color"), "on_surface"), + fontSize = 11, + flexGrow = 1, + maxLines = 2, + }), + ui.label({ text = meta, color = "on_surface_variant", fontSize = 9, maxLines = 1 }), + }) + end + end + return ui.column({ + paddingH = 12, + paddingV = 9, + gap = 5, + radius = 10, + fill = colorWithOpacity(cfg("card_color"), "surface_variant", 0.25), + border = "outline", + borderWidth = 1, + }, children) +end + +local function creatorCategoryNames() + if creatorOpen and #creatorCategoryNamesFrozen > 0 then + return creatorCategoryNamesFrozen + end + local names = {} + local seen = {} + for _, category in ipairs(asArray(snapshot.categories)) do + local name = noctalia.string.trim(asString(category.name)) + if name ~= "" and not seen[name] then + seen[name] = true + names[#names + 1] = name + end + end + return names +end + +local function creatorCategory() + local names = creatorCategoryNames() + if creatorCategoryIndex >= #names then + return noctalia.string.trim(creatorNewCategory) + end + return names[creatorCategoryIndex + 1] or "" +end + +local function creatorChord() + local parts = {} + for _, modifier in ipairs(MODIFIER_ORDER) do + if activeModifiers[modifier] then + parts[#parts + 1] = modifier == "SUPER" and "Super" or ( + modifier == "CTRL" and "Ctrl" or (modifier == "SHIFT" and "Shift" or "Alt") + ) + end + end + for _, code in ipairs(creatorKeys) do + parts[#parts + 1] = KEYBOARD_LABELS[code] or configKeyName(code) + end + return table.concat(parts, " + ") +end + +local function creatorKeysDisplay() + local labels = {} + for _, code in ipairs(creatorKeys) do + labels[#labels + 1] = KEYBOARD_LABELS[code] or configKeyName(code) + end + return table.concat(labels, ", ") +end + +local function creatorConflict(index) + local signature = activeModifierSignature() + for _, category in ipairs(asArray(snapshot.categories)) do + for _, bind in ipairs(asArray(category.binds)) do + if not (formMode == "edit" and asString(bind.id) == editingBindId) + and modifierSignature(bind.modifiers) == signature + and asString(bind.activation, "press") == creatorActivation then + local rawKeys = type(bind.keys) == "table" and bind.keys or expandedKeys(bind.key) + if #rawKeys == #creatorKeys then + local matches = true + for index, rawKey in ipairs(rawKeys) do + if canonicalKey(rawKey) ~= creatorKeys[index] then matches = false break end + end + if matches then + return { bind = bind, category = asString(category.name, tr("panel.uncategorized")) } + end + end + end + end + end + return nil +end + +local function creatorErrorText() + if creatorError == "" then + return "" + end + local key = "panel.creator.errors." .. creatorError + local translated = tr(key) + return translated ~= key and translated or creatorError +end + +local function creatorCanSave(index) + local compositor = normalized(snapshot.compositor) + if snapshot.status ~= "ready" or snapshot.compositor ~= creatorContextCompositor + or snapshot.source ~= creatorContextSource or creatorBusy or #creatorKeys == 0 + or (editingCapabilities.command ~= false and noctalia.string.trim(creatorCommand) == "") + or creatorCommand:find("{{", 1, true) ~= nil + or noctalia.string.trim(creatorDescription) == "" or creatorCategory() == "" then + return false + end + if compositor ~= "hyprland" and #creatorKeys ~= 1 then + return false + end + if compositor == "niri" and creatorActivation == "release" then + return false + end + return creatorConflict(index) == nil +end + +local function commandLibraryCompositorSource() + local compositor = normalized(snapshot.compositor) + if compositor == "hyprland" or compositor == "niri" or compositor == "mangowc" then + return compositor + end + return "" +end + +local function commandLibrarySourceLabel(source) + local key = "panel.command_library.sources." .. source + local translated = tr(key) + return translated ~= key and translated or source +end + +local function commandLibraryCategoryLabel(category) + local key = "panel.command_library.categories." .. category + local translated = tr(key) + return translated ~= key and translated or category +end + +local function commandLibrarySourceIds() + local result = { "", "noctalia" } + local compositorSource = commandLibraryCompositorSource() + if formMode ~= "edit" and compositorSource ~= "" then result[#result + 1] = compositorSource end + return result +end + +local function commandLibraryEntryAllowed(entry) + if type(entry) ~= "table" then return false end + if entry.source == "noctalia" then return entry.kind == "shell" end + return formMode ~= "edit" and entry.kind == "native" + and entry.source == commandLibraryCompositorSource() +end + +local function commandLibraryCategoryIds() + local sourceIds = commandLibrarySourceIds() + local selectedSource = sourceIds[commandLibrarySourceIndex + 1] or "" + local seen = {} + for _, entry in ipairs(asArray(COMMAND_LIBRARY.entries)) do + if commandLibraryEntryAllowed(entry) and type(entry.category) == "string" + and (selectedSource == "" or entry.source == selectedSource) then + seen[entry.category] = true + end + end + local result = {} + for category, _ in pairs(seen) do result[#result + 1] = category end + table.sort(result, function(left, right) + return commandLibraryCategoryLabel(left):lower() < commandLibraryCategoryLabel(right):lower() + end) + return result +end + +local function commandLibraryFilteredEntries() + local sourceIds = commandLibrarySourceIds() + local selectedSource = sourceIds[commandLibrarySourceIndex + 1] or "" + local categoryIds = commandLibraryCategoryIds() + local selectedCategory = categoryIds[commandLibraryCategoryIndex] or "" + local query = normalized(commandLibraryQuery) + local result = {} + for _, entry in ipairs(asArray(COMMAND_LIBRARY.entries)) do + if commandLibraryEntryAllowed(entry) + and (selectedSource == "" or entry.source == selectedSource) + and (selectedCategory == "" or entry.category == selectedCategory) then + local needsInput = asString(entry.template):find("{{", 1, true) ~= nil + local readinessMatches = commandLibraryReadinessIndex == 0 + or (commandLibraryReadinessIndex == 1 and not needsInput) + or (commandLibraryReadinessIndex == 2 and needsInput) + local haystack = normalized(table.concat({ + asString(entry.id), asString(entry.template), asString(entry.usage), + commandLibrarySourceLabel(asString(entry.source)), + commandLibraryCategoryLabel(asString(entry.category)), + }, " ")) + if readinessMatches and (query == "" or haystack:find(query, 1, true) ~= nil) then + result[#result + 1] = entry + end + end + end + return result +end + +local function commandLibraryNode() + if not commandLibraryOpen then return ui.row({ visible = false }, {}) end + local sourceIds = commandLibrarySourceIds() + if commandLibrarySourceIndex >= #sourceIds then commandLibrarySourceIndex = 0 end + local sourceOptions = { tr("panel.command_library.all_sources") } + for index = 2, #sourceIds do + sourceOptions[#sourceOptions + 1] = commandLibrarySourceLabel(sourceIds[index]) + end + local categoryIds = commandLibraryCategoryIds() + if commandLibraryCategoryIndex > #categoryIds then commandLibraryCategoryIndex = 0 end + local categoryOptions = { tr("panel.command_library.all_categories") } + for _, category in ipairs(categoryIds) do + categoryOptions[#categoryOptions + 1] = commandLibraryCategoryLabel(category) + end + local matches = commandLibraryFilteredEntries() + local resultNodes = {} + local visibleCount = math.min(#matches, 6) + for index = 1, visibleCount do + local entry = matches[index] + local callbackName = "onCommandLibraryUse:" .. asString(entry.id) + registerDynamicCallback(callbackName, function() + creatorCommand = asString(entry.template) + creatorCommandKind = asString(entry.kind, "shell") + creatorLibraryEntryId = asString(entry.id) + commandLibraryOpen = false + creatorError = "" + creatorRevision += 1 + render() + end) + resultNodes[#resultNodes + 1] = ui.row({ gap = 6, align = "center" }, { + ui.glyph({ + name = entry.kind == "native" and "binary-tree" or "terminal-2", + size = 15, color = entry.kind == "native" and "primary" or "on_surface_variant", + }), + ui.column({ gap = 1, flexGrow = 1 }, { + ui.label({ text = asString(entry.usage, asString(entry.id)), fontSize = 10, maxLines = 1 }), + ui.label({ + text = commandLibrarySourceLabel(asString(entry.source)) .. " · " + .. commandLibraryCategoryLabel(asString(entry.category)), + color = "on_surface_variant", fontSize = 9, maxLines = 1, + }), + }), + asString(entry.template):find("{{", 1, true) ~= nil and ui.label({ + text = tr("panel.command_library.requires_input"), color = "tertiary", fontSize = 9, + }) or ui.label({ text = tr("panel.command_library.ready"), color = "secondary", fontSize = 9 }), + ui.button({ + text = tr("panel.command_library.use"), variant = "ghost", controlSize = "sm", + onClick = callbackName, + }), + }) + end + if #resultNodes == 0 then + resultNodes[1] = ui.label({ + text = tr("panel.command_library.no_results"), color = "on_surface_variant", fontSize = 10, + }) + end + return ui.column({ + gap = 6, paddingH = 8, paddingV = 8, radius = 8, + fill = colorWithOpacity(cfg("card_color"), "surface", 0.35), + border = "outline", borderWidth = 1, + }, { + ui.row({ gap = 6, align = "center" }, { + ui.glyph({ name = "books", size = 16, color = "primary" }), + ui.label({ text = tr("panel.command_library.title"), fontSize = 11, fontWeight = "bold", flexGrow = 1 }), + ui.button({ + text = tr("panel.command_library.custom_command"), variant = "ghost", controlSize = "sm", + onClick = "onCommandLibraryUseCustom", + }), + }), + ui.input({ + key = "command-library-search-" .. tostring(commandLibraryRevision), + value = commandLibraryQuery, placeholder = tr("panel.command_library.search_placeholder"), + controlSize = "sm", onChange = "onCommandLibraryQueryChanged", + }), + ui.row({ gap = 6, align = "center" }, { + ui.select({ + options = sourceOptions, selectedIndex = commandLibrarySourceIndex, + flexGrow = 1, controlSize = "sm", onChange = "onCommandLibrarySourceChanged", + }), + ui.select({ + options = categoryOptions, selectedIndex = commandLibraryCategoryIndex, + flexGrow = 1, controlSize = "sm", onChange = "onCommandLibraryCategoryChanged", + }), + ui.select({ + options = { + tr("panel.command_library.all_readiness"), tr("panel.command_library.ready"), + tr("panel.command_library.requires_input"), + }, + selectedIndex = commandLibraryReadinessIndex, flexGrow = 1, controlSize = "sm", + onChange = "onCommandLibraryReadinessChanged", + }), + }), + ui.label({ + text = tr("panel.command_library.showing", { shown = visibleCount, total = #matches }), + color = "on_surface_variant", fontSize = 9, + }), + ui.column({ gap = 3 }, resultNodes), + }) +end + +local function creatorForm(index) + local names = creatorCategoryNames() + local options = {} + for _, name in ipairs(names) do + options[#options + 1] = name + end + options[#options + 1] = tr("panel.creator.new_category") + if creatorCategoryIndex > #names then + creatorCategoryIndex = #names + end + local isEditing = formMode == "edit" + local showManualCombination = isEditing or viewMode == "list" + local usesNewCategory = creatorCategoryIndex >= #names + local compositor = normalized(snapshot.compositor) + local releaseSupported = compositor ~= "niri" and (not isEditing or editingCapabilities.activation == true) + local conflict = creatorConflict(index) + local chord = creatorChord() + local statusText = creatorErrorText() + if conflict ~= nil and statusText == "" then + statusText = tr("panel.creator.errors.conflict", { + description = asString(conflict.bind.description, tr("panel.no_description")), + }) + end + + local hintKey = isEditing and "panel.editor.hint" or (viewMode == "list" and "panel.creator.list_hint" or ( + compositor == "hyprland" and "panel.creator.hyprland_hint" or ( + compositor == "niri" and "panel.creator.niri_hint" or "panel.creator.mangowc_hint" + ))) + + local categoryControl = ui.select({ + key = "creator-category-" .. tostring(creatorRevision), options = options, + selectedIndex = creatorCategoryIndex, width = 250, controlSize = "sm", + onChange = "onCreatorCategoryChanged", + }) + + return ui.column({ + paddingH = 12, + paddingV = 10, + gap = 8, + radius = 10, + fill = colorWithOpacity(cfg("card_color"), "surface_variant", 0.25), + border = "primary", + borderWidth = 1, + }, { + ui.row({ gap = 8, align = "center" }, { + ui.glyph({ name = isEditing and "pencil" or "pencil-plus", size = 18, color = "primary" }), + ui.label({ + text = tr(isEditing and "panel.editor.title" or "panel.creator.title"), + fontSize = 13, fontWeight = "bold", flexGrow = 1, + }), + ui.label({ + text = chord ~= "" and chord or tr("panel.creator.no_combination"), + color = chord ~= "" and "primary" or "on_surface_variant", + fontSize = 11, + fontWeight = "bold", + maxLines = 1, + }), + }), + ui.label({ text = tr(hintKey), color = "on_surface_variant", fontSize = 10, maxLines = 2 }), + showManualCombination and ui.row({ gap = 6, align = "center" }, { + ui.label({ text = tr("panel.editor.combination"), color = "on_surface_variant", fontSize = 11 }), + ui.button({ + text = "Super", selected = activeModifiers.SUPER, + variant = activeModifiers.SUPER and "primary" or "ghost", controlSize = "sm", + onClick = "onToggleSuper", + }), + ui.button({ + text = "Ctrl", selected = activeModifiers.CTRL, + variant = activeModifiers.CTRL and "primary" or "ghost", controlSize = "sm", + onClick = "onToggleCtrl", + }), + ui.button({ + text = "Shift", selected = activeModifiers.SHIFT, + variant = activeModifiers.SHIFT and "primary" or "ghost", controlSize = "sm", + onClick = "onToggleShift", + }), + ui.button({ + text = "Alt", selected = activeModifiers.ALT, + variant = activeModifiers.ALT and "primary" or "ghost", controlSize = "sm", + onClick = "onToggleAlt", + }), + ui.input({ + key = "editor-keys-" .. tostring(creatorRevision), value = creatorKeysText, + placeholder = tr("panel.editor.keys_placeholder"), flexGrow = 1, controlSize = "sm", + onChange = "onEditorKeysChanged", + }), + }) or ui.row({ visible = false }, {}), + ui.row({ gap = 8, align = "center" }, { + ui.label({ text = tr("panel.creator.trigger"), color = "on_surface_variant", fontSize = 11 }), + ui.button({ + text = tr("panel.creator.press"), selected = creatorActivation == "press", + variant = creatorActivation == "press" and "primary" or "ghost", controlSize = "sm", + onClick = "onCreatorPress", + }), + ui.button({ + text = tr("panel.creator.release"), selected = creatorActivation == "release", + variant = creatorActivation == "release" and "primary" or "ghost", controlSize = "sm", + enabled = releaseSupported, tooltip = releaseSupported and "" or tr("panel.creator.release_unavailable"), + onClick = "onCreatorRelease", + }), + ui.spacer({ flexGrow = 1 }), + ui.label({ text = tr("panel.creator.category"), color = "on_surface_variant", fontSize = 11 }), + categoryControl, + ui.input({ + key = "creator-new-category-" .. tostring(creatorRevision), value = creatorNewCategory, + placeholder = tr("panel.creator.category_placeholder"), width = 220, controlSize = "sm", + visible = usesNewCategory, onChange = "onCreatorNewCategoryChanged", + }), + }), + ui.row({ gap = 8, align = "center" }, { + ui.input({ + key = "creator-description-" .. tostring(creatorRevision), value = creatorDescription, + placeholder = tr("panel.creator.description_placeholder"), flexGrow = 1, controlSize = "sm", + onChange = "onCreatorDescriptionChanged", + }), + ui.input({ + key = "creator-command-" .. tostring(creatorRevision), + value = isEditing and editingCapabilities.command ~= true and editingAction or creatorCommand, + placeholder = isEditing and editingCapabilities.command ~= true + and tr("panel.editor.action_placeholder") or tr("panel.creator.command_placeholder"), + flexGrow = 2, controlSize = "sm", + enabled = not isEditing or editingCapabilities.command == true, + onChange = "onCreatorCommandChanged", + }), + ui.button({ + text = tr("panel.command_library.open"), glyph = "books", + variant = commandLibraryOpen and "primary" or "ghost", controlSize = "sm", + enabled = not isEditing or editingCapabilities.command == true, + tooltip = tr("panel.command_library.open_hint"), + onClick = "onCommandLibraryToggle", + }), + }), + (not isEditing or editingCapabilities.command == true) and ui.row({ gap = 6, align = "center" }, { + ui.label({ + text = tr(creatorCommandKind == "native" + and "panel.command_library.native_action" or "panel.command_library.custom_command"), + color = creatorCommandKind == "native" and "primary" or "on_surface_variant", fontSize = 9, + }), + creatorCommand:find("{{", 1, true) ~= nil and ui.label({ + text = tr("panel.command_library.placeholder_hint"), color = "tertiary", fontSize = 9, + maxLines = 2, flexGrow = 1, + }) or ui.spacer({ flexGrow = 1 }), + }) or ui.row({ visible = false }, {}), + commandLibraryNode(), + ui.row({ gap = 8, align = "center" }, { + ui.label({ + text = statusText, + color = (conflict ~= nil or creatorError ~= "") and "error" or "on_surface_variant", + fontSize = 10, maxLines = 2, flexGrow = 1, + }), + ui.button({ text = tr("panel.creator.cancel"), variant = "ghost", controlSize = "sm", onClick = "onCreatorCancel" }), + ui.button({ + text = creatorBusy and tr("panel.creator.saving") + or tr(isEditing and "panel.editor.save" or "panel.creator.save"), + glyph = "device-floppy", variant = "primary", controlSize = "sm", + enabled = creatorCanSave(index), onClick = "onCreatorSave", + }), + }), + }) +end + +local function keyboardBody() + local index = keyboardIndex() + local layout = activeKeyboardLayout() + local signature = activeModifierSignature() + local occupied = 0 + for code, _ in pairs(layout.codeSet) do + if #asArray(index.exact[signature .. "|" .. code]) > 0 then + occupied = occupied + 1 + end + end + + local unmapped = 0 + for key, entries in pairs(index.any) do + if not layout.codeSet[key] then + unmapped = unmapped + #entries + end + end + + local keyboardRows = {} + for rowIndex, row in ipairs(layout.rows) do + local keys = {} + for _, spec in ipairs(row) do + keys[#keys + 1] = keyboardKeyNode(spec, index) + end + keyboardRows[#keyboardRows + 1] = ui.row({ + key = "keyboard-row-" .. layout.id .. "-" .. tostring(rowIndex), + width = keyboardUnitsWidth(layout.rowUnits), + gap = KEYBOARD_GAP, + align = "center", + justify = "start", + }, keys) + end + + local layer = signature ~= "" and signature:gsub("%+", " + ") or tr("panel.keyboard.no_modifiers") + local summary = tr("panel.keyboard.summary", { layer = layer, count = occupied }) + if unmapped > 0 then + summary = summary .. " · " .. tr("panel.keyboard.outside", { count = unmapped }) + end + + return ui.scroll({ flexGrow = 1 }, { + ui.column({ gap = 9, align = "stretch" }, { + keyboardLayerToolbar(), + ui.label({ text = summary, color = "on_surface_variant", fontSize = 10, textAlign = "center" }), + ui.column({ + gap = KEYBOARD_GAP, + padding = 10, + radius = 13, + fill = colorWithOpacity(cfg("card_color"), "surface_variant", 0.18), + border = "outline", + borderWidth = 1, + align = "center", + }, keyboardRows), + creatorOpen and creatorForm(index) or selectedKeyDetails(index), + }), + }) +end + +selectKeyboardKey = function(code) + local modifier = MODIFIER_ALIASES[normalized(code)] + if modifier ~= nil and activeModifiers[modifier] ~= nil then + activeModifiers[modifier] = not activeModifiers[modifier] + selectedKeyboardKey = nil + else + local canonical = canonicalKey(code) + selectedKeyboardKey = canonical + if creatorOpen then + if creatorHasKey(canonical) then + local remaining = {} + for _, selectedCode in ipairs(creatorKeys) do + if selectedCode ~= canonical then + remaining[#remaining + 1] = selectedCode + end + end + creatorKeys = remaining + elseif normalized(snapshot.compositor) == "hyprland" then + if #creatorKeys < 4 then + creatorKeys[#creatorKeys + 1] = canonical + end + else + creatorKeys = { canonical } + end + creatorKeysText = creatorKeysDisplay() + creatorError = "" + end + end + render() +end + + +local function keyPill(token, isKey) + local fill + local textColor + if isKey then + fill = cfg("key_color") + textColor = cfg("key_text_color") + else + fill, textColor = modifierStyle(token) + end + return ui.row({ + fill = asString(fill, "surface"), + border = "outline", + borderWidth = 1, + radius = 7, + paddingH = 7, + paddingV = 2, + align = "center", + justify = "center", + }, { + ui.label({ + text = asString(token, "?"), + color = asString(textColor, "on_surface"), + fontSize = 11, + fontWeight = "bold", + maxLines = 1, + }), + }) +end + +local function keyWidth(columnCount) + if columnCount <= 1 then + return 330 + elseif columnCount == 2 then + return 245 + elseif columnCount == 3 then + return 210 + end + return 180 +end + +local DND_BIND_TYPE = "keybind-category" + +local function bindHasWritableProvenance(bind) + return not asString(bind.id):match("^range:") + and asString(bind.source) ~= "" and tonumber(bind.start_line) ~= nil + and tonumber(bind.end_line) ~= nil and asString(bind.raw_snippet) ~= "" + and asString(bind.fingerprint) ~= "" +end + +local function bindEditable(bind) + local capabilities = type(bind.capabilities) == "table" and bind.capabilities or {} + return capabilities.combo == true and capabilities.description == true + and bindHasWritableProvenance(bind) +end + +local function bindCategoryMovable(bind) + local capabilities = type(bind.capabilities) == "table" and bind.capabilities or {} + local bindId = asString(bind.id) + return capabilities.category == true and bindHasWritableProvenance(bind) + and bindId ~= "" and #bindId <= 256 +end + +local function categoryCanAcceptDrop(categoryName) + local name = noctalia.string.trim(asString(categoryName)) + return name ~= "" and #name <= 80 and not name:find("%c") + and not name:find('"', 1, true) + and not name:find("Keymap managed", 1, true) +end + +local function categoryRenameable(category) + local binds = asArray(type(category) == "table" and category.binds or nil) + if asString(type(category) == "table" and category.id or "") == "" or #binds == 0 then return false end + for _, bind in ipairs(binds) do + local firstLine = tonumber(bind.start_line) + local lastLine = tonumber(bind.end_line) + if not bindCategoryMovable(bind) or bind.hidden == true + or asString(bind.source):sub(1, 1) ~= "/" + or firstLine == nil or lastLine == nil or firstLine % 1 ~= 0 or lastLine % 1 ~= 0 + or firstLine < 1 or lastLine < firstLine then return false end + end + return true +end + +local function findSnapshotCategory(categoryId) + for _, category in ipairs(asArray(snapshot.categories)) do + if asString(category.id) == categoryId then return category end + end + return nil +end + +local function categoryNameExists(name, excludedId) + local wanted = normalized(name) + if wanted == "" then return false end + for _, category in ipairs(asArray(snapshot.categories)) do + if asString(category.id) ~= excludedId and normalized(category.name) == wanted then return true end + end + return false +end + +local function optimisticRenameCategory(categoryId, oldName, newName) + local categories = {} + local renamed = false + for _, category in ipairs(asArray(snapshot.categories)) do + local nextCategory = {} + for key, value in pairs(category) do nextCategory[key] = value end + if asString(category.id) == categoryId and asString(category.name) == oldName then + nextCategory.name = newName + local binds = {} + for _, bind in ipairs(asArray(category.binds)) do + local nextBind = {} + for key, value in pairs(bind) do nextBind[key] = value end + if nextBind.category ~= nil then nextBind.category = newName end + binds[#binds + 1] = nextBind + end + nextCategory.binds = binds + renamed = true + end + categories[#categories + 1] = nextCategory + end + if not renamed then return false end + local nextSnapshot = {} + for key, value in pairs(snapshot) do nextSnapshot[key] = value end + nextSnapshot.categories = categories + snapshot = nextSnapshot + return true +end + +local function findSnapshotBind(bindId) + for _, category in ipairs(asArray(snapshot.categories)) do + for _, bind in ipairs(asArray(category.binds)) do + if asString(bind.id) == bindId then + return bind, asString(category.name, tr("panel.uncategorized")) + end + end + end + return nil, "" +end + +local function findHiddenBind(bindId) + for _, bind in ipairs(asArray(snapshot.hidden)) do + if bind.hidden == true and asString(bind.id) == bindId then return bind end + end + return nil +end + +local function snapshotHasCategory(categoryName) + for _, category in ipairs(asArray(snapshot.categories)) do + if asString(category.name, tr("panel.uncategorized")) == categoryName then + return true + end + end + return false +end + +local function optimisticPlaceBind(bindId, targetCategory, anchorId, placement) + local moved = nil + local categories = {} + for _, category in ipairs(asArray(snapshot.categories)) do + local nextCategory = {} + for key, value in pairs(category) do nextCategory[key] = value end + local binds = {} + for _, bind in ipairs(asArray(category.binds)) do + if asString(bind.id) == bindId then moved = bind else binds[#binds + 1] = bind end + end + nextCategory.binds = binds + categories[#categories + 1] = nextCategory + end + if moved == nil then return false end + + local inserted = false + for _, category in ipairs(categories) do + if asString(category.name, tr("panel.uncategorized")) == targetCategory then + local binds = category.binds + if asString(anchorId) == "" then + binds[#binds + 1] = moved + inserted = true + else + for index, bind in ipairs(binds) do + if asString(bind.id) == anchorId then + table.insert(binds, placement == "after" and index + 1 or index, moved) + inserted = true + break + end + end + end + break + end + end + if not inserted then return false end + + local nextSnapshot = {} + for key, value in pairs(snapshot) do nextSnapshot[key] = value end + nextSnapshot.categories = categories + snapshot = nextSnapshot + return true +end + +-- Logical pixels: the declarative UI scales explicit dimensions with the +-- shell UI scale. Keep row actions no taller than the compact key pills so +-- enabling edit mode does not change the height of every keybind row. +local BIND_ROW_ACTION_SIZE = 18 +local BIND_ROW_ACTION_GLYPH_SIZE = 12 + +local function beginBindOperation(bind, operation) + if creatorBusy or not bindEditable(bind) then return end + local bindId = asString(bind.id) + if operation == "delete" and deleteConfirmBindId ~= bindId then + deleteConfirmBindId = bindId + render() + return + end + deleteConfirmBindId = "" + creatorRequestCounter += 1 + local requestId = tostring(os.time()) .. "-action-" .. tostring(creatorRequestCounter) + formMode = "edit" + editingOperation = operation + editingBindId = bindId + editingRequestId = requestId + creatorContextCompositor = asString(snapshot.compositor) + creatorContextSource = asString(snapshot.source) + creatorBusy = true + creatorError = "" + creatorOpen = false + noctalia.state.set(UPDATE_REQUEST_KEY, { + request_id = requestId, + target_id = bindId, + operation = operation, + compositor = creatorContextCompositor, + source = creatorContextSource, + }) + render() +end + +local function beginHiddenOperation(bind, operation) + if creatorBusy or type(bind) ~= "table" or bind.hidden ~= true + or not bindHasWritableProvenance(bind) then return end + local capabilities = type(bind.capabilities) == "table" and bind.capabilities or {} + if operation == "restore" and capabilities.restore ~= true then return end + if operation == "delete" and capabilities.delete ~= true then return end + + local bindId = asString(bind.id) + if operation == "delete" and hiddenDeleteConfirmBindId ~= bindId then + hiddenDeleteConfirmBindId = bindId + render() + return + end + hiddenDeleteConfirmBindId = "" + creatorRequestCounter += 1 + local requestId = tostring(os.time()) .. "-hidden-" .. tostring(creatorRequestCounter) + formMode = "edit" + editingOperation = operation + editingBindId = bindId + editingRequestId = requestId + creatorContextCompositor = asString(snapshot.compositor) + creatorContextSource = asString(snapshot.source) + creatorBusy = true + creatorError = "" + creatorOpen = false + noctalia.state.set(UPDATE_REQUEST_KEY, { + request_id = requestId, + target_id = bindId, + operation = operation, + hidden = true, + compositor = creatorContextCompositor, + source = creatorContextSource, + }) + render() +end + +local function clearCategoryRename() + renamingCategoryId = "" + renamingCategoryOriginal = "" + renamingCategoryValue = "" + creatorError = "" +end + +local function openCategoryRename(category) + if creatorBusy or not editorMode or not categoryRenameable(category) then return end + creatorOpen = false + formMode = "edit" + editingBindId = "" + editingOperation = "update" + deleteConfirmBindId = "" + hiddenDeleteConfirmBindId = "" + renamingCategoryId = asString(category.id) + renamingCategoryOriginal = asString(category.name, tr("panel.uncategorized")) + renamingCategoryValue = renamingCategoryOriginal + renamingCategoryRevision += 1 + creatorError = "" + render() +end + +local function openBindEditor(bind, categoryName) + if creatorBusy or not bindEditable(bind) then return end + clearCategoryRename() + formMode = "edit" + editingOperation = "update" + deleteConfirmBindId = "" + editingBindId = asString(bind.id) + editingCategory = categoryName + editingCapabilities = type(bind.capabilities) == "table" and bind.capabilities or {} + editingAction = asString(bind.action, asString(bind.dispatcher)) + creatorCategoryNamesFrozen = {} + local categoryNames = creatorCategoryNames() + creatorCategoryNamesFrozen = categoryNames + creatorCategoryIndex = 0 + for index, name in ipairs(categoryNames) do + if name == categoryName then creatorCategoryIndex = index - 1 break end + end + creatorNewCategory = "" + creatorKeys = {} + local rawKeys = type(bind.keys) == "table" and bind.keys or expandedKeys(bind.key) + for _, rawKey in ipairs(rawKeys) do creatorKeys[#creatorKeys + 1] = canonicalKey(rawKey) end + creatorKeysText = creatorKeysDisplay() + for _, modifier in ipairs(MODIFIER_ORDER) do activeModifiers[modifier] = false end + for _, rawModifier in ipairs(asArray(bind.modifiers)) do + local modifier = canonicalModifier(rawModifier) + if activeModifiers[modifier] ~= nil then activeModifiers[modifier] = true end + end + creatorActivation = asString(bind.activation, bind.release == true and "release" or "press") + creatorCommand = asString(bind.command) + creatorCommandKind = "shell" + creatorLibraryEntryId = "" + creatorDescription = asString(bind.description) + creatorContextCompositor = asString(snapshot.compositor) + creatorContextSource = asString(snapshot.source) + creatorError = "" + creatorBusy = false + creatorOpen = true + commandLibraryOpen = false + commandLibraryQuery = "" + commandLibrarySourceIndex = 0 + commandLibraryCategoryIndex = 0 + commandLibraryReadinessIndex = 0 + commandLibraryRevision += 1 + selectedKeyboardKey = creatorKeys[1] + creatorRevision += 1 + viewMode = "list" + render() +end + +local function registerCategoryRenameCallback(categoryId) + local name = "onCategoryRename:" .. categoryId + return registerDynamicCallback(name, function() + local category = findSnapshotCategory(categoryId) + if category ~= nil then openCategoryRename(category) end + end) +end + +local function registerBindCallback(bindId, operation) + local name = "onBindAction:" .. operation .. ":" .. bindId + return registerDynamicCallback(name, function() + local bind, categoryName = findSnapshotBind(bindId) + if bind == nil then return end + if operation == "edit" then + openBindEditor(bind, categoryName) + else + beginBindOperation(bind, operation) + end + end) +end + +local function registerHiddenCallback(bindId, operation) + local name = "onHiddenBindAction:" .. operation .. ":" .. bindId + return registerDynamicCallback(name, function() + local bind = findHiddenBind(bindId) + if bind ~= nil then beginHiddenOperation(bind, operation) end + end) +end + +local function editBindActions(bind, categoryName) + if not editorMode then return {} end + local actions = {} + if bindCategoryMovable(bind) then + actions[#actions + 1] = ui.dragSource({ + key = "drag-bind-" .. asString(bind.id), + dragType = DND_BIND_TYPE, + payload = asString(bind.id), + previewAncestor = 1, + liftFromLayout = true, + enabled = not creatorBusy and not creatorOpen and renamingCategoryId == "", + tooltip = tr("panel.editor.drag"), + width = BIND_ROW_ACTION_SIZE, + height = BIND_ROW_ACTION_SIZE, + radius = 4, + align = "center", + justify = "center", + }, { + ui.glyph({ + name = "menu-2", size = BIND_ROW_ACTION_GLYPH_SIZE, + color = "on_surface_variant", + }), + }) + end + if not bindEditable(bind) then + actions[#actions + 1] = ui.button({ + glyph = "lock", variant = "ghost", + width = BIND_ROW_ACTION_SIZE, height = BIND_ROW_ACTION_SIZE, + glyphSize = BIND_ROW_ACTION_GLYPH_SIZE, enabled = false, + tooltip = tr("panel.editor.read_only"), + }) + return actions + end + local bindId = asString(bind.id) + local editCallback = registerBindCallback(bindId, "edit") + local hideCallback = registerBindCallback(bindId, "hide") + local deleteCallback = registerBindCallback(bindId, "delete") + + actions[#actions + 1] = ui.button({ + glyph = "pencil", variant = "ghost", + width = BIND_ROW_ACTION_SIZE, height = BIND_ROW_ACTION_SIZE, + glyphSize = BIND_ROW_ACTION_GLYPH_SIZE, + tooltip = tr("panel.editor.edit"), onClick = editCallback, + }) + actions[#actions + 1] = ui.button({ + glyph = "eye-off", variant = "ghost", + width = BIND_ROW_ACTION_SIZE, height = BIND_ROW_ACTION_SIZE, + glyphSize = BIND_ROW_ACTION_GLYPH_SIZE, + tooltip = tr("panel.editor.hide"), onClick = hideCallback, + }) + + if deleteConfirmBindId == asString(bind.id) then + local cancelCallback = "onCancelDeleteBind" + actions[#actions + 1] = ui.button({ + glyph = "x", variant = "ghost", + width = BIND_ROW_ACTION_SIZE, height = BIND_ROW_ACTION_SIZE, + glyphSize = BIND_ROW_ACTION_GLYPH_SIZE, + tooltip = tr("panel.editor.cancel_delete"), onClick = cancelCallback, + }) + actions[#actions + 1] = ui.button({ + glyph = "trash", variant = "destructive", + width = BIND_ROW_ACTION_SIZE, height = BIND_ROW_ACTION_SIZE, + glyphSize = BIND_ROW_ACTION_GLYPH_SIZE, + tooltip = tr("panel.editor.confirm_delete"), onClick = deleteCallback, + }) + else + actions[#actions + 1] = ui.button({ + glyph = "trash", variant = "ghost", + width = BIND_ROW_ACTION_SIZE, height = BIND_ROW_ACTION_SIZE, + glyphSize = BIND_ROW_ACTION_GLYPH_SIZE, + tooltip = tr("panel.editor.delete"), onClick = deleteCallback, + }) + end + return actions +end + +local function bindRow(bind, categoryName, columnCount, rowIndex) + local width = keyWidth(columnCount) + local keys = {} + local tokens = {} + local estimatedWidth = 0 + for _, modifier in ipairs(asArray(bind.modifiers)) do + tokens[#tokens + 1] = asString(modifier, "?") + estimatedWidth = estimatedWidth + #asString(modifier, "?") * 7 + 18 + keys[#keys + 1] = keyPill(modifier, false) + end + local mainKey = asString(bind.key, "?") + tokens[#tokens + 1] = mainKey + estimatedWidth = estimatedWidth + #mainKey * 7 + 18 + math.max(0, #tokens - 1) * 4 + keys[#keys + 1] = keyPill(mainKey, true) + + local keyArea + if estimatedWidth > width then + keyArea = ui.row({ + width = width, + fill = asString(cfg("key_color"), "surface"), + border = "outline", + borderWidth = 1, + radius = 7, + paddingH = 6, + paddingV = 2, + align = "center", + justify = "center", + }, { + ui.label({ + text = table.concat(tokens, " + "), + color = asString(cfg("key_text_color"), "on_surface"), + fontSize = 10, + fontWeight = "bold", + textAlign = "center", + maxWidth = width - 12, + maxLines = 2, + }), + }) + else + keyArea = ui.row({ width = width, gap = 4, align = "center" }, keys) + end + + local description = asString(bind.description, tr("panel.no_description")) + local details = {} + local mode = asString(bind.mode) + if mode ~= "" and normalized(mode) ~= "default" then + details[#details + 1] = ui.label({ + text = mode, + color = "secondary", + fontSize = 10, + fontWeight = "bold", + maxLines = 1, + }) + end + details[#details + 1] = ui.label({ + text = description, + color = asString(cfg("description_color"), "on_surface"), + fontSize = 12, + maxLines = 2, + flexGrow = 1, + }) + + local children = { + keyArea, + ui.row({ gap = 6, align = "center", flexGrow = 1 }, details), + } + for _, action in ipairs(editBindActions(bind, categoryName)) do + children[#children + 1] = action + end + + return ui.row({ + key = "bind-" .. asString(bind.id, tostring(rowIndex)), + gap = 10, + paddingH = 6, + paddingV = 4, + radius = 8, + fill = "surface/0.42", + align = "center", + }, children) +end + +local function reorderInsertionZone(bind, placement) + if not editorMode or not bindCategoryMovable(bind) then return nil end + return ui.dropZone({ + key = "reorder-" .. placement .. "-" .. asString(bind.id), + accepts = { DND_BIND_TYPE }, + value = placement .. "|" .. asString(bind.id), + onDrop = "onBindReordered", + height = 3, + radius = 8, + expandOnDrag = true, + hitSlop = 64, + enabled = not creatorBusy and not creatorOpen and renamingCategoryId == "", + }, {}) +end + +local function hiddenBindRow(bind, columnCount, rowIndex) + local keys = {} + for _, modifier in ipairs(asArray(bind.modifiers)) do keys[#keys + 1] = keyPill(modifier, false) end + local rawKeys = type(bind.keys) == "table" and bind.keys or { asString(bind.key, "?") } + for _, key in ipairs(rawKeys) do keys[#keys + 1] = keyPill(key, true) end + + local bindId = asString(bind.id) + local restoreCallback = registerHiddenCallback(bindId, "restore") + local deleteCallback = registerHiddenCallback(bindId, "delete") + local actions = { + ui.button({ + glyph = "restore", variant = "ghost", + width = BIND_ROW_ACTION_SIZE, height = BIND_ROW_ACTION_SIZE, + glyphSize = BIND_ROW_ACTION_GLYPH_SIZE, + tooltip = tr("panel.editor.restore"), onClick = restoreCallback, + }), + } + if hiddenDeleteConfirmBindId == bindId then + actions[#actions + 1] = ui.button({ + glyph = "x", variant = "ghost", + width = BIND_ROW_ACTION_SIZE, height = BIND_ROW_ACTION_SIZE, + glyphSize = BIND_ROW_ACTION_GLYPH_SIZE, + tooltip = tr("panel.editor.cancel_delete"), onClick = "onCancelDeleteHiddenBind", + }) + actions[#actions + 1] = ui.button({ + glyph = "trash", variant = "destructive", + width = BIND_ROW_ACTION_SIZE, height = BIND_ROW_ACTION_SIZE, + glyphSize = BIND_ROW_ACTION_GLYPH_SIZE, + tooltip = tr("panel.editor.confirm_delete_hidden"), onClick = deleteCallback, + }) + else + actions[#actions + 1] = ui.button({ + glyph = "trash", variant = "ghost", + width = BIND_ROW_ACTION_SIZE, height = BIND_ROW_ACTION_SIZE, + glyphSize = BIND_ROW_ACTION_GLYPH_SIZE, + tooltip = tr("panel.editor.delete_hidden"), onClick = deleteCallback, + }) + end + + local details = { + ui.label({ + text = asString(bind.description, tr("panel.no_description")), + color = asString(cfg("description_color"), "on_surface"), + fontSize = 12, maxLines = 2, flexGrow = 1, + }), + } + if asString(bind.category) ~= "" then + details[#details + 1] = ui.label({ + text = asString(bind.category), color = "secondary", fontSize = 10, + fontWeight = "bold", maxLines = 1, + }) + end + + local children = { + ui.row({ width = keyWidth(columnCount), gap = 4, align = "center" }, keys), + ui.row({ gap = 6, align = "center", flexGrow = 1 }, details), + } + for _, action in ipairs(actions) do children[#children + 1] = action end + return ui.row({ + key = "hidden-bind-" .. bindId .. "-" .. tostring(rowIndex), + gap = 10, paddingH = 6, paddingV = 4, radius = 8, + fill = "surface/0.42", align = "center", + }, children) +end + +local function hiddenSection(hidden, columnCount) + if not editorMode or #hidden == 0 then return nil end + local children = { + ui.row({ gap = 8, align = "center" }, { + ui.glyph({ name = "eye-off", size = 16, color = "secondary" }), + ui.label({ + text = tr("panel.editor.hidden_title"), color = "secondary", + fontSize = 14, fontWeight = "bold", flexGrow = 1, + }), + ui.label({ + text = noctalia.trp("panel.editor.hidden_count", #hidden), + color = "on_surface_variant", fontSize = 11, + }), + }), + ui.label({ + text = tr("panel.editor.hidden_hint"), color = "on_surface_variant", + fontSize = 11, maxLines = 2, + }), + } + for index, bind in ipairs(hidden) do + children[#children + 1] = hiddenBindRow(bind, columnCount, index) + end + return ui.column({ + key = "hidden-shortcuts", gap = 3, padding = 8, radius = 12, + fill = cardFill(), border = "secondary", borderWidth = 1, + }, children) +end + +local function categoryCard(category, columnCount) + local categoryName = asString(category.name, tr("panel.uncategorized")) + local categoryId = asString(category.id, categoryName) + local isRenaming = editorMode and renamingCategoryId == categoryId + local canRename = categoryRenameable(category) + local header + if isRenaming then + local nextName = noctalia.string.trim(renamingCategoryValue) + local canSave = not creatorBusy and categoryCanAcceptDrop(nextName) + and nextName ~= categoryName and not categoryNameExists(nextName, categoryId) + header = ui.row({ gap = 6, align = "center" }, { + ui.glyph({ name = "pencil", size = 15, color = "primary" }), + ui.input({ + key = "category-name-" .. tostring(renamingCategoryRevision), + value = renamingCategoryValue, + placeholder = tr("panel.editor.category_name_placeholder"), + flexGrow = 1, + controlSize = "sm", + enabled = not creatorBusy, + focus = true, + onChange = "onCategoryRenameChanged", + onSubmit = "onCategoryRenameSave", + }), + ui.button({ + glyph = "x", variant = "ghost", controlSize = "sm", + tooltip = tr("panel.editor.category_rename_cancel"), + enabled = not creatorBusy, onClick = "onCategoryRenameCancel", + }), + ui.button({ + glyph = "device-floppy", variant = "primary", controlSize = "sm", + tooltip = tr("panel.editor.category_rename_save"), + enabled = canSave, onClick = "onCategoryRenameSave", + }), + }) + else + local headerChildren = { + ui.label({ + text = categoryName, + color = asString(cfg("category_color"), "primary"), + fontSize = 14, + fontWeight = "bold", + maxLines = 1, + flexGrow = 1, + }), + ui.label({ + text = noctalia.trp("panel.category_count", #asArray(category.binds)), + color = "on_surface_variant", + fontSize = 11, + }), + } + if editorMode then + local renameProps = { + glyph = "pencil", variant = "ghost", + width = BIND_ROW_ACTION_SIZE, height = BIND_ROW_ACTION_SIZE, + glyphSize = BIND_ROW_ACTION_GLYPH_SIZE, + enabled = canRename and not creatorBusy and not creatorOpen and renamingCategoryId == "", + tooltip = canRename and tr("panel.editor.category_rename") + or tr("panel.editor.category_read_only"), + } + if canRename then renameProps.onClick = registerCategoryRenameCallback(categoryId) end + headerChildren[#headerChildren + 1] = ui.button(renameProps) + end + header = ui.row({ gap = 8, align = "center" }, headerChildren) + end + local children = { header } + if isRenaming and creatorError ~= "" then + children[#children + 1] = ui.label({ + text = creatorErrorText(), color = "error", fontSize = 10, maxLines = 2, + }) + end + + local categoryBinds = asArray(category.binds) + for index, bind in ipairs(categoryBinds) do + local insertion = reorderInsertionZone(bind, "before") + if insertion ~= nil then children[#children + 1] = insertion end + children[#children + 1] = bindRow( + bind, categoryName, columnCount, index + ) + end + if #categoryBinds > 0 then + local insertion = reorderInsertionZone(categoryBinds[#categoryBinds], "after") + if insertion ~= nil then children[#children + 1] = insertion end + end + local props = { + key = "category-" .. categoryId, + gap = editorMode and 0 or 3, + padding = 8, + radius = 12, + fill = cardFill(), + border = "outline", + borderWidth = 1, + } + if editorMode and categoryCanAcceptDrop(categoryName) then + props.accepts = { DND_BIND_TYPE } + props.value = categoryName + props.onDrop = "onBindDropped" + props.enabled = not creatorBusy and not creatorOpen and renamingCategoryId == "" + return ui.dropZone(props, children) + end + return ui.column(props, children) +end + +local function readyBody(categories, requestedColumns) + local hidden = filteredHidden() + if #categories == 0 and (not editorMode or #hidden == 0) then + local searching = normalized(searchQuery) ~= "" + local message = searching and tr("panel.no_results") or tr("panel.empty") + local hint = searching and tr("panel.no_results_hint") or tr("panel.empty_hint") + local children = { + ui.glyph({ name = "keyboard-off", size = 44, color = "on_surface_variant" }), + ui.label({ text = message, fontSize = 16, fontWeight = "bold", color = "on_surface" }), + ui.label({ text = hint, color = "on_surface_variant", textAlign = "center", maxWidth = 560, maxLines = 3 }), + } + if not searching then + children[#children + 1] = ui.button({ + text = tr("panel.open_settings_action"), glyph = "settings", variant = "ghost", + onClick = "onOpenSettingsClicked", + }) + end + return ui.column({ flexGrow = 1, align = "center", justify = "center", gap = 10 }, children) + end + + local columns = partitionCategories(categories, requestedColumns) + local columnNodes = {} + for columnIndex, column in ipairs(columns) do + local cards = {} + for _, category in ipairs(column) do + cards[#cards + 1] = categoryCard(category, #columns) + end + columnNodes[#columnNodes + 1] = ui.column({ + key = "column-" .. tostring(columnIndex), + gap = 12, + flexGrow = 1, + align = "stretch", + }, cards) + end + + local content = {} + local hiddenNode = hiddenSection(hidden, math.max(1, #columns)) + if hiddenNode ~= nil then content[#content + 1] = hiddenNode end + if #columnNodes > 0 then + content[#content + 1] = ui.row({ gap = 12, align = "start" }, columnNodes) + end + return ui.scroll({ flexGrow = 1, gap = 0 }, { + ui.column({ gap = 12, align = "stretch" }, content), + }) +end + +local function statusBody(status, errorText) + local isError = status == "error" + local errorKey = "panel.errors." .. asString(errorText, "unknown") + local translatedError = tr(errorKey) + if translatedError == errorKey then + translatedError = tr("panel.unknown_error") + end + return ui.column({ flexGrow = 1, align = "center", justify = "center", gap = 12 }, { + ui.glyph({ + name = isError and "alert-circle" or "refresh", + size = 44, + color = isError and "error" or "primary", + }), + ui.label({ + text = isError and tr("panel.load_failed") or tr("panel.loading"), + fontSize = 16, + fontWeight = "bold", + color = isError and "error" or "on_surface", + }), + ui.label({ + text = isError and translatedError or tr("panel.loading_hint"), + color = "on_surface_variant", + textAlign = "center", + maxWidth = 680, + maxLines = 4, + }), + ui.label({ + text = tr("panel.path_hint"), + color = "on_surface_variant", + textAlign = "center", + maxWidth = 680, + maxLines = 3, + visible = isError, + }), + ui.button({ + text = tr("panel.retry"), + glyph = "refresh", + variant = "primary", + visible = isError, + onClick = "onRefreshClicked", + }), + ui.button({ + text = tr("panel.open_settings_action"), + glyph = "settings", + variant = "ghost", + visible = isError, + onClick = "onOpenSettingsClicked", + }), + }) +end + +local function countVisibleBinds(categories) + local count = 0 + for _, category in ipairs(categories) do + count += #asArray(category.binds) + end + return count +end + +local function header() + local compositor = asString(snapshot.compositor, tr("panel.detecting")) + local total = tonumber(snapshot.total) or countVisibleBinds(asArray(snapshot.categories)) + local subtitle = compositor .. " · " .. noctalia.trp("panel.bind_count", total) + if asString(snapshot.updated_at) ~= "" then + subtitle ..= " · " .. tr("panel.updated", { time = snapshot.updated_at }) + end + + return ui.row({ gap = 10, align = "center" }, { + ui.glyph({ name = "keyboard", size = 26, color = "primary" }), + ui.column({ gap = 2, flexGrow = 1 }, { + ui.label({ text = tr("title"), fontSize = 18, fontWeight = "bold", color = "on_surface" }), + ui.label({ text = subtitle, fontSize = 11, color = "on_surface_variant", maxLines = 1 }), + }), + ui.button({ + glyph = "keyboard", + variant = viewMode == "keyboard" and "primary" or "ghost", + selected = viewMode == "keyboard", + tooltip = tr("panel.keyboard.view"), + onClick = "onShowKeyboard", + }), + ui.button({ + glyph = "list", + variant = viewMode == "list" and "primary" or "ghost", + selected = viewMode == "list", + tooltip = tr("panel.list_view"), + onClick = "onShowList", + }), + ui.label({ + text = tr("panel.keyboard.layout"), + color = "on_surface_variant", + fontSize = 10, + visible = editorMode, + }), + ui.select({ + key = "keyboard-layout-selector", + options = keyboardLayoutOptions(), + selectedIndex = keyboardLayoutSelectedIndex(), + width = 120, + controlSize = "sm", + visible = editorMode, + enabled = not creatorBusy, + onChange = "onKeyboardLayoutChanged", + }), + ui.spacer({ width = 8, flexGrow = 0 }), + ui.button({ + text = tr("panel.creator.new_shortcut"), + glyph = "pencil-plus", + variant = creatorOpen and formMode == "create" and "primary" or "ghost", + selected = creatorOpen and formMode == "create", + enabled = snapshot.status == "ready", + tooltip = tr("panel.creator.open"), + onClick = "onCreatorToggle", + }), + ui.button({ + text = tr("panel.editor.open"), + glyph = "edit", + variant = editorMode and "primary" or "ghost", + selected = editorMode, + enabled = snapshot.status == "ready", + tooltip = tr("panel.editor.open_hint"), + onClick = "onEditorToggle", + }), + ui.button({ + glyph = "refresh", + variant = "ghost", + tooltip = tr("panel.refresh"), + enabled = snapshot.status ~= "loading", + onClick = "onRefreshClicked", + }), + ui.button({ + glyph = "folder-open", + variant = "ghost", + tooltip = tr("panel.open_config_folder"), + enabled = sourceDirectory() ~= "" and not creatorBusy, + onClick = "onOpenConfigFolderClicked", + }), + ui.button({ + glyph = "settings", + variant = "ghost", + tooltip = tr("panel.open_settings"), + enabled = not creatorBusy, + onClick = "onOpenSettingsClicked", + }), + ui.button({ glyph = "close", variant = "ghost", tooltip = tr("panel.close"), onClick = "onCloseClicked" }), + }) +end + +local function searchBar(categories) + local children = { + ui.input({ + key = "search-" .. tostring(searchRevision), + value = searchQuery, + placeholder = tr("panel.search_placeholder"), + focus = true, + flexGrow = 1, + onChange = "onSearchChanged", + onSubmit = "onSearchChanged", + }), + } + + if searchQuery ~= "" then + children[#children + 1] = ui.label({ + text = noctalia.trp("panel.search_results", countVisibleBinds(categories)), + color = "on_surface_variant", + fontSize = 11, + }) + children[#children + 1] = ui.button({ + glyph = "x", + variant = "ghost", + tooltip = tr("panel.clear_search"), + onClick = "onClearSearch", + }) + end + + return ui.row({ gap = 8, align = "center" }, children) +end + +local function warningsBanner() + local warnings = asArray(snapshot.warnings) + if #warnings == 0 then + return nil + end + return ui.row({ + gap = 8, + paddingH = 10, + paddingV = 7, + radius = 9, + fill = "warning/0.12", + border = "warning", + borderWidth = 1, + align = "center", + }, { + ui.glyph({ name = "alert-triangle", size = 17, color = "tertiary" }), + ui.label({ + text = noctalia.trp("panel.warning_count", #warnings), + color = "on_surface", + fontSize = 11, + maxLines = 2, + flexGrow = 1, + }), + }) +end + +render = function() + renderCallbackNames = {} + local status = asString(snapshot.status, "idle") + local categories = filteredCategories() + local requestedColumns = math.max(1, math.min(4, math.floor(tonumber(cfg("columns")) or 3))) + local body + if status == "error" then + body = statusBody(status, snapshot.error) + elseif status == "loading" or status == "idle" then + body = statusBody(status, "") + elseif viewMode == "keyboard" then + body = keyboardBody() + else + body = readyBody(categories, requestedColumns) + end + + local children = { + header(), + ui.separator({ orientation = "horizontal", color = "outline", opacity = 0.65 }), + } + if viewMode == "list" then + children[#children + 1] = searchBar(categories) + end + if viewMode == "list" and creatorOpen and status == "ready" then + children[#children + 1] = creatorForm(keyboardIndex()) + end + local warning = warningsBanner() + if warning ~= nil and status == "ready" then + children[#children + 1] = warning + end + children[#children + 1] = body + panel.render(ui.column({ flexGrow = 1, gap = 12, align = "stretch" }, children)) + finishDynamicCallbackRender() +end + +local function requestRefresh() + local current = tonumber(noctalia.state.get(REFRESH_KEY)) or 0 + noctalia.state.set(REFRESH_KEY, current + 1) +end + +noctalia.state.watch(SNAPSHOT_KEY, function(value) + if type(value) == "table" and value.status == "loading" and snapshot.status == "ready" then + return + end + if type(value) == "table" then + snapshot = value + else + snapshot = EMPTY_SNAPSHOT + end + render() +end) + +noctalia.state.watch(CREATE_RESULT_KEY, function(result) + if formMode ~= "create" or type(result) ~= "table" + or tostring(result.request_id or "") ~= creatorRequestId or not creatorBusy then + return + end + creatorBusy = false + if result.ok == true then + local chord = creatorChord() + creatorOpen = false + creatorKeys = {} + selectedKeyboardKey = nil + noctalia.notify(tr("title"), tr("panel.creator.saved", { chord = chord })) + else + creatorError = asString(result.error, "write_failed") + noctalia.notifyError(tr("title"), creatorErrorText()) + end + render() +end) + +noctalia.state.watch(UPDATE_RESULT_KEY, function(result) + if formMode ~= "edit" or type(result) ~= "table" + or tostring(result.request_id or "") ~= editingRequestId or not creatorBusy then + return + end + creatorBusy = false + if result.ok == true then + editingSnapshotBefore = nil + creatorOpen = false + editingBindId = "" + selectedKeyboardKey = nil + viewMode = "list" + local resultKey = editingOperation == "hide" and "hidden" + or (editingOperation == "restore" and "restored" + or (editingOperation == "delete" and "deleted" + or (editingOperation == "move" and "moved" + or (editingOperation == "reorder" and "reordered" + or (editingOperation == "rename_category" and "category_renamed" or "updated"))))) + noctalia.notify(tr("title"), tr("panel.editor." .. resultKey)) + if editingOperation == "rename_category" then clearCategoryRename() end + else + if editingSnapshotBefore ~= nil then snapshot = editingSnapshotBefore end + editingSnapshotBefore = nil + creatorError = asString(result.error, "write_failed") + noctalia.notifyError(tr("title"), creatorErrorText()) + end + editingOperation = "update" + deleteConfirmBindId = "" + hiddenDeleteConfirmBindId = "" + render() +end) + +function onOpen(_context) + local configuredLayout = asString(cfg("keyboard_layout"), "100") + keyboardLayoutId = KEYBOARD_LAYOUTS[configuredLayout] ~= nil and configuredLayout or "100" + local current = noctalia.state.get(SNAPSHOT_KEY) + snapshot = type(current) == "table" and current or EMPTY_SNAPSHOT + searchQuery = "" + searchRevision += 1 + if creatorBusy then + creatorOpen = editingOperation == "update" + viewMode = formMode == "edit" and "list" or "keyboard" + else + creatorOpen = false + creatorError = "" + editorMode = false + formMode = "create" + editingBindId = "" + editingOperation = "update" + deleteConfirmBindId = "" + hiddenDeleteConfirmBindId = "" + clearCategoryRename() + end + if snapshot.status == nil or snapshot.status == "idle" then + requestRefresh() + end + render() +end + +function onConfigChanged() + render() +end + +function update() + render() +end + +function onSearchChanged(value) + searchQuery = asString(value) + render() +end + +function onClearSearch() + searchQuery = "" + searchRevision += 1 + render() +end + +function onShowKeyboard() + viewMode = "keyboard" + render() +end + +function onShowList() + if creatorBusy then return end + viewMode = "list" + creatorOpen = false + render() +end + +function onKeyboardLayoutChanged(index, _label) + if creatorBusy then return end + local selectedIndex = math.floor(tonumber(index) or -1) + 1 + local layoutId = KEYBOARD_LAYOUT_ORDER[selectedIndex] + if layoutId == nil or KEYBOARD_LAYOUTS[layoutId] == nil then return end + keyboardLayoutId = layoutId + render() +end + +local function resetCreator() + formMode = "create" + editingBindId = "" + editingCategory = "" + editingCapabilities = {} + editingAction = "" + creatorKeys = viewMode == "keyboard" and selectedKeyboardKey ~= nil and { selectedKeyboardKey } or {} + creatorKeysText = creatorKeysDisplay() + creatorActivation = "press" + creatorCommand = "" + creatorCommandKind = "shell" + creatorLibraryEntryId = "" + creatorDescription = "" + creatorCategoryIndex = 0 + creatorNewCategory = "" + creatorCategoryNamesFrozen = creatorCategoryNames() + creatorContextCompositor = asString(snapshot.compositor) + creatorContextSource = asString(snapshot.source) + creatorBusy = false + creatorError = "" + commandLibraryOpen = false + commandLibraryQuery = "" + commandLibrarySourceIndex = 0 + commandLibraryCategoryIndex = 0 + commandLibraryReadinessIndex = 0 + commandLibraryRevision += 1 + creatorRevision += 1 +end + +function onCreatorToggle() + if creatorBusy then return end + clearCategoryRename() + if creatorOpen and formMode == "create" then + creatorOpen = false + else + editorMode = false + resetCreator() + creatorOpen = true + end + render() +end + +function onCreatorCancel() + if creatorBusy then return end + creatorOpen = false + creatorBusy = false + creatorError = "" + commandLibraryOpen = false + if formMode == "edit" then viewMode = "list" end + render() +end + +function onEditorToggle() + if creatorBusy then return end + if editorMode then + editorMode = false + creatorOpen = false + formMode = "create" + editingBindId = "" + deleteConfirmBindId = "" + hiddenDeleteConfirmBindId = "" + clearCategoryRename() + else + editorMode = true + creatorOpen = false + formMode = "edit" + editingBindId = "" + deleteConfirmBindId = "" + hiddenDeleteConfirmBindId = "" + clearCategoryRename() + viewMode = "list" + end + render() +end + +function onCategoryRenameChanged(value) + if creatorBusy or renamingCategoryId == "" then return end + renamingCategoryValue = asString(value) + creatorError = "" + render() +end + +function onCategoryRenameCancel() + if creatorBusy then return end + clearCategoryRename() + render() +end + +function onCategoryRenameSave() + if creatorBusy or renamingCategoryId == "" or snapshot.status ~= "ready" then return end + local category = findSnapshotCategory(renamingCategoryId) + local nextName = noctalia.string.trim(renamingCategoryValue) + if snapshot.compositor == "" or snapshot.source == "" then + creatorError = "stale_context" + elseif category == nil or asString(category.name) ~= renamingCategoryOriginal then + creatorError = "category_changed" + elseif not categoryRenameable(category) then + creatorError = "category_not_editable" + elseif nextName == "" then + creatorError = "category_required" + elseif not categoryCanAcceptDrop(nextName) then + creatorError = "category_invalid" + elseif categoryNameExists(nextName, renamingCategoryId) then + creatorError = "category_exists" + elseif nextName == renamingCategoryOriginal then + clearCategoryRename() + else + creatorRequestCounter += 1 + local requestId = tostring(os.time()) .. "-rename-category-" .. tostring(creatorRequestCounter) + formMode = "edit" + editingOperation = "rename_category" + editingBindId = "" + editingRequestId = requestId + creatorContextCompositor = asString(snapshot.compositor) + creatorContextSource = asString(snapshot.source) + creatorBusy = true + creatorError = "" + creatorOpen = false + editingSnapshotBefore = snapshot + if not optimisticRenameCategory( + renamingCategoryId, renamingCategoryOriginal, nextName + ) then + editingSnapshotBefore = nil + creatorBusy = false + creatorError = "category_changed" + else + noctalia.state.set(UPDATE_REQUEST_KEY, { + request_id = requestId, + operation = "rename_category", + compositor = creatorContextCompositor, + source = creatorContextSource, + category_id = renamingCategoryId, + old_category = renamingCategoryOriginal, + new_category = nextName, + }) + end + end + render() +end + +function onCreatorPress() + creatorActivation = "press" + creatorError = "" + render() +end + +function onCreatorRelease() + if normalized(snapshot.compositor) ~= "niri" then + creatorActivation = "release" + creatorError = "" + render() + end +end + +function onCreatorCategoryChanged(index, _text) + creatorCategoryIndex = tonumber(index) or 0 + creatorError = "" + render() +end + +function onCreatorNewCategoryChanged(value) + creatorNewCategory = asString(value) + creatorError = "" + render() +end + +function onCreatorDescriptionChanged(value) + creatorDescription = asString(value) + creatorError = "" + render() +end + +function onCommandLibraryToggle() + if formMode == "edit" and editingCapabilities.command ~= true then return end + commandLibraryOpen = not commandLibraryOpen + creatorError = "" + render() +end + +function onCommandLibraryQueryChanged(value) + commandLibraryQuery = asString(value) + render() +end + +function onCommandLibrarySourceChanged(index, _text) + commandLibrarySourceIndex = math.max(0, math.floor(tonumber(index) or 0)) + commandLibraryCategoryIndex = 0 + render() +end + +function onCommandLibraryCategoryChanged(index, _text) + commandLibraryCategoryIndex = math.max(0, math.floor(tonumber(index) or 0)) + render() +end + +function onCommandLibraryReadinessChanged(index, _text) + commandLibraryReadinessIndex = math.max(0, math.min(2, math.floor(tonumber(index) or 0))) + render() +end + +function onCommandLibraryUseCustom() + if creatorCommandKind == "native" then + creatorCommand = "" + creatorRevision += 1 + end + creatorCommandKind = "shell" + creatorLibraryEntryId = "" + commandLibraryOpen = false + creatorError = "" + render() +end + +function onCreatorCommandChanged(value) + creatorCommand = asString(value) + creatorError = "" + render() +end + +function onEditorKeysChanged(value) + creatorKeysText = asString(value) + local keys = {} + local seen = {} + for part in (creatorKeysText .. ","):gmatch("(.-),") do + local key = canonicalKey(noctalia.string.trim(part)) + if key ~= "" and not seen[key] then + seen[key] = true + keys[#keys + 1] = key + end + end + creatorKeys = keys + selectedKeyboardKey = keys[1] + creatorError = "" + render() +end + +function onCreatorSave() + if creatorBusy then + return + end + local index = keyboardIndex() + local compositor = normalized(snapshot.compositor) + local category = creatorCategory() + if snapshot.status ~= "ready" or snapshot.compositor ~= creatorContextCompositor + or snapshot.source ~= creatorContextSource then + creatorError = "stale_context" + elseif #creatorKeys == 0 then + creatorError = "combination_required" + elseif (formMode ~= "edit" or editingCapabilities.command == true) + and noctalia.string.trim(creatorCommand) == "" then + creatorError = "command_required" + elseif creatorCommand:find("{{", 1, true) ~= nil then + creatorError = "library_arguments_required" + elseif noctalia.string.trim(creatorDescription) == "" then + creatorError = "description_required" + elseif category == "" then + creatorError = "category_required" + elseif compositor ~= "hyprland" and #creatorKeys ~= 1 then + creatorError = "single_key_only" + elseif compositor == "niri" and creatorActivation == "release" then + creatorError = "release_unsupported" + elseif creatorConflict(index) ~= nil then + creatorError = "conflict_blocked" + else + local keys = {} + for _, code in ipairs(creatorKeys) do + keys[#keys + 1] = configKeyName(code) + end + local modifiers = {} + for _, modifier in ipairs(MODIFIER_ORDER) do + if activeModifiers[modifier] then + modifiers[#modifiers + 1] = modifier + end + end + creatorRequestCounter += 1 + local requestId = tostring(os.time()) .. "-" .. tostring(creatorRequestCounter) + creatorBusy = true + creatorError = "" + local request = { + request_id = requestId, + compositor = creatorContextCompositor, + source = creatorContextSource, + modifiers = modifiers, + keys = keys, + activation = creatorActivation, + command = noctalia.string.trim(creatorCommand), + command_kind = creatorCommandKind, + library_entry_id = creatorLibraryEntryId, + description = noctalia.string.trim(creatorDescription), + category = category, + } + if formMode == "edit" then + editingOperation = "update" + editingRequestId = requestId + request.target_id = editingBindId + noctalia.state.set(UPDATE_REQUEST_KEY, request) + else + creatorRequestId = requestId + noctalia.state.set(CREATE_REQUEST_KEY, request) + end + end + render() +end + +local function toggleModifier(modifier) + activeModifiers[modifier] = not activeModifiers[modifier] + selectedKeyboardKey = nil + render() +end + +function onToggleSuper() toggleModifier("SUPER") end +function onToggleCtrl() toggleModifier("CTRL") end +function onToggleShift() toggleModifier("SHIFT") end +function onToggleAlt() toggleModifier("ALT") end + +function onClearModifiers() + for _, modifier in ipairs(MODIFIER_ORDER) do + activeModifiers[modifier] = false + end + selectedKeyboardKey = nil + render() +end + +function onCancelDeleteBind() + deleteConfirmBindId = "" + render() +end + +function onCancelDeleteHiddenBind() + hiddenDeleteConfirmBindId = "" + render() +end + +function onBindDropped(bindId, targetCategory) + if not editorMode or creatorBusy or snapshot.status ~= "ready" then return end + local targetName = noctalia.string.trim(asString(targetCategory)) + if not categoryCanAcceptDrop(targetName) or not snapshotHasCategory(targetName) then return end + local bind, currentCategory = findSnapshotBind(asString(bindId)) + if bind == nil or not bindCategoryMovable(bind) or currentCategory == targetName then return end + + creatorRequestCounter += 1 + local requestId = tostring(os.time()) .. "-move-" .. tostring(creatorRequestCounter) + formMode = "edit" + editingOperation = "move" + editingBindId = asString(bind.id) + editingRequestId = requestId + creatorContextCompositor = asString(snapshot.compositor) + creatorContextSource = asString(snapshot.source) + creatorBusy = true + creatorError = "" + creatorOpen = false + deleteConfirmBindId = "" + editingSnapshotBefore = snapshot + if not optimisticPlaceBind(editingBindId, targetName, "", "after") then + editingSnapshotBefore = nil + creatorBusy = false + return + end + noctalia.state.set(UPDATE_REQUEST_KEY, { + request_id = requestId, + target_id = editingBindId, + operation = "move", + category = targetName, + compositor = creatorContextCompositor, + source = creatorContextSource, + }) + render() +end + +function onBindReordered(bindId, targetValue) + if not editorMode or creatorBusy or snapshot.status ~= "ready" then return end + local placement, anchorId = asString(targetValue):match("^(%a+)|(.+)$") + if (placement ~= "before" and placement ~= "after") or asString(anchorId) == "" then return end + local bind, bindCategory = findSnapshotBind(asString(bindId)) + local anchor, anchorCategory = findSnapshotBind(asString(anchorId)) + if bind == nil or anchor == nil or asString(bind.id) == asString(anchor.id) + or not bindCategoryMovable(bind) or not bindCategoryMovable(anchor) then return end + if asString(bind.source) ~= asString(anchor.source) then + if bindCategory ~= anchorCategory then onBindDropped(bindId, anchorCategory) end + return + end + + creatorRequestCounter += 1 + local requestId = tostring(os.time()) .. "-reorder-" .. tostring(creatorRequestCounter) + formMode = "edit" + editingOperation = "reorder" + editingBindId = asString(bind.id) + editingRequestId = requestId + creatorContextCompositor = asString(snapshot.compositor) + creatorContextSource = asString(snapshot.source) + creatorBusy = true + creatorError = "" + creatorOpen = false + deleteConfirmBindId = "" + hiddenDeleteConfirmBindId = "" + editingSnapshotBefore = snapshot + if not optimisticPlaceBind(editingBindId, anchorCategory, asString(anchor.id), placement) then + editingSnapshotBefore = nil + creatorBusy = false + return + end + noctalia.state.set(UPDATE_REQUEST_KEY, { + request_id = requestId, + target_id = editingBindId, + anchor_id = asString(anchor.id), + operation = "reorder", + placement = placement, + compositor = creatorContextCompositor, + source = creatorContextSource, + }) + render() +end + +function onIpc(event, payload) + if event == "view-keyboard" then + onShowKeyboard() + elseif event == "view-list" then + onShowList() + elseif event == "keyboard-layout" and KEYBOARD_LAYOUTS[asString(payload)] ~= nil then + keyboardLayoutId = asString(payload) + render() + elseif event == "keyboard-key" and asString(payload) ~= "" then + selectKeyboardKey(payload) + elseif event == "clear-modifiers" then + onClearModifiers() + elseif event == "creator-open" and not creatorOpen then + onCreatorToggle() + elseif event == "creator-cancel" and creatorOpen then + onCreatorCancel() + elseif event == "editor-open" and not editorMode then + onEditorToggle() + elseif event == "editor-bind" and asString(payload) ~= "" then + for _, category in ipairs(asArray(snapshot.categories)) do + for _, bind in ipairs(asArray(category.binds)) do + if asString(bind.id) == asString(payload) then + editorMode = true + openBindEditor(bind, asString(category.name, tr("panel.uncategorized"))) + return + end + end + end + end +end + +function onRefreshClicked() + requestRefresh() +end + +function onOpenConfigFolderClicked() + if creatorBusy then return end + local directory = sourceDirectory() + if directory == "" then + noctalia.notifyError(tr("title"), tr("panel.config_folder_unavailable")) + return + end + local info = noctalia.fileInfo(directory) + if type(info) ~= "table" or info.isDir ~= true then + noctalia.notifyError(tr("title"), tr("panel.config_folder_unavailable")) + return + end + if not noctalia.commandExists("xdg-open") then + noctalia.notifyError(tr("title"), tr("panel.xdg_open_unavailable")) + return + end + local started = noctalia.runAsync("xdg-open " .. shellQuote(directory) .. " >/dev/null 2>&1", function(result) + if result.timedOut == true or tonumber(result.exitCode) ~= 0 then + noctalia.notifyError(tr("title"), tr("panel.config_folder_failed")) + end + end) + if not started then + noctalia.notifyError(tr("title"), tr("panel.config_folder_failed")) + end +end + +function onOpenSettingsClicked() + if creatorBusy then return end + local started = noctalia.runAsync("noctalia msg settings-open plugins") + if not started then + noctalia.notifyError(tr("title"), tr("panel.settings_failed")) + end +end + +function onCloseClicked() + if creatorBusy then return end + panel.close() +end diff --git a/keymap/plugin.toml b/keymap/plugin.toml new file mode 100644 index 0000000..aad8572 --- /dev/null +++ b/keymap/plugin.toml @@ -0,0 +1,229 @@ +id = "blackbartblues/keymap" +name = "Keymap" +version = "1.3.0" +plugin_api = 5 +author = "blackbartblues" +license = "MIT" +dependencies = ["hyprctl", "Hyprland", "niri", "mango", "mmsg", "xdg-open"] +tags = ["bar", "panel", "service", "hyprland", "niri", "mangowc", "productivity", "utility"] +icon = "keyboard" +description = "View, create, organize, and edit Hyprland, Niri, and MangoWC keybindings." + +[[setting]] +key = "compositor" +type = "select" +label_key = "settings.compositor.label" +description_key = "settings.compositor.description" +options = [ + { value = "auto", label_key = "settings.compositor.options.auto" }, + { value = "hyprland", label_key = "settings.compositor.options.hyprland" }, + { value = "niri", label_key = "settings.compositor.options.niri" }, + { value = "mangowc", label_key = "settings.compositor.options.mangowc" }, +] +default = "auto" + +[[setting]] +key = "hyprland_config" +type = "file" +label_key = "settings.hyprland_config.label" +description_key = "settings.hyprland_config.description" +default = "~/.config/hypr/hyprland.lua" +visible_when = { key = "compositor", values = ["auto", "hyprland"] } + +[[setting]] +key = "niri_config" +type = "file" +label_key = "settings.niri_config.label" +description_key = "settings.niri_config.description" +default = "~/.config/niri/config.kdl" +visible_when = { key = "compositor", values = ["auto", "niri"] } + +[[setting]] +key = "mangowc_config" +type = "file" +label_key = "settings.mangowc_config.label" +description_key = "settings.mangowc_config.description" +default = "~/.config/mango/config.conf" +visible_when = { key = "compositor", values = ["auto", "mangowc"] } + +[[setting]] +key = "merge_sequential" +type = "bool" +label_key = "settings.merge_sequential.label" +description_key = "settings.merge_sequential.description" +default = true + +[[setting]] +key = "show_undescribed" +type = "bool" +label_key = "settings.show_undescribed.label" +description_key = "settings.show_undescribed.description" +default = true + +[[setting]] +key = "keyboard_layout" +type = "select" +label_key = "settings.keyboard_layout.label" +description_key = "settings.keyboard_layout.description" +options = [ + { value = "100", label_key = "settings.keyboard_layout.options.p100" }, + { value = "96", label_key = "settings.keyboard_layout.options.p96" }, + { value = "80", label_key = "settings.keyboard_layout.options.p80" }, + { value = "75", label_key = "settings.keyboard_layout.options.p75" }, + { value = "65", label_key = "settings.keyboard_layout.options.p65" }, + { value = "60", label_key = "settings.keyboard_layout.options.p60" }, +] +default = "100" + +[[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 = "card_color" +type = "color" +label_key = "settings.card_color.label" +description_key = "settings.card_color.description" +default = "surface_variant" + +[[setting]] +key = "card_opacity" +type = "int" +label_key = "settings.card_opacity.label" +description_key = "settings.card_opacity.description" +default = 35 +min = 0 +max = 100 +step = 5 + +[[setting]] +key = "category_color" +type = "color" +label_key = "settings.category_color.label" +description_key = "settings.category_color.description" +default = "primary" + +[[setting]] +key = "description_color" +type = "color" +label_key = "settings.description_color.label" +description_key = "settings.description_color.description" +default = "on_surface" + +[[setting]] +key = "super_color" +type = "color" +label_key = "settings.super_color.label" +description_key = "settings.modifier_color.description" +default = "primary" + +[[setting]] +key = "super_text_color" +type = "color" +label_key = "settings.super_text_color.label" +description_key = "settings.modifier_text_color.description" +default = "on_primary" + +[[setting]] +key = "ctrl_color" +type = "color" +label_key = "settings.ctrl_color.label" +description_key = "settings.modifier_color.description" +default = "secondary" + +[[setting]] +key = "ctrl_text_color" +type = "color" +label_key = "settings.ctrl_text_color.label" +description_key = "settings.modifier_text_color.description" +default = "on_secondary" + +[[setting]] +key = "shift_color" +type = "color" +label_key = "settings.shift_color.label" +description_key = "settings.modifier_color.description" +default = "tertiary" + +[[setting]] +key = "shift_text_color" +type = "color" +label_key = "settings.shift_text_color.label" +description_key = "settings.modifier_text_color.description" +default = "on_tertiary" + +[[setting]] +key = "alt_color" +type = "color" +label_key = "settings.alt_color.label" +description_key = "settings.modifier_color.description" +default = "surface_variant" + +[[setting]] +key = "alt_text_color" +type = "color" +label_key = "settings.alt_text_color.label" +description_key = "settings.modifier_text_color.description" +default = "on_surface_variant" + +[[setting]] +key = "key_color" +type = "color" +label_key = "settings.key_color.label" +description_key = "settings.key_color.description" +default = "surface" + +[[setting]] +key = "key_text_color" +type = "color" +label_key = "settings.key_text_color.label" +description_key = "settings.key_text_color.description" +default = "on_surface" + +[[widget]] +id = "widget" +entry = "widget.luau" + + [[widget.setting]] + key = "glyph" + type = "glyph" + label_key = "settings.widget.glyph.label" + description_key = "settings.widget.glyph.description" + default = "keyboard" + + [[widget.setting]] + key = "show_label" + type = "bool" + label_key = "settings.widget.show_label.label" + description_key = "settings.widget.show_label.description" + default = false + +[[panel]] +id = "panel" +entry = "panel.luau" +width = 1400 +height = 850 +placement = "floating" +position = "center" + +[[service]] +id = "service" +entry = "service.luau" + +[[service]] +id = "niri-service" +entry = "niri_service.luau" + +[[service]] +id = "mangowc-service" +entry = "mangowc_service.luau" + +[[service]] +id = "writer-service" +entry = "writer_service.luau" diff --git a/keymap/screenshots/command-library.webp b/keymap/screenshots/command-library.webp new file mode 100644 index 0000000..08cb72c Binary files /dev/null and b/keymap/screenshots/command-library.webp differ diff --git a/keymap/screenshots/creator.webp b/keymap/screenshots/creator.webp new file mode 100644 index 0000000..b8dec13 Binary files /dev/null and b/keymap/screenshots/creator.webp differ diff --git a/keymap/screenshots/editor.webp b/keymap/screenshots/editor.webp new file mode 100644 index 0000000..467b844 Binary files /dev/null and b/keymap/screenshots/editor.webp differ diff --git a/keymap/screenshots/keyboard-100.webp b/keymap/screenshots/keyboard-100.webp new file mode 100644 index 0000000..d7cc5e3 Binary files /dev/null and b/keymap/screenshots/keyboard-100.webp differ diff --git a/keymap/screenshots/mangowc-list.webp b/keymap/screenshots/mangowc-list.webp new file mode 100644 index 0000000..1a7d6ca Binary files /dev/null and b/keymap/screenshots/mangowc-list.webp differ diff --git a/keymap/screenshots/niri-list.webp b/keymap/screenshots/niri-list.webp new file mode 100644 index 0000000..b71a70e Binary files /dev/null and b/keymap/screenshots/niri-list.webp differ diff --git a/keymap/screenshots/settings.webp b/keymap/screenshots/settings.webp new file mode 100644 index 0000000..da70301 Binary files /dev/null and b/keymap/screenshots/settings.webp differ diff --git a/keymap/service.luau b/keymap/service.luau new file mode 100644 index 0000000..3ba0c7f --- /dev/null +++ b/keymap/service.luau @@ -0,0 +1,1109 @@ +--!nonstrict +-- Keymap data service. +-- +-- The live Hyprland bind registry is authoritative. Lua sources are scanned only +-- to recover user-defined category order and map descriptions back to categories. + +local SNAPSHOT_KEY = "keymap.snapshot" +local REFRESH_REQUEST_KEY = "keymap.refresh_request" +local MAX_LUA_FILES = 64 +local MAX_SOURCE_BYTES = 512 * 1024 +local MAX_HIDDEN_BYTES = 2 * 1024 * 1024 +local HYPRCTL_TIMEOUT_MS = 5000 + +local refreshing = false +local refreshQueued = false +local refreshGeneration = 0 +local snapshotCompositor = "Hyprland" + +local function config(key, fallback) + local value = noctalia.getConfig(key) + if value == nil then + return fallback + end + return value +end + +local function environment(name) + local value = noctalia.getenv(name) + return type(value) == "string" and value or "" +end + +local function configuredCompositor() + local selected = tostring(config("compositor", "auto")):lower() + if selected ~= "auto" then + return selected + end + if environment("NIRI_SOCKET") ~= "" then + return "niri" + end + if environment("HYPRLAND_INSTANCE_SIGNATURE") ~= "" then + return "hyprland" + end + if environment("MANGO_INSTANCE_SIGNATURE") ~= "" then + return "mangowc" + end + local desktop = (environment("XDG_CURRENT_DESKTOP") + .. ":" .. environment("XDG_SESSION_DESKTOP") + .. ":" .. environment("DESKTOP_SESSION")):lower() + if desktop:find("niri", 1, true) ~= nil then + return "niri" + end + if desktop:find("hyprland", 1, true) ~= nil then + return "hyprland" + end + if desktop:find("mangowc", 1, true) ~= nil or desktop:find("mango", 1, true) ~= nil then + return "mangowc" + end + return nil +end + +local function trim(value) + if type(value) ~= "string" then + return "" + end + return value:match("^%s*(.-)%s*$") or "" +end + +local function appendUnique(items, seen, value) + if value == "" or seen[value] then + return + end + seen[value] = true + items[#items + 1] = value +end + +local function dirname(path) + local directory = path:match("^(.*)/[^/]*$") + if directory == nil or directory == "" then + return "." + end + return directory +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 + parts[#parts + 1] = part + end + elseif part ~= "." and part ~= "" then + parts[#parts + 1] = part + end + end + local normalized = table.concat(parts, "/") + if absolute then + return "/" .. normalized + end + return normalized ~= "" and normalized or "." +end + +local function expandedConfigPath() + local configured = config("hyprland_config", "~/.config/hypr/hyprland.lua") + local expanded = noctalia.expandPath(configured) + if type(expanded) ~= "string" or expanded == "" then + expanded = configured + end + expanded = normalizePath(expanded) + if noctalia.fileExists(expanded) then return expanded end + + local xdg = environment("XDG_CONFIG_HOME") + local configRoot = xdg ~= "" and xdg or (environment("HOME") .. "/.config") + local hyprDir = normalizePath(configRoot .. "/hypr") + local candidates = { + normalizePath(hyprDir .. "/hyprland.lua"), + normalizePath(hyprDir .. "/init.lua"), + "/etc/xdg/hypr/hyprland.lua", + } + for _, path in ipairs(candidates) do + if noctalia.fileExists(path) then return path end + end + + local entries = noctalia.listDir(hyprDir) + if type(entries) ~= "table" then return expanded end + table.sort(entries) + local bestPath, bestScore = nil, -1 + for _, name in ipairs(entries) do + if type(name) == "string" and name:match("%.lua$") and name ~= "keymap.lua" + and not name:lower():find("backup", 1, true) then + local path = normalizePath(hyprDir .. "/" .. name) + local source = noctalia.readFile(path) + if type(source) == "string" and #source <= MAX_SOURCE_BYTES then + local _, bindCount = source:gsub("hl%.bind%s*%(", "") + local _, requireCount = source:gsub("%f[%a]require%f[^%a]", "") + local score = bindCount * 10 + requireCount * 2 + if name:lower():find("keybind", 1, true) then score = score + 5 end + if score > bestScore then bestPath, bestScore = path, score end + end + end + end + return bestScore > 0 and bestPath or expanded +end + +local function modulePath(currentFile, moduleName) + local name = trim(moduleName) + if name == "" then + return nil + end + if name:sub(-4) == ".lua" then + name = name:sub(1, -5) + end + if not name:find("/", 1, true) then + name = name:gsub("%.", "/") + end + name = name .. ".lua" + if name:sub(1, 1) == "/" then + return normalizePath(name) + end + return normalizePath(dirname(currentFile) .. "/" .. name) +end + +local ESCAPES = { + a = "\a", + b = "\b", + f = "\f", + n = "\n", + r = "\r", + t = "\t", + v = "\v", + ["\\"] = "\\", + ['"'] = '"', + ["'"] = "'", +} + +-- Returns a quoted literal assigned to `field` and whether it is immediately +-- concatenated (for example: description = "Workspace " .. i). +local function assignmentLiteral(line, field) + local pattern = "%f[%w_]" .. field .. "%f[^%w_]" + local _, fieldEnd = line:find(pattern) + if fieldEnd == nil then + return nil, false + end + + local suffix = line:sub(fieldEnd + 1) + local _, assignmentEnd = suffix:find("^%s*=%s*") + if assignmentEnd == nil then + return nil, false + end + local literal = suffix:sub(assignmentEnd + 1) + local quote = literal:sub(1, 1) + if quote ~= '"' and quote ~= "'" then + return nil, false + end + + local out = {} + local escaped = false + for index = 2, #literal do + local char = literal:sub(index, index) + if escaped then + out[#out + 1] = ESCAPES[char] or char + escaped = false + elseif char == "\\" then + escaped = true + elseif char == quote then + local tail = literal:sub(index + 1) + return table.concat(out), tail:match("^%s*%.%.") ~= nil + else + out[#out + 1] = char + end + end + return nil, false +end + +local function requiredModules(line) + local modules = {} + local seen = {} + for moduleName in line:gmatch('require%s*%(%s*"([^"]+)"%s*%)') do + appendUnique(modules, seen, moduleName) + end + for moduleName in line:gmatch("require%s*%(%s*'([^']+)'%s*%)") do + appendUnique(modules, seen, moduleName) + end + for moduleName in line:gmatch('require%s+"([^"]+)"') do + appendUnique(modules, seen, moduleName) + end + for moduleName in line:gmatch("require%s+'([^']+)'") do + appendUnique(modules, seen, moduleName) + end + return modules +end + +local function multiKeySequence(line) + local combo = line:match('hl%.bind%s*%(%s*"([^"]+)"') + or line:match("hl%.bind%s*%(%s*'([^']+)'") + if combo == nil then return nil end + local keys = {} + for token in combo:gmatch("[^+]+") do + local value = trim(token) + local upper = value:upper() + if upper ~= "SUPER" and upper ~= "CTRL" and upper ~= "CONTROL" + and upper ~= "SHIFT" and upper ~= "ALT" and upper ~= "META" + and upper ~= "MOD" and upper ~= "MOD1" and upper ~= "MOD4" then + keys[#keys + 1] = value + end + end + return #keys > 1 and keys or nil +end + +local function generatedCommand(line) + local startIndex, endIndex = line:find("hl%.dsp%.exec_cmd%s*%(%s*") + if endIndex == nil then return nil end + local suffix = line:sub(endIndex + 1) + local quote = suffix:sub(1, 1) + if quote == '"' or quote == "'" then + local out = {} + local escaped = false + for index = 2, #suffix do + local char = suffix:sub(index, index) + if escaped then + out[#out + 1] = ESCAPES[char] or char + escaped = false + elseif char == "\\" then + escaped = true + elseif char == quote then + return table.concat(out) + else + out[#out + 1] = char + end + end + return nil + end + local equals = suffix:match("^%[(=*)%[") + if equals == nil then return nil end + local openingLength = #equals + 2 + local closeIndex = suffix:find("]" .. equals .. "]", openingLength + 1, true) + if closeIndex == nil then return nil end + return suffix:sub(openingLength + 1, closeIndex - 1) +end + +local function generatedAction(line) + local startIndex = line:find("hl%.dsp%.") + if startIndex == nil then return nil end + local quote, escaped, depth, opened = nil, false, 0, false + for index = startIndex, #line do + local char = line:sub(index, index) + if quote ~= nil then + if escaped then escaped = false + elseif char == "\\" then escaped = true + elseif char == quote then quote = nil end + elseif char == '"' or char == "'" then + quote = char + elseif char == "(" then + depth = depth + 1 + opened = true + elseif char == ")" then + depth = depth - 1 + if opened and depth == 0 then return line:sub(startIndex, index) end + end + end + return nil +end + +local function xorByte(left, right) + local result, place = 0, 1 + for _ = 1, 8 do + if left % 2 ~= right % 2 then result = result + place end + left = math.floor(left / 2) + right = math.floor(right / 2) + place = place * 2 + end + return result +end + +local function fingerprint(value) + local hash = 2166136261 + for index = 1, #value do + local low = hash % 256 + hash = hash - low + xorByte(low, value:byte(index)) + hash = (hash * 403 + (hash % 256) * 16777216) % 4294967296 + end + return string.format("%08x", hash) +end + +local function sourceLines(source) + local lines = {} + for line in (source .. "\n"):gmatch("([^\n]*)\n") do lines[#lines + 1] = line end + return lines +end + +local function hexDecode(value) + if value == "" or #value % 2 ~= 0 or value:find("[^0-9a-f]") ~= nil then return nil end + local output = {} + for index = 1, #value, 2 do + local byte = tonumber(value:sub(index, index + 1), 16) + if byte == nil then return nil end + output[#output + 1] = string.char(byte) + end + return table.concat(output) +end + +-- Exact v1 sentinel parser. It runs before ordinary comment handling so only +-- blocks emitted by the writer become restorable targets. +local function hiddenBlockAt(lines, startLine) + local namespace = "^([\t ]*)%-%- Keymap hidden " + if lines[startLine]:match(namespace) == nil then return nil, startLine, false end + local indent, blockId, originalFingerprint = lines[startLine]:match( + namespace .. "v1 begin ([0-9a-f]+) ([0-9a-f]+)$" + ) + if indent == nil or #blockId ~= 8 or #originalFingerprint ~= 8 then + return nil, startLine, true + end + local marker = indent .. "-- Keymap hidden v1" + local escapedMarker = marker:gsub("(%W)", "%%%1") + local encoded = {} + local cursor = startLine + 1 + while cursor <= #lines do + local chunk = lines[cursor]:match("^" .. escapedMarker .. " data ([0-9a-f]+)$") + if chunk ~= nil then + if #chunk > 96 or #chunk % 2 ~= 0 then return nil, cursor, true end + encoded[#encoded + 1] = chunk + cursor = cursor + 1 + else + local endId = lines[cursor]:match("^" .. escapedMarker .. " end ([0-9a-f]+)$") + if endId == nil then return nil, math.max(startLine, cursor - 1), true end + local original = hexDecode(table.concat(encoded)) + if #encoded == 0 or endId ~= blockId or original == nil or original == "" + or #original > MAX_HIDDEN_BYTES or fingerprint(original) ~= originalFingerprint + or fingerprint("Hyprland\0" .. original) ~= blockId then + return nil, cursor, true + end + return { + block_id = blockId, original = original, + original_fingerprint = originalFingerprint, + raw_snippet = table.concat(lines, "\n", startLine, cursor), + }, cursor, true + end + end + return nil, #lines, true +end + +local function scanLuaSources(rootPath) + local headers = {} + local headerSeen = {} + local exactCategories = {} + local prefixes = {} + local prefixSeen = {} + local keySequences = {} + local origins = {} + local commands = {} + local actions = {} + local warnings = {} + local hidden = {} + + local queue = { rootPath } + local queued = { [rootPath] = true } + local visited = {} + local cursor = 1 + local filesRead = 0 + + while cursor <= #queue and filesRead < MAX_LUA_FILES do + local path = queue[cursor] + cursor = cursor + 1 + if not visited[path] then + visited[path] = true + local source = noctalia.readFile(path) + if type(source) ~= "string" then + if path == rootPath then + warnings[#warnings + 1] = "lua_config_unreadable" + end + else + filesRead = filesRead + 1 + if #source > MAX_SOURCE_BYTES then + source = source:sub(1, MAX_SOURCE_BYTES) + warnings[#warnings + 1] = "lua_source_truncated" + end + + local currentCategory = nil + local pendingBindCategory = nil + local pendingMarkerLine = nil + local pendingMarkerRaw = nil + local lines = sourceLines(source) + local lineNumber = 1 + while lineNumber <= #lines do + local line = lines[lineNumber] + local block, blockEnd, candidate = hiddenBlockAt(lines, lineNumber) + if candidate then + if block == nil then + warnings[#warnings + 1] = "hidden_block_invalid:" .. path .. ":" .. tostring(lineNumber) + else + local originalLine = block.original:match("([^\n]*hl%.bind[^\n]*)") or "" + local combo = originalLine:match('hl%.bind%s*%(%s*"([^"]+)"') + or originalLine:match("hl%.bind%s*%(%s*'([^']+)'") or "" + local modifiers, keys = {}, {} + for token in combo:gmatch("[^+]+") do + local value = trim(token) + local upper = value:upper() + if upper == "SUPER" or upper == "CTRL" or upper == "CONTROL" + or upper == "SHIFT" or upper == "ALT" or upper == "META" + or upper == "MOD" or upper == "MOD1" or upper == "MOD4" then + modifiers[#modifiers + 1] = upper == "CONTROL" and "Ctrl" + or upper:sub(1, 1) .. upper:sub(2):lower() + elseif value ~= "" then keys[#keys + 1] = value end + end + local category = block.original:match( + "^%s*%-%-%s*Keymap bind%-category:%s*([^\n]-)%s*\n" + ) or currentCategory + local description = assignmentLiteral(originalLine, "description") + or assignmentLiteral(originalLine, "desc") or "" + hidden[#hidden + 1] = { + hidden = true, + id = "hidden:hypr:" .. fingerprint(path .. "\0" .. tostring(lineNumber) .. "\0" .. block.block_id), + source = path, line = lineNumber, start_line = lineNumber, end_line = blockEnd, + raw_snippet = block.raw_snippet, fingerprint = fingerprint(block.raw_snippet), + original_fingerprint = block.original_fingerprint, + modifiers = modifiers, key = keys[#keys] or "", keys = #keys > 1 and keys or nil, + description = description, dispatcher = generatedAction(originalLine) or "", + command = generatedCommand(originalLine) or "", action = generatedAction(originalLine) or "", + activation = originalLine:match("release%s*=%s*true") and "release" or "press", + category = category or "", capabilities = { restore = true, delete = true }, + } + end + lineNumber = blockEnd + 1 + else + local bindCategory = line:match( + "^%s*%-%-%s*Keymap bind%-category:%s*(.-)%s*$" + ) + local header = line:match("^%s*%-%-%s*%d+%.%s*(.-)%s*$") + if bindCategory ~= nil and bindCategory ~= "" then + pendingBindCategory = bindCategory + pendingMarkerLine = lineNumber + pendingMarkerRaw = line + elseif header ~= nil and header ~= "" then + currentCategory = header + appendUnique(headers, headerSeen, header) + elseif not line:match("^%s*%-%-") then + local modules = requiredModules(line) + for _, moduleName in ipairs(modules) do + local requiredPath = modulePath(path, moduleName) + if requiredPath ~= nil and not queued[requiredPath] and noctalia.fileExists(requiredPath) then + queued[requiredPath] = true + queue[#queue + 1] = requiredPath + end + end + + local effectiveCategory = pendingBindCategory or currentCategory + if effectiveCategory ~= nil then + local description, dynamic = assignmentLiteral(line, "description") + if description == nil then + description, dynamic = assignmentLiteral(line, "desc") + end + if description ~= nil and trim(description) ~= "" then + if dynamic then + local prefixKey = description .. "\0" .. effectiveCategory + if not prefixSeen[prefixKey] then + prefixSeen[prefixKey] = true + prefixes[#prefixes + 1] = { + prefix = description, + category = effectiveCategory, + } + end + elseif exactCategories[description] == nil then + exactCategories[description] = effectiveCategory + end + if keySequences[description] == nil then + keySequences[description] = multiKeySequence(line) + end + if origins[description] == nil then + local command = generatedCommand(line) + local action = generatedAction(line) + local rawSnippet = pendingMarkerRaw ~= nil + and (pendingMarkerRaw .. "\n" .. line) or line + origins[description] = { + source = path, + line = pendingMarkerLine or lineNumber, + start_line = pendingMarkerLine or lineNumber, + end_line = lineNumber, + managed = path:match("([^/]+)$") == "keymap.lua", + raw_snippet = rawSnippet, + fingerprint = fingerprint(rawSnippet), + action = action, + capabilities = { + combo = not dynamic, + category = not dynamic, + description = not dynamic, + command = not dynamic and command ~= nil, + activation = not dynamic, + }, + } + end + if commands[description] == nil then commands[description] = generatedCommand(line) end + if actions[description] == nil then actions[description] = generatedAction(line) end + end + end + if line:find("hl.bind", 1, true) ~= nil then + pendingBindCategory = nil + pendingMarkerLine = nil + pendingMarkerRaw = nil + end + end + lineNumber = lineNumber + 1 + end + end + end + end + end + + if cursor <= #queue then + warnings[#warnings + 1] = "lua_scan_limit_reached" + end + + return { + headers = headers, + exactCategories = exactCategories, + prefixes = prefixes, + keySequences = keySequences, + origins = origins, + commands = commands, + actions = actions, + warnings = warnings, + hidden = hidden, + } +end + +local function hasMaskBit(mask, bit) + return math.floor(mask / bit) % 2 == 1 +end + +local function decodeModifiers(value) + local mask = tonumber(value) or 0 + local modifiers = {} + if hasMaskBit(mask, 64) then + modifiers[#modifiers + 1] = "Super" + end + if hasMaskBit(mask, 1) then + modifiers[#modifiers + 1] = "Shift" + end + if hasMaskBit(mask, 4) then + modifiers[#modifiers + 1] = "Ctrl" + end + if hasMaskBit(mask, 8) then + modifiers[#modifiers + 1] = "Alt" + end + if hasMaskBit(mask, 16) then + modifiers[#modifiers + 1] = "Mod2" + end + if hasMaskBit(mask, 32) then + modifiers[#modifiers + 1] = "Mod3" + end + if hasMaskBit(mask, 128) then + modifiers[#modifiers + 1] = "Mod5" + end + return modifiers +end + +local KEY_NAMES = { + RETURN = "Enter", + SPACE = "Space", + ESCAPE = "Esc", + PRINT = "PrtSc", + PRIOR = "PgUp", + NEXT = "PgDn", + BRACKETLEFT = "[", + BRACKETRIGHT = "]", + LEFT = "Left", + RIGHT = "Right", + UP = "Up", + DOWN = "Down", + MOUSE_DOWN = "Scroll Down", + MOUSE_UP = "Scroll Up", + ["MOUSE:272"] = "Left Click", + ["MOUSE:273"] = "Right Click", + ["MOUSE:274"] = "Middle Click", + XF86AUDIORAISEVOLUME = "Vol Up", + XF86AUDIOLOWERVOLUME = "Vol Down", + XF86AUDIOMUTE = "Mute", + XF86AUDIOMICMUTE = "Mic Mute", + XF86AUDIOPLAY = "Play", + XF86AUDIOPAUSE = "Pause", + XF86AUDIONEXT = "Next", + XF86AUDIOPREV = "Prev", + XF86AUDIOSTOP = "Stop", + XF86AUDIOMEDIA = "Media", + XF86MONBRIGHTNESSUP = "Bright Up", + XF86MONBRIGHTNESSDOWN = "Bright Down", + XF86CALCULATOR = "Calc", + XF86MAIL = "Mail", + XF86SEARCH = "Search", + XF86EXPLORER = "Files", + XF86WWW = "Browser", + XF86HOMEPAGE = "Home", + XF86FAVORITES = "Favorites", + XF86POWEROFF = "Power", + XF86SLEEP = "Sleep", + XF86EJECT = "Eject", +} + +local function formatKey(value) + local key = tostring(value or "") + return KEY_NAMES[key:upper()] or key +end + +local function boolFlag(value) + return value == true and "1" or "0" +end + +local function stableBindId(bind) + local dispatcher = tostring(bind.dispatcher or "") + local dispatchIdentity = dispatcher + if dispatcher ~= "__lua" then + dispatchIdentity = dispatcher .. ":" .. tostring(bind.arg or "") + end + local flags = boolFlag(bind.release) .. boolFlag(bind.mouse) .. boolFlag(bind.long_press) + local identity = table.concat({ + tostring(bind.submap or ""), + tostring(bind.modmask or 0), + tostring(bind.key or ""), + flags, + dispatchIdentity, + }, "|") + return "hypr:" .. fingerprint(identity) +end + +-- Hyprland 0.56 can emit invalid JSON for native Lua binds while its plain +-- `hyprctl binds` output remains complete. Keep a strict, line-oriented +-- fallback so the plugin still reads the live registry instead of guessing +-- solely from source files. +local function parseHyprctlText(output) + if type(output) ~= "string" or output == "" then return nil end + local records = {} + local current = nil + local function finish() + if current == nil then return end + if type(current.key) == "string" and current.key ~= "" then + current.modmask = tonumber(current.modmask) or 0 + current.has_description = type(current.description) == "string" and current.description ~= "" + local kind = tostring(current._kind or "") + current.release = kind:find("r", 1, true) ~= nil + current.mouse = kind:find("m", 1, true) ~= nil + records[#records + 1] = current + end + current = nil + end + for line in (output .. "\n"):gmatch("([^\n]*)\n") do + local kind = line:match("^bind([%a]*)%s*$") + if kind ~= nil then + finish() + current = { _kind = kind } + elseif current ~= nil then + local key, value = line:match("^%s+([%w_]+):%s?(.*)$") + if key ~= nil then current[key] = value end + end + end + finish() + return #records > 0 and records or nil +end + +local function categoryForDescription(description, metadata) + local exact = metadata.exactCategories[description] + if exact ~= nil then + return exact + end + local bestCategory = nil + local bestLength = -1 + for _, entry in ipairs(metadata.prefixes) do + if description:sub(1, #entry.prefix) == entry.prefix and #entry.prefix > bestLength then + bestCategory = entry.category + bestLength = #entry.prefix + end + end + return bestCategory +end + +local function slug(value) + local id = value:lower():gsub("[^%a%d]+", "-"):gsub("^-+", ""):gsub("-+$", "") + return id ~= "" and id or "category" +end + +local function cleanBind(bind) + return { + id = bind.id, + modifiers = bind.modifiers, + key = bind.key, + keys = bind.keys, + description = bind.description, + dispatcher = bind.dispatcher, + activation = bind.release == true and "release" or "press", + source = bind.source, + line = bind.line, + managed = bind.managed == true, + command = bind.command, + action = bind.action, + start_line = bind.start_line, + end_line = bind.end_line, + raw_snippet = bind.raw_snippet, + fingerprint = bind.fingerprint, + capabilities = bind.capabilities, + } +end + +local function descriptionTemplate(description) + local template, replacements = description:gsub("%f[%d]%d+%f[%D]", "%%N%%") + return template, replacements > 0 +end + +local function mergedBind(run) + local first = run[1].bind + local last = run[#run].bind + local range = tostring(run[1].number) .. "-" .. tostring(run[#run].number) + local description = first.description:gsub("%f[%d]%d+%f[%D]", range, 1) + return { + id = "range:" .. first.id .. ":" .. last.id, + modifiers = first.modifiers, + key = range, + description = description, + dispatcher = first.dispatcher, + activation = first.release == true and "release" or "press", + } +end + +-- Merge runs even when Hyprland interleaves them (Super+1, Super+Alt+1, +-- Super+2, Super+Alt+2, ...). The V4 adjacent-only implementation missed this. +local function mergeSequential(binds) + if #binds < 3 then + local output = {} + for _, bind in ipairs(binds) do + output[#output + 1] = cleanBind(bind) + end + return output + end + + local groups = {} + for index, bind in ipairs(binds) do + local rawKey = tostring(bind._rawKey or "") + local number = rawKey:match("^%d+$") and tonumber(rawKey) or nil + local template, hasNumber = descriptionTemplate(bind.description) + if number ~= nil and hasNumber then + local signature = table.concat(bind.modifiers, "+") + .. "|" .. bind.dispatcher .. "|" .. template + groups[signature] = groups[signature] or {} + groups[signature][#groups[signature] + 1] = { + index = index, + number = number, + bind = bind, + } + end + end + + local replacements = {} + local skipped = {} + for _, group in pairs(groups) do + table.sort(group, function(a, b) + if a.number == b.number then + return a.index < b.index + end + return a.number < b.number + end) + + local runStart = 1 + for cursor = 2, #group + 1 do + local continues = cursor <= #group + and group[cursor].number == group[cursor - 1].number + 1 + if not continues then + local runLength = cursor - runStart + if runLength >= 3 then + local run = {} + local insertionIndex = group[runStart].index + for itemIndex = runStart, cursor - 1 do + local item = group[itemIndex] + run[#run + 1] = item + if item.index < insertionIndex then + insertionIndex = item.index + end + end + replacements[insertionIndex] = mergedBind(run) + for _, item in ipairs(run) do + if item.index ~= insertionIndex then + skipped[item.index] = true + end + end + end + runStart = cursor + end + end + end + + local output = {} + for index, bind in ipairs(binds) do + if replacements[index] ~= nil then + output[#output + 1] = replacements[index] + elseif not skipped[index] then + output[#output + 1] = cleanBind(bind) + end + end + return output +end + +local function buildCategories(liveBinds, metadata) + local showUndescribed = config("show_undescribed", true) == true + local shouldMerge = config("merge_sequential", true) == true + local otherName = noctalia.tr("category.other") + local undescribedName = noctalia.tr("category.undescribed") + local byName = {} + local total = 0 + local uncategorized = 0 + + for _, raw in ipairs(liveBinds) do + if type(raw) == "table" and raw.key ~= nil then + local description = "" + if raw.has_description ~= false and type(raw.description) == "string" then + description = raw.description + end + local isUndescribed = description == "" + if showUndescribed or not isUndescribed then + local categoryName + if isUndescribed then + categoryName = undescribedName + else + categoryName = categoryForDescription(description, metadata) + if categoryName == nil then + categoryName = otherName + uncategorized = uncategorized + 1 + end + end + + byName[categoryName] = byName[categoryName] or {} + byName[categoryName][#byName[categoryName] + 1] = { + id = stableBindId(raw), + modifiers = decodeModifiers(raw.modmask), + key = formatKey(raw.key), + keys = (function() + local sequence = metadata.keySequences[description] + if type(sequence) ~= "table" then return nil end + local formatted = {} + for _, value in ipairs(sequence) do formatted[#formatted + 1] = formatKey(value) end + return formatted + end)(), + source = type(metadata.origins[description]) == "table" and metadata.origins[description].source or nil, + line = type(metadata.origins[description]) == "table" and metadata.origins[description].line or nil, + managed = type(metadata.origins[description]) == "table" + and metadata.origins[description].managed == true, + command = metadata.commands[description], + action = metadata.actions[description], + start_line = type(metadata.origins[description]) == "table" + and metadata.origins[description].start_line or nil, + end_line = type(metadata.origins[description]) == "table" + and metadata.origins[description].end_line or nil, + raw_snippet = type(metadata.origins[description]) == "table" + and metadata.origins[description].raw_snippet or nil, + fingerprint = type(metadata.origins[description]) == "table" + and metadata.origins[description].fingerprint or nil, + capabilities = type(metadata.origins[description]) == "table" + and metadata.origins[description].capabilities or nil, + description = description, + dispatcher = tostring(raw.dispatcher or ""), + release = raw.release == true, + _rawKey = tostring(raw.key), + } + total = total + 1 + end + end + end + + local categories = {} + local emitted = {} + local usedIds = {} + local function emit(name, preferredId) + local binds = byName[name] + if binds == nil or #binds == 0 or emitted[name] then + return + end + emitted[name] = true + local baseId = preferredId or slug(name) + local id = baseId + local suffix = 2 + while usedIds[id] do + id = baseId .. "-" .. tostring(suffix) + suffix = suffix + 1 + end + usedIds[id] = true + categories[#categories + 1] = { + id = id, + name = name, + binds = shouldMerge and mergeSequential(binds) or (function() + local output = {} + for _, bind in ipairs(binds) do + output[#output + 1] = cleanBind(bind) + end + return output + end)(), + } + end + + for _, name in ipairs(metadata.headers) do + emit(name) + end + -- These synthetic buckets are deliberately last and have fixed IDs for UI use. + local otherBinds = byName[otherName] + local undescribedBinds = byName[undescribedName] + for name, _ in pairs(byName) do + if name ~= otherName and name ~= undescribedName then + emit(name) + end + end + if otherBinds ~= nil then + emit(otherName, "other") + end + if undescribedBinds ~= nil then + emit(undescribedName, "undescribed") + end + + return categories, total, uncategorized +end + +local function snapshot(status, source, errorCode, categories, total, warnings, updatedAt, hidden) + return { + status = status, + error = errorCode or "", + compositor = snapshotCompositor, + source = source, + updated_at = updatedAt or "", + total = total or 0, + categories = categories or {}, + warnings = warnings or {}, + hidden = hidden or {}, + } +end + +local function publishError(source, errorCode, warnings) + noctalia.state.set(SNAPSHOT_KEY, snapshot("error", source, errorCode, {}, 0, warnings, os.date("%H:%M:%S"))) +end + +local function finishRefresh() + refreshing = false + if refreshQueued then + refreshQueued = false + refresh() + end +end + +function refresh() + refreshGeneration = refreshGeneration + 1 + local requestGeneration = refreshGeneration + local compositor = configuredCompositor() + if compositor == nil then + snapshotCompositor = "Unknown" + noctalia.state.set( + SNAPSHOT_KEY, + snapshot("error", "", "compositor_unknown", {}, 0, {}, os.date("%H:%M:%S")) + ) + return + end + if compositor ~= "hyprland" then + return + end + snapshotCompositor = "Hyprland" + if refreshing then + refreshQueued = true + return + end + refreshing = true + + local source = expandedConfigPath() + local previous = noctalia.state.get(SNAPSHOT_KEY) + local previousCategories = type(previous) == "table" and previous.categories or {} + local previousTotal = type(previous) == "table" and previous.total or 0 + local previousUpdatedAt = type(previous) == "table" and previous.updated_at or "" + local previousHidden = type(previous) == "table" and previous.hidden or {} + noctalia.state.set( + SNAPSHOT_KEY, + snapshot("loading", source, "", previousCategories, previousTotal, {}, previousUpdatedAt, previousHidden) + ) + + if not noctalia.commandExists("hyprctl") then + publishError(source, "hyprctl_not_found", {}) + finishRefresh() + return + end + + local metadata = scanLuaSources(source) + local function publishLive(liveBinds) + local categories, total, uncategorized = buildCategories(liveBinds, metadata) + if uncategorized > 0 then + metadata.warnings[#metadata.warnings + 1] = "uncategorized_binds:" .. tostring(uncategorized) + end + if total == 0 then + metadata.warnings[#metadata.warnings + 1] = "no_binds" + end + noctalia.state.set( + SNAPSHOT_KEY, + snapshot("ready", source, "", categories, total, metadata.warnings, os.date("%H:%M:%S"), metadata.hidden) + ) + finishRefresh() + end + local function requestTextFallback() + local fallbackStarted = noctalia.runAsync("hyprctl binds", function(fallbackResult) + if requestGeneration ~= refreshGeneration or configuredCompositor() ~= "hyprland" then + finishRefresh() + return + end + if fallbackResult.timedOut == true then + publishError(source, "hyprctl_timeout", metadata.warnings) + finishRefresh() + return + end + local decoded = fallbackResult.exitCode == 0 and parseHyprctlText(fallbackResult.stdout) or nil + if decoded == nil then + publishError(source, "hyprctl_invalid_json", metadata.warnings) + finishRefresh() + return + end + publishLive(decoded) + end, HYPRCTL_TIMEOUT_MS) + if not fallbackStarted then + publishError(source, "hyprctl_start_failed", metadata.warnings) + finishRefresh() + end + end + local started = noctalia.runAsync("hyprctl binds -j", function(result) + -- A config change can activate another compositor while hyprctl is still + -- running. Never let that stale result replace the newer adapter snapshot. + if requestGeneration ~= refreshGeneration or configuredCompositor() ~= "hyprland" then + finishRefresh() + return + end + if result.timedOut == true then + publishError(source, "hyprctl_timeout", metadata.warnings) + finishRefresh() + return + end + if result.exitCode ~= 0 or type(result.stdout) ~= "string" or result.stdout == "" then + publishError(source, "hyprctl_failed", metadata.warnings) + finishRefresh() + return + end + + local ok, decoded = pcall(noctalia.json.decode, result.stdout) + if not ok or type(decoded) ~= "table" then + requestTextFallback() + return + end + + publishLive(decoded) + end, HYPRCTL_TIMEOUT_MS) + + if not started then + publishError(source, "hyprctl_start_failed", metadata.warnings) + finishRefresh() + end +end + +function onIpc(event, _payload) + if event == "refresh" then + local current = tonumber(noctalia.state.get(REFRESH_REQUEST_KEY)) or 0 + noctalia.state.set(REFRESH_REQUEST_KEY, current + 1) + end +end + +function onConfigChanged() + refresh() +end + +noctalia.state.watch(REFRESH_REQUEST_KEY, function(_request) + refresh() +end) + +refresh() diff --git a/keymap/tests/category_marker_parser_test.lua b/keymap/tests/category_marker_parser_test.lua new file mode 100644 index 0000000..51d9d1c --- /dev/null +++ b/keymap/tests/category_marker_parser_test.lua @@ -0,0 +1,121 @@ +local function stateMock() + local values = {} + local watchers = {} + return values, { + get = function(key) return values[key] end, + set = function(key, value) + values[key] = value + if watchers[key] ~= nil then watchers[key](value) end + end, + watch = function(key, callback) watchers[key] = callback end, + } +end + +local function findCategory(snapshot, name) + for _, category in ipairs(snapshot.categories or {}) do + if category.name == name then return category end + end + return nil +end + +local function assertMarkedBind(snapshot, categoryName, expectedSnippet, expectedStart, expectedEnd) + assert(snapshot.status == "ready", categoryName .. ": snapshot not ready") + local category = findCategory(snapshot, categoryName) + assert(type(category) == "table", categoryName .. ": category missing") + assert(#category.binds == 1, categoryName .. ": unexpected bind count") + local bind = category.binds[1] + assert(bind.raw_snippet == expectedSnippet, categoryName .. ": marker missing from provenance") + assert(bind.start_line == expectedStart and bind.end_line == expectedEnd, categoryName .. ": line range mismatch") + assert(bind.capabilities.category == true, categoryName .. ": category editing disabled") +end + +do + local values, state = stateMock() + local source = table.concat({ + "-- 1. General", + "-- Keymap bind-category: Media", + 'hl.bind("SUPER + G", hl.dsp.exec_cmd([[playerctl play-pause]]), { description = "Media action" })', + 'hl.bind("SUPER + H", hl.dsp.exec_cmd([[true]]), { description = "General action" })', + "", + }, "\n") + noctalia = { + state = state, + getConfig = function(key) + local config = { compositor = "hyprland", hyprland_config = "/tmp/hyprland.lua", merge_sequential = false } + return config[key] + end, + getenv = function(key) return key == "HYPRLAND_INSTANCE_SIGNATURE" and "test" or "" end, + expandPath = function(path) return path end, + fileExists = function(path) return path == "/tmp/hyprland.lua" end, + readFile = function(path) return path == "/tmp/hyprland.lua" and source or nil end, + commandExists = function(command) return command == "hyprctl" end, + tr = function(key) return key end, + runAsync = function(_command, callback) + callback({ exitCode = 0, timedOut = false, stdout = "[]" }) + return true + end, + json = { + decode = function() + return { + { modmask = 64, key = "G", dispatcher = "__lua", arg = "", description = "Media action", has_description = true }, + { modmask = 64, key = "H", dispatcher = "__lua", arg = "", description = "General action", has_description = true }, + } + end, + }, + } + assert(loadfile("service.luau"))() + local snapshot = values["keymap.snapshot"] + assertMarkedBind( + snapshot, "Media", + '-- Keymap bind-category: Media\n' + .. 'hl.bind("SUPER + G", hl.dsp.exec_cmd([[playerctl play-pause]]), { description = "Media action" })', + 2, 3 + ) + assert(#findCategory(snapshot, "General").binds == 1, "Hyprland marker leaked into following bind") +end + +do + local values, state = stateMock() + local marker = " // Keymap bind-category: Media" + local bindLine = ' Mod+G hotkey-overlay-title="Media action" { spawn-sh "playerctl play-pause"; }' + local source = table.concat({ "binds {", marker, bindLine, "}", "" }, "\n") + noctalia = { + state = state, + getConfig = function(key) + local config = { compositor = "niri", niri_config = "/tmp/config.kdl", merge_sequential = false } + return config[key] + end, + getenv = function(key) return key == "NIRI_SOCKET" and "test" or "" end, + fileExists = function(path) return path == "/tmp/config.kdl" end, + readFile = function(path) return path == "/tmp/config.kdl" and source or nil end, + tr = function(key) return key end, + } + assert(loadfile("niri_service.luau"))() + assertMarkedBind(values["keymap.snapshot"], "Media", marker .. "\n" .. bindLine, 2, 3) +end + +do + local values, state = stateMock() + local marker = "# Keymap bind-category: Media" + local moved = 'bind=SUPER,G,spawn_shell,playerctl play-pause #"Media action"' + local general = 'bind=SUPER,H,spawn_shell,true #"General action"' + local source = table.concat({ "# General", marker, moved, general, "" }, "\n") + noctalia = { + state = state, + getConfig = function(key) + local config = { compositor = "mangowc", mangowc_config = "/tmp/mango.conf", merge_sequential = false } + return config[key] + end, + getenv = function(key) return key == "MANGO_INSTANCE_SIGNATURE" and "test" or "" end, + expandPath = function(path) return path end, + fileExists = function(path) return path == "/tmp/mango.conf" end, + readFile = function(path) return path == "/tmp/mango.conf" and source or nil end, + tr = function(key) return key end, + } + assert(loadfile("mangowc_service.luau"))() + local snapshot = values["keymap.snapshot"] + assertMarkedBind(snapshot, "Media", marker .. "\n" .. moved, 2, 3) + assert(#findCategory(snapshot, "General").binds == 1, "MangoWC marker leaked into following bind") +end + +print("category marker parser tests: ok") diff --git a/keymap/tests/command_library_test.py b/keymap/tests/command_library_test.py new file mode 100644 index 0000000..1f3cb1a --- /dev/null +++ b/keymap/tests/command_library_test.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Structural regression checks for Keymap's source-backed command catalog.""" + +import json +import re +from collections import Counter +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +catalog = json.loads((ROOT / "command_library.json").read_text(encoding="utf-8")) +translations = json.loads((ROOT / "translations" / "en.json").read_text(encoding="utf-8")) +panel = (ROOT / "panel.luau").read_text(encoding="utf-8") + +assert catalog["schema"] == 1 +assert set(catalog["sources"]) == {"noctalia", "hyprland", "niri", "mangowc"} +assert all(catalog["sources"][source]["revision"] for source in catalog["sources"]) + +entries = catalog["entries"] +counts = Counter(entry["source"] for entry in entries) +assert counts == {"noctalia": 98, "hyprland": 51, "niri": 135, "mangowc": 78}, counts + +ids = [entry["id"] for entry in entries] +assert len(ids) == len(set(ids)), "command-library ids must be unique" +assert ids == sorted(ids, key=lambda entry_id: next( + (item["source"], item["category"], item["id"]) + for item in entries if item["id"] == entry_id +)), "catalog ordering must remain deterministic" + +category_translations = translations["panel"]["command_library"]["categories"] +for entry in entries: + assert set(entry) == {"id", "source", "category", "kind", "template", "usage"} + assert entry["id"].startswith(entry["source"] + "/") + assert entry["category"] in category_translations + assert entry["kind"] == ("shell" if entry["source"] == "noctalia" else "native") + assert entry["template"].strip() == entry["template"] and entry["template"] + assert not re.search(r"[\r\n\x00-\x1f]", entry["template"]) + assert entry["template"].count("{{") == entry["template"].count("}}") + +by_id = {entry["id"]: entry for entry in entries} +for required_id in ( + "noctalia/panel-open", + "noctalia/session", + "hyprland/window.close", + "hyprland/workspace.swap_monitors", + "niri/close-window", + "niri/toggle-overview", + "mangowc/killclient", + "mangowc/reload_config", +): + assert required_id in by_id, required_id + +assert all(entry["template"].startswith("noctalia msg ") + for entry in entries if entry["source"] == "noctalia") +assert all(entry["template"].startswith("hl.dsp.") and entry["template"].endswith(")") + for entry in entries if entry["source"] == "hyprland") +assert all(";" not in entry["template"] and "{" not in re.sub(r"\{\{[^}]+\}\}", "", entry["template"]) + for entry in entries if entry["source"] == "niri") +assert all("#" not in entry["template"] + for entry in entries if entry["source"] == "mangowc") + +# The retained UI deliberately renders only a small result window and removes +# callbacks that are no longer part of the current render. +assert "local visibleCount = math.min(#matches, 6)" in panel +assert "finishDynamicCallbackRender()" in panel +assert 'registerDynamicCallback(callbackName' in panel + +print(f"command library tests: ok ({len(entries)} entries: {dict(counts)})") diff --git a/keymap/tests/example_configs_test.lua b/keymap/tests/example_configs_test.lua new file mode 100644 index 0000000..05f2ada --- /dev/null +++ b/keymap/tests/example_configs_test.lua @@ -0,0 +1,166 @@ +local function read(path) + local file = assert(io.open(path, "rb")) + local value = file:read("*a") + file:close() + return value +end + +local function stateMock() + local values, watchers = {}, {} + return values, { + get = function(key) return values[key] end, + set = function(key, value) + values[key] = value + if watchers[key] ~= nil then watchers[key](value) end + end, + watch = function(key, callback) watchers[key] = callback end, + } +end + +local function assertExample(snapshot, compositor, expectedTotal) + assert(type(snapshot) == "table" and snapshot.status == "ready", compositor .. ": snapshot not ready") + assert(snapshot.compositor == compositor, compositor .. ": wrong compositor") + assert(snapshot.total == expectedTotal, compositor .. ": unexpected bind count " .. tostring(snapshot.total)) + local expected = { + ["Applications"] = true, + ["Windows"] = true, + ["Workspaces"] = true, + ["Screenshots"] = true, + ["Noctalia"] = true, + ["Media"] = true, + ["Utilities"] = true, + } + local found = {} + for _, category in ipairs(snapshot.categories or {}) do + found[category.name] = true + for _, bind in ipairs(category.binds or {}) do + assert(type(bind.id) == "string" and bind.id ~= "", compositor .. ": bind id missing") + assert(#bind.id <= 249, compositor .. ": bind id exceeds DnD target budget") + assert(#("before|" .. bind.id) <= 256, compositor .. ": reorder target exceeds core limit") + end + end + for name, _ in pairs(expected) do + assert(found[name], compositor .. ": missing category " .. name) + end +end + +do + local values, state = stateMock() + local source = read("examples/hyprland.lua") + local descriptions = {} + local modifierMasks = { SUPER = 64, SHIFT = 1, CTRL = 4, ALT = 8 } + for line in source:gmatch("[^\r\n]+") do + local combo = line:match('hl%.bind%s*%(%s*"([^"]+)"') + local description = line:match('description%s*=%s*"([^"]+)"') + if combo ~= nil and description ~= nil then + local modmask, key = 0, nil + for token in combo:gmatch("[^+]+") do + local normalized = token:match("^%s*(.-)%s*$") + local mask = modifierMasks[normalized:upper()] + if mask ~= nil then modmask = modmask + mask else key = normalized end + end + assert(key ~= nil, "Hyprland example bind has no non-modifier key: " .. combo) + descriptions[#descriptions + 1] = { + modmask, key, description, line:match("release%s*=%s*true") ~= nil, + } + end + end + local plainBlocks = {} + for index, item in ipairs(descriptions) do + plainBlocks[#plainBlocks + 1] = table.concat({ + item[4] == true and "bindrd" or "bindd", + "\tmodmask: " .. tostring(item[1]), + "\tsubmap: ", + "\tkey: " .. item[2], + "\tkeycode: 0", + "\tcatchall: false", + "\tdescription: " .. item[3], + "\tdispatcher: __lua", + "\targ: " .. tostring(index), + }, "\n") + end + local plain = table.concat(plainBlocks, "\n\n") .. "\n" + noctalia = { + state = state, + getConfig = function(key) + local config = { + compositor = "hyprland", hyprland_config = "/missing/hyprland.lua", + merge_sequential = false, show_undescribed = true, + } + return config[key] + end, + getenv = function(key) + if key == "HYPRLAND_INSTANCE_SIGNATURE" then return "test" end + if key == "HOME" then return "/example-home" end + return "" + end, + expandPath = function(path) return path end, + fileExists = function(path) return path == "/example-home/.config/hypr/shortcuts.lua" end, + listDir = function(path) + return path == "/example-home/.config/hypr" + and { "colors.lua", "keymap.lua", "old.lua.backup", "shortcuts.lua" } or nil + end, + readFile = function(path) return path == "/example-home/.config/hypr/shortcuts.lua" and source or nil end, + commandExists = function(command) return command == "hyprctl" end, + tr = function(key) + return ({ ["category.other"] = "Other", ["category.undescribed"] = "Without description" })[key] or key + end, + runAsync = function(command, callback) + callback({ exitCode = 0, timedOut = false, stdout = command == "hyprctl binds" and plain or "invalid-json" }) + return true + end, + json = { decode = function() error("malformed JSON fixture") end }, + } + assert(loadfile("service.luau"))() + assertExample(values["keymap.snapshot"], "Hyprland", 40) + assert(values["keymap.snapshot"].source == "/example-home/.config/hypr/shortcuts.lua") +end + +do + local values, state = stateMock() + local source = read("examples/niri.kdl") + noctalia = { + state = state, + getConfig = function(key) + local config = { compositor = "niri", niri_config = "/missing/niri.kdl", merge_sequential = false } + return config[key] + end, + getenv = function(key) + if key == "NIRI_SOCKET" then return "test" end + if key == "HOME" then return "/example-home" end + return "" + end, + fileExists = function(path) return path == "/example-home/.config/niri/config.kdl" end, + readFile = function(path) return path == "/example-home/.config/niri/config.kdl" and source or nil end, + tr = function(key) return key == "category.other" and "Other" or key end, + } + assert(loadfile("niri_service.luau"))() + assertExample(values["keymap.snapshot"], "Niri", 39) + assert(values["keymap.snapshot"].source == "/example-home/.config/niri/config.kdl") +end + +do + local values, state = stateMock() + local source = read("examples/mangowc.conf") + noctalia = { + state = state, + getConfig = function(key) + local config = { compositor = "mangowc", mangowc_config = "/missing/mangowc.conf", merge_sequential = false } + return config[key] + end, + getenv = function(key) + if key == "MANGO_INSTANCE_SIGNATURE" then return "test" end + if key == "HOME" then return "/example-home" end + return "" + end, + expandPath = function(path) return path end, + fileExists = function(path) return path == "/example-home/.config/mango/config.conf" end, + readFile = function(path) return path == "/example-home/.config/mango/config.conf" and source or nil end, + tr = function(key) return key == "category.other" and "Other" or key end, + } + assert(loadfile("mangowc_service.luau"))() + assertExample(values["keymap.snapshot"], "MangoWC", 40) + assert(values["keymap.snapshot"].source == "/example-home/.config/mango/config.conf") +end + +print("example config tests: ok") diff --git a/keymap/tests/hidden_sentinel_parser_test.lua b/keymap/tests/hidden_sentinel_parser_test.lua new file mode 100644 index 0000000..95c4a1d --- /dev/null +++ b/keymap/tests/hidden_sentinel_parser_test.lua @@ -0,0 +1,115 @@ +local function xorByte(left, right) + local result, place = 0, 1 + for _ = 1, 8 do + if left % 2 ~= right % 2 then result = result + place end + left, right, place = math.floor(left / 2), math.floor(right / 2), place * 2 + end + return result +end + +local function fingerprint(value) + local hash = 2166136261 + for index = 1, #value do + local low = hash % 256 + hash = hash - low + xorByte(low, value:byte(index)) + hash = (hash * 403 + (hash % 256) * 16777216) % 4294967296 + end + return string.format("%08x", hash) +end + +local function hex(value) + local output = {} + for index = 1, #value do output[#output + 1] = string.format("%02x", value:byte(index)) end + return table.concat(output) +end + +local function hiddenSnippet(compositor, comment, original) + local indent = original:match("^([\t ]*)") or "" + local marker = indent .. comment .. " Keymap hidden v1" + local blockId = fingerprint(compositor .. "\0" .. original) + local encoded = hex(original) + local lines = { marker .. " begin " .. blockId .. " " .. fingerprint(original) } + for offset = 1, #encoded, 96 do lines[#lines + 1] = marker .. " data " .. encoded:sub(offset, offset + 95) end + lines[#lines + 1] = marker .. " end " .. blockId + return table.concat(lines, "\n") +end + +local function stateMock() + local values, watchers = {}, {} + return values, { + get = function(key) return values[key] end, + set = function(key, value) + values[key] = value + if watchers[key] then watchers[key](value) end + end, + watch = function(key, callback) watchers[key] = callback end, + } +end + +local function assertHidden(snapshot, source, original, raw) + assert(snapshot.status == "ready", "hidden-only config must be ready") + assert(snapshot.total == 0 and #(snapshot.categories or {}) == 0, "hidden bind leaked into active data") + assert(#(snapshot.hidden or {}) == 1, "hidden target missing") + local target = snapshot.hidden[1] + assert(target.hidden == true and target.source == source, "hidden provenance missing") + assert(target.start_line <= target.end_line and target.raw_snippet == raw, "hidden raw block/range mismatch") + assert(target.fingerprint == fingerprint(raw), "hidden block fingerprint mismatch") + assert(target.original_fingerprint == fingerprint(original), "original fingerprint mismatch") + assert(target.capabilities.restore == true and target.capabilities.delete == true, "hidden capabilities missing") + assert(type(target.id) == "string" and target.id ~= "", "hidden id missing") + assert(#(snapshot.warnings or {}) >= 1, "malformed sentinel was not reported") +end + +do + local values, state = stateMock() + local root, child = "/tmp/hidden/hyprland.lua", "/tmp/hidden/child.lua" + local original = '-- Keymap bind-category: Media\nhl.bind("SUPER + H", hl.dsp.exec_cmd("true"), { description = "Hidden Hypr" })' + local raw = hiddenSnippet("Hyprland", "--", original) + local files = { + [root] = 'require("child")\n', + [child] = raw .. "\n-- Keymap hidden v1 begin BAD bad\n", + } + noctalia = { + state = state, getConfig = function(key) return ({ compositor = "hyprland", hyprland_config = root })[key] end, + getenv = function() return "" end, expandPath = function(path) return path end, + fileExists = function(path) return files[path] ~= nil end, readFile = function(path) return files[path] end, + commandExists = function() return true end, tr = function(key) return key end, + runAsync = function(_, callback) callback({ exitCode = 0, timedOut = false, stdout = "[]" }); return true end, + json = { decode = function() return {} end }, + } + assert(loadfile("service.luau"))() + assertHidden(values["keymap.snapshot"], child, original, raw) +end + +do + local values, state = stateMock() + local root, child = "/tmp/hidden/config.kdl", "/tmp/hidden/child.kdl" + local original = ' // Keymap bind-category: Media\n Super+H hotkey-overlay-title="Hidden Niri" { spawn-sh "true"; }' + local raw = hiddenSnippet("Niri", "//", original) + local files = { [root] = 'include "child.kdl"\n', [child] = "binds {\n" .. raw .. "\n}\n// Keymap hidden V1 begin bad bad\n" } + noctalia = { + state = state, getConfig = function(key) return ({ compositor = "niri", niri_config = root })[key] end, + getenv = function() return "" end, fileExists = function(path) return files[path] ~= nil end, + readFile = function(path) return files[path] end, tr = function(key) return key end, + } + assert(loadfile("niri_service.luau"))() + assertHidden(values["keymap.snapshot"], child, original, raw) +end + +do + local values, state = stateMock() + local root, child = "/tmp/hidden/mango.conf", "/tmp/hidden/child.conf" + local original = '# Keymap bind-category: Media\nbind=SUPER,H,spawn_shell,true #"Hidden Mango"' + local raw = hiddenSnippet("MangoWC", "#", original) + local files = { [root] = "source=child.conf\n", [child] = raw .. "\n# Keymap hidden v1 begin 00000000 00000000\n" } + noctalia = { + state = state, getConfig = function(key) return ({ compositor = "mangowc", mangowc_config = root })[key] end, + getenv = function() return "" end, expandPath = function(path) return path end, + fileExists = function(path) return files[path] ~= nil end, readFile = function(path) return files[path] end, + tr = function(key) return key end, + } + assert(loadfile("mangowc_service.luau"))() + assertHidden(values["keymap.snapshot"], child, original, raw) +end + +print("hidden sentinel parser tests: ok") diff --git a/keymap/tests/hypr_command_parser_test.lua b/keymap/tests/hypr_command_parser_test.lua new file mode 100644 index 0000000..074497f --- /dev/null +++ b/keymap/tests/hypr_command_parser_test.lua @@ -0,0 +1,67 @@ +local stateValues = {} +local watchers = {} +local sourcePath = "/tmp/keybind-test/keybind.lua" +local source = table.concat({ + "-- 1. Applications", + [[hl.bind("SUPER + RETURN", hl.dsp.exec_cmd("kitty"), { description = "Terminal" })]], + [=[hl.bind("SUPER + Q", hl.dsp.exec_cmd([[browser --private]]), { description = "Browser" })]=], + [[hl.bind("SUPER + P", hl.dsp.exec_cmd("printf \"ok\""), { description = "Quoted" })]], + [[hl.bind("SUPER + W", hl.dsp.window.close(), { description = "Close Window" })]], +}, "\n") .. "\n" + +local liveBinds = { + { key = "RETURN", modmask = 64, description = "Terminal", has_description = true, dispatcher = "__lua" }, + { key = "Q", modmask = 64, description = "Browser", has_description = true, dispatcher = "__lua" }, + { key = "P", modmask = 64, description = "Quoted", has_description = true, dispatcher = "__lua" }, + { key = "W", modmask = 64, description = "Close Window", has_description = true, dispatcher = "__lua" }, +} + +noctalia = { + getConfig = function(key) + local values = { + compositor = "hyprland", hyprland_config = sourcePath, + show_undescribed = true, merge_sequential = false, + } + return values[key] + end, + getenv = function() return "" end, + expandPath = function(path) return path end, + readFile = function(path) return path == sourcePath and source or nil end, + fileExists = function(path) return path == sourcePath end, + commandExists = function(command) return command == "hyprctl" end, + runAsync = function(_command, callback, _timeout) + callback({ exitCode = 0, timedOut = false, stdout = "live-binds" }) + return true + end, + json = { decode = function(value) assert(value == "live-binds") return liveBinds end }, + tr = function(key) + if key == "category.other" then return "Other" end + if key == "category.undescribed" then return "Without description" end + return key + end, + state = { + get = function(key) return stateValues[key] end, + set = function(key, value) + stateValues[key] = value + if watchers[key] ~= nil then watchers[key](value) end + end, + watch = function(key, callback) watchers[key] = callback end, + }, +} + +assert(loadfile("service.luau"))() + +local snapshot = stateValues["keymap.snapshot"] +assert(type(snapshot) == "table" and snapshot.status == "ready", "service did not publish a ready snapshot") +local byDescription = {} +for _, category in ipairs(snapshot.categories or {}) do + for _, bind in ipairs(category.binds or {}) do byDescription[bind.description] = bind end +end + +assert(byDescription.Terminal.command == "kitty", "double-quoted exec_cmd was not parsed") +assert(byDescription.Terminal.capabilities.command == true, "double-quoted command was not marked editable") +assert(byDescription.Browser.command == "browser --private", "long-string exec_cmd regressed") +assert(byDescription.Quoted.command == 'printf "ok"', "escaped quoted command was decoded incorrectly") +assert(byDescription["Close Window"].capabilities.command == false, "native action was exposed as a shell command") + +print("hypr command parser tests: ok") diff --git a/keymap/tests/i18n_test.py b/keymap/tests/i18n_test.py new file mode 100644 index 0000000..b6ebc41 --- /dev/null +++ b/keymap/tests/i18n_test.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +import json +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +TRANSLATIONS = json.loads((ROOT / "translations" / "en.json").read_text()) + + +def resolves(key: str) -> bool: + value = TRANSLATIONS + for part in key.split("."): + if not isinstance(value, dict) or part not in value: + return False + value = value[part] + return isinstance(value, (str, dict)) + + +required = set() +manifest = (ROOT / "plugin.toml").read_text() +required.update(re.findall(r'(?:label_key|description_key)\s*=\s*"([^"]+)"', manifest)) + +for path in ROOT.glob("*.luau"): + source = path.read_text() + required.update(re.findall(r'(?:noctalia\.)?trp?\(\s*"([^"]+)"', source)) + required.update(re.findall(r'"((?:actions|category)\.[a-z0-9_.]+)"', source)) + +missing = sorted(key for key in required if not key.endswith(".") and not resolves(key)) +assert not missing, "missing English translation keys: " + ", ".join(missing) + +# Physical modifier legends are standardized key names. Other literal UI prose +# must go through noctalia.tr so new locales can translate it. +allowed_key_labels = {"Super", "Ctrl", "Shift", "Alt"} +literal_ui = [] +panel_source = (ROOT / "panel.luau").read_text() +for match in re.finditer(r'\b(?:text|placeholder|tooltip)\s*=\s*"([^"]+)"', panel_source): + if match.group(1) not in allowed_key_labels: + literal_ui.append(match.group(1)) +assert not literal_ui, "untranslated literal UI strings: " + ", ".join(literal_ui) + +print(f"i18n tests: ok ({len(required)} referenced keys)") diff --git a/keymap/tests/keyboard_layouts_test.lua b/keymap/tests/keyboard_layouts_test.lua new file mode 100644 index 0000000..23f3643 --- /dev/null +++ b/keymap/tests/keyboard_layouts_test.lua @@ -0,0 +1,118 @@ +local sourceFile = assert(io.open("panel.luau", "rb")) +local source = sourceFile:read("*a") +sourceFile:close() + +local beginMarker = "-- BEGIN KEYBOARD LAYOUT DATA" +local endMarker = "-- END KEYBOARD LAYOUT DATA" +local beginAt = assert(source:find(beginMarker, 1, true), "layout data start marker missing") +local bodyAt = assert(source:find("\n", beginAt, true)) + 1 +local endAt = assert(source:find(endMarker, bodyAt, true), "layout data end marker missing") +local layoutSource = source:sub(bodyAt, endAt - 1) +local loader = assert(load(layoutSource .. [[ +return { + order = KEYBOARD_LAYOUT_ORDER, + layouts = KEYBOARD_LAYOUTS, +} +]], "keyboard layout data", "t", _G)) +local data = loader() + +local expectedOrder = { "100", "96", "80", "75", "65", "60" } +local expectedRows = { ["100"] = 6, ["96"] = 6, ["80"] = 6, ["75"] = 6, ["65"] = 5, ["60"] = 5 } +local expectedPixels = { ["100"] = 1100, ["96"] = 908, ["80"] = 884, ["75"] = 764, ["65"] = 764, ["60"] = 716 } + +local function keyWidth(units) + return math.floor(units * 44 + (units - 1) * 4 + 0.5) +end + +local globalIds = {} +for orderIndex, layoutId in ipairs(expectedOrder) do + assert(data.order[orderIndex] == layoutId, "unexpected layout order at " .. orderIndex) + local layout = assert(data.layouts[layoutId], "missing layout " .. layoutId) + assert(layout.id == layoutId) + assert(#layout.rows == expectedRows[layoutId], "unexpected row count for " .. layoutId) + assert(keyWidth(layout.rowUnits) == expectedPixels[layoutId], "unexpected pixel width for " .. layoutId) + + local layoutIds = {} + local hasLetters, hasDigits = {}, {} + for rowIndex, row in ipairs(layout.rows) do + local units = 0 + assert(#row > 0, "empty row in " .. layoutId) + for _, spec in ipairs(row) do + local isSpacer = spec.spacer ~= nil + local isKey = spec.id ~= nil + assert(isSpacer ~= isKey, "spec must be exactly one of spacer or key in " .. layoutId) + local width = tonumber(isSpacer and spec.spacer or spec.units) + assert(width and width > 0 and width * 4 % 1 == 0, "invalid quarter-unit width in " .. layoutId) + units = units + width + if isKey then + assert(type(spec.id) == "string" and spec.id:match("^[a-z0-9_]+$"), "invalid key id") + assert(not layoutIds[spec.id], "duplicate key id " .. spec.id .. " in " .. layoutId) + layoutIds[spec.id] = true + assert(type(spec.label) == "string" and spec.label ~= "", "key label missing for " .. spec.id) + if spec.bindable == false then + assert(spec.code == nil, "passive key must not expose a compositor code") + else + assert(type(spec.code) == "string" and spec.code ~= "", "bindable code missing for " .. spec.id) + end + if globalIds[spec.id] ~= nil then + assert(globalIds[spec.id] == spec.code, "key id changed semantic code: " .. spec.id) + else + globalIds[spec.id] = spec.code or false + end + local letter = spec.id:match("^key_([a-z])$") + local digit = spec.id:match("^digit_(%d)$") + if letter then hasLetters[letter] = true end + if digit then hasDigits[digit] = true end + end + end + assert(math.abs(units - layout.rowUnits) < 0.0001, + string.format("%s row %d is %.2fu instead of %.2fu", layoutId, rowIndex, units, layout.rowUnits)) + end + for byte = string.byte("a"), string.byte("z") do + assert(hasLetters[string.char(byte)], "letter missing from " .. layoutId) + end + for digit = 0, 9 do assert(hasDigits[tostring(digit)], "digit missing from " .. layoutId) end +end +assert(#data.order == #expectedOrder, "unexpected extra keyboard layout") + +local function hasId(layoutId, wanted) + for _, row in ipairs(data.layouts[layoutId].rows) do + for _, spec in ipairs(row) do if spec.id == wanted then return true end end + end + return false +end + +local function keyX(layoutId, rowIndex, wanted) + local x = 0 + for _, spec in ipairs(data.layouts[layoutId].rows[rowIndex]) do + if spec.id == wanted then return x end + x = x + tonumber(spec.spacer or spec.units) + end + return nil +end + +assert(hasId("100", "kp_1") and hasId("96", "kp_1"), "full layouts need a numpad") +for _, layoutId in ipairs({ "80", "75", "65", "60" }) do + assert(not hasId(layoutId, "kp_1"), layoutId .. " unexpectedly contains a numpad") +end +for _, layoutId in ipairs({ "100", "96", "80", "75" }) do + assert(hasId(layoutId, "f1"), layoutId .. " needs a function row") +end +assert(not hasId("65", "f1") and not hasId("60", "f1")) +assert(hasId("65", "arrow_up") and not hasId("60", "arrow_up")) +assert(hasId("65", "fn") and not hasId("75", "fn"), "Fn must be passive and limited to the 65% view") + +assert(keyX("96", 5, "arrow_up") == 14 and keyX("96", 5, "kp_1") == 15) +assert(keyX("96", 6, "arrow_left") == 13 and keyX("96", 6, "arrow_down") == 14) +assert(keyX("96", 6, "arrow_right") == 15 and keyX("96", 6, "kp_0") == 16) +for _, layoutId in ipairs({ "75", "65" }) do + local shiftRow = layoutId == "75" and 5 or 4 + local bottomRow = layoutId == "75" and 6 or 5 + assert(keyX(layoutId, shiftRow, "arrow_up") == 14) + assert(keyX(layoutId, shiftRow, "end") == 15) + assert(keyX(layoutId, bottomRow, "arrow_left") == 13) + assert(keyX(layoutId, bottomRow, "arrow_down") == 14) + assert(keyX(layoutId, bottomRow, "arrow_right") == 15) +end + +print("keyboard layout tests: ok") diff --git a/keymap/tests/writer_create_test.lua b/keymap/tests/writer_create_test.lua new file mode 100644 index 0000000..2aa02c5 --- /dev/null +++ b/keymap/tests/writer_create_test.lua @@ -0,0 +1,413 @@ +local files = { ["command_library.json"] = "test-command-library" } +local stateValues = {} +local watchers = {} +local commands = {} +local failure = { + preflight = false, + validator = false, + reloadOnce = false, +} +local reloadAttempts = 0 + +local function commandResult(command) + local result = { exitCode = 0, timedOut = false } + if failure.preflight and command:find("[ ! -L ", 1, true) == 1 then + result.exitCode = 1 + elseif failure.validator and command == failure.validatorCommand then + result.exitCode = 1 + elseif failure.reloadOnce and command == failure.reloadCommand then + reloadAttempts = reloadAttempts + 1 + if reloadAttempts == 1 then result.exitCode = 1 end + end + return result +end + +noctalia = { + state = { + get = function(key) return stateValues[key] end, + set = function(key, value) + stateValues[key] = value + if watchers[key] ~= nil then watchers[key](value) end + end, + watch = function(key, callback) watchers[key] = callback end, + }, + readFile = function(path) return files[path] end, + fileExists = function(path) return files[path] ~= nil end, + writeFile = function(path, content) + files[path] = content + return true + end, + renameFile = function(source, target) + if files[source] == nil then return false end + files[target] = files[source] + files[source] = nil + return true + end, + removeFile = function(path) + files[path] = nil + return true + end, + runAsync = function(command, callback, timeout) + commands[#commands + 1] = { command = command, timeout = timeout } + callback(commandResult(command)) + return true + end, + json = { decode = function(encoded) + if encoded == "test-command-library" then + return { + schema = 1, + entries = { + { id = "hyprland/window.close", source = "hyprland", kind = "native", template = "hl.dsp.window.close()" }, + { id = "hyprland/exec_cmd", source = "hyprland", kind = "native", template = "hl.dsp.exec_cmd({{command}})" }, + { id = "niri/close-window", source = "niri", kind = "native", template = "close-window" }, + { id = "mangowc/killclient", source = "mangowc", kind = "native", template = "killclient" }, + }, + } + end + return {} + end }, +} + +local CASES = { + { + id = "hyprland", + compositor = "Hyprland", + rootName = "hyprland.lua", + managedName = "keymap.lua", + comment = "--", + root = "-- user config\n", + includeLine = 'require("keymap")', + validatorPrefix = "Hyprland --verify-config -c ", + reloadCommand = "hyprctl reload", + first = { + modifiers = { "SUPER", "SHIFT" }, keys = { "A" }, activation = "press", + command = "noctalia msg panel-open launcher", description = "Open app", category = "Applications", + entry = '-- 1. Applications\nhl.bind("SUPER + SHIFT + A", hl.dsp.exec_cmd([[noctalia msg panel-open launcher]]), { description = "Open app" })\n', + }, + second = { + modifiers = { "SUPER" }, keys = { "B" }, activation = "release", + command = "second-command", description = "Second action", category = "System", + entry = '-- 1. System\nhl.bind("SUPER + B", hl.dsp.exec_cmd([[second-command]]), { release = true, description = "Second action" })\n', + }, + }, + { + id = "niri", + compositor = "Niri", + rootName = "config.kdl", + managedName = "keymap.kdl", + comment = "//", + root = "// user config\n", + includeLine = 'include "keymap.kdl"', + validatorPrefix = "niri validate -c ", + reloadCommand = "niri msg action load-config-file", + first = { + modifiers = { "SUPER", "SHIFT" }, keys = { "A" }, activation = "press", + command = "launch-app", description = "Open app", category = "Applications", + entry = ' //"Applications"\n Mod+Shift+A repeat=false hotkey-overlay-title="Open app" { spawn-sh "launch-app"; }\n', + }, + second = { + modifiers = { "CTRL", "ALT" }, keys = { "B" }, activation = "press", + command = "second-command", description = "Second action", category = "System", + entry = ' //"System"\n Ctrl+Alt+B repeat=false hotkey-overlay-title="Second action" { spawn-sh "second-command"; }\n', + }, + }, + { + id = "mangowc", + compositor = "MangoWC", + rootName = "config.conf", + managedName = "keymap.conf", + comment = "#", + root = "# user config\n", + includeLine = "source=./keymap.conf", + validatorPrefix = "mango -c ", + validatorSuffix = " -p", + reloadCommand = "mmsg dispatch reload_config", + first = { + modifiers = { "SUPER", "ALT" }, keys = { "A" }, activation = "release", + command = "launch-app", description = "Open app", category = "Applications", + entry = '# Keymap category: Applications\nbindr=SUPER+ALT,A,spawn_shell,launch-app #"Open app"\n', + }, + second = { + modifiers = {}, keys = { "B" }, activation = "press", + command = "second-command", description = "Second action", category = "System", + entry = '# Keymap category: System\nbind=NONE,B,spawn_shell,second-command #"Second action"\n', + }, + }, +} + +local function reset(case) + files = {} + stateValues = {} + commands = {} + failure = { preflight = false, validator = false, reloadOnce = false } + reloadAttempts = 0 + local directory = "/tmp/keymap-create-test/" .. case.id + case.rootPath = directory .. "/" .. case.rootName + case.managedPath = directory .. "/" .. case.managedName + files[case.rootPath] = case.root + stateValues["keymap.snapshot"] = { + status = "ready", + compositor = case.compositor, + source = case.rootPath, + categories = {}, + hidden = {}, + } +end + +local function createRequest(case, spec, requestId, source) + return { + request_id = requestId, + compositor = case.compositor, + source = source or case.rootPath, + modifiers = spec.modifiers, + keys = spec.keys, + activation = spec.activation, + command = spec.command, + command_kind = spec.command_kind, + library_entry_id = spec.library_entry_id, + description = spec.description, + category = spec.category, + } +end + +local function submit(case, spec, requestId, source) + noctalia.state.set("keymap.create_request", createRequest(case, spec, requestId, source)) + local result = stateValues["keymap.create_result"] + assert(type(result) == "table", case.id .. ": missing create result") + assert(result.request_id == requestId, case.id .. ": result request id mismatch") + return result +end + +local function includeBlock(case) + return case.comment .. " BEGIN Keymap managed include\n" + .. case.includeLine .. "\n" + .. case.comment .. " END Keymap managed include" +end + +local function expectedRoot(case) + return case.root .. "\n" .. includeBlock(case) .. "\n" +end + +local function managedHeader(case) + return case.comment .. " Managed by Noctalia Keymap.\n" + .. case.comment .. " Existing entries are preserved; new entries are appended.\n" +end + +local function expectedManaged(case, entries) + local content + if case.compositor == "Niri" then + content = managedHeader(case) .. "binds {\n" + for _, entry in ipairs(entries) do content = content .. entry end + return content .. "}\n" + end + if case.compositor == "MangoWC" then + content = managedHeader(case) .. "\nkeymode=default\n\n" + else + content = managedHeader(case) .. "\n" + end + for _, entry in ipairs(entries) do content = content .. entry end + return content +end + +local function validatorCommand(case) + return case.validatorPrefix .. "'" .. case.rootPath .. "'" .. (case.validatorSuffix or "") +end + +local function assertNormalCommandSequence(case, label) + assert(#commands == 3, label .. ": expected preflight, validator and reload") + assert(commands[1].command:find("[ ! -L ", 1, true) == 1, label .. ": preflight was not first") + assert(commands[1].timeout == 2000, label .. ": unexpected preflight timeout") + assert(commands[2].command == validatorCommand(case), label .. ": wrong validator command") + assert(commands[2].timeout == 8000, label .. ": unexpected validator timeout") + assert(commands[3].command == case.reloadCommand, label .. ": wrong reload command") + assert(commands[3].timeout == 5000, label .. ": unexpected reload timeout") +end + +local function assertNoTemporaryFiles(label) + for path, _ in pairs(files) do + assert(not path:match("%.keymap%-.+%.tmp$"), label .. ": temporary file left behind: " .. path) + end +end + +local function runHappyPath(case) + reset(case) + local firstResult = submit(case, case.first, case.id .. "-first") + assert(firstResult.ok == true, case.id .. ": first create failed: " .. tostring(firstResult.error)) + assert(firstResult.managed_path == case.managedPath, case.id .. ": wrong managed path") + assert(files[case.rootPath] == expectedRoot(case), case.id .. ": marked include differs") + assert(files[case.managedPath] == expectedManaged(case, { case.first.entry }), + case.id .. ": first managed content differs\n" .. tostring(files[case.managedPath])) + assertNormalCommandSequence(case, case.id .. " first create") + assertNoTemporaryFiles(case.id .. " first create") + + commands = {} + local secondResult = submit(case, case.second, case.id .. "-second") + assert(secondResult.ok == true, case.id .. ": second create failed: " .. tostring(secondResult.error)) + assert(files[case.rootPath] == expectedRoot(case), case.id .. ": second create duplicated include") + local _, includeCount = files[case.rootPath]:gsub(case.includeLine:gsub("([^%w])", "%%%1"), "") + assert(includeCount == 1, case.id .. ": include line count is " .. tostring(includeCount)) + assert(files[case.managedPath] == expectedManaged(case, { case.first.entry, case.second.entry }), + case.id .. ": second managed content differs\n" .. tostring(files[case.managedPath])) + assertNormalCommandSequence(case, case.id .. " second create") + + local rootBefore = files[case.rootPath] + local managedBefore = files[case.managedPath] + commands = {} + local duplicateResult = submit(case, case.second, case.id .. "-duplicate") + assert(duplicateResult.ok == true, case.id .. ": exact duplicate was not idempotent") + assert(files[case.rootPath] == rootBefore and files[case.managedPath] == managedBefore, + case.id .. ": exact duplicate changed files") + assert(#commands == 1 and commands[1].command:find("[ ! -L ", 1, true) == 1, + case.id .. ": exact duplicate unexpectedly validated or reloaded") + assertNoTemporaryFiles(case.id .. " duplicate") +end + +local function runVerifyRollback(case) + reset(case) + failure.validator = true + failure.validatorCommand = validatorCommand(case) + local result = submit(case, case.first, case.id .. "-verify-rollback") + assert(result.ok == false and result.error == "verify_failed", + case.id .. ": expected verify_failed, got " .. tostring(result.error)) + assert(files[case.rootPath] == case.root, case.id .. ": verify rollback did not restore root") + assert(files[case.managedPath] == nil, case.id .. ": verify rollback left a new managed file") + assert(#commands == 2 and commands[2].command == validatorCommand(case), + case.id .. ": unexpected verify rollback command sequence") + assertNoTemporaryFiles(case.id .. " verify rollback") +end + +local function runReloadRollbackOnAppend(case) + reset(case) + local baseline = submit(case, case.first, case.id .. "-reload-baseline") + assert(baseline.ok == true, case.id .. ": reload rollback baseline failed") + local rootBefore = files[case.rootPath] + local managedBefore = files[case.managedPath] + + commands = {} + failure.reloadOnce = true + failure.reloadCommand = case.reloadCommand + reloadAttempts = 0 + local result = submit(case, case.second, case.id .. "-reload-rollback") + assert(result.ok == false and result.error == "reload_failed", + case.id .. ": expected reload_failed, got " .. tostring(result.error)) + assert(files[case.rootPath] == rootBefore, case.id .. ": reload rollback changed root") + assert(files[case.managedPath] == managedBefore, case.id .. ": reload rollback did not restore managed file") + assert(#commands == 4, case.id .. ": expected preflight, validator, failed reload and recovery reload") + assert(commands[1].command:find("[ ! -L ", 1, true) == 1, case.id .. ": missing preflight") + assert(commands[2].command == validatorCommand(case), case.id .. ": missing validator") + assert(commands[3].command == case.reloadCommand and commands[4].command == case.reloadCommand, + case.id .. ": restored configuration was not reloaded") + assert(reloadAttempts == 2, case.id .. ": expected two reload attempts") + assertNoTemporaryFiles(case.id .. " reload rollback") +end + +assert(loadfile("writer_service.luau"))() + +for _, case in ipairs(CASES) do + runHappyPath(case) + runVerifyRollback(case) + runReloadRollbackOnAppend(case) +end + +local NATIVE_CASES = { + { + base = CASES[1], id = "hypr-native", command = "hl.dsp.window.close()", + library_entry_id = "hyprland/window.close", + entry = '-- 1. Windows\nhl.bind("SUPER + N", hl.dsp.window.close(), { description = "Close window" })\n', + }, + { + base = CASES[2], id = "niri-native", command = "close-window", + library_entry_id = "niri/close-window", + entry = ' //"Windows"\n Mod+N repeat=false hotkey-overlay-title="Close window" { close-window; }\n', + }, + { + base = CASES[3], id = "mango-native", command = "killclient", + library_entry_id = "mangowc/killclient", + entry = '# Keymap category: Windows\nbind=SUPER,N,killclient #"Close window"\n', + }, +} + +for _, native in ipairs(NATIVE_CASES) do + local case = native.base + reset(case) + local spec = { + modifiers = { "SUPER" }, keys = { "N" }, activation = "press", + command = native.command, command_kind = "native", + library_entry_id = native.library_entry_id, + description = "Close window", category = "Windows", + } + local result = submit(case, spec, native.id) + assert(result.ok == true, native.id .. ": native create failed: " .. tostring(result.error)) + assert(files[case.managedPath] == expectedManaged(case, { native.entry }), + native.id .. ": wrong native managed entry") +end + +do + local case = CASES[1] + reset(case) + local spec = { + modifiers = { "SUPER" }, keys = { "N" }, activation = "press", + command = "hl.dsp.window.kill()", command_kind = "native", + library_entry_id = "hyprland/window.close", + description = "Tampered action", category = "Windows", + } + local result = submit(case, spec, "native-tampered") + assert(result.ok == false and result.error == "library_entry_invalid", + "tampered native action was accepted") + assert(files[case.managedPath] == nil, "tampered native action changed files") +end + +do + local case = CASES[1] + reset(case) + local spec = { + modifiers = { "SUPER" }, keys = { "N" }, activation = "press", + command = "hl.dsp.exec_cmd({{command}})", command_kind = "native", + library_entry_id = "hyprland/exec_cmd", + description = "Incomplete action", category = "Applications", + } + local result = submit(case, spec, "native-placeholder") + assert(result.ok == false and result.error == "library_arguments_required", + "unresolved native placeholder was accepted") + assert(files[case.managedPath] == nil, "unresolved native placeholder changed files") +end + +do + local case = CASES[3] + reset(case) + local spec = { + modifiers = { "SUPER" }, keys = { "N" }, activation = "press", + command = "hl.dsp.window.close()", command_kind = "native", + library_entry_id = "hyprland/window.close", + description = "Foreign action", category = "Windows", + } + local result = submit(case, spec, "native-foreign-source") + assert(result.ok == false and result.error == "library_entry_invalid", + "native action from another compositor was accepted") + assert(files[case.managedPath] == nil, "foreign native action changed files") +end + +do + local case = CASES[1] + reset(case) + stateValues["keymap.snapshot"].source = case.rootPath .. ".other" + local result = submit(case, case.first, "stale-context") + assert(result.ok == false and result.error == "stale_context", "stale context was accepted") + assert(files[case.rootPath] == case.root and files[case.managedPath] == nil, + "stale context changed files") + assert(#commands == 0, "stale context reached preflight") +end + +do + local case = CASES[1] + reset(case) + failure.preflight = true + local result = submit(case, case.first, "symlink-preflight") + assert(result.ok == false and result.error == "symlink_unsupported", "symlink preflight was accepted") + assert(files[case.rootPath] == case.root and files[case.managedPath] == nil, + "failed symlink preflight changed files") + assert(#commands == 1 and commands[1].command:find("[ ! -L ", 1, true) == 1, + "symlink rejection did not stop after preflight") +end + +print("writer create tests: ok") diff --git a/keymap/tests/writer_update_test.lua b/keymap/tests/writer_update_test.lua new file mode 100644 index 0000000..d7074e3 --- /dev/null +++ b/keymap/tests/writer_update_test.lua @@ -0,0 +1,1009 @@ +local files = {} +local stateValues = {} +local watchers = {} +local failVerify = false +local failReload = false +local reloadAttempts = 0 +local writeCount = 0 +local failRenameAt = nil +local renameAttempts = 0 + +noctalia = { + state = { + get = function(key) return stateValues[key] end, + set = function(key, value) + stateValues[key] = value + if watchers[key] ~= nil then watchers[key](value) end + end, + watch = function(key, callback) watchers[key] = callback end, + }, + readFile = function(path) return files[path] end, + fileExists = function(path) return files[path] ~= nil end, + writeFile = function(path, content) + writeCount = writeCount + 1 + files[path] = content + return true + end, + renameFile = function(source, target) + renameAttempts = renameAttempts + 1 + if failRenameAt ~= nil and renameAttempts == failRenameAt then return false end + if files[source] == nil then return false end + files[target] = files[source] + files[source] = nil + return true + end, + removeFile = function(path) files[path] = nil return true end, + runAsync = function(command, callback, _timeout) + local result = { exitCode = 0, timedOut = false } + if failVerify and command:find("verify", 1, true) ~= nil then result.exitCode = 1 end + if failReload and command == "hyprctl reload" then + reloadAttempts = reloadAttempts + 1 + if reloadAttempts == 1 then result.exitCode = 1 end + end + callback(result) + return true + end, + json = { decode = function() return {} end }, +} + +local function xorByte(left, right) + local result, place = 0, 1 + for _ = 1, 8 do + if left % 2 ~= right % 2 then result = result + place end + left = math.floor(left / 2) + right = math.floor(right / 2) + place = place * 2 + end + return result +end + +local function fingerprint(value) + local hash = 2166136261 + for index = 1, #value do + local low = hash % 256 + hash = hash - low + xorByte(low, value:byte(index)) + hash = (hash * 403 + (hash % 256) * 16777216) % 4294967296 + end + return string.format("%08x", hash) +end + +local function hexEncode(value) + local output = {} + for index = 1, #value do output[#output + 1] = string.format("%02x", value:byte(index)) end + return table.concat(output) +end + +local function hiddenSnippet(compositor, snippet) + local comments = { Hyprland = "--", Niri = "//", MangoWC = "#" } + local indent = snippet:match("^([\t ]*)") or "" + local marker = indent .. comments[compositor] .. " Keymap hidden v1" + local blockId = fingerprint(compositor .. "\0" .. snippet) + local output = { marker .. " begin " .. blockId .. " " .. fingerprint(snippet) } + for offset = 1, #snippet, 48 do + output[#output + 1] = marker .. " data " .. hexEncode(snippet:sub(offset, offset + 47)) + end + output[#output + 1] = marker .. " end " .. blockId + return table.concat(output, "\n") +end + +local function lineCount(value) + local count = 1 + for _ in value:gmatch("\n") do count = count + 1 end + return count +end + +local function target(id, source, snippet, capabilities, startLine, endLine) + return { + id = id, source = source, start_line = startLine or 2, end_line = endLine or 2, + raw_snippet = snippet, fingerprint = fingerprint(snippet), + capabilities = capabilities, + } +end + +local function runCase(case) + files = {} + stateValues = {} + local root = "/tmp/keybind-test/" .. case.rootName + local source = case.separate and "/tmp/keybind-test/entries-" .. case.rootName or root + files[root] = case.root + files[source] = case.content + local bind = target( + case.id, source, case.snippet, case.capabilities, + case.startLine, case.endLine + ) + stateValues["keymap.snapshot"] = { + status = "ready", compositor = case.compositor, source = root, + categories = { { name = case.category, binds = { bind } } }, + } + stateValues["keymap.update_request"] = nil + failVerify = case.failVerify == true + failReload = false + reloadAttempts = 0 + noctalia.state.set("keymap.update_request", { + request_id = case.id .. "-request", target_id = case.id, + compositor = case.compositor, source = root, + modifiers = case.modifiers, keys = case.keys, + activation = case.activation, command = case.command, + command_kind = case.commandKind, library_entry_id = case.libraryEntryId, + description = case.description, category = case.newCategory or case.category, + }) + local result = stateValues["keymap.update_result"] + assert(type(result) == "table", case.id .. ": missing update result") + if case.error ~= nil then + assert(result.ok == false and result.error == case.error, + case.id .. ": expected " .. case.error .. ", got " .. tostring(result.error)) + assert(files[source] == case.content, case.id .. ": rejected update changed source") + elseif case.failVerify then + assert(result.ok == false and result.error == "verify_failed", case.id .. ": expected verify rollback") + assert(files[source] == case.content, case.id .. ": rollback did not restore source") + else + assert(result.ok == true, case.id .. ": " .. tostring(result.error)) + assert(files[source] == case.expected, case.id .. ": unexpected updated content\n" .. tostring(files[source])) + end +end + +local function runMutationCase(case) + files = {} + stateValues = {} + local root = "/tmp/keybind-test/" .. case.rootName + local source = case.separate and "/tmp/keybind-test/entries-" .. case.rootName or root + files[root] = case.root + files[source] = case.content + local bind = target(case.id, source, case.targetSnippet or case.snippet, case.capabilities or { + combo = true, description = true, command = true, activation = true, + }, case.startLine, case.endLine) + bind.hidden = case.hidden == true + bind.original_fingerprint = case.originalFingerprint + if case.badFingerprint then bind.fingerprint = "00000000" end + local categories = case.hidden and {} or { { name = case.category or "Windows", binds = { bind } } } + stateValues["keymap.snapshot"] = { + status = "ready", compositor = case.compositor, source = root, + categories = categories, hidden = case.hidden and { bind } or {}, + } + stateValues["keymap.update_request"] = nil + failVerify = case.failVerify == true + failReload = case.failReload == true + reloadAttempts = 0 + writeCount = 0 + failRenameAt = case.failRenameAt + renameAttempts = 0 + noctalia.state.set("keymap.update_request", { + request_id = case.id .. "-request", target_id = case.id, + operation = case.operation, compositor = case.compositor, source = root, hidden = case.hidden == true, + }) + local result = stateValues["keymap.update_result"] + assert(type(result) == "table", case.id .. ": missing mutation result") + if case.error ~= nil then + assert(result.ok == false and result.error == case.error, + case.id .. ": expected " .. case.error .. ", got " .. tostring(result.error)) + assert(files[source] == case.content, case.id .. ": rejected mutation changed source") + elseif case.failVerify or case.failReload then + local expectedError = case.failReload and "reload_failed" or "verify_failed" + assert(result.ok == false and result.error == expectedError, + case.id .. ": expected " .. expectedError .. ", got " .. tostring(result.error)) + assert(files[source] == case.content, case.id .. ": mutation rollback did not restore source") + if case.failReload then + assert(reloadAttempts == 2, case.id .. ": restored config was not reloaded after rollback") + end + else + assert(result.ok == true, case.id .. ": " .. tostring(result.error)) + assert(files[source] == case.expected, case.id .. ": unexpected mutated content\n" .. tostring(files[source])) + end +end + +local function runMoveCase(case) + files = {} + stateValues = {} + local root = "/tmp/keybind-test/" .. case.rootName + local source = case.separate and "/tmp/keybind-test/entries-" .. case.rootName or root + files[root] = case.root + files[source] = case.content + local bind = target( + case.id, source, case.snippet, case.capabilities or { category = true }, + case.startLine, case.endLine + ) + if case.badFingerprint then bind.fingerprint = "00000000" end + local categories = { { name = case.category, binds = { bind } } } + if case.newCategory ~= case.category then + categories[#categories + 1] = { name = case.newCategory, binds = {} } + end + stateValues["keymap.snapshot"] = { + status = "ready", compositor = case.compositor, source = case.contextSource or root, categories = categories, + } + stateValues["keymap.update_request"] = nil + failVerify = case.failVerify == true + failReload = false + reloadAttempts = 0 + writeCount = 0 + noctalia.state.set("keymap.update_request", { + request_id = case.id .. "-request", target_id = case.id, + operation = "move", compositor = case.compositor, source = root, + category = case.newCategory, + }) + local result = stateValues["keymap.update_result"] + assert(type(result) == "table", case.id .. ": missing move result") + if case.error ~= nil then + assert(result.ok == false and result.error == case.error, + case.id .. ": expected " .. case.error .. ", got " .. tostring(result.error)) + assert(files[source] == case.content, case.id .. ": rejected move changed source") + elseif case.failVerify then + assert(result.ok == false and result.error == "verify_failed", case.id .. ": expected verify rollback") + assert(files[source] == case.content, case.id .. ": move rollback did not restore source") + else + assert(result.ok == true, case.id .. ": " .. tostring(result.error)) + assert(files[source] == case.expected, case.id .. ": unexpected moved content\n" .. tostring(files[source])) + if case.expectedWrites ~= nil then + assert(writeCount == case.expectedWrites, + case.id .. ": expected " .. case.expectedWrites .. " writes, got " .. writeCount) + end + end +end + +local function runReorderCase(case) + files = {} + stateValues = {} + local root = "/tmp/keybind-test/" .. case.rootName + local targetSource = case.targetSource or root + local anchorSource = case.anchorSource or targetSource + files[root] = case.root or case.content + files[targetSource] = case.content + if anchorSource ~= targetSource then files[anchorSource] = case.anchorContent end + local targetBind = target( + case.targetId or "reorder-target", targetSource, case.targetSnippet, + case.targetCapabilities or {}, case.targetStart, case.targetEnd + ) + local anchorBind = target( + case.anchorId or "reorder-anchor", anchorSource, case.anchorSnippet, + case.anchorCapabilities or {}, case.anchorStart, case.anchorEnd + ) + if case.badTargetFingerprint then targetBind.fingerprint = "00000000" end + if case.badAnchorFingerprint then anchorBind.fingerprint = "00000000" end + if case.hiddenTarget then targetBind.hidden = true end + local targetCategory = case.targetCategory or "Test" + local anchorCategory = case.anchorCategory or targetCategory + local categories + if targetCategory == anchorCategory then + categories = { { name = targetCategory, binds = { targetBind, anchorBind } } } + else + categories = { + { name = targetCategory, binds = { targetBind } }, + { name = anchorCategory, binds = { anchorBind } }, + } + end + stateValues["keymap.snapshot"] = { + status = "ready", compositor = case.compositor or "Hyprland", source = root, + categories = categories, hidden = {}, + } + stateValues["keymap.update_request"] = nil + failVerify = case.failVerify == true + failReload = case.failReload == true + reloadAttempts = 0 + writeCount = 0 + noctalia.state.set("keymap.update_request", { + request_id = (case.targetId or "reorder-target") .. "-request-" .. case.rootName, + target_id = case.targetId or "reorder-target", + anchor_id = case.anchorId or "reorder-anchor", + operation = "reorder", placement = case.placement, + compositor = case.compositor or "Hyprland", source = root, + }) + local result = stateValues["keymap.update_result"] + assert(type(result) == "table", case.rootName .. ": missing reorder result") + if case.error ~= nil then + assert(result.ok == false and result.error == case.error, + case.rootName .. ": expected " .. case.error .. ", got " .. tostring(result.error)) + assert(files[targetSource] == case.content, case.rootName .. ": rejected reorder changed source") + elseif case.failVerify or case.failReload then + local expectedError = case.failReload and "reload_failed" or "verify_failed" + assert(result.ok == false and result.error == expectedError, + case.rootName .. ": expected " .. expectedError .. ", got " .. tostring(result.error)) + assert(files[targetSource] == case.content, case.rootName .. ": reorder rollback did not restore source") + if case.failReload then + assert(reloadAttempts == 2, case.rootName .. ": restored config was not reloaded after rollback") + end + else + assert(result.ok == true, case.rootName .. ": " .. tostring(result.error)) + assert(files[targetSource] == case.expected, + case.rootName .. ": unexpected reordered content\n" .. tostring(files[targetSource])) + if case.expectedWrites ~= nil then + assert(writeCount == case.expectedWrites, + case.rootName .. ": expected " .. case.expectedWrites .. " writes, got " .. writeCount) + end + end + failReload = false +end + +local function runRenameCategoryCase(case) + files = {} + stateValues = {} + failRenameAt = case.failRenameAt + renameAttempts = 0 + local root = "/tmp/keybind-test/" .. case.rootName + for path, content in pairs(case.files or {}) do files[path] = content end + files[root] = case.root or files[root] or "-- root\n" + local binds = {} + for index, spec in ipairs(case.binds) do + local bind = target( + spec.id or ("rename-bind-" .. tostring(index)), spec.source or root, spec.snippet, + spec.capabilities or { category = true }, spec.startLine, spec.endLine + ) + if spec.badFingerprint then bind.fingerprint = "00000000" end + if spec.hidden then bind.hidden = true end + binds[#binds + 1] = bind + end + local categoryId = case.categoryId or "windows" + local oldCategory = case.oldCategory or "Windows" + local categories = { { id = categoryId, name = oldCategory, binds = binds } } + if case.existingCategory ~= nil then + categories[#categories + 1] = { id = "existing", name = case.existingCategory, binds = {} } + end + stateValues["keymap.snapshot"] = { + status = "ready", compositor = case.compositor or "Hyprland", + source = case.contextSource or root, categories = categories, hidden = {}, + } + stateValues["keymap.update_request"] = nil + failVerify = case.failVerify == true + failReload = case.failReload == true + reloadAttempts = 0 + writeCount = 0 + local originals = {} + for path, content in pairs(files) do originals[path] = content end + noctalia.state.set("keymap.update_request", { + request_id = "rename-request-" .. case.rootName, + operation = "rename_category", compositor = case.compositor or "Hyprland", source = root, + category_id = case.requestCategoryId or categoryId, + old_category = case.requestOldCategory or oldCategory, + new_category = case.newCategory, + }) + local result = stateValues["keymap.update_result"] + assert(type(result) == "table", case.rootName .. ": missing category rename result") + if case.error ~= nil then + assert(result.ok == false and result.error == case.error, + case.rootName .. ": expected " .. case.error .. ", got " .. tostring(result.error)) + for path, content in pairs(originals) do + assert(files[path] == content, case.rootName .. ": rejected rename changed " .. path) + end + elseif case.failVerify or case.failReload then + local expectedError = case.failReload and "reload_failed" or "verify_failed" + assert(result.ok == false and result.error == expectedError, + case.rootName .. ": expected " .. expectedError .. ", got " .. tostring(result.error)) + for path, content in pairs(originals) do + assert(files[path] == content, case.rootName .. ": rollback did not restore " .. path) + end + if case.failReload then + assert(reloadAttempts == 2, case.rootName .. ": restored config was not reloaded") + end + else + assert(result.ok == true, case.rootName .. ": " .. tostring(result.error)) + assert(result.target_path == root, case.rootName .. ": result did not identify root source") + for path, expected in pairs(case.expectedFiles or {}) do + assert(files[path] == expected, + case.rootName .. ": unexpected renamed content in " .. path .. "\n" .. tostring(files[path])) + end + if case.expectedWrites ~= nil then + assert(writeCount == case.expectedWrites, + case.rootName .. ": expected " .. case.expectedWrites .. " writes, got " .. writeCount) + end + end + failVerify = false + failReload = false + failRenameAt = nil +end + +assert(loadfile("writer_service.luau"))() + +local hyprSnippet = 'hl.bind("SUPER + G", hl.dsp.exec_cmd([[old-command]]), { description = "Old title" })' +local niriSnippet = ' Mod+G repeat=false hotkey-overlay-title="Old title" { spawn-sh "old-command"; }' +runCase({ + id = "hypr", compositor = "Hyprland", rootName = "hyprland.lua", separate = true, + root = 'require("entries-hyprland")\n', content = "-- binds\n" .. hyprSnippet .. "\n", + snippet = hyprSnippet, category = "Windows", + capabilities = { combo = true, description = true, command = true, activation = true }, + modifiers = { "SUPER", "SHIFT" }, keys = { "G" }, activation = "release", + command = "noctalia msg bar-toggle", description = "New title", + expected = '-- binds\nhl.bind("SUPER + SHIFT + G", hl.dsp.exec_cmd([[noctalia msg bar-toggle]]), { release = true, description = "New title" })\n', +}) + +runCase({ + id = "hypr-native-conversion", compositor = "Hyprland", rootName = "hypr-native-conversion.lua", separate = true, + root = 'require("entries-hypr-native-conversion")\n', content = "-- binds\n" .. hyprSnippet .. "\n", + snippet = hyprSnippet, category = "Windows", + capabilities = { combo = true, description = true, command = true, activation = true }, + modifiers = { "SUPER" }, keys = { "G" }, activation = "press", + command = "hl.dsp.window.close()", commandKind = "native", + libraryEntryId = "hyprland/window.close", description = "Close window", + error = "native_update_unsupported", +}) + +runCase({ + id = "hypr-category", compositor = "Hyprland", rootName = "hypr-category.lua", separate = true, + root = 'require("entries-hypr-category")\n', content = "-- binds\n" .. hyprSnippet .. "\n", + snippet = hyprSnippet, category = "Windows", newCategory = "Media", + capabilities = { combo = true, category = true, description = true, command = true, activation = true }, + modifiers = { "SUPER" }, keys = { "G" }, activation = "press", + command = "old-command", description = "Old title", + expected = '-- binds\n-- Keymap bind-category: Media\n' + .. hyprSnippet .. '\n', +}) + +local markedHyprSnippet = "-- Keymap bind-category: Media\n" .. hyprSnippet +runCase({ + id = "hypr-category-again", compositor = "Hyprland", rootName = "hypr-category-again.lua", separate = true, + root = 'require("entries-hypr-category-again")\n', content = "-- binds\n" .. markedHyprSnippet .. "\n", + snippet = markedHyprSnippet, startLine = 2, endLine = 3, + category = "Media", newCategory = "System", + capabilities = { combo = true, category = true, description = true, command = true, activation = true }, + modifiers = { "SUPER" }, keys = { "G" }, activation = "press", + command = "old-command", description = "Old title", + expected = '-- binds\n-- Keymap bind-category: System\n' + .. hyprSnippet .. '\n', +}) + +local hyprNativeSnippet = 'hl.bind("SUPER + G", hl.dsp.group.toggle(), { description = "Group Windows" })' +runCase({ + id = "hypr-native", compositor = "Hyprland", rootName = "native.lua", separate = true, + root = 'require("entries-native")\n', content = "-- binds\n" .. hyprNativeSnippet .. "\n", + snippet = hyprNativeSnippet, category = "Windows", + capabilities = { combo = true, description = true, command = false, activation = true }, + modifiers = { "SUPER", "SHIFT" }, keys = { "G" }, activation = "press", + command = "", description = "Toggle window group", + expected = '-- binds\nhl.bind("SUPER + SHIFT + G", hl.dsp.group.toggle(), { description = "Toggle window group" })\n', +}) + +runCase({ + id = "niri-category", compositor = "Niri", rootName = "niri-category.kdl", + root = "binds {\n" .. niriSnippet .. "\n}\n", + content = "binds {\n" .. niriSnippet .. "\n}\n", + snippet = niriSnippet, category = "Windows", newCategory = "Media", + capabilities = { combo = true, category = true, description = true, command = true, activation = false }, + modifiers = { "SUPER" }, keys = { "G" }, activation = "press", + command = "old-command", description = "Old title", + expected = 'binds {\n // Keymap bind-category: Media\n' + .. niriSnippet .. '\n}\n', +}) + +runCase({ + id = "niri", compositor = "Niri", rootName = "config.kdl", + root = "binds {\n" .. niriSnippet .. "\n}\n", content = "binds {\n" .. niriSnippet .. "\n}\n", + snippet = niriSnippet, category = "Windows", + capabilities = { combo = true, description = true, command = true, activation = false }, + modifiers = { "SUPER", "SHIFT" }, keys = { "G" }, activation = "press", + command = "new-command --flag", description = "New title", + expected = 'binds {\n Mod+Shift+G repeat=false hotkey-overlay-title="New title" { spawn-sh "new-command --flag"; }\n}\n', +}) + +local mangoSnippet = 'bind=SUPER,G,spawn_shell,old-command #"Old title"' +runCase({ + id = "mango-category", compositor = "MangoWC", rootName = "mango-category.conf", + root = "# binds\n" .. mangoSnippet .. "\n", content = "# binds\n" .. mangoSnippet .. "\n", + snippet = mangoSnippet, category = "Windows", newCategory = "Media", + capabilities = { combo = true, category = true, description = true, command = true, activation = true }, + modifiers = { "SUPER" }, keys = { "G" }, activation = "press", + command = "old-command", description = "Old title", + expected = '# binds\n# Keymap bind-category: Media\n' .. mangoSnippet .. '\n', +}) + +runCase({ + id = "mango", compositor = "MangoWC", rootName = "config.conf", + root = "# binds\n" .. mangoSnippet .. "\n", content = "# binds\n" .. mangoSnippet .. "\n", + snippet = mangoSnippet, category = "Windows", + capabilities = { combo = true, description = true, command = true, activation = true }, + modifiers = { "SUPER", "SHIFT" }, keys = { "G" }, activation = "release", + command = "new-command --flag", description = "New title", + expected = '# binds\nbindr=SUPER+SHIFT,G,spawn_shell,new-command --flag #"New title"\n', +}) + +local hyprMoveSnippet = " hl.bind( 'SUPER + G' , hl.dsp.group.toggle(), {description='Keep spacing'} ) -- trailing" +runMoveCase({ + id = "hypr-move", compositor = "Hyprland", rootName = "hypr-move.lua", separate = true, + root = 'require("entries-hypr-move")\n', content = "-- binds\n" .. hyprMoveSnippet .. "\n", + snippet = hyprMoveSnippet, category = "Windows", newCategory = "Media", + expected = "-- binds\n -- Keymap bind-category: Media\n" .. hyprMoveSnippet .. "\n", +}) + +local niriMoveBind = ' Mod+G repeat=false { spawn-sh "keep exact --flag"; }' +local niriMoveSnippet = " // Keymap bind-category: Windows\n" .. niriMoveBind +runMoveCase({ + id = "niri-move", compositor = "Niri", rootName = "niri-move.kdl", + root = "binds {\n" .. niriMoveSnippet .. "\n}\n", + content = "binds {\n" .. niriMoveSnippet .. "\n}\n", + snippet = niriMoveSnippet, startLine = 2, endLine = 3, + category = "Windows", newCategory = "Media", + expected = "binds {\n // Keymap bind-category: Media\n" .. niriMoveBind .. "\n}\n", +}) + +local mangoMoveSnippet = ' bind=SUPER,G,spawn_shell,printf "keep, exact" #"Title"' +runMoveCase({ + id = "mango-move", compositor = "MangoWC", rootName = "mango-move.conf", + root = "# binds\n" .. mangoMoveSnippet .. "\n", + content = "# binds\n" .. mangoMoveSnippet .. "\n", + snippet = mangoMoveSnippet, category = "Windows", newCategory = "Media", + expected = "# binds\n # Keymap bind-category: Media\n" .. mangoMoveSnippet .. "\n", +}) + +runMoveCase({ + id = "move-same-category", compositor = "Hyprland", rootName = "move-same.lua", separate = true, + root = 'require("entries-move-same")\n', content = "-- binds\n" .. hyprMoveSnippet .. "\n", + snippet = hyprMoveSnippet, category = "Windows", newCategory = "Windows", + expected = "-- binds\n" .. hyprMoveSnippet .. "\n", + expectedWrites = 0, +}) + +runMoveCase({ + id = "move-from-legacy-category", compositor = "Hyprland", rootName = "move-legacy.lua", separate = true, + root = 'require("entries-move-legacy")\n', content = "-- binds\n" .. hyprMoveSnippet .. "\n", + snippet = hyprMoveSnippet, category = 'Legacy "quoted" category', newCategory = "Media", + expected = "-- binds\n -- Keymap bind-category: Media\n" .. hyprMoveSnippet .. "\n", +}) + +runMoveCase({ + id = "move-no-capability", compositor = "Niri", rootName = "move-no-capability.kdl", + root = "binds {\n" .. niriMoveBind .. "\n}\n", + content = "binds {\n" .. niriMoveBind .. "\n}\n", + snippet = niriMoveBind, category = "Windows", newCategory = "Media", + capabilities = { category = false }, error = "target_not_editable", +}) + +runMoveCase({ + id = "move-invalid-category", compositor = "MangoWC", rootName = "move-invalid-category.conf", + root = "# binds\n" .. mangoSnippet .. "\n", content = "# binds\n" .. mangoSnippet .. "\n", + snippet = mangoSnippet, category = "Windows", newCategory = "bad\ncategory", + error = "invalid_category", +}) + +runMoveCase({ + id = "move-stale-context", compositor = "Hyprland", rootName = "move-stale-context.lua", separate = true, + root = 'require("entries-move-stale-context")\n', content = "-- binds\n" .. hyprSnippet .. "\n", + snippet = hyprSnippet, category = "Windows", newCategory = "Media", + contextSource = "/tmp/keybind-test/other.lua", error = "stale_context", +}) + +runMoveCase({ + id = "move-stale-provenance", compositor = "Niri", rootName = "move-stale-provenance.kdl", + root = "binds {\n" .. niriMoveBind .. "\n}\n", + content = "binds {\n" .. niriMoveBind .. "\n}\n", + snippet = niriMoveBind, category = "Windows", newCategory = "Media", + badFingerprint = true, error = "target_changed", +}) + +runMoveCase({ + id = "move-rollback", compositor = "Hyprland", rootName = "move-rollback.lua", separate = true, + root = 'require("entries-move-rollback")\n', content = "-- binds\n" .. hyprSnippet .. "\n", + snippet = hyprSnippet, category = "Windows", newCategory = "Media", failVerify = true, +}) + +local renameHyprA = 'hl.bind("SUPER + A", action_a, { description = "A" })' +local renameHyprB = 'hl.bind("SUPER + B", action_b, { description = "B" })' +local renameHyprPath = "/tmp/keybind-test/rename-hypr.lua" +local renameHyprContent = "-- 1. Windows\n" .. renameHyprA .. "\n" .. renameHyprB .. "\n" +runRenameCategoryCase({ + rootName = "rename-hypr.lua", root = renameHyprContent, newCategory = "Applications", + binds = { + { id = "rename-hypr-a", snippet = renameHyprA, startLine = 2, endLine = 2 }, + { id = "rename-hypr-b", snippet = renameHyprB, startLine = 3, endLine = 3 }, + }, + expectedFiles = { [renameHyprPath] = "-- 1. Windows\n" + .. "-- Keymap bind-category: Applications\n" .. renameHyprA .. "\n" + .. "-- Keymap bind-category: Applications\n" .. renameHyprB .. "\n" }, +}) + +local renameNiriA = table.concat({ + " Mod+A {", ' spawn "a";', " }", +}, "\n") +local renameNiriB = ' Mod+B { spawn "b"; }' +local renameNiriPath = "/tmp/keybind-test/rename-niri.kdl" +local renameNiriContent = "binds {\n // \"Windows\"\n" .. renameNiriA .. "\n" .. renameNiriB .. "\n}\n" +runRenameCategoryCase({ + rootName = "rename-niri.kdl", compositor = "Niri", root = renameNiriContent, + newCategory = "Applications", + binds = { + { id = "rename-niri-a", snippet = renameNiriA, startLine = 3, endLine = 5 }, + { id = "rename-niri-b", snippet = renameNiriB, startLine = 6, endLine = 6 }, + }, + expectedFiles = { [renameNiriPath] = "binds {\n // \"Windows\"\n" + .. " // Keymap bind-category: Applications\n" .. renameNiriA .. "\n" + .. " // Keymap bind-category: Applications\n" .. renameNiriB .. "\n}\n" }, +}) + +local renameMangoA = 'bind=SUPER,A,spawn_shell,a #"A"' +local renameMangoB = 'bind=SUPER,B,spawn_shell,b #"B"' +local renameMangoPath = "/tmp/keybind-test/rename-mango.conf" +local renameMangoContent = "# Keymap category: Windows\n" + .. renameMangoA .. "\n" .. renameMangoB .. "\n" +runRenameCategoryCase({ + rootName = "rename-mango.conf", compositor = "MangoWC", root = renameMangoContent, + newCategory = "Applications", + binds = { + { id = "rename-mango-a", snippet = renameMangoA, startLine = 2, endLine = 2 }, + { id = "rename-mango-b", snippet = renameMangoB, startLine = 3, endLine = 3 }, + }, + expectedFiles = { [renameMangoPath] = "# Keymap category: Windows\n" + .. "# Keymap bind-category: Applications\n" .. renameMangoA .. "\n" + .. "# Keymap bind-category: Applications\n" .. renameMangoB .. "\n" }, +}) + +local renameMultiRoot = "/tmp/keybind-test/rename-multi.lua" +local renameMultiOne = "/tmp/keybind-test/rename-multi-one.lua" +local renameMultiTwo = "/tmp/keybind-test/rename-multi-two.lua" +local renameMultiRootContent = 'require("rename-multi-one")\nrequire("rename-multi-two")\n' +local renameMultiOneContent = "-- 1. Windows\n" .. renameHyprA .. "\n" +local renameMultiTwoContent = "-- 1. Windows\n" .. renameHyprB .. "\n" +local renameMultiFiles = { + [renameMultiOne] = renameMultiOneContent, + [renameMultiTwo] = renameMultiTwoContent, +} +local renameMultiBinds = { + { id = "rename-multi-a", source = renameMultiOne, snippet = renameHyprA, startLine = 2, endLine = 2 }, + { id = "rename-multi-b", source = renameMultiTwo, snippet = renameHyprB, startLine = 2, endLine = 2 }, +} +local renameMultiExpected = { + [renameMultiRoot] = renameMultiRootContent, + [renameMultiOne] = "-- 1. Windows\n-- Keymap bind-category: Media\n" .. renameHyprA .. "\n", + [renameMultiTwo] = "-- 1. Windows\n-- Keymap bind-category: Media\n" .. renameHyprB .. "\n", +} +runRenameCategoryCase({ + rootName = "rename-multi.lua", root = renameMultiRootContent, files = renameMultiFiles, + newCategory = "Media", binds = renameMultiBinds, + expectedFiles = renameMultiExpected, expectedWrites = 2, +}) + +runRenameCategoryCase({ + rootName = "rename-write-rollback.lua", root = renameMultiRootContent, files = renameMultiFiles, + newCategory = "Media", binds = renameMultiBinds, failRenameAt = 2, + error = "source_write_failed", +}) + +runRenameCategoryCase({ + rootName = "rename-no-op.lua", root = renameHyprContent, + newCategory = "Windows", + binds = { + { id = "rename-no-op-a", snippet = renameHyprA, startLine = 2, endLine = 2 }, + { id = "rename-no-op-b", snippet = renameHyprB, startLine = 3, endLine = 3 }, + }, + expectedFiles = { ["/tmp/keybind-test/rename-no-op.lua"] = renameHyprContent }, expectedWrites = 0, +}) + +runRenameCategoryCase({ + rootName = "rename-stale-fingerprint.lua", root = renameHyprContent, newCategory = "Media", + binds = { + { id = "rename-stale-a", snippet = renameHyprA, startLine = 2, endLine = 2 }, + { id = "rename-stale-b", snippet = renameHyprB, startLine = 3, endLine = 3, badFingerprint = true }, + }, + error = "target_changed", +}) + +runRenameCategoryCase({ + rootName = "rename-stale-context.lua", root = renameHyprContent, newCategory = "Media", + contextSource = "/tmp/keybind-test/different-root.lua", + binds = { + { id = "rename-context-a", snippet = renameHyprA, startLine = 2, endLine = 2 }, + }, + error = "stale_context", +}) + +runRenameCategoryCase({ + rootName = "rename-stale-category.lua", root = renameHyprContent, newCategory = "Media", + requestOldCategory = "Old Windows", + binds = { + { id = "rename-category-a", snippet = renameHyprA, startLine = 2, endLine = 2 }, + }, + error = "stale_category", +}) + +runRenameCategoryCase({ + rootName = "rename-existing-category.lua", root = renameHyprContent, + newCategory = "Media", existingCategory = "Media", + binds = { + { id = "rename-existing-a", snippet = renameHyprA, startLine = 2, endLine = 2 }, + }, + error = "category_exists", +}) + +runRenameCategoryCase({ + rootName = "rename-range.lua", root = renameHyprContent, newCategory = "Media", + binds = { + { id = "range:a:b", snippet = renameHyprA, startLine = 2, endLine = 2 }, + }, + error = "category_not_editable", +}) + +runRenameCategoryCase({ + rootName = "rename-dynamic.lua", root = renameHyprContent, newCategory = "Media", + binds = { + { id = "rename-dynamic-a", snippet = renameHyprA, startLine = 2, endLine = 2, + capabilities = { category = false } }, + }, + error = "category_not_editable", +}) + +runRenameCategoryCase({ + rootName = "rename-overlap.lua", root = renameHyprContent, newCategory = "Media", + binds = { + { id = "rename-overlap-a", snippet = renameHyprA, startLine = 2, endLine = 2 }, + { id = "rename-overlap-b", snippet = renameHyprA, startLine = 2, endLine = 2 }, + }, + error = "category_ranges_overlap", +}) + +runRenameCategoryCase({ + rootName = "rename-invalid-name.lua", root = renameHyprContent, newCategory = "bad\nname", + binds = { + { id = "rename-invalid-a", snippet = renameHyprA, startLine = 2, endLine = 2 }, + }, + error = "invalid_category", +}) + +runRenameCategoryCase({ + rootName = "rename-validator-rollback.lua", root = renameMultiRootContent, files = renameMultiFiles, + newCategory = "Media", binds = renameMultiBinds, failVerify = true, +}) + +runRenameCategoryCase({ + rootName = "rename-reload-rollback.lua", root = renameMultiRootContent, files = renameMultiFiles, + newCategory = "Media", binds = renameMultiBinds, failReload = true, +}) + +local reorderA = 'hl.bind("SUPER + A", action_a, { description = "A" })' +local reorderB = 'hl.bind("SUPER + B", action_b, { description = "B" })' +local reorderC = 'hl.bind("SUPER + C", action_c, { description = "C" })' +local reorderContent = "-- header\n" .. reorderA .. "\n" .. reorderB .. "\n" .. reorderC .. "\n-- footer\n" + +runReorderCase({ + rootName = "reorder-before-up.lua", content = reorderContent, + targetSnippet = reorderC, targetStart = 4, targetEnd = 4, + anchorSnippet = reorderA, anchorStart = 2, anchorEnd = 2, placement = "before", + expected = "-- header\n" .. reorderC .. "\n" .. reorderA .. "\n" .. reorderB .. "\n-- footer\n", +}) + +runReorderCase({ + rootName = "reorder-cross-category.lua", content = reorderContent, + targetSnippet = reorderA, targetStart = 2, targetEnd = 2, + anchorSnippet = reorderB, anchorStart = 3, anchorEnd = 3, placement = "after", + targetCategory = "Applications", anchorCategory = "Media", + targetCapabilities = { category = true }, + expected = "-- header\n" .. reorderB + .. "\n-- Keymap bind-category: Media\n" .. reorderA + .. "\n" .. reorderC .. "\n-- footer\n", +}) + +runReorderCase({ + rootName = "reorder-cross-category-no-capability.lua", content = reorderContent, + targetSnippet = reorderA, targetStart = 2, targetEnd = 2, + anchorSnippet = reorderB, anchorStart = 3, anchorEnd = 3, placement = "after", + targetCategory = "Applications", anchorCategory = "Media", + error = "target_not_editable", +}) + +runReorderCase({ + rootName = "reorder-after-down.lua", content = reorderContent, + targetSnippet = reorderA, targetStart = 2, targetEnd = 2, + anchorSnippet = reorderC, anchorStart = 4, anchorEnd = 4, placement = "after", + expected = "-- header\n" .. reorderB .. "\n" .. reorderC .. "\n" .. reorderA .. "\n-- footer\n", +}) + +runReorderCase({ + rootName = "reorder-after-adjacent.lua", content = reorderContent, + targetSnippet = reorderA, targetStart = 2, targetEnd = 2, + anchorSnippet = reorderB, anchorStart = 3, anchorEnd = 3, placement = "after", + expected = "-- header\n" .. reorderB .. "\n" .. reorderA .. "\n" .. reorderC .. "\n-- footer\n", +}) + +runReorderCase({ + rootName = "reorder-before-adjacent.lua", content = reorderContent, + targetSnippet = reorderC, targetStart = 4, targetEnd = 4, + anchorSnippet = reorderB, anchorStart = 3, anchorEnd = 3, placement = "before", + expected = "-- header\n" .. reorderA .. "\n" .. reorderC .. "\n" .. reorderB .. "\n-- footer\n", +}) + +runReorderCase({ + rootName = "reorder-no-op.lua", content = reorderContent, + targetSnippet = reorderA, targetStart = 2, targetEnd = 2, + anchorSnippet = reorderB, anchorStart = 3, anchorEnd = 3, placement = "before", + expected = reorderContent, expectedWrites = 0, +}) + +local reorderNiriA = ' Mod+A { spawn "a"; }\r' +local reorderNiriB = ' Mod+B { spawn "b"; }\r' +runReorderCase({ + rootName = "reorder-crlf.kdl", compositor = "Niri", + content = "binds {\r\n" .. reorderNiriA .. "\n" .. reorderNiriB .. "\n}\r\n", + targetSnippet = reorderNiriB, targetStart = 3, targetEnd = 3, + anchorSnippet = reorderNiriA, anchorStart = 2, anchorEnd = 2, placement = "before", + expected = "binds {\r\n" .. reorderNiriB .. "\n" .. reorderNiriA .. "\n}\r\n", +}) + +runReorderCase({ + rootName = "reorder-different-source.lua", content = "-- target\n" .. reorderA .. "\n", + targetSource = "/tmp/keybind-test/reorder-target.lua", + anchorSource = "/tmp/keybind-test/reorder-anchor.lua", anchorContent = "-- anchor\n" .. reorderB .. "\n", + targetSnippet = reorderA, targetStart = 2, targetEnd = 2, + anchorSnippet = reorderB, anchorStart = 2, anchorEnd = 2, placement = "before", + error = "different_source", +}) + +runReorderCase({ + rootName = "reorder-stale-target.lua", content = reorderContent, + targetSnippet = reorderC, targetStart = 4, targetEnd = 4, badTargetFingerprint = true, + anchorSnippet = reorderA, anchorStart = 2, anchorEnd = 2, placement = "before", + error = "target_changed", +}) + +runReorderCase({ + rootName = "reorder-stale-anchor.lua", content = reorderContent, + targetSnippet = reorderC, targetStart = 4, targetEnd = 4, + anchorSnippet = reorderA, anchorStart = 2, anchorEnd = 2, badAnchorFingerprint = true, + placement = "before", error = "anchor_changed", +}) + +runReorderCase({ + rootName = "reorder-overlap.lua", content = reorderContent, + targetSnippet = reorderA, targetStart = 2, targetEnd = 2, + anchorSnippet = reorderA, anchorStart = 2, anchorEnd = 2, placement = "before", + error = "target_anchor_overlap", +}) + +runReorderCase({ + rootName = "reorder-range-id.lua", content = reorderContent, targetId = "range:2-2", + targetSnippet = reorderA, targetStart = 2, targetEnd = 2, + anchorSnippet = reorderB, anchorStart = 3, anchorEnd = 3, placement = "before", + error = "target_not_editable", +}) + +runReorderCase({ + rootName = "reorder-hidden.lua", content = reorderContent, hiddenTarget = true, + targetSnippet = reorderA, targetStart = 2, targetEnd = 2, + anchorSnippet = reorderB, anchorStart = 3, anchorEnd = 3, placement = "before", + error = "target_not_editable", +}) + +runReorderCase({ + rootName = "reorder-validator-rollback.lua", content = reorderContent, + targetSnippet = reorderC, targetStart = 4, targetEnd = 4, + anchorSnippet = reorderA, anchorStart = 2, anchorEnd = 2, placement = "before", + failVerify = true, +}) + +runReorderCase({ + rootName = "reorder-reload-rollback.lua", content = reorderContent, + targetSnippet = reorderC, targetStart = 4, targetEnd = 4, + anchorSnippet = reorderA, anchorStart = 2, anchorEnd = 2, placement = "before", + failReload = true, +}) + +runMutationCase({ + id = "hypr-hide", operation = "hide", compositor = "Hyprland", rootName = "hide.lua", separate = true, + root = 'require("entries-hide")\n', content = "-- binds\n" .. hyprSnippet .. "\n", + snippet = hyprSnippet, + expected = "-- binds\n" .. hiddenSnippet("Hyprland", hyprSnippet) .. "\n", +}) + +local niriMultilineSnippet = table.concat({ + ' Mod+G repeat=false hotkey-overlay-title="Old title" {', + ' spawn-sh "old-command --with-flag";', + ' }', +}, "\n") +runMutationCase({ + id = "niri-hide-multiline", operation = "hide", compositor = "Niri", rootName = "hide-multiline.kdl", + root = "binds {\n" .. niriMultilineSnippet .. "\n}\n", + content = "binds {\n" .. niriMultilineSnippet .. "\n}\n", + snippet = niriMultilineSnippet, startLine = 2, endLine = 4, + expected = "binds {\n" .. hiddenSnippet("Niri", niriMultilineSnippet) .. "\n}\n", +}) + +runMutationCase({ + id = "niri-delete", operation = "delete", compositor = "Niri", rootName = "delete.kdl", + root = "binds {\n" .. niriSnippet .. "\n}\n", content = "binds {\n" .. niriSnippet .. "\n}\n", + snippet = niriSnippet, + expected = "binds {\n}\n", +}) + +runMutationCase({ + id = "mango-hide", operation = "hide", compositor = "MangoWC", rootName = "hide.conf", + root = "# binds\n" .. mangoSnippet .. "\n", content = "# binds\n" .. mangoSnippet .. "\n", + snippet = mangoSnippet, + expected = "# binds\n" .. hiddenSnippet("MangoWC", mangoSnippet) .. "\n", +}) + +local hyprHidden = hiddenSnippet("Hyprland", hyprSnippet) +runMutationCase({ + id = "hypr-restore", operation = "restore", compositor = "Hyprland", rootName = "restore.lua", separate = true, + root = 'require("entries-restore")\n', content = "-- binds\n" .. hyprHidden .. "\n", + snippet = hyprHidden, startLine = 2, endLine = 1 + lineCount(hyprHidden), hidden = true, + originalFingerprint = fingerprint(hyprSnippet), + expected = "-- binds\n" .. hyprSnippet .. "\n", +}) + +local niriHidden = hiddenSnippet("Niri", niriMultilineSnippet) +runMutationCase({ + id = "niri-restore-multiline", operation = "restore", compositor = "Niri", rootName = "restore.kdl", + root = "binds {\n" .. niriHidden .. "\n}\n", content = "binds {\n" .. niriHidden .. "\n}\n", + snippet = niriHidden, startLine = 2, endLine = 1 + lineCount(niriHidden), hidden = true, + originalFingerprint = fingerprint(niriMultilineSnippet), + expected = "binds {\n" .. niriMultilineSnippet .. "\n}\n", +}) + +local niriCrlfSnippet = ' Mod+H repeat=false {\r\n spawn-sh "keep-crlf";\r\n }\r' +local niriCrlfHidden = hiddenSnippet("Niri", niriCrlfSnippet) +runMutationCase({ + id = "niri-restore-crlf", operation = "restore", compositor = "Niri", rootName = "restore-crlf.kdl", + root = "binds {\r\n" .. niriCrlfHidden .. "\n}\r\n", + content = "binds {\r\n" .. niriCrlfHidden .. "\n}\r\n", + snippet = niriCrlfHidden, startLine = 2, endLine = 1 + lineCount(niriCrlfHidden), hidden = true, + originalFingerprint = fingerprint(niriCrlfSnippet), + expected = "binds {\r\n" .. niriCrlfSnippet .. "\n}\r\n", +}) + +local mangoHidden = hiddenSnippet("MangoWC", mangoSnippet) +runMutationCase({ + id = "mango-restore", operation = "restore", compositor = "MangoWC", rootName = "restore.conf", + root = "# binds\n" .. mangoHidden .. "\n", content = "# binds\n" .. mangoHidden .. "\n", + snippet = mangoHidden, startLine = 2, endLine = 1 + lineCount(mangoHidden), hidden = true, + originalFingerprint = fingerprint(mangoSnippet), + expected = "# binds\n" .. mangoSnippet .. "\n", +}) + +runMutationCase({ + id = "hidden-delete", operation = "delete", compositor = "MangoWC", rootName = "delete-hidden.conf", + root = "# binds\n" .. mangoHidden .. "\n", content = "# binds\n" .. mangoHidden .. "\n", + snippet = mangoHidden, startLine = 2, endLine = 1 + lineCount(mangoHidden), hidden = true, + expected = "# binds\n", +}) + +local malformedHidden = hyprHidden:gsub(" end ([0-9a-f]+)$", " end deadbeef") +runMutationCase({ + id = "restore-malformed", operation = "restore", compositor = "Hyprland", rootName = "malformed.lua", + root = "-- binds\n" .. malformedHidden .. "\n", content = "-- binds\n" .. malformedHidden .. "\n", + snippet = malformedHidden, startLine = 2, endLine = 1 + lineCount(malformedHidden), hidden = true, + error = "hidden_block_invalid", +}) + +runMutationCase({ + id = "delete-malformed", operation = "delete", compositor = "Hyprland", rootName = "delete-malformed.lua", + root = "-- binds\n" .. malformedHidden .. "\n", content = "-- binds\n" .. malformedHidden .. "\n", + snippet = malformedHidden, startLine = 2, endLine = 1 + lineCount(malformedHidden), hidden = true, + error = "hidden_block_invalid", +}) + +runMutationCase({ + id = "restore-stale", operation = "restore", compositor = "Niri", rootName = "stale.kdl", + root = "binds {\n" .. niriHidden .. "\n}\n", content = "binds {\n" .. niriHidden .. "\n}\n", + snippet = niriHidden, startLine = 2, endLine = 1 + lineCount(niriHidden), hidden = true, + badFingerprint = true, error = "target_changed", +}) + +local changedNiriHidden = niriHidden:gsub(" data ([0-9a-f])", function(first) + return " data " .. (first == "0" and "1" or "0") +end, 1) +runMutationCase({ + id = "restore-disk-changed", operation = "restore", compositor = "Niri", rootName = "disk-changed.kdl", + root = "binds {\n" .. changedNiriHidden .. "\n}\n", + content = "binds {\n" .. changedNiriHidden .. "\n}\n", + snippet = changedNiriHidden, targetSnippet = niriHidden, + startLine = 2, endLine = 1 + lineCount(niriHidden), hidden = true, + error = "target_changed", +}) + +runMutationCase({ + id = "restore-rollback", operation = "restore", compositor = "Hyprland", rootName = "restore-rollback.lua", + root = "-- binds\n" .. hyprHidden .. "\n", content = "-- binds\n" .. hyprHidden .. "\n", + snippet = hyprHidden, startLine = 2, endLine = 1 + lineCount(hyprHidden), hidden = true, + failVerify = true, +}) + +runMutationCase({ + id = "restore-reload-rollback", operation = "restore", compositor = "Hyprland", + rootName = "restore-reload-rollback.lua", + root = "-- binds\n" .. hyprHidden .. "\n", content = "-- binds\n" .. hyprHidden .. "\n", + snippet = hyprHidden, startLine = 2, endLine = 1 + lineCount(hyprHidden), hidden = true, + failReload = true, +}) + +runCase({ + id = "rollback", compositor = "Hyprland", rootName = "rollback.lua", separate = true, + root = 'require("entries-rollback")\n', content = "-- binds\n" .. hyprSnippet .. "\n", + snippet = hyprSnippet, category = "Windows", failVerify = true, + capabilities = { combo = true, description = true, command = true, activation = true }, + modifiers = { "SUPER" }, keys = { "H" }, activation = "press", + command = "will-not-stick", description = "Rollback title", +}) + +print("writer update tests: ok") diff --git a/keymap/thumbnail.webp b/keymap/thumbnail.webp new file mode 100644 index 0000000..c16a51a Binary files /dev/null and b/keymap/thumbnail.webp differ diff --git a/keymap/translations/en.json b/keymap/translations/en.json new file mode 100644 index 0000000..ab8bce5 --- /dev/null +++ b/keymap/translations/en.json @@ -0,0 +1,459 @@ +{ + "title": "Keymap", + "widget": { + "tooltip": "Show system keybindings" + }, + "panel": { + "bind_count": { + "one": "{count} keybinding", + "other": "{count} keybindings" + }, + "category_count": { + "one": "{count} shortcut", + "other": "{count} shortcuts" + }, + "search_results": { + "one": "{count} result", + "other": "{count} results" + }, + "search_placeholder": "Search shortcuts, keys, categories, or actions…", + "clear_search": "Clear search", + "list_view": "Shortcut list", + "keyboard": { + "view": "Keyboard view", + "layout": "Keyboard size", + "layout_edit_hint": "Choose the physical keyboard shown by the editor.", + "layouts": { + "p100": "100%", + "p96": "96%", + "p80": "80% (TKL)", + "p75": "75%", + "p65": "65%", + "p60": "60%" + }, + "layer": "Layer", + "clear_modifiers": "Clear modifiers", + "free": "Free", + "occupied": "Occupied", + "other_layer": "Used on another layer", + "no_modifiers": "No modifiers", + "summary": "{layer}: {count} occupied keys", + "outside": "{count} shortcuts outside the keyboard view", + "select_hint": "Select modifiers, then click a key to inspect that combination.", + "free_hint": "This combination is not used in the currently selected layer.", + "more_actions": "+{count} more actions" + }, + "creator": { + "open": "Create a new keybinding in the current view", + "new_shortcut": "New shortcut", + "title": "Create keybinding", + "no_combination": "Select a key combination", + "trigger": "Run on", + "press": "Press", + "release": "Release", + "release_unavailable": "Niri does not support release-triggered keybindings.", + "category": "Category", + "new_category": "New category…", + "category_placeholder": "Category name", + "description_placeholder": "Shortcut description", + "command_placeholder": "Command to run", + "cancel": "Cancel", + "save": "Save keybinding", + "saving": "Saving…", + "saved": "Saved {chord}", + "list_hint": "Choose modifiers and enter the key or comma-separated key sequence. You can also create shortcuts from the keyboard view.", + "hyprland_hint": "Hyprland supports one or more ordinary keys. Click selected keys again to remove them.", + "niri_hint": "Niri supports one ordinary key and triggers bindings on press.", + "mangowc_hint": "MangoWC supports one ordinary key and can trigger on press or release.", + "errors": { + "conflict": "Already used by: {description}", + "conflict_blocked": "This combination is already occupied. Choose a free combination.", + "combination_required": "Select at least one ordinary key.", + "command_required": "Enter a command.", + "library_arguments_required": "Replace every command-library placeholder before saving.", + "library_entry_invalid": "The selected native action no longer matches the command library.", + "invalid_command_kind": "The selected command type is not supported.", + "native_update_unsupported": "Existing shortcuts cannot be converted to native actions safely.", + "description_required": "Enter a shortcut description.", + "category_required": "Select or enter a category.", + "single_key_only": "This compositor supports only one ordinary key per shortcut.", + "release_unsupported": "This compositor does not support release-triggered shortcuts.", + "invalid_request": "The writer received an invalid request.", + "invalid_compositor": "The active compositor is not supported by the writer.", + "invalid_operation": "The requested shortcut operation is not supported.", + "unsupported_compositor": "The active compositor is not supported by the writer.", + "source_unreadable": "The main compositor configuration could not be read.", + "source_changed": "The main configuration changed while the shortcut was being prepared. Refresh and try again.", + "target_changed": "This shortcut changed on disk. Refresh the list before editing it again.", + "target_not_found": "This shortcut is no longer present in the current configuration.", + "target_not_hidden": "This shortcut is no longer present in the hidden shortcuts list.", + "anchor_not_found": "The destination shortcut is no longer present. Refresh and try again.", + "anchor_ambiguous": "The destination shortcut could not be identified uniquely.", + "anchor_changed": "The destination shortcut changed on disk. Refresh and try again.", + "anchor_range_invalid": "The destination shortcut source location is no longer valid.", + "anchor_is_target": "Choose a different destination shortcut.", + "different_source": "These shortcuts are stored in different files and cannot be reordered safely.", + "target_anchor_overlap": "The source ranges of these shortcuts overlap and cannot be reordered safely.", + "target_not_editable": "This shortcut is generated dynamically or its source syntax cannot be edited safely.", + "hidden_block_invalid": "The hidden shortcut block is incomplete or damaged, so it was left unchanged.", + "target_range_invalid": "The shortcut source location is no longer valid. Refresh the list and try again.", + "description_not_editable": "The description in this shortcut cannot be changed safely.", + "command_not_editable": "The command in this shortcut cannot be changed safely.", + "activation_not_editable": "The activation mode in this shortcut cannot be changed safely.", + "category_edit_separate": "Moving shortcuts between categories will be available in the category editor.", + "managed_file_changed": "The generated shortcuts file changed while the shortcut was being prepared. Refresh and try again.", + "source_is_managed_file": "Select the main compositor configuration, not the generated shortcuts file.", + "stale_context": "The compositor configuration changed while the creator was open. Refresh and reopen the creator.", + "symlink_unsupported": "The main or managed configuration is a symbolic link. Saving is blocked to preserve the link safely.", + "preflight_failed": "The configuration safety check could not be started.", + "invalid_text": "The category, description, and command must be single-line text without control characters.", + "category_invalid": "The category name contains characters unsupported by the compositor format.", + "category_exists": "Another category already uses this name.", + "category_changed": "The category changed while its editor was open. Refresh and try again.", + "stale_category": "The category changed while its editor was open. Refresh and try again.", + "category_not_found": "This category is no longer present in the current configuration.", + "category_not_editable": "This category contains generated, grouped, or otherwise read-only shortcuts and cannot be renamed safely.", + "category_ambiguous": "The category could not be identified uniquely in the current snapshot.", + "category_ranges_overlap": "The category contains overlapping source ranges and cannot be renamed safely.", + "mango_command_hash_unsupported": "MangoWC commands containing # are not supported by this first writer version.", + "file_too_large": "The managed keybinding file is too large.", + "managed_file_too_large": "The managed keybinding file is too large.", + "managed_file_invalid": "The managed keybinding file has an invalid structure.", + "managed_file_unreadable": "The managed keybinding file could not be read.", + "managed_file_collision": "A file with the managed filename already exists and does not belong to this plugin.", + "managed_include_invalid": "The managed include block in the main configuration is incomplete or damaged.", + "write_failed": "The managed keybinding file could not be written.", + "managed_write_failed": "The managed keybinding file could not be written.", + "managed_rename_failed": "The managed keybinding file could not be installed atomically.", + "managed_temporary_collision": "A safe temporary filename could not be allocated.", + "integrate_failed": "The managed file could not be included from the main configuration.", + "source_write_failed": "The managed file could not be included from the main configuration.", + "source_rename_failed": "The main configuration could not be updated atomically.", + "source_temporary_collision": "A safe temporary filename could not be allocated.", + "verify_failed": "The compositor rejected the generated configuration.", + "verify_timeout": "Configuration validation timed out; no changes were kept.", + "verify_start_failed": "Configuration validation could not be started; no changes were kept.", + "validator_unavailable": "The compositor validation command is unavailable; no changes were kept.", + "reload_failed": "The compositor rejected the reload, so the previous configuration was restored.", + "reload_timeout": "Reloading the compositor timed out, so the previous configuration was restored.", + "reload_start_failed": "Reloading the compositor could not be started, so the previous configuration was restored.", + "rollback_failed": "The shortcut could not be saved and the automatic rollback was incomplete. Check the compositor configuration files.", + "recovery_reload_failed": "The previous files were restored, but reloading the restored compositor configuration also failed.", + "busy": "Another shortcut is currently being saved. Try again in a moment." + } + }, + "command_library": { + "title": "Known commands", + "open": "Commands", + "open_hint": "Browse Noctalia commands and native actions for the active compositor", + "search_placeholder": "Search commands, actions, or categories", + "all_sources": "All sources", + "all_categories": "All categories", + "all_readiness": "All commands", + "ready": "Ready", + "requires_input": "Needs input", + "showing": "Showing {shown} of {total} matching commands", + "use": "Use", + "no_results": "No commands match these filters.", + "custom_command": "Custom command", + "native_action": "Native action", + "placeholder_hint": "Replace every placeholder in double braces before saving.", + "sources": { + "noctalia": "Noctalia", + "hyprland": "Hyprland", + "niri": "Niri", + "mangowc": "MangoWC" + }, + "categories": { + "applications": "Applications", + "appearance": "Appearance", + "audio": "Audio", + "bars": "Bars", + "casting": "Casting", + "clipboard": "Clipboard", + "connectivity": "Connectivity", + "cursor": "Cursor", + "display": "Displays", + "focus": "Focus", + "groups": "Groups", + "input": "Input", + "layout": "Layout", + "media": "Media", + "monitors": "Monitors", + "notifications": "Notifications", + "panels": "Panels", + "plugins": "Plugins", + "power": "Power", + "screenshots": "Screenshots", + "session": "Session", + "system": "System", + "widgets": "Widgets", + "windows": "Windows", + "workspaces": "Workspaces" + } + }, + "editor": { + "open": "Edit shortcuts", + "open_hint": "Show shortcut and category edit controls", + "drag": "Drag this shortcut to another category", + "edit": "Edit this shortcut", + "hide": "Hide this shortcut by commenting it out in the source file", + "restore": "Restore this shortcut", + "delete": "Delete this shortcut permanently", + "delete_hidden": "Delete this hidden shortcut permanently", + "confirm_delete": "Click again to confirm permanent deletion", + "confirm_delete_hidden": "Click again to permanently delete this hidden shortcut", + "cancel_delete": "Cancel deletion", + "hidden_title": "Hidden shortcuts", + "hidden_hint": "These shortcuts are inactive. Restore one to put its exact source text back, or delete it permanently.", + "hidden_count": { + "one": "One hidden shortcut", + "other": "{count} hidden shortcuts" + }, + "title": "Edit keybinding", + "hint": "Update the combination, description, command, trigger mode, or category.", + "combination": "Combination", + "keys_placeholder": "Key, or comma-separated sequence", + "action_placeholder": "Native compositor action", + "save": "Update keybinding", + "updated": "The keybinding was updated.", + "moved": "The keybinding was moved to the selected category.", + "reordered": "The keybinding order was updated.", + "category_rename": "Rename this category", + "category_read_only": "This category contains generated, grouped, or read-only shortcuts and cannot be renamed safely", + "category_name_placeholder": "Category name", + "category_rename_cancel": "Cancel category rename", + "category_rename_save": "Save category name", + "category_renamed": "The category was renamed.", + "hidden": "The keybinding was hidden and can be restored from the editor.", + "restored": "The keybinding was restored.", + "deleted": "The keybinding was deleted.", + "read_only": "This entry is generated dynamically or cannot be edited safely.", + "native_command": "This shortcut uses a native compositor action. Its action is preserved; only supported fields can be changed." + }, + "refresh": "Refresh keybindings", + "open_config_folder": "Open the current keybinding file's folder", + "config_folder_unavailable": "No readable keybinding source is available yet.", + "xdg_open_unavailable": "xdg-open is not installed or is not available on PATH.", + "config_folder_failed": "The keybinding folder could not be opened.", + "open_settings": "Open Keymap settings", + "settings_failed": "Noctalia settings could not be opened.", + "open_settings_action": "Open settings", + "path_hint": "If Keymap cannot find your keybindings automatically, open Keymap settings and enter the correct compositor configuration file path.", + "close": "Close", + "detecting": "Detecting compositor", + "retry": "Try again", + "updated": "Updated {time}", + "loading": "Loading keybindings", + "loading_hint": "Reading keybindings from the active compositor and its configuration.", + "load_failed": "Could not load keybindings", + "unknown_error": "The service returned an unknown error.", + "warning_count": { + "one": "One parser warning occurred. The list may be incomplete.", + "other": "{count} parser warnings occurred. The list may be incomplete." + }, + "errors": { + "unknown": "The service returned an unknown error.", + "hyprctl_not_found": "hyprctl is not installed or is not available on PATH.", + "hyprctl_timeout": "Hyprland did not answer before the request timed out.", + "hyprctl_failed": "Hyprland rejected the request for live keybindings.", + "hyprctl_invalid_json": "Hyprland returned malformed keybinding data.", + "hyprctl_start_failed": "The hyprctl process could not be started.", + "compositor_unknown": "No supported compositor could be detected. Select one manually in the plugin settings.", + "niri_config_unreadable": "The configured Niri KDL file could not be read.", + "niri_required_include_missing": "A required Niri include could not be read. Fix the configuration path and try again.", + "niri_no_binds": "No active keybindings were found in the Niri configuration.", + "mangowc_config_unreadable": "The configured MangoWC file could not be read.", + "mangowc_required_source_missing": "A required MangoWC source could not be read. Fix the configuration path and try again.", + "mangowc_no_binds": "No keybindings were found in the MangoWC configuration." + }, + "empty": "No keybindings found", + "empty_hint": "No shortcuts were found automatically. Open Keymap settings, enter the correct compositor configuration file path, and refresh the panel.", + "no_results": "No matching keybindings", + "no_results_hint": "Try a different description, key, category, or dispatcher.", + "uncategorized": "Other", + "no_description": "No description" + }, + "category": { + "other": "Other", + "undescribed": "Without description", + "noctalia": "Noctalia", + "niri": { + "applications": "Applications", + "column_navigation": "Column navigation", + "window_focus": "Window focus", + "workspace_navigation": "Workspace navigation", + "workspace_management": "Workspace management", + "move_columns": "Move columns", + "move_windows": "Move windows", + "window_management": "Window management", + "column_management": "Column management", + "column_width": "Column width", + "window_size": "Window size", + "screenshots": "Screenshots", + "power": "Power", + "system": "System", + "animations": "Animations" + } + }, + "actions": { + "run": "Run: {command}", + "run_command": "Run command", + "native": "Native action: {action}", + "with_argument": "{action} {argument}", + "view_tag": "View tag", + "view_tag_number": "View tag {tag}", + "move_to_tag": "Move to tag", + "move_to_tag_number": "Move to tag {tag}", + "set_layout": "Set layout", + "layout_value": "Layout: {layout}", + "set_key_mode": "Set key mode", + "key_mode_value": "Enter key mode: {mode}", + "mangowc": { + "killclient": "Close window", + "togglefullscreen": "Toggle fullscreen", + "togglefakefullscreen": "Toggle fake fullscreen", + "togglemaximizescreen": "Maximize", + "togglefloating": "Toggle floating", + "toggle_all_floating": "Toggle all floating", + "toggleglobal": "Toggle global window", + "toggleoverview": "Toggle overview", + "togglejump": "Toggle jump overview", + "toggleoverlay": "Toggle overlay", + "toggle_scratchpad": "Toggle scratchpad", + "minimized": "Minimize window", + "restore_minimized": "Restore minimized window", + "reload_config": "Reload config", + "quit": "Quit compositor", + "switch_proportion_preset": "Cycle column width", + "switch_keyboard_layout": "Switch keyboard layout", + "zoom": "Zoom", + "restart": "Restart", + "incnmaster": "Increase masters", + "switch_layout": "Switch layout", + "togglegaps": "Toggle gaps", + "dwindle_toggle_split_direction": "Toggle split direction", + "focusdir": "Focus", + "exchange_client": "Swap", + "focusmon": "Focus monitor", + "tagmon": "Send to monitor", + "groupjoin": "Join group", + "groupfocus": "Focus group", + "smartmovewin": "Move window", + "smartresizewin": "Resize window", + "scroller_stack": "Move in scroller stack" + } + }, + "settings": { + "compositor": { + "label": "Compositor", + "description": "Detect the active compositor automatically or select one manually.", + "options": { + "auto": "Automatic", + "hyprland": "Hyprland (Lua)", + "niri": "Niri", + "mangowc": "MangoWC" + } + }, + "hyprland_config": { + "label": "Hyprland Lua configuration", + "description": "Main Lua configuration file. Required modules are discovered from this file." + }, + "niri_config": { + "label": "Niri configuration", + "description": "Main KDL configuration file. Include nodes are followed recursively." + }, + "mangowc_config": { + "label": "MangoWC configuration", + "description": "Main config.conf file. Source directives are followed recursively." + }, + "merge_sequential": { + "label": "Merge sequential shortcuts", + "description": "Combine related numbered shortcuts, such as workspaces 1–9, into one row." + }, + "show_undescribed": { + "label": "Show shortcuts without descriptions", + "description": "Include Hyprland shortcuts without explicit descriptions. Niri and MangoWC use generated action descriptions." + }, + "keyboard_layout": { + "label": "Default keyboard size", + "description": "Physical keyboard layout used when opening the Keymap keyboard view.", + "options": { + "p100": "100%", + "p96": "96%", + "p80": "80% (TKL)", + "p75": "75%", + "p65": "65%", + "p60": "60%" + } + }, + "columns": { + "label": "Panel columns", + "description": "Number of category columns shown in the Keymap panel." + }, + "card_color": { + "label": "Category card color", + "description": "Background role or custom color used for category cards." + }, + "card_opacity": { + "label": "Category card opacity", + "description": "Opacity of category card backgrounds." + }, + "category_color": { + "label": "Category heading color", + "description": "Theme role or custom color used for category headings." + }, + "description_color": { + "label": "Description color", + "description": "Theme role or custom color used for shortcut descriptions." + }, + "modifier_color": { + "description": "Theme role or custom background color for this modifier." + }, + "modifier_text_color": { + "description": "Theme role or custom text color for this modifier." + }, + "super_color": { + "label": "Super background" + }, + "super_text_color": { + "label": "Super text" + }, + "ctrl_color": { + "label": "Control background" + }, + "ctrl_text_color": { + "label": "Control text" + }, + "shift_color": { + "label": "Shift background" + }, + "shift_text_color": { + "label": "Shift text" + }, + "alt_color": { + "label": "Alt background" + }, + "alt_text_color": { + "label": "Alt text" + }, + "key_color": { + "label": "Key background", + "description": "Theme role or custom background color for non-modifier keys." + }, + "key_text_color": { + "label": "Key text", + "description": "Theme role or custom text color for non-modifier keys." + }, + "widget": { + "glyph": { + "label": "Glyph", + "description": "Glyph shown in the bar." + }, + "show_label": { + "label": "Show label", + "description": "Show the plugin name next to the bar glyph." + } + } + } +} diff --git a/keymap/widget.luau b/keymap/widget.luau new file mode 100644 index 0000000..4c160ee --- /dev/null +++ b/keymap/widget.luau @@ -0,0 +1,32 @@ +--!nonstrict + +local PANEL_ID = "blackbartblues/keymap:panel" + +local glyph = "keyboard" +local showLabel = false + +local function readConfig() + glyph = noctalia.getConfig("glyph") or "keyboard" + showLabel = noctalia.getConfig("show_label") == true +end + +local function render() + barWidget.setGlyph(glyph) + barWidget.setText(showLabel and noctalia.tr("title") or "") + barWidget.setTooltip(noctalia.tr("widget.tooltip")) +end + +function update() + noctalia.setUpdateInterval(60000) + readConfig() + render() +end + +function onConfigChanged() + readConfig() + render() +end + +function onClick() + noctalia.togglePanel(PANEL_ID) +end diff --git a/keymap/writer_service.luau b/keymap/writer_service.luau new file mode 100644 index 0000000..675f121 --- /dev/null +++ b/keymap/writer_service.luau @@ -0,0 +1,1951 @@ +--!nonstrict +-- Managed keybind writer for Keymap. +-- The Keymap storage namespace is shared by managed files, includes, +-- category markers, and hidden blocks. + +local CREATE_REQUEST_KEY = "keymap.create_request" +local CREATE_RESULT_KEY = "keymap.create_result" +local UPDATE_REQUEST_KEY = "keymap.update_request" +local UPDATE_RESULT_KEY = "keymap.update_result" +local REFRESH_REQUEST_KEY = "keymap.refresh_request" +local LAST_HANDLED_REQUEST_KEY = "keymap.last_handled_create_request" +local LAST_HANDLED_UPDATE_KEY = "keymap.last_handled_update_request" +local SNAPSHOT_KEY = "keymap.snapshot" + +local MAX_ROOT_BYTES = 2 * 1024 * 1024 +local MAX_MANAGED_BYTES = 1024 * 1024 +local MAX_COMMAND_BYTES = 16 * 1024 +local RELOAD_TIMEOUT_MS = 5000 +local VERIFY_TIMEOUT_MS = 8000 +local HIDDEN_BLOCK_VERSION = "v1" +local HIDDEN_DATA_CHUNK_BYTES = 48 + +local busy = false +local temporaryCounter = 0 + +local FORMATS = { + Hyprland = { + managedName = "keymap.lua", + comment = "--", + includeLine = 'require("keymap")', + reloadCommand = "hyprctl reload", + validator = "Hyprland --verify-config -c ", + }, + Niri = { + managedName = "keymap.kdl", + comment = "//", + includeLine = 'include "keymap.kdl"', + reloadCommand = "niri msg action load-config-file", + validator = "niri validate -c ", + }, + MangoWC = { + managedName = "keymap.conf", + comment = "#", + includeLine = "source=./keymap.conf", + reloadCommand = "mmsg dispatch reload_config", + validator = "mango -c ", + validatorSuffix = " -p", + }, +} + +local VALID_MODIFIERS = { SUPER = true, CTRL = true, SHIFT = true, ALT = true } + +local COMMAND_LIBRARY_ENTRIES = {} +do + local encoded = noctalia.readFile("command_library.json") + if type(encoded) == "string" and type(noctalia.json) == "table" + and type(noctalia.json.decode) == "function" then + local ok, decoded = pcall(noctalia.json.decode, encoded) + if ok and type(decoded) == "table" and decoded.schema == 1 + and type(decoded.entries) == "table" then + for _, entry in ipairs(decoded.entries) do + if type(entry) == "table" and type(entry.id) == "string" then + COMMAND_LIBRARY_ENTRIES[entry.id] = entry + end + end + end + end +end + +local function trim(value) + if type(value) ~= "string" then return "" end + return value:match("^%s*(.-)%s*$") or "" +end + +local function dirname(path) + local directory = path:match("^(.*)/[^/]*$") + return directory ~= nil and directory ~= "" and directory or "/" +end + +local function hasUnsafeLineCharacters(value) + return value:find("%c") ~= nil +end + +local function validateText(value, name, allowEmpty, maxBytes) + if type(value) ~= "string" then return nil, "invalid_" .. name end + if hasUnsafeLineCharacters(value) then return nil, "invalid_" .. name end + if not allowEmpty and trim(value) == "" then return nil, "empty_" .. name end + if maxBytes ~= nil and #value > maxBytes then return nil, name .. "_too_large" end + return value, nil +end + +local function validateCategory(value) + local category, errorCode = validateText(value, "category", false, 80) + if errorCode ~= nil then return nil, errorCode end + category = trim(category) + if category:find('"', 1, true) ~= nil + or category:find("Keymap managed", 1, true) ~= nil then + return nil, "category_invalid" + end + return category, nil +end + +local function escapePattern(value) + return (value:gsub("([^%w])", "%%%1")) +end + +local function matchesLibraryTemplate(template, value) + if type(template) ~= "string" then return false end + local pattern = "^" + local cursor = 1 + while true do + local first, last = template:find("{{[^}]+}}", cursor) + if first == nil then + pattern = pattern .. escapePattern(template:sub(cursor)) + break + end + pattern = pattern .. escapePattern(template:sub(cursor, first - 1)) .. "(.+)" + cursor = last + 1 + end + return value:match(pattern .. "$") ~= nil +end + +local function validateCommandLibrary(raw, compositor, command) + local commandKind = raw.command_kind == nil and "shell" or raw.command_kind + if commandKind ~= "shell" and commandKind ~= "native" then + return nil, nil, "invalid_command_kind" + end + local libraryEntryId = type(raw.library_entry_id) == "string" and raw.library_entry_id or "" + if commandKind == "shell" then return commandKind, libraryEntryId, nil end + if libraryEntryId == "" then return nil, nil, "library_entry_invalid" end + local entry = COMMAND_LIBRARY_ENTRIES[libraryEntryId] + local expectedSource = ({ Hyprland = "hyprland", Niri = "niri", MangoWC = "mangowc" })[compositor] + if type(entry) ~= "table" or entry.kind ~= "native" or entry.source ~= expectedSource + or command:find("{{", 1, true) ~= nil + or not matchesLibraryTemplate(entry.template, command) then + return nil, nil, command:find("{{", 1, true) ~= nil + and "library_arguments_required" or "library_entry_invalid" + end + return commandKind, libraryEntryId, nil +end + +local function validateRequest(raw) + if type(raw) ~= "table" then return nil, "invalid_request" end + + local rawRequestId = raw.request_id + if type(rawRequestId) == "number" then rawRequestId = tostring(rawRequestId) end + local requestId, errorCode = validateText(rawRequestId, "request_id", false, 256) + if errorCode ~= nil then return nil, errorCode end + local compositor = raw.compositor + local format = FORMATS[compositor] + if format == nil then return nil, "invalid_compositor" end + local source + source, errorCode = validateText(raw.source, "source", false, 4096) + if errorCode ~= nil then return nil, errorCode end + if source:sub(1, 1) ~= "/" then return nil, "source_not_absolute" end + if source:match("([^/]+)$") == format.managedName then + return nil, "source_is_managed_file" + end + + local activation = raw.activation + if activation ~= "press" and activation ~= "release" then return nil, "invalid_activation" end + if compositor == "Niri" and activation ~= "press" then return nil, "niri_release_unsupported" end + + local command + command, errorCode = validateText(raw.command, "command", false, MAX_COMMAND_BYTES) + if errorCode ~= nil then return nil, errorCode end + local commandKind, libraryEntryId + commandKind, libraryEntryId, errorCode = validateCommandLibrary(raw, compositor, command) + if errorCode ~= nil then return nil, errorCode end + local description + description, errorCode = validateText(raw.description, "description", false, 1024) + if errorCode ~= nil then return nil, errorCode end + local category + category, errorCode = validateCategory(raw.category) + if errorCode ~= nil then return nil, errorCode end + if compositor == "MangoWC" and command:find("#", 1, true) ~= nil then + return nil, "mango_command_hash_unsupported" + end + + if type(raw.modifiers) ~= "table" then return nil, "invalid_modifiers" end + local modifiers = {} + local seenModifiers = {} + for _, modifier in ipairs(raw.modifiers) do + if type(modifier) ~= "string" or not VALID_MODIFIERS[modifier] or seenModifiers[modifier] then + return nil, "invalid_modifiers" + end + seenModifiers[modifier] = true + modifiers[#modifiers + 1] = modifier + end + + if type(raw.keys) ~= "table" or #raw.keys == 0 then return nil, "empty_keys" end + if compositor ~= "Hyprland" and #raw.keys ~= 1 then return nil, "multiple_keys_unsupported" end + if #raw.keys > 16 then return nil, "too_many_keys" end + local keys = {} + local seenKeys = {} + for _, key in ipairs(raw.keys) do + local checked + checked, errorCode = validateText(key, "key", false, 128) + if errorCode ~= nil then return nil, errorCode end + -- Target key names are tokens in all three compositor grammars. Reject + -- delimiters instead of attempting to quote compositor syntax itself. + if not checked:match("^[%w_:%-]+$") then return nil, "invalid_key" end + if seenKeys[checked] then return nil, "duplicate_key" end + seenKeys[checked] = true + keys[#keys + 1] = checked + end + + return { + request_id = requestId, + compositor = compositor, + format = format, + source = source, + modifiers = modifiers, + keys = keys, + activation = activation, + command = command, + command_kind = commandKind, + library_entry_id = libraryEntryId, + description = description, + category = category, + }, nil +end + +local function validateCurrentContext(request) + local snapshot = noctalia.state.get(SNAPSHOT_KEY) + if type(snapshot) ~= "table" or snapshot.status ~= "ready" then return false end + return snapshot.compositor == request.compositor and snapshot.source == request.source +end + +local KEY_EQUIVALENTS = { + RETURN = "ENTER", ESCAPE = "ESC", PRINT = "PRTSC", SCROLL_LOCK = "SCROLL LOCK", + PRIOR = "PGUP", NEXT = "PGDN", KP_ENTER = "NUM ENTER", KP_ADD = "NUM +", + KP_SUBTRACT = "NUM -", KP_MULTIPLY = "NUM *", KP_DIVIDE = "NUM /", KP_DECIMAL = "NUM .", +} + +local function comparableKey(value) + local key = trim(tostring(value or "")):upper():gsub("%s+", " ") + return KEY_EQUIVALENTS[key] or key +end + +local function comparableModifiers(modifiers) + local result = {} + for _, modifier in ipairs(type(modifiers) == "table" and modifiers or {}) do + local value = tostring(modifier):upper() + if value == "MOD" or value == "META" or value == "WIN" or value == "LOGO" then value = "SUPER" end + if value == "CONTROL" then value = "CTRL" end + result[#result + 1] = value + end + table.sort(result) + return table.concat(result, "+") +end + +local function comparableKeys(values) + local result = {} + for _, value in ipairs(type(values) == "table" and values or {}) do + result[#result + 1] = comparableKey(value) + end + return result +end + +local function conflictsWithSnapshot(request, excludedId) + local snapshot = noctalia.state.get(SNAPSHOT_KEY) + local modifiers = comparableModifiers(request.modifiers) + local keys = comparableKeys(request.keys) + for _, category in ipairs(type(snapshot) == "table" and snapshot.categories or {}) do + for _, bind in ipairs(type(category.binds) == "table" and category.binds or {}) do + local bindKeys = comparableKeys(type(bind.keys) == "table" and bind.keys or { bind.key }) + local sameKeys = #bindKeys == #keys + if sameKeys then + for index, key in ipairs(keys) do + if bindKeys[index] ~= key then sameKeys = false break end + end + end + if tostring(bind.id or "") ~= tostring(excludedId or "") and sameKeys + and comparableModifiers(bind.modifiers) == modifiers + and tostring(bind.activation or "press") == request.activation then + return true + end + end + end + return false +end + +local function publishResult(requestId, ok, errorCode, managedPath) + noctalia.state.set(CREATE_RESULT_KEY, { + request_id = requestId or "", + ok = ok == true, + error = errorCode or "", + managed_path = managedPath or "", + }) +end + +local function publishUpdateResult(requestId, ok, errorCode, targetPath) + noctalia.state.set(UPDATE_RESULT_KEY, { + request_id = requestId or "", ok = ok == true, error = errorCode or "", target_path = targetPath or "", + }) +end + +local function publishOperationResult(request, ok, errorCode, path) + if request.operation ~= nil then + publishUpdateResult(request.request_id, ok, errorCode, path) + else + publishResult(request.request_id, ok, errorCode, path) + end +end + +local function luaLongString(value) + local equals = "" + while value:find("]" .. equals .. "]", 1, true) ~= nil do + equals = equals .. "=" + end + return "[" .. equals .. "[" .. value .. "]" .. equals .. "]" +end + +local function quoted(value) + return '"' .. value:gsub("\\", "\\\\"):gsub('"', '\\"'):gsub("\t", "\\t") .. '"' +end + +local function hyprCombo(request) + local parts = {} + for _, modifier in ipairs(request.modifiers) do parts[#parts + 1] = modifier end + for _, key in ipairs(request.keys) do parts[#parts + 1] = key end + return table.concat(parts, " + ") +end + +local function niriCombo(request) + local parts = {} + local aliases = { SUPER = "Mod", CTRL = "Ctrl", SHIFT = "Shift", ALT = "Alt" } + for _, modifier in ipairs(request.modifiers) do parts[#parts + 1] = aliases[modifier] end + parts[#parts + 1] = request.keys[1] + return table.concat(parts, "+") +end + +local function mangoCombo(request) + return #request.modifiers > 0 and table.concat(request.modifiers, "+") or "NONE" +end + +local function managedHeader(compositor) + local prefix = FORMATS[compositor].comment + return prefix .. " Managed by Noctalia Keymap.\n" + .. prefix .. " Existing entries are preserved; new entries are appended.\n" +end + +local function ownsManagedFile(compositor, content) + return content:sub(1, #managedHeader(compositor)) == managedHeader(compositor) +end + +local function generatedEntry(request) + if request.compositor == "Hyprland" then + local options = request.activation == "release" + and ("{ release = true, description = " .. quoted(request.description) .. " }") + or ("{ description = " .. quoted(request.description) .. " }") + local dispatcher = request.command_kind == "native" + and request.command or ("hl.dsp.exec_cmd(" .. luaLongString(request.command) .. ")") + return "-- 1. " .. request.category .. "\n" + .. "hl.bind(" .. quoted(hyprCombo(request)) .. ", " .. dispatcher + .. ", " .. options .. ")\n" + end + if request.compositor == "Niri" then + local action = request.command_kind == "native" + and request.command or ("spawn-sh " .. quoted(request.command)) + return " //\"" .. request.category .. "\"\n" + .. " " .. niriCombo(request) .. " repeat=false hotkey-overlay-title=" .. quoted(request.description) + .. " { " .. action .. "; }\n" + end + local directive = request.activation == "release" and "bindr" or "bind" + local description = request.description:gsub("\\", "\\\\"):gsub('"', '\\"') + local dispatcher = request.command_kind == "native" + and request.command or ("spawn_shell," .. request.command) + return "# Keymap category: " .. request.category .. "\n" + .. directive .. "=" .. mangoCombo(request) .. "," .. request.keys[1] + .. "," .. dispatcher .. " #\"" .. description .. "\"\n" +end + +local function buildManagedContent(request, existing, entry) + if existing == nil then + if request.compositor == "Niri" then + return managedHeader(request.compositor) .. "binds {\n" .. entry .. "}\n", nil + end + if request.compositor == "MangoWC" then + return managedHeader(request.compositor) .. "\nkeymode=default\n\n" .. entry, nil + end + return managedHeader(request.compositor) .. "\n" .. entry, nil + end + if not ownsManagedFile(request.compositor, existing) then return nil, "managed_file_collision" end + if #existing > MAX_MANAGED_BYTES then return nil, "managed_file_too_large" end + if existing:find(entry, 1, true) ~= nil then return existing, nil end + + local output + if request.compositor == "Niri" then + local beforeClose = existing:match("^(.*\n)%s*}%s*$") + if beforeClose == nil then return nil, "managed_file_invalid" end + output = beforeClose .. entry .. "}\n" + else + local separator = (existing == "" or existing:sub(-1) == "\n") and "" or "\n" + output = existing .. separator .. entry + end + if #output > MAX_MANAGED_BYTES then return nil, "managed_file_too_large" end + return output, nil +end + +local function includeBlock(format) + local beginMarker = format.comment .. " BEGIN Keymap managed include" + local endMarker = format.comment .. " END Keymap managed include" + return beginMarker, endMarker, beginMarker .. "\n" .. format.includeLine .. "\n" .. endMarker +end + +local function hasExactLine(content, expected) + for line in (content .. "\n"):gmatch("([^\n]*)\n") do + if trim(line) == expected then return true end + end + return false +end + +local function buildRootContent(root, format) + local beginMarker, endMarker, block = includeBlock(format) + if root:find(block, 1, true) ~= nil or hasExactLine(root, format.includeLine) then + return root, nil + end + if root:find(beginMarker, 1, true) ~= nil or root:find(endMarker, 1, true) ~= nil then + return nil, "managed_include_invalid" + end + local separator = root == "" and "" or (root:sub(-1) == "\n" and "\n" or "\n\n") + return root .. separator .. block .. "\n", nil +end + +local function atomicWrite(path, content) + temporaryCounter = temporaryCounter + 1 + local temporary = path .. ".keymap-" .. tostring(os.time()) + .. "-" .. tostring(temporaryCounter) .. ".tmp" + if noctalia.fileExists(temporary) then return false, "temporary_collision" end + local written = noctalia.writeFile(temporary, content) + if written ~= true then + return false, "write_failed" + end + local renamed = noctalia.renameFile(temporary, path) + if renamed ~= true then + noctalia.removeFile(temporary) + return false, "rename_failed" + end + return true, nil +end + +local function shellQuote(value) + return "'" .. value:gsub("'", "'\\''") .. "'" +end + +local function rollbackTransaction(transaction) + if transaction.rootWritten then + local current = noctalia.readFile(transaction.source) + if current ~= transaction.rootNew then + -- Keep the managed file when an external edit prevents restoring the + -- root. The external root may still contain our include. + return false + end + local restored = atomicWrite(transaction.source, transaction.rootOld) + if restored ~= true then return false end + end + if transaction.managedWritten then + local current = noctalia.readFile(transaction.managedPath) + if current ~= transaction.managedNew then + return false + elseif transaction.managedExisted then + local restored = atomicWrite(transaction.managedPath, transaction.managedOld) + if restored ~= true then return false end + elseif noctalia.removeFile(transaction.managedPath) ~= true then + return false + end + end + return true +end + +local function publishFailureAfterRollback(request, transaction, errorCode, reloadOld) + local rolledBack = rollbackTransaction(transaction) + if reloadOld and rolledBack then + -- A failed reload may still have applied part of the candidate. Restore the + -- previous on-disk state and ask the compositor to read it once more. + local started = noctalia.runAsync(request.format.reloadCommand, function(result) + local recovered = result.timedOut ~= true and result.exitCode == 0 + publishOperationResult( + request, false, recovered and errorCode or "recovery_reload_failed", transaction.managedPath + ) + busy = false + end, RELOAD_TIMEOUT_MS) + if not started then + publishOperationResult(request, false, "recovery_reload_failed", transaction.managedPath) + busy = false + end + return + end + publishOperationResult(request, false, rolledBack and errorCode or "rollback_failed", transaction.managedPath) + busy = false +end + +local function finishSuccess(request, managedPath) + local current = tonumber(noctalia.state.get(REFRESH_REQUEST_KEY)) or 0 + noctalia.state.set(REFRESH_REQUEST_KEY, current + 1) + publishOperationResult(request, true, "", managedPath) + busy = false +end + +local function reloadAndFinish(request, transaction) + local started = noctalia.runAsync(request.format.reloadCommand, function(result) + if result.timedOut == true then + publishFailureAfterRollback(request, transaction, "reload_timeout", true) + return + end + if result.exitCode ~= 0 then + publishFailureAfterRollback(request, transaction, "reload_failed", true) + return + end + finishSuccess(request, transaction.managedPath) + end, RELOAD_TIMEOUT_MS) + if not started then + publishFailureAfterRollback(request, transaction, "reload_start_failed", true) + end +end + +local function verifyAndReload(request, transaction) + local command = request.format.validator .. shellQuote(request.source) + .. (request.format.validatorSuffix or "") + local started = noctalia.runAsync(command, function(result) + if result.timedOut == true then + publishFailureAfterRollback(request, transaction, "verify_timeout", false) + return + end + if result.exitCode ~= 0 then + local errorCode = result.exitCode == 127 and "validator_unavailable" or "verify_failed" + publishFailureAfterRollback(request, transaction, errorCode, false) + return + end + reloadAndFinish(request, transaction) + end, VERIFY_TIMEOUT_MS) + if not started then + publishFailureAfterRollback(request, transaction, "verify_start_failed", false) + end +end + +local function rollbackFileSet(files) + for index = #files, 1, -1 do + local file = files[index] + if file.written then + if noctalia.readFile(file.path) ~= file.new then return false end + if atomicWrite(file.path, file.old) ~= true then return false end + end + end + return true +end + +local function publishFileSetFailure(request, files, errorCode, reloadOld) + local rolledBack = rollbackFileSet(files) + if reloadOld and rolledBack then + local started = noctalia.runAsync(request.format.reloadCommand, function(result) + local recovered = result.timedOut ~= true and result.exitCode == 0 + publishUpdateResult( + request.request_id, false, + recovered and errorCode or "recovery_reload_failed", request.source + ) + busy = false + end, RELOAD_TIMEOUT_MS) + if not started then + publishUpdateResult(request.request_id, false, "recovery_reload_failed", request.source) + busy = false + end + return + end + publishUpdateResult( + request.request_id, false, rolledBack and errorCode or "rollback_failed", request.source + ) + busy = false +end + +local function reloadFileSetAndFinish(request, files) + local started = noctalia.runAsync(request.format.reloadCommand, function(result) + if result.timedOut == true then + publishFileSetFailure(request, files, "reload_timeout", true) + return + end + if result.exitCode ~= 0 then + publishFileSetFailure(request, files, "reload_failed", true) + return + end + finishSuccess(request, request.source) + end, RELOAD_TIMEOUT_MS) + if not started then publishFileSetFailure(request, files, "reload_start_failed", true) end +end + +local function verifyFileSetAndReload(request, files) + local command = request.format.validator .. shellQuote(request.source) + .. (request.format.validatorSuffix or "") + local started = noctalia.runAsync(command, function(result) + if result.timedOut == true then + publishFileSetFailure(request, files, "verify_timeout", false) + return + end + if result.exitCode ~= 0 then + local errorCode = result.exitCode == 127 and "validator_unavailable" or "verify_failed" + publishFileSetFailure(request, files, errorCode, false) + return + end + reloadFileSetAndFinish(request, files) + end, VERIFY_TIMEOUT_MS) + if not started then publishFileSetFailure(request, files, "verify_start_failed", false) end +end + +local processValidatedRequest + +local function processRequest(raw) + local fallbackId = "" + if type(raw) == "table" then + local rawId = raw.request_id + if type(rawId) == "number" then rawId = tostring(rawId) end + if type(rawId) == "string" and #rawId <= 256 and not hasUnsafeLineCharacters(rawId) then + fallbackId = rawId + end + end + if busy then + publishResult(fallbackId, false, "busy", "") + return + end + local request, errorCode = validateRequest(raw) + if request == nil then + publishResult(fallbackId, false, errorCode, "") + return + end + if not validateCurrentContext(request) then + publishResult(request.request_id, false, "stale_context", "") + return + end + busy = true + local managedPath = dirname(request.source) .. "/" .. request.format.managedName + local preflight = "[ ! -L " .. shellQuote(request.source) .. " ] && [ ! -L " + .. shellQuote(managedPath) .. " ]" + local started = noctalia.runAsync(preflight, function(result) + if result.timedOut == true or result.exitCode ~= 0 then + publishResult(request.request_id, false, "symlink_unsupported", managedPath) + busy = false + return + end + processValidatedRequest(request) + end, 2000) + if not started then + publishResult(request.request_id, false, "preflight_failed", managedPath) + busy = false + end +end + +processValidatedRequest = function(request) + local root = noctalia.readFile(request.source) + if type(root) ~= "string" then + publishResult(request.request_id, false, "source_unreadable", "") + busy = false + return + end + if #root > MAX_ROOT_BYTES then + publishResult(request.request_id, false, "source_too_large", "") + busy = false + return + end + + local managedPath = dirname(request.source) .. "/" .. request.format.managedName + local existing = nil + if noctalia.fileExists(managedPath) then + existing = noctalia.readFile(managedPath) + if type(existing) ~= "string" then + publishResult(request.request_id, false, "managed_file_unreadable", managedPath) + busy = false + return + end + end + local entry = generatedEntry(request) + local existingExactEntry = existing ~= nil and existing:find(entry, 1, true) ~= nil + if conflictsWithSnapshot(request) and not existingExactEntry then + publishResult(request.request_id, false, "conflict_blocked", managedPath) + busy = false + return + end + local managed, managedError = buildManagedContent(request, existing, entry) + if managed == nil then + publishResult(request.request_id, false, managedError, managedPath) + busy = false + return + end + local newRoot, rootError = buildRootContent(root, request.format) + if newRoot == nil then + publishResult(request.request_id, false, rootError, managedPath) + busy = false + return + end + if #newRoot > MAX_ROOT_BYTES then + publishResult(request.request_id, false, "source_too_large", managedPath) + busy = false + return + end + + -- Refuse to overwrite an edit made after the initial read. This check is + -- intentionally immediately before the first rename. + if noctalia.readFile(request.source) ~= root then + publishResult(request.request_id, false, "source_changed", managedPath) + busy = false + return + end + if existing ~= nil then + if noctalia.readFile(managedPath) ~= existing then + publishResult(request.request_id, false, "managed_file_changed", managedPath) + busy = false + return + end + elseif noctalia.fileExists(managedPath) then + publishResult(request.request_id, false, "managed_file_changed", managedPath) + busy = false + return + end + + local transaction = { + source = request.source, + rootOld = root, + rootNew = newRoot, + rootWritten = false, + managedPath = managedPath, + managedOld = existing, + managedNew = managed, + managedExisted = existing ~= nil, + managedWritten = false, + } + if existing ~= managed then + if existing ~= nil and noctalia.readFile(managedPath) ~= existing then + publishResult(request.request_id, false, "managed_file_changed", managedPath) + busy = false + return + end + local ok, writeError = atomicWrite(managedPath, managed) + if not ok then + publishResult(request.request_id, false, "managed_" .. writeError, managedPath) + busy = false + return + end + transaction.managedWritten = true + end + if root ~= newRoot then + if noctalia.readFile(request.source) ~= root then + publishFailureAfterRollback(request, transaction, "source_changed", false) + return + end + local ok, writeError = atomicWrite(request.source, newRoot) + if not ok then + publishFailureAfterRollback(request, transaction, "source_" .. writeError, false) + return + end + transaction.rootWritten = true + end + if not transaction.managedWritten and not transaction.rootWritten then + finishSuccess(request, managedPath) + return + end + verifyAndReload(request, transaction) +end + +local function contentFingerprint(value) + local function xorByte(left, right) + local result, place = 0, 1 + for _ = 1, 8 do + if left % 2 ~= right % 2 then result = result + place end + left = math.floor(left / 2) + right = math.floor(right / 2) + place = place * 2 + end + return result + end + local hash = 2166136261 + for index = 1, #value do + local low = hash % 256 + hash = hash - low + xorByte(low, value:byte(index)) + hash = (hash * 403 + (hash % 256) * 16777216) % 4294967296 + end + return string.format("%08x", hash) +end + +local function splitLines(content) + local lines, cursor = {}, 1 + while cursor <= #content do + local newline = content:find("\n", cursor, true) + if newline == nil then + lines[#lines + 1] = content:sub(cursor) + break + end + lines[#lines + 1] = content:sub(cursor, newline - 1) + cursor = newline + 1 + end + return lines, content:sub(-1) == "\n" +end + +local function replaceLineRange(content, firstLine, lastLine, expected, replacement) + local lines, trailingNewline = splitLines(content) + if firstLine < 1 or lastLine < firstLine or lastLine > #lines then return nil, "target_range_invalid" end + local current = table.concat(lines, "\n", firstLine, lastLine) + if current ~= expected or contentFingerprint(current) ~= contentFingerprint(expected) then + return nil, "target_changed" + end + local replacementLines = {} + if replacement ~= "" then + for line in (replacement .. "\n"):gmatch("([^\n]*)\n") do replacementLines[#replacementLines + 1] = line end + end + local output = {} + for index = 1, firstLine - 1 do output[#output + 1] = lines[index] end + for _, line in ipairs(replacementLines) do output[#output + 1] = line end + for index = lastLine + 1, #lines do output[#output + 1] = lines[index] end + return table.concat(output, "\n") .. (trailingNewline and "\n" or ""), nil +end + +local function reorderLineRanges(content, target, anchor, placement, replacementRaw) + local lines, trailingNewline = splitLines(content) + local targetFirst, targetLast = tonumber(target.start_line), tonumber(target.end_line) + local anchorFirst, anchorLast = tonumber(anchor.start_line), tonumber(anchor.end_line) + if targetFirst < 1 or targetLast < targetFirst or targetLast > #lines then + return nil, "target_range_invalid" + end + if anchorFirst < 1 or anchorLast < anchorFirst or anchorLast > #lines then + return nil, "anchor_range_invalid" + end + if not (targetLast < anchorFirst or anchorLast < targetFirst) then + return nil, "target_anchor_overlap" + end + + local targetRaw = tostring(target.raw_snippet or "") + replacementRaw = replacementRaw == nil and targetRaw or tostring(replacementRaw) + local anchorRaw = tostring(anchor.raw_snippet or "") + local currentTarget = table.concat(lines, "\n", targetFirst, targetLast) + local currentAnchor = table.concat(lines, "\n", anchorFirst, anchorLast) + if currentTarget ~= targetRaw + or contentFingerprint(currentTarget) ~= tostring(target.fingerprint or "") then + return nil, "target_changed" + end + if currentAnchor ~= anchorRaw + or contentFingerprint(currentAnchor) ~= tostring(anchor.fingerprint or "") then + return nil, "anchor_changed" + end + + if replacementRaw == targetRaw and (placement == "before" and targetLast + 1 == anchorFirst + or placement == "after" and anchorLast + 1 == targetFirst) then + return content, nil + end + + local targetLines = splitLines(replacementRaw) + local remaining = {} + for index, line in ipairs(lines) do + if index < targetFirst or index > targetLast then remaining[#remaining + 1] = line end + end + local targetLength = targetLast - targetFirst + 1 + if targetFirst < anchorFirst then + anchorFirst = anchorFirst - targetLength + anchorLast = anchorLast - targetLength + end + local insertionIndex = placement == "before" and anchorFirst or anchorLast + 1 + local output = {} + for index = 1, insertionIndex - 1 do output[#output + 1] = remaining[index] end + for _, line in ipairs(targetLines) do output[#output + 1] = line end + for index = insertionIndex, #remaining do output[#output + 1] = remaining[index] end + return table.concat(output, "\n") .. (trailingNewline and "\n" or ""), nil +end + +local function hexEncode(value) + local output = {} + for index = 1, #value do output[#output + 1] = string.format("%02x", value:byte(index)) end + return table.concat(output) +end + +local function hexDecode(value) + if value == "" or #value % 2 ~= 0 or value:find("[^0-9a-f]") ~= nil then return nil end + local output = {} + for index = 1, #value, 2 do + local byte = tonumber(value:sub(index, index + 1), 16) + if byte == nil then return nil end + output[#output + 1] = string.char(byte) + end + return table.concat(output) +end + +-- Hidden blocks are deliberately recognizable only when written by this +-- plugin. Ordinary commented-out bindings remain ordinary user comments and +-- are never offered as restorable targets. The original snippet is hex encoded +-- as bytes so multiline entries, indentation, CRLF and arbitrary command text +-- can be restored without interpreting or normalizing compositor syntax. +local function hiddenSnippet(compositor, snippet) + local prefix = FORMATS[compositor].comment + local indent = snippet:match("^([\t ]*)") or "" + local originalFingerprint = contentFingerprint(snippet) + local blockId = contentFingerprint(compositor .. "\0" .. snippet) + local output = {} + local marker = indent .. prefix .. " Keymap hidden " .. HIDDEN_BLOCK_VERSION + output[#output + 1] = marker .. " begin " .. blockId .. " " .. originalFingerprint + for offset = 1, #snippet, HIDDEN_DATA_CHUNK_BYTES do + output[#output + 1] = marker .. " data " .. hexEncode(snippet:sub(offset, offset + HIDDEN_DATA_CHUNK_BYTES - 1)) + end + output[#output + 1] = marker .. " end " .. blockId + return table.concat(output, "\n") +end + +local function parseHiddenSnippet(compositor, snippet) + local format = FORMATS[compositor] + if format == nil or type(snippet) ~= "string" or snippet == "" then return nil, "hidden_block_invalid" end + local lines, trailingNewline = splitLines(snippet) + if trailingNewline or #lines < 3 then return nil, "hidden_block_invalid" end + local escapedPrefix = format.comment:gsub("(%W)", "%%%1") + local markerPattern = "^([\t ]*)" .. escapedPrefix .. " Keymap hidden " + .. HIDDEN_BLOCK_VERSION + local indent, blockId, expectedFingerprint = lines[1]:match( + markerPattern .. " begin ([0-9a-f]+) ([0-9a-f]+)$" + ) + if indent == nil or #blockId ~= 8 or #expectedFingerprint ~= 8 then + return nil, "hidden_block_invalid" + end + local marker = indent .. format.comment .. " Keymap hidden " .. HIDDEN_BLOCK_VERSION + if lines[#lines] ~= marker .. " end " .. blockId then return nil, "hidden_block_invalid" end + local encoded = {} + for index = 2, #lines - 1 do + local chunk = lines[index]:match("^" .. marker:gsub("(%W)", "%%%1") .. " data ([0-9a-f]+)$") + if chunk == nil or #chunk > HIDDEN_DATA_CHUNK_BYTES * 2 or #chunk % 2 ~= 0 then + return nil, "hidden_block_invalid" + end + encoded[#encoded + 1] = chunk + end + local original = hexDecode(table.concat(encoded)) + if original == nil or original == "" or #original > MAX_ROOT_BYTES then return nil, "hidden_block_invalid" end + if contentFingerprint(original) ~= expectedFingerprint + or contentFingerprint(compositor .. "\0" .. original) ~= blockId then + return nil, "hidden_block_invalid" + end + return { + original = original, original_fingerprint = expectedFingerprint, block_id = blockId, + }, nil +end + +local function replaceLiteralAfter(text, prefixPattern, value, useLongString) + local _, prefixEnd = text:find(prefixPattern) + if prefixEnd == nil then return nil end + local startIndex = prefixEnd + 1 + while text:sub(startIndex, startIndex):match("%s") do startIndex = startIndex + 1 end + local first = text:sub(startIndex, startIndex) + local endIndex + if first == '"' or first == "'" then + local escaped = false + for index = startIndex + 1, #text do + local char = text:sub(index, index) + if escaped then escaped = false + elseif char == "\\" then escaped = true + elseif char == first then endIndex = index break end + end + elseif first == "[" then + local equals = text:sub(startIndex):match("^%[(=*)%[") + if equals ~= nil then + local close = "]" .. equals .. "]" + local closeStart = text:find(close, startIndex + #equals + 2, true) + if closeStart ~= nil then endIndex = closeStart + #close - 1 end + end + end + if endIndex == nil then return nil end + local replacement = useLongString and luaLongString(value) or quoted(value) + return text:sub(1, startIndex - 1) .. replacement .. text:sub(endIndex + 1) +end + +local function splitBindCategoryMarker(compositor, snippet) + local prefix = FORMATS[compositor].comment + local first, rest = snippet:match("^([^\n]*)\n(.*)$") + if first == nil then return snippet, false end + local escapedPrefix = prefix:gsub("(%W)", "%%%1") + if first:match("^%s*" .. escapedPrefix .. "%s*Keymap bind%-category:%s*.+$") then + return rest, true + end + return snippet, false +end + +local function withBindCategoryMarker(compositor, snippet, category, forceMarker) + if not forceMarker then return snippet end + local prefix = FORMATS[compositor].comment + local indent = snippet:match("^(%s*)") or "" + return indent .. prefix .. " Keymap bind-category: " .. category .. "\n" .. snippet +end + +local function renderHyprUpdate(target, request) + local line = replaceLiteralAfter(target.raw_snippet, "hl%.bind%s*%(%s*", hyprCombo(request), false) + if line == nil then return nil, "target_not_editable" end + line = replaceLiteralAfter(line, "description%s*=%s*", request.description, false) + if line == nil then return nil, "description_not_editable" end + if target.capabilities.command == true then + line = replaceLiteralAfter(line, "hl%.dsp%.exec_cmd%s*%(%s*", request.command, true) + if line == nil then return nil, "command_not_editable" end + end + line = line:gsub("release%s*=%s*true%s*,%s*", "", 1) + line = line:gsub(",%s*release%s*=%s*true%s*", "", 1) + if request.activation == "release" then + local prefix, suffix = line:match("^(.*,%s*{%s*)(.*)$") + if prefix == nil then return nil, "activation_not_editable" end + line = prefix .. "release = true, " .. suffix + end + return line, nil +end + +local function renderNiriUpdate(target, request) + local snippet, count = target.raw_snippet:gsub("^(%s*)%S+", "%1" .. niriCombo(request), 1) + if count ~= 1 then return nil, "target_not_editable" end + local described = replaceLiteralAfter(snippet, "hotkey%-overlay%-title%s*=%s*", request.description, false) + if described == nil then + described = snippet:gsub("^(%s*%S+)", "%1 hotkey-overlay-title=" .. quoted(request.description), 1) + end + snippet = described + if target.capabilities.command == true then + snippet = replaceLiteralAfter(snippet, "spawn%-sh%s+", request.command, false) + if snippet == nil then return nil, "command_not_editable" end + end + return snippet, nil +end + +local function renderMangoUpdate(target, request) + local indent, flags, _mods, _key, tail = target.raw_snippet:match("^(%s*)bind([lsrp]*)=([^,]*),([^,]*),(.*)$") + if indent == nil then return nil, "target_not_editable" end + flags = flags:gsub("r", "") + if request.activation == "release" then flags = flags .. "r" end + local action = tail:gsub('%s+#".*"%s*$', "") + if target.capabilities.command == true then action = "spawn_shell," .. request.command end + local description = request.description:gsub("\\", "\\\\"):gsub('"', '\\"') + return indent .. "bind" .. flags .. "=" .. mangoCombo(request) .. "," .. request.keys[1] + .. "," .. action .. ' #"' .. description .. '"', nil +end + +local function findSnapshotTarget(targetId, hiddenOnly) + local snapshot = noctalia.state.get(SNAPSHOT_KEY) + if hiddenOnly ~= true then + for _, category in ipairs(type(snapshot) == "table" and snapshot.categories or {}) do + for _, bind in ipairs(type(category.binds) == "table" and category.binds or {}) do + if tostring(bind.id or "") == tostring(targetId or "") then return bind, category end + end + end + end + for _, bind in ipairs(type(snapshot) == "table" and type(snapshot.hidden) == "table" and snapshot.hidden or {}) do + if tostring(bind.id or "") == tostring(targetId or "") then return bind, nil end + end + return nil, nil +end + +local function findActiveSnapshotTarget(targetId) + local snapshot = noctalia.state.get(SNAPSHOT_KEY) + local found, foundCategory + for _, category in ipairs(type(snapshot) == "table" and snapshot.categories or {}) do + for _, bind in ipairs(type(category.binds) == "table" and category.binds or {}) do + if tostring(bind.id or "") == tostring(targetId or "") then + if found ~= nil then return nil, nil, true end + found, foundCategory = bind, category + end + end + end + return found, foundCategory, false +end + +local function findSnapshotCategory(categoryId) + local snapshot = noctalia.state.get(SNAPSHOT_KEY) + local found + for _, category in ipairs(type(snapshot) == "table" and snapshot.categories or {}) do + if tostring(category.id or "") == tostring(categoryId or "") then + if found ~= nil then return nil, true end + found = category + end + end + return found, false +end + +local function snapshotHasOtherCategoryName(categoryId, name) + local snapshot = noctalia.state.get(SNAPSHOT_KEY) + for _, category in ipairs(type(snapshot) == "table" and snapshot.categories or {}) do + if tostring(category.id or "") ~= tostring(categoryId or "") + and tostring(category.name or "") == name then return true end + end + return false +end + +local function processValidatedUpdate(request, target, currentCategory) + local targetContent = noctalia.readFile(target.source) + if type(targetContent) ~= "string" then + publishUpdateResult(request.request_id, false, "source_unreadable", target.source) + busy = false + return + end + if #targetContent > MAX_ROOT_BYTES then + publishUpdateResult(request.request_id, false, "source_too_large", target.source) + busy = false + return + end + if contentFingerprint(tostring(target.raw_snippet or "")) ~= tostring(target.fingerprint or "") then + publishUpdateResult(request.request_id, false, "target_changed", target.source) + busy = false + return + end + local editableSnippet, hadCategoryMarker = splitBindCategoryMarker( + request.compositor, tostring(target.raw_snippet or "") + ) + local renderTarget = {} + for key, value in pairs(target) do renderTarget[key] = value end + renderTarget.raw_snippet = editableSnippet + local replacement, renderError + if request.compositor == "Hyprland" then replacement, renderError = renderHyprUpdate(renderTarget, request) + elseif request.compositor == "Niri" then replacement, renderError = renderNiriUpdate(renderTarget, request) + else replacement, renderError = renderMangoUpdate(renderTarget, request) end + if replacement == nil then + publishUpdateResult(request.request_id, false, renderError, target.source) + busy = false + return + end + replacement = withBindCategoryMarker( + request.compositor, replacement, request.category, + hadCategoryMarker or request.category ~= tostring(currentCategory or "") + ) + local updated, rangeError = replaceLineRange( + targetContent, tonumber(target.start_line) or 0, tonumber(target.end_line) or 0, + tostring(target.raw_snippet or ""), replacement + ) + if updated == nil then + publishUpdateResult(request.request_id, false, rangeError, target.source) + busy = false + return + end + if #updated > MAX_ROOT_BYTES then + publishUpdateResult(request.request_id, false, "source_too_large", target.source) + busy = false + return + end + if noctalia.readFile(target.source) ~= targetContent then + publishUpdateResult(request.request_id, false, "target_changed", target.source) + busy = false + return + end + local rootOld = noctalia.readFile(request.source) + if type(rootOld) ~= "string" then + publishUpdateResult(request.request_id, false, "source_unreadable", request.source) + busy = false + return + end + local transaction = { + source = request.source, rootOld = rootOld, rootNew = rootOld, rootWritten = false, + managedPath = target.source, managedOld = targetContent, managedNew = updated, + managedExisted = true, managedWritten = false, + } + if target.source == request.source then + transaction.rootNew = updated + local ok = atomicWrite(target.source, updated) + if not ok then + publishUpdateResult(request.request_id, false, "source_write_failed", target.source) + busy = false + return + end + transaction.rootWritten = true + else + local ok = atomicWrite(target.source, updated) + if not ok then + publishUpdateResult(request.request_id, false, "source_write_failed", target.source) + busy = false + return + end + transaction.managedWritten = true + end + verifyAndReload(request, transaction) +end + +local function processValidatedMove(request, target, currentCategory) + local targetContent = noctalia.readFile(target.source) + if type(targetContent) ~= "string" then + publishUpdateResult(request.request_id, false, "source_unreadable", target.source) + busy = false + return + end + if #targetContent > MAX_ROOT_BYTES then + publishUpdateResult(request.request_id, false, "source_too_large", target.source) + busy = false + return + end + local rawSnippet = tostring(target.raw_snippet or "") + if contentFingerprint(rawSnippet) ~= tostring(target.fingerprint or "") then + publishUpdateResult(request.request_id, false, "target_changed", target.source) + busy = false + return + end + local bindSnippet = splitBindCategoryMarker(request.compositor, rawSnippet) + local replacement = withBindCategoryMarker(request.compositor, bindSnippet, request.category, true) + local updated, rangeError = replaceLineRange( + targetContent, tonumber(target.start_line) or 0, tonumber(target.end_line) or 0, + rawSnippet, replacement + ) + if updated == nil then + publishUpdateResult(request.request_id, false, rangeError, target.source) + busy = false + return + end + if #updated > MAX_ROOT_BYTES then + publishUpdateResult(request.request_id, false, "source_too_large", target.source) + busy = false + return + end + if noctalia.readFile(target.source) ~= targetContent then + publishUpdateResult(request.request_id, false, "target_changed", target.source) + busy = false + return + end + if request.category == currentCategory or updated == targetContent then + finishSuccess(request, target.source) + return + end + local rootOld = noctalia.readFile(request.source) + if type(rootOld) ~= "string" then + publishUpdateResult(request.request_id, false, "source_unreadable", request.source) + busy = false + return + end + local transaction = { + source = request.source, rootOld = rootOld, rootNew = rootOld, rootWritten = false, + managedPath = target.source, managedOld = targetContent, managedNew = updated, + managedExisted = true, managedWritten = false, + } + if noctalia.readFile(target.source) ~= targetContent then + publishUpdateResult(request.request_id, false, "target_changed", target.source) + busy = false + return + end + local ok = atomicWrite(target.source, updated) + if target.source == request.source then + transaction.rootNew = updated + transaction.rootWritten = ok == true + else + transaction.managedWritten = ok == true + end + if ok ~= true then + publishUpdateResult(request.request_id, false, "source_write_failed", target.source) + busy = false + return + end + verifyAndReload(request, transaction) +end + +local function processValidatedReorder(request, target, anchor) + local targetContent = noctalia.readFile(target.source) + if type(targetContent) ~= "string" then + publishUpdateResult(request.request_id, false, "source_unreadable", target.source) + busy = false + return + end + if #targetContent > MAX_ROOT_BYTES then + publishUpdateResult(request.request_id, false, "source_too_large", target.source) + busy = false + return + end + if contentFingerprint(tostring(target.raw_snippet or "")) ~= tostring(target.fingerprint or "") then + publishUpdateResult(request.request_id, false, "target_changed", target.source) + busy = false + return + end + if contentFingerprint(tostring(anchor.raw_snippet or "")) ~= tostring(anchor.fingerprint or "") then + publishUpdateResult(request.request_id, false, "anchor_changed", anchor.source) + busy = false + return + end + local replacementRaw = tostring(target.raw_snippet or "") + if request.category ~= request.current_category then + local bindSnippet = splitBindCategoryMarker(request.compositor, replacementRaw) + replacementRaw = withBindCategoryMarker(request.compositor, bindSnippet, request.category, true) + end + local updated, reorderError = reorderLineRanges( + targetContent, target, anchor, request.placement, replacementRaw + ) + if updated == nil then + publishUpdateResult(request.request_id, false, reorderError, target.source) + busy = false + return + end + if updated == targetContent then + finishSuccess(request, target.source) + return + end + if noctalia.readFile(target.source) ~= targetContent then + publishUpdateResult(request.request_id, false, "target_changed", target.source) + busy = false + return + end + local rootOld = noctalia.readFile(request.source) + if type(rootOld) ~= "string" then + publishUpdateResult(request.request_id, false, "source_unreadable", request.source) + busy = false + return + end + local transaction = { + source = request.source, rootOld = rootOld, rootNew = rootOld, rootWritten = false, + managedPath = target.source, managedOld = targetContent, managedNew = updated, + managedExisted = true, managedWritten = false, + } + if noctalia.readFile(target.source) ~= targetContent then + publishUpdateResult(request.request_id, false, "target_changed", target.source) + busy = false + return + end + local ok = atomicWrite(target.source, updated) + if target.source == request.source then + transaction.rootNew = updated + transaction.rootWritten = ok == true + else + transaction.managedWritten = ok == true + end + if ok ~= true then + publishUpdateResult(request.request_id, false, "source_write_failed", target.source) + busy = false + return + end + verifyAndReload(request, transaction) +end + +local function processValidatedCategoryRename(request, category) + local grouped = {} + local paths = {} + for _, bind in ipairs(category.binds) do + local group = grouped[bind.source] + if group == nil then + group = { path = bind.source, entries = {} } + grouped[bind.source] = group + paths[#paths + 1] = bind.source + end + group.entries[#group.entries + 1] = bind + end + table.sort(paths) + + local files = {} + for _, path in ipairs(paths) do + local group = grouped[path] + local content = noctalia.readFile(path) + if type(content) ~= "string" then + publishUpdateResult(request.request_id, false, "source_unreadable", path) + busy = false + return + end + if #content > MAX_ROOT_BYTES then + publishUpdateResult(request.request_id, false, "source_too_large", path) + busy = false + return + end + local lines = splitLines(content) + table.sort(group.entries, function(left, right) + return left.start_line < right.start_line + end) + local previousLast = 0 + for _, bind in ipairs(group.entries) do + local rawSnippet = tostring(bind.raw_snippet or "") + if bind.start_line <= previousLast then + publishUpdateResult(request.request_id, false, "category_ranges_overlap", path) + busy = false + return + end + previousLast = bind.end_line + if bind.end_line > #lines then + publishUpdateResult(request.request_id, false, "target_range_invalid", path) + busy = false + return + end + local current = table.concat(lines, "\n", bind.start_line, bind.end_line) + if contentFingerprint(rawSnippet) ~= tostring(bind.fingerprint or "") + or current ~= rawSnippet + or contentFingerprint(current) ~= tostring(bind.fingerprint or "") then + publishUpdateResult(request.request_id, false, "target_changed", path) + busy = false + return + end + end + + local updated = content + if request.new_category ~= request.old_category then + for index = #group.entries, 1, -1 do + local bind = group.entries[index] + local bindSnippet = splitBindCategoryMarker(request.compositor, bind.raw_snippet) + local replacement = withBindCategoryMarker( + request.compositor, bindSnippet, request.new_category, true + ) + local rangeError + updated, rangeError = replaceLineRange( + updated, bind.start_line, bind.end_line, bind.raw_snippet, replacement + ) + if updated == nil then + publishUpdateResult(request.request_id, false, rangeError, path) + busy = false + return + end + end + end + if #updated > MAX_ROOT_BYTES then + publishUpdateResult(request.request_id, false, "source_too_large", path) + busy = false + return + end + files[#files + 1] = { path = path, old = content, new = updated, written = false } + end + + local rootContent = noctalia.readFile(request.source) + if type(rootContent) ~= "string" then + publishUpdateResult(request.request_id, false, "source_unreadable", request.source) + busy = false + return + end + local changed = false + for _, file in ipairs(files) do + if file.old ~= file.new then changed = true end + if noctalia.readFile(file.path) ~= file.old then + publishUpdateResult(request.request_id, false, "target_changed", file.path) + busy = false + return + end + end + if not changed then + finishSuccess(request, request.source) + return + end + for _, file in ipairs(files) do + if file.old ~= file.new then + local ok = atomicWrite(file.path, file.new) + if ok ~= true then + local rolledBack = rollbackFileSet(files) + publishUpdateResult( + request.request_id, false, + rolledBack and "source_write_failed" or "rollback_failed", file.path + ) + busy = false + return + end + file.written = true + end + end + verifyFileSetAndReload(request, files) +end + +local function processValidatedMutation(request, target) + local targetContent = noctalia.readFile(target.source) + if type(targetContent) ~= "string" then + publishUpdateResult(request.request_id, false, "source_unreadable", target.source) + busy = false + return + end + if #targetContent > MAX_ROOT_BYTES then + publishUpdateResult(request.request_id, false, "source_too_large", target.source) + busy = false + return + end + local rawSnippet = tostring(target.raw_snippet or "") + if contentFingerprint(rawSnippet) ~= tostring(target.fingerprint or "") then + publishUpdateResult(request.request_id, false, "target_changed", target.source) + busy = false + return + end + local replacement = "" + if request.operation == "hide" then + replacement = hiddenSnippet(request.compositor, rawSnippet) + elseif target.hidden == true then + local hidden, hiddenError = parseHiddenSnippet(request.compositor, rawSnippet) + if hidden == nil then + publishUpdateResult(request.request_id, false, hiddenError, target.source) + busy = false + return + end + if type(target.original_fingerprint) == "string" and target.original_fingerprint ~= "" + and target.original_fingerprint ~= hidden.original_fingerprint then + publishUpdateResult(request.request_id, false, "hidden_block_invalid", target.source) + busy = false + return + end + if request.operation == "restore" then replacement = hidden.original end + end + local updated, rangeError = replaceLineRange( + targetContent, tonumber(target.start_line) or 0, tonumber(target.end_line) or 0, + rawSnippet, replacement + ) + if updated == nil then + publishUpdateResult(request.request_id, false, rangeError, target.source) + busy = false + return + end + if #updated > MAX_ROOT_BYTES then + publishUpdateResult(request.request_id, false, "source_too_large", target.source) + busy = false + return + end + if noctalia.readFile(target.source) ~= targetContent then + publishUpdateResult(request.request_id, false, "target_changed", target.source) + busy = false + return + end + local rootOld = noctalia.readFile(request.source) + if type(rootOld) ~= "string" then + publishUpdateResult(request.request_id, false, "source_unreadable", request.source) + busy = false + return + end + local transaction = { + source = request.source, rootOld = rootOld, rootNew = rootOld, rootWritten = false, + managedPath = target.source, managedOld = targetContent, managedNew = updated, + managedExisted = true, managedWritten = false, + } + if noctalia.readFile(target.source) ~= targetContent then + publishUpdateResult(request.request_id, false, "target_changed", target.source) + busy = false + return + end + local ok + if target.source == request.source then + transaction.rootNew = updated + ok = atomicWrite(target.source, updated) + transaction.rootWritten = ok == true + else + ok = atomicWrite(target.source, updated) + transaction.managedWritten = ok == true + end + if ok ~= true then + publishUpdateResult(request.request_id, false, "source_write_failed", target.source) + busy = false + return + end + verifyAndReload(request, transaction) +end + +local function processMoveRequest(raw, target, currentCategory) + local rawId = raw.request_id + if type(rawId) == "number" then rawId = tostring(rawId) end + local requestId, errorCode = validateText(rawId, "request_id", false, 256) + if errorCode ~= nil then publishUpdateResult(rawId, false, errorCode, target.source) return end + if raw.operation ~= "move" then + publishUpdateResult(requestId, false, "invalid_operation", target.source) + return + end + local compositor = raw.compositor + local format = FORMATS[compositor] + if format == nil then publishUpdateResult(requestId, false, "invalid_compositor", target.source) return end + local source + source, errorCode = validateText(raw.source, "source", false, 4096) + if errorCode ~= nil or source:sub(1, 1) ~= "/" then + publishUpdateResult(requestId, false, errorCode or "source_not_absolute", target.source) + return + end + local category + category, errorCode = validateCategory(raw.category) + if errorCode ~= nil then publishUpdateResult(requestId, false, errorCode, target.source) return end + local targetId + targetId, errorCode = validateText(raw.target_id, "target_id", false, 256) + if errorCode ~= nil then publishUpdateResult(requestId, false, errorCode, target.source) return end + local targetSource, targetSourceError = validateText(target.source, "target_source", false, 4096) + if targetSourceError ~= nil then targetSource = "" end + local firstLine, lastLine = tonumber(target.start_line), tonumber(target.end_line) + local capabilities = type(target.capabilities) == "table" and target.capabilities or {} + local currentCategoryName = type(currentCategory) == "table" and tostring(currentCategory.name or "") or nil + if capabilities.category ~= true or tostring(target.id or "") ~= targetId + or targetId:match("^range:") or targetSource == "" or targetSource:sub(1, 1) ~= "/" + or type(target.start_line) ~= "number" or firstLine < 1 or firstLine % 1 ~= 0 + or type(target.end_line) ~= "number" or lastLine < firstLine or lastLine % 1 ~= 0 + or type(target.raw_snippet) ~= "string" or target.raw_snippet == "" + or type(target.fingerprint) ~= "string" or target.fingerprint == "" + or currentCategoryName == nil then + publishUpdateResult(requestId, false, "target_not_editable", targetSource) + return + end + local request = { + request_id = requestId, operation = "move", compositor = compositor, + format = format, source = source, target_id = targetId, category = category, + } + if not validateCurrentContext(request) then + publishUpdateResult(requestId, false, "stale_context", targetSource) + return + end + busy = true + local preflight = "[ ! -L " .. shellQuote(request.source) .. " ] && [ ! -L " + .. shellQuote(targetSource) .. " ]" + local started = noctalia.runAsync(preflight, function(result) + if result.timedOut == true or result.exitCode ~= 0 then + publishUpdateResult(requestId, false, "symlink_unsupported", targetSource) + busy = false + else + processValidatedMove(request, target, currentCategoryName) + end + end, 2000) + if not started then + publishUpdateResult(requestId, false, "preflight_failed", targetSource) + busy = false + end +end + +local function validReorderEndpoint(bind, expectedId) + local firstLine, lastLine = tonumber(bind.start_line), tonumber(bind.end_line) + return bind.hidden ~= true + and tostring(bind.id or "") == expectedId + and not expectedId:match("^range:") + and type(bind.source) == "string" and bind.source:sub(1, 1) == "/" + and type(bind.start_line) == "number" and firstLine >= 1 and firstLine % 1 == 0 + and type(bind.end_line) == "number" and lastLine >= firstLine and lastLine % 1 == 0 + and type(bind.raw_snippet) == "string" and bind.raw_snippet ~= "" + and type(bind.fingerprint) == "string" and bind.fingerprint ~= "" +end + +local function validCategoryRenameBind(bind) + local firstLine, lastLine = tonumber(bind.start_line), tonumber(bind.end_line) + local capabilities = type(bind.capabilities) == "table" and bind.capabilities or {} + local bindId = tostring(bind.id or "") + return bind.hidden ~= true and bindId ~= "" and not bindId:match("^range:") + and capabilities.category == true + and type(bind.source) == "string" and bind.source:sub(1, 1) == "/" + and type(bind.start_line) == "number" and firstLine >= 1 and firstLine % 1 == 0 + and type(bind.end_line) == "number" and lastLine >= firstLine and lastLine % 1 == 0 + and type(bind.raw_snippet) == "string" and bind.raw_snippet ~= "" + and type(bind.fingerprint) == "string" and bind.fingerprint ~= "" +end + +local function processCategoryRenameRequest(raw, category) + local rawId = raw.request_id + if type(rawId) == "number" then rawId = tostring(rawId) end + local requestId, errorCode = validateText(rawId, "request_id", false, 256) + if errorCode ~= nil then publishUpdateResult(rawId, false, errorCode, "") return end + local compositor = raw.compositor + local format = FORMATS[compositor] + if format == nil then publishUpdateResult(requestId, false, "invalid_compositor", "") return end + local source + source, errorCode = validateText(raw.source, "source", false, 4096) + if errorCode ~= nil or source:sub(1, 1) ~= "/" then + publishUpdateResult(requestId, false, errorCode or "source_not_absolute", "") + return + end + local categoryId + categoryId, errorCode = validateText(raw.category_id, "category_id", false, 256) + if errorCode ~= nil then publishUpdateResult(requestId, false, errorCode, source) return end + local oldCategory + oldCategory, errorCode = validateCategory(raw.old_category) + if errorCode ~= nil then publishUpdateResult(requestId, false, errorCode, source) return end + local newCategory + newCategory, errorCode = validateCategory(raw.new_category) + if errorCode ~= nil then publishUpdateResult(requestId, false, errorCode, source) return end + if tostring(category.id or "") ~= categoryId or tostring(category.name or "") ~= oldCategory then + publishUpdateResult(requestId, false, "stale_category", source) + return + end + if snapshotHasOtherCategoryName(categoryId, newCategory) then + publishUpdateResult(requestId, false, "category_exists", source) + return + end + if not validateCurrentContext({ compositor = compositor, source = source }) then + publishUpdateResult(requestId, false, "stale_context", source) + return + end + if type(category.binds) ~= "table" or #category.binds == 0 then + publishUpdateResult(requestId, false, "category_not_editable", source) + return + end + local bindIds = {} + local paths, pathSeen = { source }, { [source] = true } + for _, bind in ipairs(category.binds) do + local bindId = tostring(bind.id or "") + if not validCategoryRenameBind(bind) or bindIds[bindId] then + publishUpdateResult(requestId, false, "category_not_editable", source) + return + end + bindIds[bindId] = true + if not pathSeen[bind.source] then + pathSeen[bind.source] = true + paths[#paths + 1] = bind.source + end + end + table.sort(paths) + local request = { + request_id = requestId, operation = "rename_category", compositor = compositor, + format = format, source = source, category_id = categoryId, + old_category = oldCategory, new_category = newCategory, + } + busy = true + local checks = {} + for _, path in ipairs(paths) do checks[#checks + 1] = "[ ! -L " .. shellQuote(path) .. " ]" end + local started = noctalia.runAsync(table.concat(checks, " && "), function(result) + if result.timedOut == true or result.exitCode ~= 0 then + publishUpdateResult(requestId, false, "symlink_unsupported", source) + busy = false + else + processValidatedCategoryRename(request, category) + end + end, 2000) + if not started then + publishUpdateResult(requestId, false, "preflight_failed", source) + busy = false + end +end + +local function processReorderRequest(raw, target, anchor, currentCategory, anchorCategory) + local rawId = raw.request_id + if type(rawId) == "number" then rawId = tostring(rawId) end + local requestId, errorCode = validateText(rawId, "request_id", false, 256) + if errorCode ~= nil then publishUpdateResult(rawId, false, errorCode, "") return end + local compositor = raw.compositor + local format = FORMATS[compositor] + if format == nil then publishUpdateResult(requestId, false, "invalid_compositor", "") return end + local source + source, errorCode = validateText(raw.source, "source", false, 4096) + if errorCode ~= nil or source:sub(1, 1) ~= "/" then + publishUpdateResult(requestId, false, errorCode or "source_not_absolute", "") + return + end + local targetId + targetId, errorCode = validateText(raw.target_id, "target_id", false, 256) + if errorCode ~= nil then publishUpdateResult(requestId, false, errorCode, "") return end + local anchorId + anchorId, errorCode = validateText(raw.anchor_id, "anchor_id", false, 256) + if errorCode ~= nil then publishUpdateResult(requestId, false, errorCode, "") return end + if targetId == anchorId then + publishUpdateResult(requestId, false, "anchor_is_target", tostring(target.source or "")) + return + end + if raw.placement ~= "before" and raw.placement ~= "after" then + publishUpdateResult(requestId, false, "invalid_placement", tostring(target.source or "")) + return + end + if not validReorderEndpoint(target, targetId) or not validReorderEndpoint(anchor, anchorId) then + publishUpdateResult(requestId, false, "target_not_editable", tostring(target.source or "")) + return + end + if target.source ~= anchor.source then + publishUpdateResult(requestId, false, "different_source", target.source) + return + end + local currentCategoryName = type(currentCategory) == "table" and tostring(currentCategory.name or "") or "" + local anchorCategoryName = type(anchorCategory) == "table" and tostring(anchorCategory.name or "") or "" + local category + category, errorCode = validateCategory(anchorCategoryName) + if errorCode ~= nil or currentCategoryName == "" then + publishUpdateResult(requestId, false, errorCode or "invalid_category", target.source) + return + end + local targetCapabilities = type(target.capabilities) == "table" and target.capabilities or {} + if currentCategoryName ~= category and targetCapabilities.category ~= true then + publishUpdateResult(requestId, false, "target_not_editable", target.source) + return + end + local targetFirst, targetLast = tonumber(target.start_line), tonumber(target.end_line) + local anchorFirst, anchorLast = tonumber(anchor.start_line), tonumber(anchor.end_line) + if not (targetLast < anchorFirst or anchorLast < targetFirst) then + publishUpdateResult(requestId, false, "target_anchor_overlap", target.source) + return + end + local request = { + request_id = requestId, operation = "reorder", compositor = compositor, format = format, + source = source, target_id = targetId, anchor_id = anchorId, placement = raw.placement, + category = category, current_category = currentCategoryName, + } + if not validateCurrentContext(request) then + publishUpdateResult(requestId, false, "stale_context", target.source) + return + end + busy = true + local preflight = "[ ! -L " .. shellQuote(request.source) .. " ] && [ ! -L " + .. shellQuote(target.source) .. " ]" + local started = noctalia.runAsync(preflight, function(result) + if result.timedOut == true or result.exitCode ~= 0 then + publishUpdateResult(requestId, false, "symlink_unsupported", target.source) + busy = false + else + processValidatedReorder(request, target, anchor) + end + end, 2000) + if not started then + publishUpdateResult(requestId, false, "preflight_failed", target.source) + busy = false + end +end + +local function processMutationRequest(raw, target) + local rawId = raw.request_id + if type(rawId) == "number" then rawId = tostring(rawId) end + local requestId, errorCode = validateText(rawId, "request_id", false, 256) + if errorCode ~= nil then publishUpdateResult(rawId, false, errorCode, target.source) return end + local operation = raw.operation + if operation ~= "hide" and operation ~= "restore" and operation ~= "delete" then + publishUpdateResult(requestId, false, "invalid_operation", target.source) + return + end + local compositor = raw.compositor + local format = FORMATS[compositor] + if format == nil then publishUpdateResult(requestId, false, "invalid_compositor", target.source) return end + local source + source, errorCode = validateText(raw.source, "source", false, 4096) + if errorCode ~= nil or source:sub(1, 1) ~= "/" then + publishUpdateResult(requestId, false, errorCode or "source_not_absolute", target.source) + return + end + local targetId + targetId, errorCode = validateText(raw.target_id, "target_id", false, 256) + if errorCode ~= nil then publishUpdateResult(requestId, false, errorCode, target.source) return end + local targetSource, targetSourceError = validateText(target.source, "target_source", false, 4096) + if targetSourceError ~= nil then targetSource = "" end + local firstLine, lastLine = tonumber(target.start_line), tonumber(target.end_line) + local capabilities = type(target.capabilities) == "table" and target.capabilities or {} + local hiddenTarget = target.hidden == true + local allowedByType = hiddenTarget + and (operation == "restore" or operation == "delete") + or (not hiddenTarget and operation ~= "restore" + and capabilities.combo == true and capabilities.description == true) + if not allowedByType or tostring(target.id or "") ~= targetId + or targetId:match("^range:") or targetSource == "" or targetSource:sub(1, 1) ~= "/" + or type(target.start_line) ~= "number" or firstLine < 1 or firstLine % 1 ~= 0 + or type(target.end_line) ~= "number" or lastLine < firstLine or lastLine % 1 ~= 0 + or type(target.raw_snippet) ~= "string" or target.raw_snippet == "" + or tostring(target.fingerprint or "") == "" then + publishUpdateResult(requestId, false, + operation == "restore" and not hiddenTarget and "target_not_hidden" or "target_not_editable", + targetSource) + return + end + local request = { + request_id = requestId, operation = operation, compositor = compositor, + format = format, source = source, target_id = targetId, + } + if not validateCurrentContext(request) then + publishUpdateResult(requestId, false, "stale_context", target.source) + return + end + busy = true + local preflight = "[ ! -L " .. shellQuote(request.source) .. " ] && [ ! -L " + .. shellQuote(targetSource) .. " ]" + local started = noctalia.runAsync(preflight, function(result) + if result.timedOut == true or result.exitCode ~= 0 then + publishUpdateResult(requestId, false, "symlink_unsupported", targetSource) + busy = false + else + processValidatedMutation(request, target) + end + end, 2000) + if not started then + publishUpdateResult(requestId, false, "preflight_failed", targetSource) + busy = false + end +end + +local function processUpdateRequest(raw) + local rawId = type(raw) == "table" and raw.request_id or "" + if type(rawId) == "number" then rawId = tostring(rawId) end + if busy then publishUpdateResult(rawId, false, "busy", "") return end + if type(raw) ~= "table" then publishUpdateResult(rawId, false, "invalid_request", "") return end + if raw.command_kind == "native" then + publishUpdateResult(rawId, false, "native_update_unsupported", "") + return + end + if raw.operation == "rename_category" then + local category, ambiguous = findSnapshotCategory(raw.category_id) + if ambiguous then publishUpdateResult(rawId, false, "category_ambiguous", "") return end + if category == nil then publishUpdateResult(rawId, false, "category_not_found", "") return end + processCategoryRenameRequest(raw, category) + return + end + if raw.operation == "reorder" then + local target, targetCategory, targetAmbiguous = findActiveSnapshotTarget(raw.target_id) + if targetAmbiguous then publishUpdateResult(rawId, false, "target_ambiguous", "") return end + if target == nil then publishUpdateResult(rawId, false, "target_not_found", "") return end + local anchor, anchorCategory, anchorAmbiguous = findActiveSnapshotTarget(raw.anchor_id) + if anchorAmbiguous then publishUpdateResult(rawId, false, "anchor_ambiguous", "") return end + if anchor == nil then publishUpdateResult(rawId, false, "anchor_not_found", "") return end + processReorderRequest(raw, target, anchor, targetCategory, anchorCategory) + return + end + local target, category = findSnapshotTarget( + raw.target_id, raw.operation == "restore" or (raw.operation == "delete" and raw.hidden == true) + ) + if target == nil then publishUpdateResult(rawId, false, "target_not_found", "") return end + if raw.operation == "move" then + processMoveRequest(raw, target, category) + return + end + if raw.operation == "hide" or raw.operation == "restore" or raw.operation == "delete" then + processMutationRequest(raw, target) + return + end + local capabilities = type(target.capabilities) == "table" and target.capabilities or {} + local validationRaw = {} + for key, value in pairs(raw) do validationRaw[key] = value end + if trim(tostring(validationRaw.command or "")) == "" and capabilities.command ~= true then + validationRaw.command = "__preserve_native_action__" + end + local request, errorCode = validateRequest(validationRaw) + if request == nil then publishUpdateResult(rawId, false, errorCode, target.source) return end + request.operation = "update" + request.target_id = tostring(raw.target_id or "") + if capabilities.command ~= true then request.command = tostring(target.command or "") end + if not validateCurrentContext(request) then + publishUpdateResult(request.request_id, false, "stale_context", target.source) return + end + if capabilities.combo ~= true or capabilities.description ~= true then + publishUpdateResult(request.request_id, false, "target_not_editable", target.source) return + end + if conflictsWithSnapshot(request, request.target_id) then + publishUpdateResult(request.request_id, false, "conflict_blocked", target.source) return + end + busy = true + local preflight = "[ ! -L " .. shellQuote(request.source) .. " ] && [ ! -L " .. shellQuote(target.source) .. " ]" + local started = noctalia.runAsync(preflight, function(result) + if result.timedOut == true or result.exitCode ~= 0 then + publishUpdateResult(request.request_id, false, "symlink_unsupported", target.source) + busy = false + else processValidatedUpdate(request, target, category.name) end + end, 2000) + if not started then + publishUpdateResult(request.request_id, false, "preflight_failed", target.source) + busy = false + end +end + +function onIpc(event, payload) + if event ~= "create-json" and event ~= "update-json" then return end + local process = event == "update-json" and processUpdateRequest or processRequest + local publish = event == "update-json" and publishUpdateResult or publishResult + if type(payload) == "table" then + process(payload) + return + end + if type(payload) ~= "string" then + publish("", false, "invalid_json", "") + return + end + local ok, decoded = pcall(noctalia.json.decode, payload) + if not ok or type(decoded) ~= "table" then + publish("", false, "invalid_json", "") + return + end + process(decoded) +end + +-- Writes are transactional and do not read plugin settings. Keeping this VM +-- alive across appearance/path setting changes lets an in-flight validation, +-- reload, or rollback finish and publish its result to the panel. +function onConfigChanged() +end + +noctalia.state.watch(CREATE_REQUEST_KEY, function(request) + local requestId = type(request) == "table" and request.request_id or nil + if type(requestId) == "number" then requestId = tostring(requestId) end + if type(requestId) == "string" and requestId ~= "" then + if tostring(noctalia.state.get(LAST_HANDLED_REQUEST_KEY) or "") == requestId then return end + -- Mark before processing so a service/config reload cannot replay a + -- request that is already validating or reloading asynchronously. + noctalia.state.set(LAST_HANDLED_REQUEST_KEY, requestId) + end + processRequest(request) +end) + +noctalia.state.watch(UPDATE_REQUEST_KEY, function(request) + local requestId = type(request) == "table" and request.request_id or nil + if type(requestId) == "number" then requestId = tostring(requestId) end + if type(requestId) == "string" and requestId ~= "" then + if tostring(noctalia.state.get(LAST_HANDLED_UPDATE_KEY) or "") == requestId then return end + noctalia.state.set(LAST_HANDLED_UPDATE_KEY, requestId) + end + processUpdateRequest(request) +end)