feat: add Keymap plugin (#68)

This commit is contained in:
blacku
2026-07-20 21:36:15 -04:00
committed by GitHub
parent 0fdd6a2b2f
commit e41961025e
30 changed files with 14547 additions and 0 deletions
+358
View File
@@ -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 <file>` | `hyprctl reload` |
| Niri | `niri validate -c <file>` | `niri msg action load-config-file` |
| MangoWC | `mango -c <file> -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 <bind-id>`, `clear-modifiers`,
`keyboard-key <key>`, and `keyboard-layout <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.
File diff suppressed because it is too large Load Diff
+56
View File
@@ -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" })
+56
View File
@@ -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"
+57
View File
@@ -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"; }
}
+967
View File
@@ -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()
+959
View File
@@ -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("&amp;", "&"):gsub("&lt;", "<"):gsub("&gt;", ">")
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()
+3281
View File
File diff suppressed because it is too large Load Diff
+229
View File
@@ -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"
Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

+1109
View File
File diff suppressed because it is too large Load Diff
@@ -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")
+68
View File
@@ -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)})")
+166
View File
@@ -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")
@@ -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")
+67
View File
@@ -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")
+41
View File
@@ -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)")
+118
View File
@@ -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")
+413
View File
@@ -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")
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

+459
View File
@@ -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."
}
}
}
}
+32
View File
@@ -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
File diff suppressed because it is too large Load Diff