feat: add Keymap plugin (#68)
@@ -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.
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
|
||||

|
||||
|
||||
## 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:
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
|
||||

|
||||
|
||||
### 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.
|
||||
|
||||

|
||||
|
||||
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. |
|
||||
|
||||

|
||||
|
||||
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.
|
||||
@@ -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" })
|
||||
@@ -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"
|
||||
@@ -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"; }
|
||||
}
|
||||
@@ -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()
|
||||
@@ -0,0 +1,959 @@
|
||||
--!nonstrict
|
||||
-- Niri data service for Keymap.
|
||||
-- Niri IPC does not expose keybindings, so this service reads the active KDL
|
||||
-- config and positional include files. It deliberately performs no subprocesses.
|
||||
|
||||
local SNAPSHOT_KEY = "keymap.snapshot"
|
||||
local REFRESH_REQUEST_KEY = "keymap.refresh_request"
|
||||
local MAX_FILES = 64
|
||||
local MAX_SOURCE_BYTES = 512 * 1024
|
||||
local MAX_HIDDEN_BYTES = 2 * 1024 * 1024
|
||||
|
||||
local refreshing = false
|
||||
local refreshQueued = false
|
||||
|
||||
local function config(key, fallback)
|
||||
local value = noctalia.getConfig(key)
|
||||
if value == nil then
|
||||
return fallback
|
||||
end
|
||||
return value
|
||||
end
|
||||
|
||||
local function trim(value)
|
||||
if type(value) ~= "string" then
|
||||
return ""
|
||||
end
|
||||
return value:match("^%s*(.-)%s*$") or ""
|
||||
end
|
||||
|
||||
local function env(name)
|
||||
return trim(noctalia.getenv(name))
|
||||
end
|
||||
|
||||
-- Shared compositor priority used by all three parser services.
|
||||
local function detectedCompositor()
|
||||
local selected = trim(config("compositor", "auto")):lower()
|
||||
if selected ~= "" and selected ~= "auto" then
|
||||
if selected == "mango" then
|
||||
return "mangowc"
|
||||
end
|
||||
return selected
|
||||
end
|
||||
if env("NIRI_SOCKET") ~= "" then
|
||||
return "niri"
|
||||
end
|
||||
if env("HYPRLAND_INSTANCE_SIGNATURE") ~= "" then
|
||||
return "hyprland"
|
||||
end
|
||||
if env("MANGO_INSTANCE_SIGNATURE") ~= "" then
|
||||
return "mangowc"
|
||||
end
|
||||
local desktop = (env("XDG_CURRENT_DESKTOP") .. ":" .. env("XDG_SESSION_DESKTOP")
|
||||
.. ":" .. env("DESKTOP_SESSION")):lower()
|
||||
if desktop:find("niri", 1, true) ~= nil then
|
||||
return "niri"
|
||||
end
|
||||
if desktop:find("hyprland", 1, true) ~= nil then
|
||||
return "hyprland"
|
||||
end
|
||||
if desktop:find("mangowc", 1, true) ~= nil or desktop:find("mango", 1, true) ~= nil then
|
||||
return "mangowc"
|
||||
end
|
||||
return "unknown"
|
||||
end
|
||||
|
||||
local function normalizePath(path)
|
||||
local absolute = path:sub(1, 1) == "/"
|
||||
local parts = {}
|
||||
for part in path:gmatch("[^/]+") do
|
||||
if part == ".." then
|
||||
if #parts > 0 and parts[#parts] ~= ".." then
|
||||
table.remove(parts)
|
||||
elseif not absolute then
|
||||
parts[#parts + 1] = part
|
||||
end
|
||||
elseif part ~= "." and part ~= "" then
|
||||
parts[#parts + 1] = part
|
||||
end
|
||||
end
|
||||
local normalized = table.concat(parts, "/")
|
||||
if absolute then
|
||||
return "/" .. normalized
|
||||
end
|
||||
return normalized ~= "" and normalized or "."
|
||||
end
|
||||
|
||||
local function dirname(path)
|
||||
local directory = path:match("^(.*)/[^/]*$")
|
||||
return directory ~= nil and directory ~= "" and directory or "."
|
||||
end
|
||||
|
||||
local function expandHome(path)
|
||||
if path == "~" then
|
||||
return env("HOME")
|
||||
end
|
||||
if path:sub(1, 2) == "~/" then
|
||||
return env("HOME") .. path:sub(2)
|
||||
end
|
||||
return path
|
||||
end
|
||||
|
||||
local function sourcePath()
|
||||
local configured = trim(config("niri_config", ""))
|
||||
if configured == "" then
|
||||
configured = "~/.config/niri/config.kdl"
|
||||
end
|
||||
local expanded = normalizePath(expandHome(configured))
|
||||
if noctalia.fileExists(expanded) then return expanded end
|
||||
|
||||
local xdg = env("XDG_CONFIG_HOME")
|
||||
local niriDir = normalizePath((xdg ~= "" and xdg or (env("HOME") .. "/.config")) .. "/niri")
|
||||
local candidates = {
|
||||
normalizePath(expandHome(env("NIRI_CONFIG"))),
|
||||
normalizePath(niriDir .. "/config.kdl"),
|
||||
}
|
||||
for _, path in ipairs(candidates) do
|
||||
if path ~= "." and noctalia.fileExists(path) then return path end
|
||||
end
|
||||
|
||||
local entries = noctalia.listDir(niriDir)
|
||||
if type(entries) ~= "table" then return expanded end
|
||||
table.sort(entries)
|
||||
local bestPath, bestScore = nil, -1
|
||||
for _, name in ipairs(entries) do
|
||||
if type(name) == "string" and name:match("%.kdl$") and name ~= "keymap.kdl"
|
||||
and not name:lower():find("backup", 1, true) then
|
||||
local path = normalizePath(niriDir .. "/" .. name)
|
||||
local source = noctalia.readFile(path)
|
||||
if type(source) == "string" and #source <= MAX_SOURCE_BYTES then
|
||||
local _, bindBlocks = source:gsub("%f[%a]binds%s*{", "")
|
||||
local _, includes = source:gsub("%f[%a]include%s+", "")
|
||||
local score = bindBlocks * 20 + includes * 2
|
||||
if name:lower():find("bind", 1, true) then score = score + 5 end
|
||||
if score > bestScore then bestPath, bestScore = path, score end
|
||||
end
|
||||
end
|
||||
end
|
||||
return bestScore > 0 and bestPath or expanded
|
||||
end
|
||||
|
||||
local function resolveInclude(currentFile, path)
|
||||
local expanded = expandHome(path)
|
||||
if expanded:sub(1, 1) == "/" then
|
||||
return normalizePath(expanded)
|
||||
end
|
||||
return normalizePath(dirname(currentFile) .. "/" .. expanded)
|
||||
end
|
||||
|
||||
local ESCAPES = {
|
||||
n = "\n",
|
||||
r = "\r",
|
||||
t = "\t",
|
||||
["\\"] = "\\",
|
||||
['"'] = '"',
|
||||
["'"] = "'",
|
||||
}
|
||||
|
||||
local function quotedLiteral(text, startAt)
|
||||
local start = startAt or 1
|
||||
while start <= #text and text:sub(start, start):match("%s") do
|
||||
start = start + 1
|
||||
end
|
||||
local quote = text:sub(start, start)
|
||||
if quote ~= '"' and quote ~= "'" then
|
||||
return nil, nil
|
||||
end
|
||||
local output = {}
|
||||
local escaped = false
|
||||
for index = start + 1, #text do
|
||||
local char = text:sub(index, index)
|
||||
if escaped then
|
||||
output[#output + 1] = ESCAPES[char] or char
|
||||
escaped = false
|
||||
elseif char == "\\" then
|
||||
escaped = true
|
||||
elseif char == quote then
|
||||
return table.concat(output), index + 1
|
||||
else
|
||||
output[#output + 1] = char
|
||||
end
|
||||
end
|
||||
return nil, nil
|
||||
end
|
||||
|
||||
-- Returns executable code and a trailing // comment. Comment markers inside
|
||||
-- strings are preserved. KDL block comments remain stateful across lines.
|
||||
local function stripComments(line, inBlockComment)
|
||||
local code = {}
|
||||
local comment = nil
|
||||
local quote = nil
|
||||
local escaped = false
|
||||
local index = 1
|
||||
while index <= #line do
|
||||
local char = line:sub(index, index)
|
||||
local pair = line:sub(index, index + 1)
|
||||
if inBlockComment then
|
||||
if pair == "*/" then
|
||||
inBlockComment = false
|
||||
index = index + 2
|
||||
else
|
||||
index = index + 1
|
||||
end
|
||||
elseif quote ~= nil then
|
||||
code[#code + 1] = char
|
||||
if escaped then
|
||||
escaped = false
|
||||
elseif char == "\\" then
|
||||
escaped = true
|
||||
elseif char == quote then
|
||||
quote = nil
|
||||
end
|
||||
index = index + 1
|
||||
elseif char == '"' or char == "'" then
|
||||
quote = char
|
||||
code[#code + 1] = char
|
||||
index = index + 1
|
||||
elseif pair == "//" then
|
||||
comment = line:sub(index + 2)
|
||||
break
|
||||
elseif pair == "/*" then
|
||||
inBlockComment = true
|
||||
index = index + 2
|
||||
else
|
||||
code[#code + 1] = char
|
||||
index = index + 1
|
||||
end
|
||||
end
|
||||
return table.concat(code), comment, inBlockComment
|
||||
end
|
||||
|
||||
local function braceDelta(text)
|
||||
local delta = 0
|
||||
local quote = nil
|
||||
local escaped = false
|
||||
for index = 1, #text do
|
||||
local char = text:sub(index, index)
|
||||
if quote ~= nil then
|
||||
if escaped then
|
||||
escaped = false
|
||||
elseif char == "\\" then
|
||||
escaped = true
|
||||
elseif char == quote then
|
||||
quote = nil
|
||||
end
|
||||
elseif char == '"' or char == "'" then
|
||||
quote = char
|
||||
elseif char == "{" then
|
||||
delta = delta + 1
|
||||
elseif char == "}" then
|
||||
delta = delta - 1
|
||||
end
|
||||
end
|
||||
return delta
|
||||
end
|
||||
|
||||
local function firstOpenBrace(text)
|
||||
local quote = nil
|
||||
local escaped = false
|
||||
for index = 1, #text do
|
||||
local char = text:sub(index, index)
|
||||
if quote ~= nil then
|
||||
if escaped then
|
||||
escaped = false
|
||||
elseif char == "\\" then
|
||||
escaped = true
|
||||
elseif char == quote then
|
||||
quote = nil
|
||||
end
|
||||
elseif char == '"' or char == "'" then
|
||||
quote = char
|
||||
elseif char == "{" then
|
||||
return index
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function contentBeforeOuterClose(text)
|
||||
local depth = 1
|
||||
local quote = nil
|
||||
local escaped = false
|
||||
for index = 1, #text do
|
||||
local char = text:sub(index, index)
|
||||
if quote ~= nil then
|
||||
if escaped then
|
||||
escaped = false
|
||||
elseif char == "\\" then
|
||||
escaped = true
|
||||
elseif char == quote then
|
||||
quote = nil
|
||||
end
|
||||
elseif char == '"' or char == "'" then
|
||||
quote = char
|
||||
elseif char == "{" then
|
||||
depth = depth + 1
|
||||
elseif char == "}" then
|
||||
depth = depth - 1
|
||||
if depth == 0 then
|
||||
return text:sub(1, index - 1)
|
||||
end
|
||||
end
|
||||
end
|
||||
return text
|
||||
end
|
||||
|
||||
local function includeNode(code)
|
||||
local rest = code:match("^%s*include%s+(.+)$")
|
||||
if rest == nil then
|
||||
return nil, false
|
||||
end
|
||||
local optional = rest:match("%f[%w]optional%s*=%s*true%f[^%w]") ~= nil
|
||||
local quoteAt = rest:find('"', 1, true) or rest:find("'", 1, true)
|
||||
if quoteAt == nil then
|
||||
return nil, optional
|
||||
end
|
||||
return quotedLiteral(rest, quoteAt), optional
|
||||
end
|
||||
|
||||
local MODIFIER_ALIASES = {
|
||||
Control = "Ctrl", Ctrl = "Ctrl", Win = "Super", Super = "Super",
|
||||
Mod = "Mod", Alt = "Alt", Shift = "Shift",
|
||||
ISO_Level3_Shift = "Mod5", Mod5 = "Mod5",
|
||||
ISO_Level5_Shift = "ISO_Level5_Shift",
|
||||
}
|
||||
|
||||
local KEY_NAMES = {
|
||||
RETURN = "Enter", SPACE = "Space", ESCAPE = "Esc", PRINT = "PrtSc",
|
||||
PRIOR = "PgUp", NEXT = "PgDn",
|
||||
WHEELSCROLLUP = "Scroll Up", WHEELSCROLLDOWN = "Scroll Down",
|
||||
WHEELSCROLLLEFT = "Scroll Left", WHEELSCROLLRIGHT = "Scroll Right",
|
||||
TOUCHPADSCROLLUP = "Touchpad Up", TOUCHPADSCROLLDOWN = "Touchpad Down",
|
||||
TOUCHPADSCROLLLEFT = "Touchpad Left", TOUCHPADSCROLLRIGHT = "Touchpad Right",
|
||||
MOUSELEFT = "Left Click", MOUSERIGHT = "Right Click", MOUSEMIDDLE = "Middle Click",
|
||||
MOUSEFORWARD = "Mouse Forward", MOUSEBACK = "Mouse Back",
|
||||
XF86AUDIORAISEVOLUME = "Vol Up", XF86AUDIOLOWERVOLUME = "Vol Down",
|
||||
XF86AUDIOMUTE = "Mute", XF86AUDIOMICMUTE = "Mic Mute",
|
||||
XF86AUDIOPLAY = "Play", XF86AUDIOPAUSE = "Pause", XF86AUDIONEXT = "Next",
|
||||
XF86AUDIOPREV = "Prev", XF86AUDIOSTOP = "Stop",
|
||||
XF86MONBRIGHTNESSUP = "Bright Up", XF86MONBRIGHTNESSDOWN = "Bright Down",
|
||||
}
|
||||
|
||||
local function splitCombo(combo)
|
||||
local modifiers = {}
|
||||
local mainKey = ""
|
||||
local signature = {}
|
||||
for part in combo:gmatch("[^+]+") do
|
||||
local item = trim(part)
|
||||
local modifier = MODIFIER_ALIASES[item]
|
||||
if modifier ~= nil then
|
||||
modifiers[#modifiers + 1] = modifier
|
||||
signature[#signature + 1] = modifier:lower()
|
||||
elseif item ~= "" then
|
||||
mainKey = item
|
||||
signature[#signature + 1] = item:lower()
|
||||
end
|
||||
end
|
||||
return modifiers, KEY_NAMES[mainKey:upper()] or mainKey, mainKey, table.concat(signature, "+")
|
||||
end
|
||||
|
||||
local function bindHeader(prefix)
|
||||
local header = trim(prefix)
|
||||
if header:sub(1, 1) == '"' or header:sub(1, 1) == "'" then
|
||||
local combo, nextAt = quotedLiteral(header, 1)
|
||||
return combo, combo ~= nil and trim(header:sub(nextAt)) or nil
|
||||
end
|
||||
local combo, attributes = header:match("^(%S+)%s*(.-)%s*$")
|
||||
return combo, attributes
|
||||
end
|
||||
|
||||
local function titleAttribute(attributes)
|
||||
local _, valueAt = (attributes or ""):find("hotkey%-overlay%-title%s*=%s*")
|
||||
if valueAt == nil then
|
||||
return nil
|
||||
end
|
||||
local suffix = attributes:sub(valueAt + 1)
|
||||
if suffix:match("^%s*null%f[^%w_]") then
|
||||
return nil
|
||||
end
|
||||
local value = quotedLiteral(suffix, 1)
|
||||
if value == nil then
|
||||
return nil
|
||||
end
|
||||
value = value:gsub("<[^>]*>", "")
|
||||
value = value:gsub("&", "&"):gsub("<", "<"):gsub(">", ">")
|
||||
return trim(value)
|
||||
end
|
||||
|
||||
local ACTION_CATEGORIES = {
|
||||
spawn = "category.niri.applications", ["spawn-sh"] = "category.niri.applications",
|
||||
["focus-column"] = "category.niri.column_navigation", ["focus-window"] = "category.niri.window_focus",
|
||||
["focus-workspace"] = "category.niri.workspace_navigation",
|
||||
["move-column-to-workspace"] = "category.niri.workspace_management",
|
||||
["move-window-to-workspace"] = "category.niri.workspace_management",
|
||||
["move-column"] = "category.niri.move_columns", ["move-window"] = "category.niri.move_windows",
|
||||
["consume-window"] = "category.niri.window_management", ["expel-window"] = "category.niri.window_management",
|
||||
["close-window"] = "category.niri.window_management", ["fullscreen-window"] = "category.niri.window_management",
|
||||
["maximize-column"] = "category.niri.column_management", ["set-column-width"] = "category.niri.column_width",
|
||||
["switch-preset-column-width"] = "category.niri.column_width", ["reset-window-height"] = "category.niri.window_size",
|
||||
screenshot = "category.niri.screenshots", ["power-off-monitors"] = "category.niri.power",
|
||||
["power-on-monitors"] = "category.niri.power", quit = "category.niri.system",
|
||||
["toggle-animation"] = "category.niri.animations",
|
||||
}
|
||||
|
||||
local function actionVerb(action)
|
||||
return action:match("^%s*([%w_-]+)") or ""
|
||||
end
|
||||
|
||||
local function actionDescription(action, verb)
|
||||
if verb == "spawn" or verb == "spawn-sh" then
|
||||
local command = quotedLiteral(action:sub(#verb + 1), 1)
|
||||
if command ~= nil and command ~= "" then
|
||||
return noctalia.tr("actions.run", { command = command })
|
||||
end
|
||||
end
|
||||
return noctalia.tr("actions.native", { action = trim(action:gsub(";%s*$", "")) })
|
||||
end
|
||||
|
||||
local function actionCategory(action, verb)
|
||||
local lower = action:lower()
|
||||
if lower:find("noctalia", 1, true) ~= nil
|
||||
and (lower:find(" msg ", 1, true) ~= nil or lower:find(" ipc ", 1, true) ~= nil)
|
||||
then
|
||||
return noctalia.tr("category.noctalia")
|
||||
end
|
||||
local best = nil
|
||||
local bestLength = -1
|
||||
for prefix, category in pairs(ACTION_CATEGORIES) do
|
||||
if verb:sub(1, #prefix) == prefix and #prefix > bestLength then
|
||||
best = category
|
||||
bestLength = #prefix
|
||||
end
|
||||
end
|
||||
return noctalia.tr(best or "category.other")
|
||||
end
|
||||
|
||||
local function slug(value)
|
||||
local id = value:lower():gsub("[^%a%d]+", "-"):gsub("^-+", ""):gsub("-+$", "")
|
||||
return id ~= "" and id or "category"
|
||||
end
|
||||
|
||||
local function xorByte(left, right)
|
||||
local result = 0
|
||||
local place = 1
|
||||
for _ = 1, 8 do
|
||||
local leftBit = left % 2
|
||||
local rightBit = right % 2
|
||||
if leftBit ~= rightBit then result = result + place end
|
||||
left = math.floor(left / 2)
|
||||
right = math.floor(right / 2)
|
||||
place = place * 2
|
||||
end
|
||||
return result
|
||||
end
|
||||
|
||||
-- FNV-1a 32-bit without bit libraries. Splitting the prime (0x01000193) into
|
||||
-- 2^24 + 403 keeps every intermediate below the exact integer limit of a
|
||||
-- Lua/Luau number.
|
||||
local function stableFingerprint(value)
|
||||
local hash = 2166136261
|
||||
for index = 1, #value do
|
||||
local low = hash % 256
|
||||
hash = hash - low + xorByte(low, value:byte(index))
|
||||
hash = (hash * 403 + (hash % 256) * 16777216) % 4294967296
|
||||
end
|
||||
return string.format("%08x", hash)
|
||||
end
|
||||
|
||||
local function sourceLines(source)
|
||||
local lines = {}
|
||||
for line in (source .. "\n"):gmatch("([^\n]*)\n") do lines[#lines + 1] = line end
|
||||
return lines
|
||||
end
|
||||
|
||||
local function hexDecode(value)
|
||||
if value == "" or #value % 2 ~= 0 or value:find("[^0-9a-f]") ~= nil then return nil end
|
||||
local output = {}
|
||||
for index = 1, #value, 2 do output[#output + 1] = string.char(tonumber(value:sub(index, index + 1), 16)) end
|
||||
return table.concat(output)
|
||||
end
|
||||
|
||||
local function hiddenBlockAt(lines, startLine)
|
||||
local namespace = "^([\t ]*)// Keymap hidden "
|
||||
if lines[startLine]:match(namespace) == nil then return nil, startLine, false end
|
||||
local indent, blockId, originalFingerprint = lines[startLine]:match(
|
||||
namespace .. "v1 begin ([0-9a-f]+) ([0-9a-f]+)$"
|
||||
)
|
||||
if indent == nil or #blockId ~= 8 or #originalFingerprint ~= 8 then return nil, startLine, true end
|
||||
local marker = indent .. "// Keymap hidden v1"
|
||||
local escaped = marker:gsub("(%W)", "%%%1")
|
||||
local encoded, cursor = {}, startLine + 1
|
||||
while cursor <= #lines do
|
||||
local chunk = lines[cursor]:match("^" .. escaped .. " data ([0-9a-f]+)$")
|
||||
if chunk ~= nil then
|
||||
if #chunk > 96 or #chunk % 2 ~= 0 then return nil, cursor, true end
|
||||
encoded[#encoded + 1] = chunk
|
||||
cursor = cursor + 1
|
||||
else
|
||||
local endId = lines[cursor]:match("^" .. escaped .. " end ([0-9a-f]+)$")
|
||||
if endId == nil then return nil, math.max(startLine, cursor - 1), true end
|
||||
local original = hexDecode(table.concat(encoded))
|
||||
if #encoded == 0 or endId ~= blockId or original == nil or original == ""
|
||||
or #original > MAX_HIDDEN_BYTES or stableFingerprint(original) ~= originalFingerprint
|
||||
or stableFingerprint("Niri\0" .. original) ~= blockId then return nil, cursor, true end
|
||||
return { block_id = blockId, original = original, original_fingerprint = originalFingerprint,
|
||||
raw_snippet = table.concat(lines, "\n", startLine, cursor) }, cursor, true
|
||||
end
|
||||
end
|
||||
return nil, #lines, true
|
||||
end
|
||||
|
||||
local function hiddenTarget(block, path, startLine, endLine, inheritedCategory)
|
||||
local original = block.original
|
||||
local markedCategory = original:match(
|
||||
"^%s*//%s*Keymap bind%-category:%s*([^\n]-)%s*\n"
|
||||
)
|
||||
local codeLines, inBlock = {}, false
|
||||
for raw in (original .. "\n"):gmatch("([^\n]*)\n") do
|
||||
local code
|
||||
code, _, inBlock = stripComments(raw, inBlock)
|
||||
if trim(code) ~= "" then codeLines[#codeLines + 1] = code end
|
||||
end
|
||||
local code = table.concat(codeLines, "\n")
|
||||
local openAt = firstOpenBrace(code)
|
||||
local combo, attributes, action = "", "", ""
|
||||
if openAt ~= nil then
|
||||
combo, attributes = bindHeader(code:sub(1, openAt - 1))
|
||||
action = contentBeforeOuterClose(code:sub(openAt + 1))
|
||||
end
|
||||
combo, attributes = combo or "", attributes or ""
|
||||
local modifiers, key, _, signature = splitCombo(combo)
|
||||
local normalizedAction = trim(action):gsub(";%s*$", "")
|
||||
local verb = actionVerb(normalizedAction)
|
||||
local description = titleAttribute(attributes)
|
||||
if description == nil or description == "" then description = actionDescription(normalizedAction, verb) end
|
||||
local command = verb == "spawn-sh" and (quotedLiteral(normalizedAction:sub(#verb + 1), 1) or "") or ""
|
||||
return {
|
||||
hidden = true, id = "hidden:niri:" .. stableFingerprint(path .. "\0" .. tostring(startLine) .. "\0" .. block.block_id),
|
||||
source = path, line = startLine, start_line = startLine, end_line = endLine,
|
||||
raw_snippet = block.raw_snippet, fingerprint = stableFingerprint(block.raw_snippet),
|
||||
original_fingerprint = block.original_fingerprint, modifiers = modifiers, key = key,
|
||||
description = description, dispatcher = verb, command = command, action = normalizedAction,
|
||||
activation = "press", category = markedCategory or inheritedCategory or actionCategory(normalizedAction, verb),
|
||||
capabilities = { restore = true, delete = true }, _signature = signature,
|
||||
}
|
||||
end
|
||||
|
||||
local function cleanBind(bind)
|
||||
return {
|
||||
id = bind.id, modifiers = bind.modifiers, key = bind.key,
|
||||
description = bind.description, dispatcher = bind.dispatcher,
|
||||
activation = "press", command = bind.command, action = bind.action,
|
||||
source = bind.source, start_line = bind.start_line, end_line = bind.end_line,
|
||||
raw_snippet = bind.raw_snippet, fingerprint = bind.fingerprint,
|
||||
managed = bind.managed == true, capabilities = bind.capabilities,
|
||||
}
|
||||
end
|
||||
|
||||
local function mergeSequential(binds)
|
||||
local groups = {}
|
||||
for index, bind in ipairs(binds) do
|
||||
local number = bind._rawKey:match("^%d+$") and tonumber(bind._rawKey) or nil
|
||||
local template, replacements = bind.description:gsub("%f[%d]%d+%f[%D]", "%%N%%")
|
||||
if number ~= nil and replacements > 0 then
|
||||
local signature = table.concat(bind.modifiers, "+") .. "|" .. bind.dispatcher .. "|" .. template
|
||||
groups[signature] = groups[signature] or {}
|
||||
groups[signature][#groups[signature] + 1] = { index = index, number = number, bind = bind }
|
||||
end
|
||||
end
|
||||
local replacements = {}
|
||||
local skipped = {}
|
||||
for _, group in pairs(groups) do
|
||||
table.sort(group, function(a, b)
|
||||
return a.number == b.number and a.index < b.index or a.number < b.number
|
||||
end)
|
||||
local runStart = 1
|
||||
for cursor = 2, #group + 1 do
|
||||
local continues = cursor <= #group and group[cursor].number == group[cursor - 1].number + 1
|
||||
if not continues then
|
||||
if cursor - runStart >= 3 then
|
||||
local first = group[runStart]
|
||||
local last = group[cursor - 1]
|
||||
local range = tostring(first.number) .. "-" .. tostring(last.number)
|
||||
local merged = cleanBind(first.bind)
|
||||
merged.id = "range:" .. first.bind.id .. ":" .. last.bind.id
|
||||
merged.key = range
|
||||
merged.description = first.bind.description:gsub("%f[%d]%d+%f[%D]", range, 1)
|
||||
replacements[first.index] = merged
|
||||
for item = runStart + 1, cursor - 1 do
|
||||
skipped[group[item].index] = true
|
||||
end
|
||||
end
|
||||
runStart = cursor
|
||||
end
|
||||
end
|
||||
end
|
||||
local output = {}
|
||||
for index, bind in ipairs(binds) do
|
||||
-- An editable range cannot safely carry the provenance of one real bind.
|
||||
-- Preserve every effective record individually once provenance is present.
|
||||
if bind.fingerprint ~= nil then
|
||||
output[#output + 1] = cleanBind(bind)
|
||||
elseif replacements[index] ~= nil then
|
||||
output[#output + 1] = replacements[index]
|
||||
elseif not skipped[index] then
|
||||
output[#output + 1] = cleanBind(bind)
|
||||
end
|
||||
end
|
||||
return output
|
||||
end
|
||||
|
||||
local function buildCategories(records)
|
||||
local shouldMerge = config("merge_sequential", true) == true
|
||||
local byName = {}
|
||||
local order = {}
|
||||
local seen = {}
|
||||
local total = 0
|
||||
for _, bind in ipairs(records) do
|
||||
if not bind._overridden then
|
||||
if not seen[bind._category] then
|
||||
seen[bind._category] = true
|
||||
order[#order + 1] = bind._category
|
||||
end
|
||||
byName[bind._category] = byName[bind._category] or {}
|
||||
byName[bind._category][#byName[bind._category] + 1] = bind
|
||||
total = total + 1
|
||||
end
|
||||
end
|
||||
local categories = {}
|
||||
local usedIds = {}
|
||||
for _, name in ipairs(order) do
|
||||
local baseId = slug(name)
|
||||
local id = baseId
|
||||
local suffix = 2
|
||||
while usedIds[id] do
|
||||
id = baseId .. "-" .. tostring(suffix)
|
||||
suffix = suffix + 1
|
||||
end
|
||||
usedIds[id] = true
|
||||
local binds = byName[name]
|
||||
local output = {}
|
||||
if shouldMerge then
|
||||
output = mergeSequential(binds)
|
||||
else
|
||||
for _, bind in ipairs(binds) do
|
||||
output[#output + 1] = cleanBind(bind)
|
||||
end
|
||||
end
|
||||
categories[#categories + 1] = { id = id, name = name, binds = output }
|
||||
end
|
||||
return categories, total
|
||||
end
|
||||
|
||||
local function parseBind(combo, attributes, action, category, records, activeByCombo, provenance)
|
||||
local modifiers, key, rawKey, signature = splitCombo(combo)
|
||||
local normalizedAction = trim(action):gsub(";%s*$", "")
|
||||
local verb = actionVerb(normalizedAction)
|
||||
if rawKey == "" or verb == "" then
|
||||
return
|
||||
end
|
||||
local previous = activeByCombo[signature]
|
||||
if previous ~= nil then
|
||||
previous._overridden = true
|
||||
end
|
||||
local description = titleAttribute(attributes)
|
||||
if description == nil or description == "" then
|
||||
description = actionDescription(normalizedAction, verb)
|
||||
end
|
||||
local command = ""
|
||||
if verb == "spawn-sh" then
|
||||
command = quotedLiteral(normalizedAction:sub(#verb + 1), 1) or ""
|
||||
end
|
||||
local rawSnippet = provenance.raw_snippet or ""
|
||||
local bind = {
|
||||
id = "niri:" .. stableFingerprint(signature .. "\0" .. verb .. "\0" .. normalizedAction),
|
||||
modifiers = modifiers, key = key, description = description, dispatcher = verb,
|
||||
activation = "press", command = command, action = normalizedAction,
|
||||
source = provenance.source, start_line = provenance.start_line,
|
||||
end_line = provenance.end_line, raw_snippet = rawSnippet,
|
||||
fingerprint = stableFingerprint(rawSnippet),
|
||||
managed = provenance.source:match("([^/]+)$") == "keymap.kdl",
|
||||
capabilities = {
|
||||
combo = true, category = true, description = true,
|
||||
command = verb == "spawn-sh", activation = false,
|
||||
},
|
||||
_rawKey = rawKey, _category = category or actionCategory(normalizedAction, verb),
|
||||
}
|
||||
records[#records + 1] = bind
|
||||
activeByCombo[signature] = bind
|
||||
end
|
||||
|
||||
local function parseConfig(root)
|
||||
local records = {}
|
||||
local hidden = {}
|
||||
local activeByCombo = {}
|
||||
local warnings = {}
|
||||
local warningSeen = {}
|
||||
local visiting = {}
|
||||
local filesRead = 0
|
||||
local fatalError = nil
|
||||
|
||||
local function warn(value)
|
||||
if not warningSeen[value] then
|
||||
warningSeen[value] = true
|
||||
warnings[#warnings + 1] = value
|
||||
end
|
||||
end
|
||||
|
||||
local parseFile
|
||||
parseFile = function(path, optional)
|
||||
if visiting[path] then
|
||||
warn("niri_include_cycle:" .. path)
|
||||
return
|
||||
end
|
||||
if filesRead >= MAX_FILES then
|
||||
warn("niri_file_limit_reached")
|
||||
return
|
||||
end
|
||||
local source = noctalia.readFile(path)
|
||||
if type(source) ~= "string" then
|
||||
warn((optional and "niri_optional_include_missing:" or "niri_include_unreadable:") .. path)
|
||||
if not optional then
|
||||
fatalError = "niri_required_include_missing"
|
||||
end
|
||||
return
|
||||
end
|
||||
filesRead = filesRead + 1
|
||||
visiting[path] = true
|
||||
if #source > MAX_SOURCE_BYTES then
|
||||
source = source:sub(1, MAX_SOURCE_BYTES)
|
||||
warn("niri_source_truncated:" .. path)
|
||||
end
|
||||
|
||||
local inBlockComment = false
|
||||
local topDepth = 0
|
||||
local inBinds = false
|
||||
local bindsDepth = 0
|
||||
local currentCategory = nil
|
||||
local pendingBindCategory = nil
|
||||
local pendingMarkerLine = nil
|
||||
local pendingMarkerRaw = nil
|
||||
local currentBind = nil
|
||||
local slashdashDepth = nil
|
||||
local lineNumber = 0
|
||||
local hiddenLines = sourceLines(source)
|
||||
local hiddenCategory = nil
|
||||
local hiddenCursor = 1
|
||||
while hiddenCursor <= #hiddenLines do
|
||||
local block, blockEnd, candidate = hiddenBlockAt(hiddenLines, hiddenCursor)
|
||||
if candidate then
|
||||
if block == nil then
|
||||
warn("hidden_block_invalid:" .. path .. ":" .. tostring(hiddenCursor))
|
||||
else
|
||||
hidden[#hidden + 1] = hiddenTarget(block, path, hiddenCursor, blockEnd, hiddenCategory)
|
||||
end
|
||||
hiddenCursor = blockEnd + 1
|
||||
else
|
||||
local _, comment = stripComments(hiddenLines[hiddenCursor], false)
|
||||
local label = type(comment) == "string" and comment:match('^%s*#?%s*"([^\"]+)"%s*$') or nil
|
||||
if label ~= nil and label ~= "" then hiddenCategory = label end
|
||||
hiddenCursor = hiddenCursor + 1
|
||||
end
|
||||
end
|
||||
|
||||
local function finishBind()
|
||||
local action = contentBeforeOuterClose(table.concat(currentBind.parts, "\n"))
|
||||
parseBind(
|
||||
currentBind.combo, currentBind.attributes, action, currentBind.category,
|
||||
records, activeByCombo,
|
||||
{
|
||||
source = path, start_line = currentBind.start_line,
|
||||
end_line = lineNumber, raw_snippet = table.concat(currentBind.raw_lines, "\n"),
|
||||
}
|
||||
)
|
||||
currentBind = nil
|
||||
end
|
||||
|
||||
for rawLine in (source .. "\n"):gmatch("([^\n]*)\n") do
|
||||
lineNumber = lineNumber + 1
|
||||
local code, comment
|
||||
code, comment, inBlockComment = stripComments(rawLine, inBlockComment)
|
||||
local stripped = trim(code)
|
||||
if slashdashDepth ~= nil then
|
||||
slashdashDepth = slashdashDepth + braceDelta(code)
|
||||
if slashdashDepth <= 0 then slashdashDepth = nil end
|
||||
elseif stripped:sub(1, 2) == "/-" then
|
||||
local delta = braceDelta(stripped:sub(3))
|
||||
if delta > 0 then slashdashDepth = delta end
|
||||
elseif inBinds then
|
||||
local delta = braceDelta(code)
|
||||
if currentBind ~= nil then
|
||||
currentBind.parts[#currentBind.parts + 1] = code
|
||||
currentBind.raw_lines[#currentBind.raw_lines + 1] = rawLine
|
||||
currentBind.depth = currentBind.depth + delta
|
||||
bindsDepth = bindsDepth + delta
|
||||
if currentBind.depth <= 0 then finishBind() end
|
||||
else
|
||||
local bindCategory = type(comment) == "string" and comment:match(
|
||||
"^%s*Keymap bind%-category:%s*(.-)%s*$"
|
||||
) or nil
|
||||
if bindCategory ~= nil and bindCategory ~= "" then
|
||||
pendingBindCategory = bindCategory
|
||||
pendingMarkerLine = lineNumber
|
||||
pendingMarkerRaw = rawLine
|
||||
else
|
||||
local label = type(comment) == "string" and comment:match('^%s*#?%s*"([^\"]+)"%s*$') or nil
|
||||
if label ~= nil and label ~= "" then currentCategory = label end
|
||||
end
|
||||
if stripped ~= "" and not stripped:match("^}") then
|
||||
local openAt = firstOpenBrace(code)
|
||||
if openAt ~= nil then
|
||||
local combo, attributes = bindHeader(code:sub(1, openAt - 1))
|
||||
if combo ~= nil then
|
||||
local actionStart = code:sub(openAt + 1)
|
||||
currentBind = {
|
||||
combo = combo, attributes = attributes,
|
||||
category = pendingBindCategory or currentCategory,
|
||||
parts = { actionStart }, depth = 1 + braceDelta(actionStart),
|
||||
start_line = pendingMarkerLine or lineNumber,
|
||||
raw_lines = pendingMarkerRaw ~= nil and { pendingMarkerRaw, rawLine } or { rawLine },
|
||||
}
|
||||
pendingBindCategory = nil
|
||||
pendingMarkerLine = nil
|
||||
pendingMarkerRaw = nil
|
||||
if currentBind.depth <= 0 then finishBind() end
|
||||
else
|
||||
pendingBindCategory = nil
|
||||
pendingMarkerLine = nil
|
||||
pendingMarkerRaw = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
bindsDepth = bindsDepth + delta
|
||||
end
|
||||
if bindsDepth <= 0 then
|
||||
inBinds = false
|
||||
bindsDepth = 0
|
||||
currentCategory = nil
|
||||
pendingBindCategory = nil
|
||||
pendingMarkerLine = nil
|
||||
pendingMarkerRaw = nil
|
||||
currentBind = nil
|
||||
end
|
||||
else
|
||||
if topDepth == 0 then
|
||||
local includePath, optionalInclude = includeNode(code)
|
||||
if includePath ~= nil then
|
||||
parseFile(resolveInclude(path, includePath), optionalInclude)
|
||||
end
|
||||
if code:match("^%s*binds%s*{") then
|
||||
inBinds = true
|
||||
bindsDepth = braceDelta(code)
|
||||
currentCategory = nil
|
||||
end
|
||||
end
|
||||
if not inBinds then topDepth = topDepth + braceDelta(code) end
|
||||
end
|
||||
end
|
||||
visiting[path] = nil
|
||||
end
|
||||
|
||||
parseFile(root, false)
|
||||
local categories, total = buildCategories(records)
|
||||
return categories, total, hidden, warnings, fatalError
|
||||
end
|
||||
|
||||
local function snapshot(status, source, errorCode, categories, total, warnings, updatedAt, hidden)
|
||||
return {
|
||||
status = status,
|
||||
error = errorCode or "",
|
||||
compositor = "Niri",
|
||||
source = source,
|
||||
updated_at = updatedAt or "",
|
||||
total = total or 0,
|
||||
categories = categories or {},
|
||||
warnings = warnings or {},
|
||||
hidden = hidden or {},
|
||||
}
|
||||
end
|
||||
|
||||
local function finishRefresh()
|
||||
refreshing = false
|
||||
if refreshQueued then
|
||||
refreshQueued = false
|
||||
refresh()
|
||||
end
|
||||
end
|
||||
|
||||
function refresh()
|
||||
-- Each compositor service receives the same events. Only the selected or
|
||||
-- auto-detected service may publish, preventing snapshot races.
|
||||
if detectedCompositor() ~= "niri" then
|
||||
return
|
||||
end
|
||||
if refreshing then
|
||||
refreshQueued = true
|
||||
return
|
||||
end
|
||||
refreshing = true
|
||||
local source = sourcePath()
|
||||
local previous = noctalia.state.get(SNAPSHOT_KEY)
|
||||
local keepPrevious = type(previous) == "table" and previous.compositor == "Niri"
|
||||
local previousCategories = keepPrevious and previous.categories or {}
|
||||
local previousTotal = keepPrevious and previous.total or 0
|
||||
local previousUpdatedAt = keepPrevious and previous.updated_at or ""
|
||||
local previousHidden = keepPrevious and previous.hidden or {}
|
||||
noctalia.state.set(
|
||||
SNAPSHOT_KEY,
|
||||
snapshot("loading", source, "", previousCategories, previousTotal, {}, previousUpdatedAt, previousHidden)
|
||||
)
|
||||
|
||||
if type(noctalia.readFile(source)) ~= "string" then
|
||||
noctalia.state.set(
|
||||
SNAPSHOT_KEY,
|
||||
snapshot("error", source, "niri_config_unreadable", {}, 0, {}, os.date("%H:%M:%S"))
|
||||
)
|
||||
finishRefresh()
|
||||
return
|
||||
end
|
||||
|
||||
local categories, total, hidden, warnings, fatalError = parseConfig(source)
|
||||
if fatalError ~= nil then
|
||||
noctalia.state.set(
|
||||
SNAPSHOT_KEY,
|
||||
snapshot("error", source, fatalError, {}, 0, warnings, os.date("%H:%M:%S"))
|
||||
)
|
||||
elseif total == 0 and #hidden == 0 then
|
||||
noctalia.state.set(
|
||||
SNAPSHOT_KEY,
|
||||
snapshot("error", source, "niri_no_binds", {}, 0, warnings, os.date("%H:%M:%S"))
|
||||
)
|
||||
else
|
||||
noctalia.state.set(
|
||||
SNAPSHOT_KEY,
|
||||
snapshot("ready", source, "", categories, total, warnings, os.date("%H:%M:%S"), hidden)
|
||||
)
|
||||
end
|
||||
finishRefresh()
|
||||
end
|
||||
|
||||
function onIpc(event, _payload)
|
||||
if event == "refresh" then
|
||||
refresh()
|
||||
end
|
||||
end
|
||||
|
||||
function onConfigChanged()
|
||||
refresh()
|
||||
end
|
||||
|
||||
-- Manual lifecycle: refreshes are driven by initial load, config changes, IPC,
|
||||
-- and the shared refresh request. The host's periodic update hook is a no-op.
|
||||
function update()
|
||||
end
|
||||
|
||||
noctalia.state.watch(REFRESH_REQUEST_KEY, function(_request)
|
||||
refresh()
|
||||
end)
|
||||
|
||||
refresh()
|
||||
@@ -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"
|
||||
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 72 KiB |
@@ -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")
|
||||
@@ -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)})")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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)")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
|
After Width: | Height: | Size: 47 KiB |
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||