feat: add keybind cheatsheet plugin (#64)
* feat: add keybind cheatsheet * docs: refresh keybind cheatsheet preview * docs: use official generated keybind thumbnail * feat(keybind-cheatsheet): preload cached snapshots Move parser and cache ownership into an event-driven data service so the panel opens from a prepared last-known-good snapshot. Document the plugin's inspiration and independent v5 implementation.
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
# Keybind Cheatsheet
|
||||
|
||||
Keybind Cheatsheet opens a searchable Noctalia panel containing the active
|
||||
Mango, Hyprland, or Niri shortcuts. It follows split configuration files,
|
||||
formats common hardware keys, and keeps custom descriptions and hidden rows.
|
||||
|
||||

|
||||
|
||||
## Acknowledgements
|
||||
|
||||
Keybind Cheatsheet was inspired by the original
|
||||
[Keybind Cheatsheet for Noctalia v4](https://github.com/4rmcyt/noctalia-plugins/tree/main/keybind-cheatsheet)
|
||||
created by [blackbartblues](https://github.com/blackbartblues).
|
||||
|
||||
This Noctalia v5 plugin is an independent implementation rather than a direct
|
||||
port. It has its own user interface, service and cache lifecycle, persistence
|
||||
model, tests, and integration with the current Noctalia plugin API.
|
||||
|
||||
## Plugin
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| ID | `kenn/keybind-cheatsheet` |
|
||||
| Entries | Data service: `data`; bar widget: `keybinds`; panel: `cheatsheet` |
|
||||
|
||||
`kenn` is the plugin's fixed publisher namespace, not your local Linux
|
||||
username. Copy the plugin IDs in the commands below unchanged. User-specific
|
||||
configuration paths use the portable `~/.config/...` form.
|
||||
|
||||
## Requirements
|
||||
|
||||
Install `hyprctl` on `PATH` when using a Hyprland Lua configuration. Mango,
|
||||
Niri, and classic Hyprland configurations do not spawn external commands.
|
||||
|
||||
No clipboard command is required. Color paste uses Noctalia's native clipboard
|
||||
API.
|
||||
|
||||
## Usage
|
||||
|
||||
Enable the plugin, then add the `keybinds` widget from Noctalia's bar widget
|
||||
picker. Clicking its keyboard glyph toggles the cheatsheet.
|
||||
|
||||
Open or close the panel without a bar widget:
|
||||
|
||||
```sh
|
||||
noctalia msg panel-toggle kenn/keybind-cheatsheet:cheatsheet
|
||||
```
|
||||
|
||||
Bind that command in the active compositor.
|
||||
|
||||
Mango:
|
||||
|
||||
```ini
|
||||
bind=SUPER,F1,spawn,noctalia msg panel-toggle kenn/keybind-cheatsheet:cheatsheet
|
||||
```
|
||||
|
||||
Hyprland classic configuration:
|
||||
|
||||
```ini
|
||||
bind = SUPER, F1, exec, noctalia msg panel-toggle kenn/keybind-cheatsheet:cheatsheet
|
||||
```
|
||||
|
||||
Niri:
|
||||
|
||||
```kdl
|
||||
Mod+F1 { spawn "noctalia" "msg" "panel-toggle" "kenn/keybind-cheatsheet:cheatsheet"; }
|
||||
```
|
||||
|
||||
Type in the search field to filter by key, description, action, or category.
|
||||
Use the header pencil to enter edit mode. Edit mode keeps hidden bindings
|
||||
visible with muted content while descriptions and visibility are changed with
|
||||
the row's pencil and eye buttons; leaving edit mode applies those visibility
|
||||
choices to the main keymap. The palette button opens key-color controls with
|
||||
native color picking, clipboard paste, and reset actions.
|
||||
|
||||
## Supported configuration
|
||||
|
||||
| Compositor | Default | Parsing |
|
||||
| --- | --- | --- |
|
||||
| Mango | `~/.config/mango/config.conf` | `bind`, `axisbind`, `mousebind`, `gesturebind`, `switchbind`, `source`, and `source-optional` |
|
||||
| Hyprland classic | `~/.config/hypr/hyprland.conf` | `bind*` directives, variables, and recursive `source` paths |
|
||||
| Hyprland Lua | `~/.config/hypr/hyprland.lua` | Live `hyprctl binds -j` data plus category and description scanning through `require()` files |
|
||||
| Niri | `~/.config/niri/config.kdl` | KDL `binds` blocks, action categorization, and recursive `include` paths |
|
||||
|
||||
Includes support `*`, `?`, and bracket glob components. Traversal is limited to
|
||||
32 levels and 256 files, and repeated paths are visited once to stop cycles.
|
||||
|
||||
Category comments use the same forms as the earlier Noctalia plugin:
|
||||
|
||||
```ini
|
||||
# Applications
|
||||
bind=SUPER,T,spawn,foot #"Terminal"
|
||||
```
|
||||
|
||||
```ini
|
||||
# 1. Applications
|
||||
bind = SUPER, T, exec, foot #"Terminal"
|
||||
```
|
||||
|
||||
```kdl
|
||||
// #"Applications"
|
||||
Mod+T hotkey-overlay-title="Terminal" { spawn "foot"; }
|
||||
```
|
||||
|
||||
Hyprland Lua category scanning recognizes `-- 1. Applications` headings and
|
||||
literal `description` or `desc` fields. Concatenated descriptions such as
|
||||
`"Workspace " .. i` are treated as prefixes.
|
||||
|
||||
## Settings
|
||||
|
||||
| Setting | Type | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `compositor` | `select` | `auto` | Detect Mango, Hyprland, or Niri, or force one parser. |
|
||||
| `mango_config` | `file` | `~/.config/mango/config.conf` | Main Mango configuration. |
|
||||
| `hyprland_config` | `file` | `~/.config/hypr/hyprland.conf` | Main classic Hyprland configuration. |
|
||||
| `hyprland_lua_config` | `file` | `~/.config/hypr/hyprland.lua` | Lua file scanned for categories. |
|
||||
| `hyprland_parser` | `select` | `auto` | Select live Lua or classic parsing. |
|
||||
| `niri_config` | `file` | `~/.config/niri/config.kdl` | Main Niri configuration. |
|
||||
| `columns` | `int` | `3` | Maximum balanced columns, from 1 to 4. |
|
||||
| `show_undescribed` | `bool` | `true` | Show bindings that have no description. |
|
||||
| `show_actions` | `bool` | `false` | Show the compositor action under descriptions. |
|
||||
| `glyph` | `glyph` | `keyboard` | Bar widget icon. |
|
||||
|
||||
Noctalia v5 owns panel dimensions and does not expose runtime auto-height.
|
||||
This plugin uses a wide 1500 x 760 panel, scrolling, and a responsive cap on
|
||||
the requested column count.
|
||||
|
||||
## IPC
|
||||
|
||||
Refresh the snapshot after editing compositor configuration:
|
||||
|
||||
```sh
|
||||
noctalia msg plugin kenn/keybind-cheatsheet:data all refresh
|
||||
```
|
||||
|
||||
The data service refreshes even when the panel is closed. Opening and reopening
|
||||
the panel only renders the prepared snapshot. The bar entry also accepts
|
||||
`toggle` and `refresh` when a widget instance exists:
|
||||
|
||||
```sh
|
||||
noctalia msg plugin kenn/keybind-cheatsheet:keybinds focused toggle
|
||||
noctalia msg plugin kenn/keybind-cheatsheet:keybinds focused refresh
|
||||
```
|
||||
|
||||
For parser development, run the fixture suite inside Noctalia:
|
||||
|
||||
```sh
|
||||
noctalia msg plugin kenn/keybind-cheatsheet:data all self-test
|
||||
```
|
||||
|
||||
The report is logged and written to the plugin's persistent data directory as
|
||||
`selftest.json`.
|
||||
|
||||
## Notes
|
||||
|
||||
One event-driven data service loads the durable binding cache and parses the
|
||||
selected compositor configuration once when Noctalia loads the plugin. It then
|
||||
remains idle until settings change or a refresh is explicitly requested. It
|
||||
uses no update interval, filesystem watcher, polling loop, network request, or
|
||||
persistent subprocess. Hyprland Lua mode runs the fixed command
|
||||
`hyprctl binds -j` asynchronously.
|
||||
|
||||
The last successful parsed snapshot is stored as `bindings-cache.json`. The
|
||||
panel reads the shared in-memory snapshot and performs no configuration I/O in
|
||||
`onOpen()`. A failed refresh keeps the previous bindings visible and reports
|
||||
the error without replacing the durable cache.
|
||||
|
||||
Custom descriptions, hidden binding identities, and color overrides are stored
|
||||
in Noctalia's per-plugin state directory as `preferences.json`. The file
|
||||
contains no command output or configuration contents.
|
||||
@@ -0,0 +1,892 @@
|
||||
--!nonstrict
|
||||
-- Snapshot-driven keybind viewer for Mango, Hyprland, and Niri.
|
||||
--
|
||||
-- Parsing, durable cache ownership, and refreshes live in service.luau. This
|
||||
-- panel only reads the shared snapshot and renders it.
|
||||
|
||||
local noctalia = noctalia
|
||||
local ui = ui
|
||||
local panel = panel
|
||||
if noctalia == nil then
|
||||
local host = require("./tests/host_mock")
|
||||
noctalia = host.noctalia
|
||||
ui = host.ui
|
||||
panel = host.panel
|
||||
end
|
||||
|
||||
local PANEL_ID = "kenn/keybind-cheatsheet:cheatsheet"
|
||||
local SNAPSHOT_KEY = "keybind-cheatsheet.snapshot"
|
||||
local REFRESH_REQUEST_KEY = "keybind-cheatsheet.refresh-request"
|
||||
local SELF_TEST_REQUEST_KEY = "keybind-cheatsheet.self-test-request"
|
||||
local PREFERENCES_FILE = "preferences.json"
|
||||
|
||||
local bindings = {}
|
||||
local parseWarnings = {}
|
||||
local currentCompositor = ""
|
||||
local loading = false
|
||||
local panelOpen = false
|
||||
local panelError = nil
|
||||
local refreshError = nil
|
||||
local snapshot = nil
|
||||
local query = ""
|
||||
local searchRevision = 0
|
||||
local view = "bindings"
|
||||
local editingId = nil
|
||||
local editDraft = ""
|
||||
local preferences = { version = 1, hidden = {}, descriptions = {}, colors = {} }
|
||||
local refreshing = false
|
||||
|
||||
local render
|
||||
local refresh
|
||||
|
||||
local env = getfenv()
|
||||
local dynamicCallbacks = {}
|
||||
local callbackIndex = 0
|
||||
|
||||
local function tr(key, values)
|
||||
return noctalia.tr(key, values)
|
||||
end
|
||||
|
||||
local function trim(value)
|
||||
local result = (value or ""):gsub("^%s+", ""):gsub("%s+$", "")
|
||||
return result
|
||||
end
|
||||
|
||||
local function lower(value)
|
||||
return string.lower(value or "")
|
||||
end
|
||||
|
||||
local function startsWith(value, prefix)
|
||||
return value:sub(1, #prefix) == prefix
|
||||
end
|
||||
|
||||
local function resetCallbacks()
|
||||
for _, name in ipairs(dynamicCallbacks) do
|
||||
env[name] = nil
|
||||
end
|
||||
dynamicCallbacks = {}
|
||||
callbackIndex = 0
|
||||
end
|
||||
|
||||
local function callback(prefix, fn)
|
||||
callbackIndex += 1
|
||||
local name = "keybindCheatsheet_" .. prefix .. "_" .. callbackIndex
|
||||
env[name] = fn
|
||||
table.insert(dynamicCallbacks, name)
|
||||
return name
|
||||
end
|
||||
|
||||
local KEY_LABELS = {
|
||||
XF86AudioRaiseVolume = "Vol Up",
|
||||
XF86AudioLowerVolume = "Vol Down",
|
||||
XF86AudioMute = "Mute",
|
||||
XF86AudioMicMute = "Mic Mute",
|
||||
XF86MonBrightnessUp = "Bright Up",
|
||||
XF86MonBrightnessDown = "Bright Down",
|
||||
XF86AudioPlay = "Play/Pause",
|
||||
XF86AudioPause = "Pause",
|
||||
XF86AudioNext = "Next",
|
||||
XF86AudioPrev = "Previous",
|
||||
Print = "PrtSc",
|
||||
Prior = "PgUp",
|
||||
Next = "PgDn",
|
||||
Return = "Enter",
|
||||
Escape = "Esc",
|
||||
space = "Space",
|
||||
btn_left = "Mouse Left",
|
||||
btn_right = "Mouse Right",
|
||||
btn_middle = "Mouse Middle",
|
||||
btn_side = "Mouse Side",
|
||||
btn_extra = "Mouse Extra",
|
||||
}
|
||||
|
||||
local function formatKey(key)
|
||||
return KEY_LABELS[key] or key:gsub("^mouse:", "Mouse ")
|
||||
end
|
||||
|
||||
local function niriCategoryFor(action)
|
||||
local value = lower(action)
|
||||
if startsWith(value, "spawn") then return "Applications" end
|
||||
if startsWith(value, "focus-column-") then return "Column Navigation" end
|
||||
if startsWith(value, "focus-window-") then return "Window Focus" end
|
||||
if startsWith(value, "focus-workspace-") then return "Workspace Navigation" end
|
||||
if startsWith(value, "move-column-") then return "Move Columns" end
|
||||
if startsWith(value, "move-window-") then return "Move Windows" end
|
||||
if startsWith(value, "screenshot") then return "Screenshots" end
|
||||
if startsWith(value, "close-window") or startsWith(value, "fullscreen-window") then return "Window Management" end
|
||||
if startsWith(value, "power-off-monitors") then return "Power" end
|
||||
if startsWith(value, "quit") then return "System" end
|
||||
return "Other"
|
||||
end
|
||||
|
||||
|
||||
local function preferencesPath()
|
||||
local directory, err = noctalia.pluginDataDir()
|
||||
if directory == nil then
|
||||
noctalia.log("Could not resolve plugin data directory: " .. (err or "unknown error"))
|
||||
return nil
|
||||
end
|
||||
return directory .. "/" .. PREFERENCES_FILE
|
||||
end
|
||||
|
||||
local function normalizePreferences(decoded)
|
||||
local result = { version = 1, hidden = {}, descriptions = {}, colors = {} }
|
||||
if type(decoded) ~= "table" then
|
||||
return result
|
||||
end
|
||||
for id, hidden in pairs(type(decoded.hidden) == "table" and decoded.hidden or {}) do
|
||||
if type(id) == "string" and hidden == true then
|
||||
result.hidden[id] = true
|
||||
end
|
||||
end
|
||||
for id, description in pairs(type(decoded.descriptions) == "table" and decoded.descriptions or {}) do
|
||||
if type(id) == "string" and type(description) == "string" and trim(description) ~= "" then
|
||||
result.descriptions[id] = trim(description)
|
||||
end
|
||||
end
|
||||
for bucket, colors in pairs(type(decoded.colors) == "table" and decoded.colors or {}) do
|
||||
if type(bucket) == "string" and type(colors) == "table" then
|
||||
result.colors[bucket] = {}
|
||||
if type(colors.background) == "string" then result.colors[bucket].background = colors.background end
|
||||
if type(colors.text) == "string" then result.colors[bucket].text = colors.text end
|
||||
end
|
||||
end
|
||||
return result
|
||||
end
|
||||
|
||||
local function loadPreferences()
|
||||
local path = preferencesPath()
|
||||
if path == nil then
|
||||
preferences = normalizePreferences(nil)
|
||||
return
|
||||
end
|
||||
local raw = noctalia.readFile(path)
|
||||
if raw == nil or raw == "" then
|
||||
preferences = normalizePreferences(nil)
|
||||
return
|
||||
end
|
||||
local decoded = noctalia.json.decode(raw)
|
||||
preferences = normalizePreferences(decoded)
|
||||
end
|
||||
|
||||
local function savePreferences()
|
||||
local path = preferencesPath()
|
||||
if path == nil then return end
|
||||
local encoded = noctalia.json.encode(preferences, true)
|
||||
if encoded == nil then return end
|
||||
local ok, err = noctalia.writeFile(path, encoded)
|
||||
if not ok then
|
||||
noctalia.notifyError(tr("title"), err or "Could not save preferences")
|
||||
end
|
||||
end
|
||||
|
||||
local function applySnapshot(value)
|
||||
snapshot = type(value) == "table" and value or nil
|
||||
if snapshot == nil then
|
||||
bindings = {}
|
||||
parseWarnings = {}
|
||||
currentCompositor = ""
|
||||
loading = true
|
||||
refreshing = false
|
||||
panelError = nil
|
||||
refreshError = nil
|
||||
return
|
||||
end
|
||||
|
||||
bindings = type(snapshot.bindings) == "table" and snapshot.bindings or {}
|
||||
parseWarnings = type(snapshot.warnings) == "table" and snapshot.warnings or {}
|
||||
currentCompositor = type(snapshot.compositor) == "string" and snapshot.compositor or ""
|
||||
refreshing = snapshot.refreshing == true
|
||||
loading = (snapshot.status == "loading" or snapshot.status == "idle") and #bindings == 0
|
||||
panelError = snapshot.status == "error" and (snapshot.error or tr("missing_config")) or nil
|
||||
refreshError = snapshot.status == "ready" and type(snapshot.error) == "string"
|
||||
and snapshot.error ~= "" and snapshot.error or nil
|
||||
end
|
||||
|
||||
refresh = function()
|
||||
local current = tonumber(noctalia.state.get(REFRESH_REQUEST_KEY)) or 0
|
||||
refreshing = true
|
||||
noctalia.state.set(REFRESH_REQUEST_KEY, current + 1)
|
||||
render()
|
||||
end
|
||||
|
||||
|
||||
local COLOR_BUCKETS = {
|
||||
{ id = "super", label = "Super", background = "primary", text = "on_primary", picker = "#6750A4" },
|
||||
{ id = "ctrl", label = "Ctrl", background = "secondary", text = "on_secondary", picker = "#625B71" },
|
||||
{ id = "shift", label = "Shift", background = "tertiary", text = "on_tertiary", picker = "#7D5260" },
|
||||
{ id = "alt", label = "Alt", background = "error", text = "on_error", picker = "#B3261E" },
|
||||
{ id = "xf86", label = "Media", background = "secondary/0.25", text = "secondary", picker = "#E8DEF8" },
|
||||
{ id = "number", label = "Numbers", background = "tertiary/0.25", text = "tertiary", picker = "#FFD8E4" },
|
||||
{ id = "mouse", label = "Mouse", background = "error/0.2", text = "error", picker = "#F9DEDC" },
|
||||
{ id = "print", label = "Print", background = "primary/0.25", text = "primary", picker = "#EADDFF" },
|
||||
{ id = "default", label = "Other keys", background = "surface_variant", text = "on_surface_variant", picker = "#E7E0EC" },
|
||||
{ id = "description", label = "Descriptions", background = "surface", text = "on_surface", picker = "#1D1B20" },
|
||||
}
|
||||
|
||||
local COLOR_BY_ID = {}
|
||||
for _, bucket in ipairs(COLOR_BUCKETS) do COLOR_BY_ID[bucket.id] = bucket end
|
||||
|
||||
local function colorValue(bucketId, property)
|
||||
local custom = preferences.colors[bucketId]
|
||||
if custom ~= nil and custom[property] ~= nil then
|
||||
return custom[property]
|
||||
end
|
||||
local bucket = COLOR_BY_ID[bucketId] or COLOR_BY_ID.default
|
||||
return bucket[property]
|
||||
end
|
||||
|
||||
local function bucketForKey(key)
|
||||
local value = lower(key)
|
||||
if startsWith(value, "xf86") then return "xf86" end
|
||||
if value:match("^%d$") then return "number" end
|
||||
if startsWith(value, "mouse") or startsWith(value, "btn_") or value:find("wheel", 1, true) ~= nil then return "mouse" end
|
||||
if value == "print" or value == "prior" or value == "next" then return "print" end
|
||||
return "default"
|
||||
end
|
||||
|
||||
local function bucketForModifier(modifier)
|
||||
local value = lower(modifier)
|
||||
if value == "super" or value == "mod4" or value == "logo" then return "super" end
|
||||
if value == "ctrl" or value == "control" then return "ctrl" end
|
||||
if value == "shift" then return "shift" end
|
||||
if value == "alt" or value == "mod1" then return "alt" end
|
||||
return "default"
|
||||
end
|
||||
|
||||
local function isHexColor(value)
|
||||
if type(value) ~= "string" then return false end
|
||||
return value:match("^#%x%x%x%x%x%x$") ~= nil or value:match("^#%x%x%x%x%x%x%x%x$") ~= nil
|
||||
end
|
||||
|
||||
local function setColor(bucketId, property, value)
|
||||
preferences.colors[bucketId] = preferences.colors[bucketId] or {}
|
||||
preferences.colors[bucketId][property] = value
|
||||
savePreferences()
|
||||
render()
|
||||
end
|
||||
|
||||
local function chooseColor(bucketId, property)
|
||||
local bucket = COLOR_BY_ID[bucketId] or COLOR_BY_ID.default
|
||||
local current = colorValue(bucketId, property)
|
||||
local initial = isHexColor(current) and current:sub(1, 7) or bucket.picker
|
||||
noctalia.openColorPicker(initial, function(color)
|
||||
if color ~= nil then
|
||||
setColor(bucketId, property, color)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
local function pasteColor(bucketId, property)
|
||||
local value = trim(noctalia.clipboardText() or "")
|
||||
if isHexColor(value) then
|
||||
setColor(bucketId, property, string.upper(value))
|
||||
else
|
||||
noctalia.notifyError(tr("title"), "Clipboard does not contain a #RRGGBB color")
|
||||
end
|
||||
end
|
||||
|
||||
local function bindingContentOpacity(hidden)
|
||||
return hidden and 0.45 or 1
|
||||
end
|
||||
|
||||
local function keyPill(text, bucketId, key)
|
||||
return ui.column({
|
||||
key = key,
|
||||
fill = colorValue(bucketId, "background"),
|
||||
radius = 4,
|
||||
paddingH = 7,
|
||||
paddingV = 3,
|
||||
align = "center",
|
||||
}, {
|
||||
ui.label({ text = text, color = colorValue(bucketId, "text"), fontSize = 12, fontWeight = "bold", maxLines = 1 }),
|
||||
})
|
||||
end
|
||||
|
||||
local function authoredDescription(binding)
|
||||
return preferences.descriptions[binding.id] or binding.description or ""
|
||||
end
|
||||
|
||||
local function titleCase(value)
|
||||
value = trim(value):gsub("[_%-]+", " ")
|
||||
return (value:gsub("(%a)([%w']*)", function(first, rest)
|
||||
return string.upper(first) .. lower(rest)
|
||||
end))
|
||||
end
|
||||
|
||||
local function friendlySpawn(commandLine)
|
||||
local value = lower(trim(commandLine))
|
||||
if value:find("noctalia msg panel-toggle launcher", 1, true) ~= nil then return "Application Launcher" end
|
||||
if value:find("noctalia msg panel-toggle session", 1, true) ~= nil then return "Power Menu" end
|
||||
if value:find("noctalia msg session lock", 1, true) ~= nil then return "Lock Screen" end
|
||||
if value:find("playerctl play-pause", 1, true) ~= nil then return "Play / Pause" end
|
||||
if value:find("playerctl next", 1, true) ~= nil then return "Next Track" end
|
||||
if value:find("playerctl previous", 1, true) ~= nil then return "Previous Track" end
|
||||
if value:find("playerctl stop", 1, true) ~= nil then return "Stop Playback" end
|
||||
if value:find("wpctl set-volume", 1, true) ~= nil then
|
||||
return value:find("+", 1, true) ~= nil and "Volume Up" or "Volume Down"
|
||||
end
|
||||
if value:find("wpctl set-mute", 1, true) ~= nil then
|
||||
return value:find("source", 1, true) ~= nil and "Toggle Microphone Mute" or "Toggle Mute"
|
||||
end
|
||||
if value:find("brightnessctl", 1, true) ~= nil then
|
||||
return value:find("+", 1, true) ~= nil and "Brightness Up" or "Brightness Down"
|
||||
end
|
||||
|
||||
local program = trim(commandLine):match("^([^%s]+)") or "Application"
|
||||
program = program:match("([^/]+)$") or program
|
||||
local programs = {
|
||||
alacritty = "Terminal",
|
||||
chromium = "Browser",
|
||||
dolphin = "File Manager",
|
||||
firefox = "Browser",
|
||||
foot = "Terminal",
|
||||
kitty = "Terminal",
|
||||
nautilus = "File Manager",
|
||||
thunar = "File Manager",
|
||||
}
|
||||
return programs[lower(program)] or ("Launch " .. titleCase(program))
|
||||
end
|
||||
|
||||
local ACTION_LABELS = {
|
||||
close_window = "Close Window",
|
||||
killactive = "Close Window",
|
||||
killclient = "Close Window",
|
||||
minimized = "Minimize Window",
|
||||
quit = "Exit Compositor",
|
||||
reload_config = "Reload Configuration",
|
||||
restore_minimized = "Restore Minimized Window",
|
||||
switch_proportion_preset = "Cycle Width Preset",
|
||||
toggle_named_scratchpad = "Toggle Scratchpad",
|
||||
togglefakefullscreen = "Toggle Fake Fullscreen",
|
||||
togglefloating = "Toggle Floating",
|
||||
togglefullscreen = "Toggle Fullscreen",
|
||||
togglegaps = "Toggle Gaps",
|
||||
toggleglobal = "Toggle Global Window",
|
||||
togglemaximizescreen = "Toggle Maximize",
|
||||
toggleoverlay = "Toggle Overlay",
|
||||
toggleoverview = "Workspace Overview",
|
||||
}
|
||||
|
||||
local function friendlyAction(binding)
|
||||
local action = trim(binding.action)
|
||||
local command, arguments = action:match("^(%S+)%s*(.*)$")
|
||||
command = lower(command or ""):gsub("%-", "_")
|
||||
arguments = trim(arguments or "")
|
||||
if command == "spawn" or command == "spawn_shell" or command == "exec" then
|
||||
return friendlySpawn(arguments)
|
||||
end
|
||||
if ACTION_LABELS[command] ~= nil then return ACTION_LABELS[command] end
|
||||
|
||||
local argument = arguments:match("^([^,%s]+)") or ""
|
||||
local direction = argument ~= "" and titleCase(argument) or ""
|
||||
if command == "focusdir" or command == "movefocus" then return "Focus " .. direction end
|
||||
if command == "exchange_client" or command == "movewindow" then return "Move Window " .. direction end
|
||||
if command == "focusmon" then return "Focus Monitor " .. direction end
|
||||
if command == "tagmon" then return "Move to Monitor " .. direction end
|
||||
if command == "scroller_stack" then return "Move Within Stack " .. direction end
|
||||
if command == "view" or command == "workspace" then return "Workspace " .. argument end
|
||||
if command == "tag" or command == "movetoworkspace" then return "Send to Workspace " .. argument end
|
||||
if command == "viewtoleft" or command == "viewtoleft_have_client" then return "Previous Workspace" end
|
||||
if command == "viewtoright" or command == "viewtoright_have_client" then return "Next Workspace" end
|
||||
if command == "setlayout" then return titleCase(argument) .. " Layout" end
|
||||
if command == "set_proportion" then return "Set Window Proportion " .. argument end
|
||||
if command == "setmfact" then return "Set Layout Size " .. argument end
|
||||
if command == "moveresize" then
|
||||
return arguments:find("curresize", 1, true) ~= nil and "Resize Window with Mouse" or "Move Window with Mouse"
|
||||
end
|
||||
return titleCase(action)
|
||||
end
|
||||
|
||||
local function effectiveDescription(binding)
|
||||
local description = authoredDescription(binding)
|
||||
return description ~= "" and description or friendlyAction(binding)
|
||||
end
|
||||
|
||||
local function effectiveCategory(binding)
|
||||
if binding.baseCategory ~= nil and binding.baseCategory ~= "" then
|
||||
return binding.baseCategory
|
||||
end
|
||||
if authoredDescription(binding) == "" and binding.compositor ~= "niri" then
|
||||
return tr("without_description")
|
||||
end
|
||||
return binding.compositor == "niri" and niriCategoryFor(binding.action) or tr("other")
|
||||
end
|
||||
|
||||
local function bindingMatches(binding)
|
||||
local needle = lower(trim(query))
|
||||
if needle == "" then return true end
|
||||
local haystack = lower(table.concat({
|
||||
table.concat(binding.modifiers, " "),
|
||||
binding.key,
|
||||
formatKey(binding.key),
|
||||
effectiveDescription(binding),
|
||||
binding.action,
|
||||
effectiveCategory(binding),
|
||||
}, " "))
|
||||
return haystack:find(needle, 1, true) ~= nil
|
||||
end
|
||||
|
||||
local CATEGORY_PRIORITIES = {
|
||||
{ "applications", 10 },
|
||||
{ "system", 20 },
|
||||
{ "window management", 30 },
|
||||
{ "focus", 40 },
|
||||
{ "navigation", 40 },
|
||||
{ "moving windows", 45 },
|
||||
{ "workspace", 50 },
|
||||
{ "tags", 50 },
|
||||
{ "monitor", 50 },
|
||||
{ "media", 60 },
|
||||
{ "brightness", 60 },
|
||||
{ "layout", 70 },
|
||||
{ "mouse", 80 },
|
||||
{ "touchpad", 90 },
|
||||
{ "gesture", 90 },
|
||||
{ "integration", 100 },
|
||||
{ lower(tr("without_description")), 900 },
|
||||
{ lower(tr("other")), 950 },
|
||||
}
|
||||
|
||||
local function categoryPriority(name)
|
||||
local value = lower(name)
|
||||
for _, entry in ipairs(CATEGORY_PRIORITIES) do
|
||||
if value:find(entry[1], 1, true) ~= nil then return entry[2] end
|
||||
end
|
||||
return 500
|
||||
end
|
||||
|
||||
local function bindingAvailableInView(hidden, undescribed, managing, showUndescribed)
|
||||
if managing then return true end
|
||||
return not hidden and (showUndescribed or not undescribed)
|
||||
end
|
||||
|
||||
local function hiddenBindingCount(bindingList, hiddenPreferences)
|
||||
local currentIds = {}
|
||||
for _, binding in ipairs(bindingList or {}) do
|
||||
currentIds[binding.id] = true
|
||||
end
|
||||
local count = 0
|
||||
for id in pairs(currentIds) do
|
||||
if hiddenPreferences[id] == true then count += 1 end
|
||||
end
|
||||
return count
|
||||
end
|
||||
|
||||
local function restoreHiddenBindings(bindingList, hiddenPreferences)
|
||||
for _, binding in ipairs(bindingList or {}) do
|
||||
hiddenPreferences[binding.id] = nil
|
||||
end
|
||||
end
|
||||
|
||||
local function visibleGroups()
|
||||
local showUndescribed = noctalia.getConfig("show_undescribed") ~= false
|
||||
local managing = view == "edit"
|
||||
local groups = {}
|
||||
local byName = {}
|
||||
for index, binding in ipairs(bindings) do
|
||||
local hidden = preferences.hidden[binding.id] == true
|
||||
local undescribed = authoredDescription(binding) == "" and binding.compositor ~= "niri"
|
||||
if bindingAvailableInView(hidden, undescribed, managing, showUndescribed) and bindingMatches(binding) then
|
||||
local category = effectiveCategory(binding)
|
||||
local group = byName[category]
|
||||
if group == nil then
|
||||
group = { name = category, bindings = {}, sourceOrder = #groups + 1, order = 0, weight = 1 }
|
||||
byName[category] = group
|
||||
table.insert(groups, group)
|
||||
end
|
||||
binding.displayIndex = index
|
||||
table.insert(group.bindings, binding)
|
||||
group.weight += 1
|
||||
end
|
||||
end
|
||||
table.sort(groups, function(a, b)
|
||||
local left = categoryPriority(a.name)
|
||||
local right = categoryPriority(b.name)
|
||||
if left ~= right then return left < right end
|
||||
return a.sourceOrder < b.sourceOrder
|
||||
end)
|
||||
for index, group in ipairs(groups) do group.order = index end
|
||||
return groups
|
||||
end
|
||||
|
||||
local function columnCount()
|
||||
local configured = math.clamp(tonumber(noctalia.getConfig("columns")) or 3, 1, 4)
|
||||
local outputWidth = 1920
|
||||
local focused = noctalia.focusedOutputName()
|
||||
for _, output in ipairs(noctalia.outputs()) do
|
||||
if output.name == focused or output.focused then
|
||||
outputWidth = output.width / math.max(output.scale or 1, 1)
|
||||
break
|
||||
end
|
||||
end
|
||||
local responsive = outputWidth < 760 and 1 or (outputWidth < 1100 and 2 or (outputWidth < 1500 and 3 or 4))
|
||||
return math.min(configured, responsive)
|
||||
end
|
||||
|
||||
local function balancedColumns(groups, count)
|
||||
local columns = {}
|
||||
for index = 1, count do columns[index] = { groups = {}, weight = 0 } end
|
||||
for _, group in ipairs(groups) do
|
||||
local target = 1
|
||||
for index = 2, count do
|
||||
if columns[index].weight < columns[target].weight then target = index end
|
||||
end
|
||||
table.insert(columns[target].groups, group)
|
||||
columns[target].weight += group.weight
|
||||
end
|
||||
for _, column in ipairs(columns) do
|
||||
table.sort(column.groups, function(a, b) return a.order < b.order end)
|
||||
end
|
||||
return columns
|
||||
end
|
||||
|
||||
local function saveCustomDescription(binding, value)
|
||||
value = trim(value)
|
||||
if value == "" or value == binding.description then
|
||||
preferences.descriptions[binding.id] = nil
|
||||
else
|
||||
preferences.descriptions[binding.id] = value
|
||||
end
|
||||
editingId = nil
|
||||
editDraft = ""
|
||||
savePreferences()
|
||||
render()
|
||||
end
|
||||
|
||||
local function bindingRow(binding, occurrence)
|
||||
local hidden = preferences.hidden[binding.id] == true
|
||||
local contentOpacity = bindingContentOpacity(hidden)
|
||||
local pills = {}
|
||||
for index, modifier in ipairs(binding.modifiers) do
|
||||
table.insert(pills, keyPill(modifier == "SUPER" and "Super" or modifier:sub(1, 1) .. lower(modifier:sub(2)), bucketForModifier(modifier), "mod-" .. index))
|
||||
end
|
||||
table.insert(pills, keyPill(formatKey(binding.key), bucketForKey(binding.key), "key"))
|
||||
|
||||
local description = effectiveDescription(binding)
|
||||
local identity = binding.id .. "#" .. occurrence
|
||||
if view == "edit" and editingId == identity then
|
||||
local visibilityChanged = callback("visibility", function()
|
||||
if hidden then
|
||||
preferences.hidden[binding.id] = nil
|
||||
else
|
||||
preferences.hidden[binding.id] = true
|
||||
end
|
||||
savePreferences()
|
||||
render()
|
||||
end)
|
||||
local inputChanged = callback("editChange", function(value) editDraft = value end)
|
||||
local submit = callback("editSubmit", function(value) saveCustomDescription(binding, value) end)
|
||||
local save = callback("editSave", function() saveCustomDescription(binding, editDraft) end)
|
||||
local cancel = callback("editCancel", function()
|
||||
editingId = nil
|
||||
editDraft = ""
|
||||
render()
|
||||
end)
|
||||
return ui.row({ key = "edit-" .. identity, gap = 6, align = "center", paddingV = 0 }, {
|
||||
ui.row({ minWidth = 188, gap = 3, align = "center", opacity = contentOpacity }, pills),
|
||||
ui.input({ key = "description-" .. identity, value = editDraft, placeholder = tr("edit_description"), focus = true, controlSize = "sm", flexGrow = 1, onChange = inputChanged, onSubmit = submit }),
|
||||
ui.button({ glyph = "check", width = 22, height = 22, glyphSize = 12, variant = "ghost", controlSize = "sm", tooltip = tr("save"), onClick = save }),
|
||||
ui.button({ glyph = "close", width = 22, height = 22, glyphSize = 12, variant = "ghost", controlSize = "sm", tooltip = tr("cancel"), onClick = cancel }),
|
||||
ui.button({ glyph = hidden and "eye-off" or "eye", width = 22, height = 22, glyphSize = 13, variant = "ghost", controlSize = "sm", selected = hidden, tooltip = hidden and tr("show_binding") or tr("hide_binding"), onClick = visibilityChanged }),
|
||||
})
|
||||
end
|
||||
|
||||
local labels = {
|
||||
ui.label({
|
||||
text = description,
|
||||
color = colorValue("description", "text"),
|
||||
fontWeight = "bold",
|
||||
fontSize = 13,
|
||||
maxLines = 1,
|
||||
}),
|
||||
}
|
||||
if noctalia.getConfig("show_actions") ~= false and description ~= "" and binding.action ~= "" then
|
||||
table.insert(labels, ui.label({ text = binding.action, color = "on_surface_variant", fontSize = 11, maxLines = 1 }))
|
||||
end
|
||||
local row = {
|
||||
ui.row({ minWidth = 188, gap = 3, align = "center", opacity = contentOpacity }, pills),
|
||||
ui.column({ flexGrow = 1, gap = 0, opacity = contentOpacity }, labels),
|
||||
}
|
||||
if view == "edit" then
|
||||
local edit = callback("edit", function()
|
||||
editingId = identity
|
||||
editDraft = description
|
||||
render()
|
||||
end)
|
||||
local visibilityChanged = callback("visibility", function()
|
||||
if hidden then
|
||||
preferences.hidden[binding.id] = nil
|
||||
else
|
||||
preferences.hidden[binding.id] = true
|
||||
end
|
||||
savePreferences()
|
||||
render()
|
||||
end)
|
||||
table.insert(row, ui.button({ glyph = "pencil", width = 22, height = 22, glyphSize = 12, variant = "ghost", controlSize = "sm", tooltip = tr("edit_description"), onClick = edit }))
|
||||
table.insert(row, ui.button({ glyph = hidden and "eye-off" or "eye", width = 22, height = 22, glyphSize = 13, variant = "ghost", controlSize = "sm", selected = hidden, tooltip = hidden and tr("show_binding") or tr("hide_binding"), onClick = visibilityChanged }))
|
||||
end
|
||||
return ui.row({ key = identity, gap = 6, align = "center", paddingV = 0 }, row)
|
||||
end
|
||||
|
||||
local function categoryNode(group)
|
||||
local children = {
|
||||
ui.label({ text = string.upper(group.name), color = "primary", fontSize = 14, fontWeight = "bold" }),
|
||||
ui.separator({ color = "outline", thickness = 1, spacing = 1 }),
|
||||
}
|
||||
local occurrences = {}
|
||||
for _, binding in ipairs(group.bindings) do
|
||||
occurrences[binding.id] = (occurrences[binding.id] or 0) + 1
|
||||
table.insert(children, bindingRow(binding, occurrences[binding.id]))
|
||||
end
|
||||
return ui.column({ key = "category-" .. group.name, gap = 1, paddingV = 3, align = "stretch" }, children)
|
||||
end
|
||||
|
||||
local function bindingsBody()
|
||||
local groups = visibleGroups()
|
||||
if #groups == 0 then
|
||||
return ui.column({ flexGrow = 1, align = "center", justify = "center" }, {
|
||||
ui.glyph({ name = "search-off", size = 32, color = "on_surface_variant" }),
|
||||
ui.label({ text = tr("no_results"), color = "on_surface_variant" }),
|
||||
})
|
||||
end
|
||||
local columns = balancedColumns(groups, columnCount())
|
||||
local columnNodes = {}
|
||||
for index, column in ipairs(columns) do
|
||||
local categoryNodes = {}
|
||||
for _, group in ipairs(column.groups) do table.insert(categoryNodes, categoryNode(group)) end
|
||||
table.insert(columnNodes, ui.column({ key = "column-" .. index, flexGrow = 1, gap = 8, align = "stretch" }, categoryNodes))
|
||||
end
|
||||
return ui.scroll({ flexGrow = 1, gap = 0 }, {
|
||||
ui.row({ key = "binding-columns", gap = 22, align = "stretch" }, columnNodes),
|
||||
})
|
||||
end
|
||||
|
||||
local function hiddenCount()
|
||||
return hiddenBindingCount(bindings, preferences.hidden)
|
||||
end
|
||||
|
||||
local function colorControl(bucket, property)
|
||||
local choose = callback("chooseColor", function() chooseColor(bucket.id, property) end)
|
||||
local paste = callback("pasteColor", function() pasteColor(bucket.id, property) end)
|
||||
local reset = callback("resetColor", function()
|
||||
if preferences.colors[bucket.id] ~= nil then
|
||||
preferences.colors[bucket.id][property] = nil
|
||||
if next(preferences.colors[bucket.id]) == nil then preferences.colors[bucket.id] = nil end
|
||||
savePreferences()
|
||||
render()
|
||||
end
|
||||
end)
|
||||
return ui.row({ gap = 5, align = "center", flexGrow = 1 }, {
|
||||
ui.box({ width = 24, height = 24, radius = 5, fill = colorValue(bucket.id, property), border = "outline", borderWidth = 1 }),
|
||||
ui.button({ text = property == "background" and tr("background") or tr("text"), variant = "ghost", controlSize = "sm", flexGrow = 1, onClick = choose }),
|
||||
ui.button({ glyph = "clipboard", variant = "ghost", controlSize = "sm", tooltip = tr("paste"), onClick = paste }),
|
||||
ui.button({ glyph = "restore", variant = "ghost", controlSize = "sm", tooltip = tr("reset"), onClick = reset }),
|
||||
})
|
||||
end
|
||||
|
||||
local function appearanceBody()
|
||||
local rows = {
|
||||
ui.label({ text = tr("customize_colors"), color = "on_surface_variant" }),
|
||||
}
|
||||
for _, bucket in ipairs(COLOR_BUCKETS) do
|
||||
table.insert(rows, ui.column({ key = "color-" .. bucket.id, gap = 5, paddingV = 4 }, {
|
||||
ui.label({ text = bucket.label, fontWeight = "medium" }),
|
||||
ui.row({ gap = 12, align = "center" }, {
|
||||
colorControl(bucket, "background"),
|
||||
colorControl(bucket, "text"),
|
||||
}),
|
||||
}))
|
||||
end
|
||||
local resetAll = callback("resetAllColors", function()
|
||||
preferences.colors = {}
|
||||
savePreferences()
|
||||
render()
|
||||
end)
|
||||
table.insert(rows, ui.separator({ spacing = 6 }))
|
||||
table.insert(rows, ui.row({ gap = 8, align = "center" }, {
|
||||
ui.button({ glyph = "restore", text = tr("reset_colors"), onClick = resetAll }),
|
||||
}))
|
||||
return ui.scroll({ flexGrow = 1, gap = 4 }, rows)
|
||||
end
|
||||
|
||||
local function renderHeader()
|
||||
local refreshClick = callback("refresh", function() refresh() end)
|
||||
local editMode = callback("editMode", function()
|
||||
view = view == "edit" and "bindings" or "edit"
|
||||
editingId = nil
|
||||
editDraft = ""
|
||||
render()
|
||||
end)
|
||||
local appearance = callback("appearance", function()
|
||||
view = view == "appearance" and "bindings" or "appearance"
|
||||
editingId = nil
|
||||
editDraft = ""
|
||||
render()
|
||||
end)
|
||||
local title = tr("title")
|
||||
if currentCompositor ~= "" then
|
||||
title = (currentCompositor == "mango" and "Mango" or (currentCompositor == "niri" and "Niri" or "Hyprland")) .. " Keymap"
|
||||
end
|
||||
local children = {
|
||||
ui.row({ minWidth = 230, gap = 7, align = "center", flexGrow = 1 }, {
|
||||
ui.glyph({ name = "keyboard", size = 16, color = "primary" }),
|
||||
ui.label({ text = title, fontSize = 15, fontWeight = "bold", color = "on_surface" }),
|
||||
}),
|
||||
}
|
||||
if (view == "bindings" or view == "edit") and not loading and panelError == nil then
|
||||
local searchChanged = callback("search", function(value)
|
||||
query = value
|
||||
editingId = nil
|
||||
render()
|
||||
end)
|
||||
local clearSearch = callback("clearSearch", function()
|
||||
query = ""
|
||||
searchRevision += 1
|
||||
render()
|
||||
end)
|
||||
table.insert(children, ui.input({ key = "search-" .. searchRevision, value = query, placeholder = tr("search_placeholder"), controlSize = "sm", width = 300, onChange = searchChanged }))
|
||||
table.insert(children, ui.button({ glyph = "x", width = 26, variant = "ghost", controlSize = "sm", enabled = query ~= "", tooltip = tr("clear"), onClick = clearSearch }))
|
||||
end
|
||||
if not loading or #bindings > 0 then
|
||||
table.insert(children, ui.label({ text = tr("binding_count", { count = #bindings }), color = "on_surface_variant", fontSize = 12 }))
|
||||
end
|
||||
if view == "edit" then
|
||||
local count = hiddenCount()
|
||||
local restoreHidden = callback("restoreHidden", function()
|
||||
restoreHiddenBindings(bindings, preferences.hidden)
|
||||
savePreferences()
|
||||
render()
|
||||
end)
|
||||
table.insert(children, ui.label({ text = tr("hidden_count", { count = count }), color = "on_surface_variant", fontSize = 12 }))
|
||||
table.insert(children, ui.button({ glyph = "eye", variant = "ghost", controlSize = "sm", enabled = count > 0, tooltip = tr("restore_hidden"), onClick = restoreHidden }))
|
||||
end
|
||||
table.insert(children, ui.button({ glyph = "refresh", variant = "ghost", tooltip = tr("refresh"), enabled = not refreshing, onClick = refreshClick }))
|
||||
table.insert(children, ui.button({ glyph = view == "edit" and "check" or "pencil", variant = "ghost", tooltip = view == "edit" and tr("finish_editing") or tr("edit_bindings"), selected = view == "edit", onClick = editMode }))
|
||||
table.insert(children, ui.button({ glyph = view == "appearance" and "check" or "palette", variant = "ghost", tooltip = view == "appearance" and tr("back") or tr("appearance"), selected = view == "appearance", onClick = appearance }))
|
||||
return ui.row({ align = "center", justify = "space_between", gap = 7 }, children)
|
||||
end
|
||||
|
||||
render = function()
|
||||
if not panelOpen then return end
|
||||
resetCallbacks()
|
||||
local contentState = "bindings"
|
||||
if view == "appearance" then
|
||||
contentState = "appearance"
|
||||
elseif loading then
|
||||
contentState = "loading"
|
||||
elseif panelError ~= nil then
|
||||
contentState = "error"
|
||||
elseif view == "edit" then
|
||||
contentState = "edit"
|
||||
end
|
||||
local children = { renderHeader() }
|
||||
if view == "appearance" then
|
||||
table.insert(children, appearanceBody())
|
||||
elseif loading then
|
||||
table.insert(children, ui.column({ flexGrow = 1, align = "center", justify = "center", gap = 10 }, {
|
||||
ui.glyph({ name = "loader", size = 32, color = "primary" }),
|
||||
ui.label({ text = tr("loading"), color = "on_surface_variant" }),
|
||||
}))
|
||||
elseif panelError ~= nil then
|
||||
table.insert(children, ui.column({ flexGrow = 1, align = "center", justify = "center", gap = 10 }, {
|
||||
ui.glyph({ name = "alert-triangle", size = 32, color = "error" }),
|
||||
ui.label({ text = panelError, color = "error", textAlign = "center", maxWidth = 640 }),
|
||||
}))
|
||||
else
|
||||
if refreshError ~= nil then
|
||||
table.insert(children, ui.label({ text = refreshError, color = "error", fontSize = 11, maxLines = 2 }))
|
||||
end
|
||||
if #parseWarnings > 0 then
|
||||
table.insert(children, ui.label({ text = tr("source_warning") .. " " .. table.concat(parseWarnings, " | "), color = "error", fontSize = 11, maxLines = 2 }))
|
||||
end
|
||||
table.insert(children, bindingsBody())
|
||||
end
|
||||
panel.render(ui.column({ key = "keybind-cheatsheet-" .. contentState, flexGrow = 1, gap = 8, align = "stretch" }, children))
|
||||
end
|
||||
|
||||
noctalia.state.watch(SNAPSHOT_KEY, function(value)
|
||||
applySnapshot(value)
|
||||
if panelOpen then render() end
|
||||
end)
|
||||
|
||||
local function releasePanelState(clearModel)
|
||||
panelOpen = false
|
||||
view = "bindings"
|
||||
query = ""
|
||||
editingId = nil
|
||||
editDraft = ""
|
||||
resetCallbacks()
|
||||
if clearModel then
|
||||
bindings = {}
|
||||
parseWarnings = {}
|
||||
currentCompositor = ""
|
||||
loading = false
|
||||
refreshing = false
|
||||
panelError = nil
|
||||
refreshError = nil
|
||||
snapshot = nil
|
||||
end
|
||||
end
|
||||
|
||||
function onOpen(_context)
|
||||
panelOpen = true
|
||||
view = "bindings"
|
||||
query = ""
|
||||
searchRevision += 1
|
||||
editingId = nil
|
||||
editDraft = ""
|
||||
loadPreferences()
|
||||
applySnapshot(noctalia.state.get(SNAPSHOT_KEY))
|
||||
render()
|
||||
end
|
||||
|
||||
function onClose()
|
||||
releasePanelState(false)
|
||||
end
|
||||
|
||||
function onExit(_signal)
|
||||
releasePanelState(true)
|
||||
end
|
||||
|
||||
function onConfigChanged()
|
||||
if panelOpen then render() end
|
||||
end
|
||||
|
||||
function onIpc(event, _payload)
|
||||
if event == "toggle" then
|
||||
if panelOpen then panel.close() else noctalia.togglePanel(PANEL_ID) end
|
||||
elseif event == "refresh" then
|
||||
refresh()
|
||||
elseif event == "self-test" then
|
||||
local current = tonumber(noctalia.state.get(SELF_TEST_REQUEST_KEY)) or 0
|
||||
noctalia.state.set(SELF_TEST_REQUEST_KEY, current + 1)
|
||||
end
|
||||
end
|
||||
|
||||
local function lifecycleState()
|
||||
return {
|
||||
panelOpen = panelOpen,
|
||||
loading = loading,
|
||||
refreshing = refreshing,
|
||||
bindingCount = #bindings,
|
||||
snapshotLoaded = snapshot ~= nil,
|
||||
callbackCount = #dynamicCallbacks,
|
||||
editDraft = editDraft,
|
||||
}
|
||||
end
|
||||
|
||||
return {
|
||||
bindingAvailableInView = bindingAvailableInView,
|
||||
bindingContentOpacity = bindingContentOpacity,
|
||||
hiddenBindingCount = hiddenBindingCount,
|
||||
restoreHiddenBindings = restoreHiddenBindings,
|
||||
categoryPriority = categoryPriority,
|
||||
friendlyAction = friendlyAction,
|
||||
applySnapshot = applySnapshot,
|
||||
onOpen = onOpen,
|
||||
onClose = onClose,
|
||||
onExit = onExit,
|
||||
onIpc = onIpc,
|
||||
lifecycleState = lifecycleState,
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
id = "kenn/keybind-cheatsheet"
|
||||
name = "Keybind Cheatsheet"
|
||||
version = "0.2.0"
|
||||
plugin_api = 4
|
||||
author = "kenn"
|
||||
license = "MIT"
|
||||
dependencies = ["hyprctl"]
|
||||
tags = ["bar", "panel", "utility", "system", "hyprland", "mangowc", "niri"]
|
||||
icon = "keyboard"
|
||||
description = "Searchable keybindings for Mango, Hyprland, and Niri."
|
||||
|
||||
[[service]]
|
||||
id = "data"
|
||||
entry = "service.luau"
|
||||
|
||||
[[setting]]
|
||||
key = "compositor"
|
||||
type = "select"
|
||||
label_key = "settings.compositor.label"
|
||||
description_key = "settings.compositor.description"
|
||||
default = "auto"
|
||||
options = [
|
||||
{ value = "auto", label_key = "settings.compositor.options.auto" },
|
||||
{ value = "mango", label_key = "settings.compositor.options.mango" },
|
||||
{ value = "hyprland", label_key = "settings.compositor.options.hyprland" },
|
||||
{ value = "niri", label_key = "settings.compositor.options.niri" },
|
||||
]
|
||||
|
||||
[[setting]]
|
||||
key = "mango_config"
|
||||
type = "file"
|
||||
label_key = "settings.mango_config.label"
|
||||
description_key = "settings.mango_config.description"
|
||||
default = "~/.config/mango/config.conf"
|
||||
|
||||
[[setting]]
|
||||
key = "hyprland_config"
|
||||
type = "file"
|
||||
label_key = "settings.hyprland_config.label"
|
||||
description_key = "settings.hyprland_config.description"
|
||||
default = "~/.config/hypr/hyprland.conf"
|
||||
|
||||
[[setting]]
|
||||
key = "hyprland_lua_config"
|
||||
type = "file"
|
||||
label_key = "settings.hyprland_lua_config.label"
|
||||
description_key = "settings.hyprland_lua_config.description"
|
||||
default = "~/.config/hypr/hyprland.lua"
|
||||
|
||||
[[setting]]
|
||||
key = "hyprland_parser"
|
||||
type = "select"
|
||||
label_key = "settings.hyprland_parser.label"
|
||||
description_key = "settings.hyprland_parser.description"
|
||||
default = "auto"
|
||||
options = [
|
||||
{ value = "auto", label_key = "settings.hyprland_parser.options.auto" },
|
||||
{ value = "lua", label_key = "settings.hyprland_parser.options.lua" },
|
||||
{ value = "conf", label_key = "settings.hyprland_parser.options.conf" },
|
||||
]
|
||||
|
||||
[[setting]]
|
||||
key = "niri_config"
|
||||
type = "file"
|
||||
label_key = "settings.niri_config.label"
|
||||
description_key = "settings.niri_config.description"
|
||||
default = "~/.config/niri/config.kdl"
|
||||
|
||||
[[setting]]
|
||||
key = "columns"
|
||||
type = "int"
|
||||
label_key = "settings.columns.label"
|
||||
description_key = "settings.columns.description"
|
||||
default = 3
|
||||
min = 1
|
||||
max = 4
|
||||
step = 1
|
||||
|
||||
[[setting]]
|
||||
key = "show_undescribed"
|
||||
type = "bool"
|
||||
label_key = "settings.show_undescribed.label"
|
||||
description_key = "settings.show_undescribed.description"
|
||||
default = true
|
||||
|
||||
[[setting]]
|
||||
key = "show_actions"
|
||||
type = "bool"
|
||||
label_key = "settings.show_actions.label"
|
||||
description_key = "settings.show_actions.description"
|
||||
default = false
|
||||
|
||||
[[panel]]
|
||||
id = "cheatsheet"
|
||||
entry = "panel.luau"
|
||||
width = 1500
|
||||
height = 760
|
||||
placement = "floating"
|
||||
position = "center"
|
||||
open_near_click = true
|
||||
|
||||
[[widget]]
|
||||
id = "keybinds"
|
||||
entry = "widget.luau"
|
||||
|
||||
[[widget.setting]]
|
||||
key = "glyph"
|
||||
type = "glyph"
|
||||
label_key = "settings.glyph.label"
|
||||
description_key = "settings.glyph.description"
|
||||
default = "keyboard"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"mango": {
|
||||
"count": 9,
|
||||
"descriptions": ["Terminal", "Close window", "Workspace 1", "Move window", "Focus left", "Lock on close"]
|
||||
},
|
||||
"hypr_conf": {
|
||||
"count": 5,
|
||||
"descriptions": ["Terminal", "Move window", "Close window", "Workspace 1"]
|
||||
},
|
||||
"hypr_lua": {
|
||||
"count": 4,
|
||||
"categories": ["Applications", "Workspaces", "Media", "Without Description"]
|
||||
},
|
||||
"niri": {
|
||||
"count": 5,
|
||||
"descriptions": ["Terminal", "Close window", "Workspace 1"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
[
|
||||
{"locked":false,"mouse":false,"release":false,"repeat":false,"has_description":true,"modmask":64,"submap":"","key":"T","keycode":0,"description":"Terminal","dispatcher":"exec","arg":"foot"},
|
||||
{"locked":false,"mouse":false,"release":false,"repeat":false,"has_description":true,"modmask":64,"submap":"","key":"1","keycode":0,"description":"Workspace 1","dispatcher":"workspace","arg":"1"},
|
||||
{"locked":false,"mouse":false,"release":false,"repeat":false,"has_description":true,"modmask":0,"submap":"","key":"XF86AudioMute","keycode":0,"description":"Mute","dispatcher":"exec","arg":"wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle"},
|
||||
{"locked":false,"mouse":false,"release":false,"repeat":false,"has_description":false,"modmask":64,"submap":"","key":"Q","keycode":0,"description":"","dispatcher":"killactive","arg":""}
|
||||
]
|
||||
@@ -0,0 +1,9 @@
|
||||
$mainMod = SUPER
|
||||
source = ./parts/*.conf
|
||||
|
||||
# 1. Applications
|
||||
bind = $mainMod, T, exec, foot #"Terminal"
|
||||
bindm = $mainMod, mouse:272, movewindow #"Move window"
|
||||
|
||||
# 2. Window Management
|
||||
bind = $mainMod SHIFT, Q, killactive, #"Close window"
|
||||
@@ -0,0 +1,9 @@
|
||||
-- 1. Applications
|
||||
Hyprland.config.bind("SUPER, T", launch_terminal, { description = "Terminal" })
|
||||
|
||||
-- 2. Workspaces
|
||||
for i = 1, 9 do
|
||||
Hyprland.config.bind("SUPER, " .. i, workspace, { description = "Workspace " .. i })
|
||||
end
|
||||
|
||||
require("parts.media")
|
||||
@@ -0,0 +1,2 @@
|
||||
-- 3. Media
|
||||
Hyprland.config.bind(", XF86AudioMute", mute, { desc = "Mute" })
|
||||
@@ -0,0 +1,3 @@
|
||||
# 3. Workspaces
|
||||
binde = $mainMod, 1, workspace, 1 #"Workspace 1"
|
||||
bind = , XF86AudioMute, exec, wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle
|
||||
@@ -0,0 +1,14 @@
|
||||
# Applications
|
||||
bind=SUPER,T,spawn,foot #"Terminal"
|
||||
bind=SUPER,B,spawn,firefox #"Browser"
|
||||
|
||||
source=./nested.conf
|
||||
source-optional=./missing.conf
|
||||
source=./parts/*.conf
|
||||
|
||||
# Media
|
||||
bind=NONE,XF86AudioRaiseVolume,spawn,wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%+
|
||||
axisbind=SUPER,UP,viewtoleft_have_client
|
||||
mousebind=SUPER,btn_left,moveresize,curmove #"Move window"
|
||||
gesturebind=NONE,left,3,focusdir,left #"Focus left"
|
||||
switchbind=fold,spawn,swaylock #"Lock on close"
|
||||
@@ -0,0 +1,3 @@
|
||||
# Window Management
|
||||
bind=SUPER,Q,killclient #"Close window"
|
||||
source=./main.conf
|
||||
@@ -0,0 +1,2 @@
|
||||
# Workspaces
|
||||
bind=SUPER,1,view,1 #"Workspace 1"
|
||||
@@ -0,0 +1,12 @@
|
||||
include "./parts/*.kdl"
|
||||
|
||||
binds {
|
||||
// #"Applications"
|
||||
Mod+T hotkey-overlay-title="Terminal" { spawn "foot"; }
|
||||
Mod+B { spawn "firefox"; }
|
||||
|
||||
// #"Window Management"
|
||||
Mod+Q hotkey-overlay-title="Close window" {
|
||||
close-window;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
binds {
|
||||
// #"Workspaces"
|
||||
Mod+1 hotkey-overlay-title="Workspace 1" { focus-workspace 1; }
|
||||
Mod+WheelScrollDown cooldown-ms=150 { focus-workspace-down; }
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
--!nonstrict
|
||||
-- Minimal Noctalia host used by the standalone Luau parser tests.
|
||||
|
||||
local files = {}
|
||||
local stateValues = {}
|
||||
local stateWatchers = {}
|
||||
local fileExistsOverrides = {}
|
||||
local configValues = {}
|
||||
local availableCommands = {}
|
||||
local asyncRequests = {}
|
||||
local renderCount = 0
|
||||
local renderedTree = nil
|
||||
local readCounts = {}
|
||||
local writeCounts = {}
|
||||
local wantsSecondTicks = false
|
||||
local encodedValues = {}
|
||||
local encodedIndex = 0
|
||||
local errorNotifications = 0
|
||||
local updateInterval = nil
|
||||
|
||||
local function copy(value)
|
||||
if type(value) ~= "table" then return value end
|
||||
local result = {}
|
||||
for key, item in pairs(value) do result[copy(key)] = copy(item) end
|
||||
return result
|
||||
end
|
||||
|
||||
local function normalize(path)
|
||||
local absolute = path:sub(1, 1) == "/"
|
||||
local parts = {}
|
||||
for part in path:gmatch("[^/]+") do
|
||||
if part == ".." then
|
||||
if #parts > 0 then table.remove(parts) end
|
||||
elseif part ~= "." and part ~= "" then
|
||||
table.insert(parts, part)
|
||||
end
|
||||
end
|
||||
return (absolute and "/" or "") .. table.concat(parts, "/")
|
||||
end
|
||||
|
||||
local function directory(path)
|
||||
local prefix = path:gsub("/+$", "") .. "/"
|
||||
local names = {}
|
||||
for file in pairs(files) do
|
||||
if file:sub(1, #prefix) == prefix then
|
||||
local rest = file:sub(#prefix + 1)
|
||||
local name = rest:match("^([^/]+)")
|
||||
if name ~= nil then names[name] = true end
|
||||
end
|
||||
end
|
||||
local result = {}
|
||||
for name in pairs(names) do table.insert(result, name) end
|
||||
table.sort(result)
|
||||
return result
|
||||
end
|
||||
|
||||
local function translation(key, substitutions)
|
||||
local value = key
|
||||
for name, replacement in pairs(substitutions or {}) do
|
||||
value = value:gsub("{" .. name .. "}", tostring(replacement))
|
||||
end
|
||||
return value
|
||||
end
|
||||
|
||||
local noctalia = {
|
||||
tr = translation,
|
||||
getenv = function(name) return name == "HOME" and "/home/test" or nil end,
|
||||
expandPath = function(path) return normalize(path:gsub("^~", "/home/test")) end,
|
||||
fileExists = function(path)
|
||||
path = normalize(path)
|
||||
if fileExistsOverrides[path] ~= nil then return fileExistsOverrides[path] end
|
||||
if files[path] ~= nil then return true end
|
||||
return #directory(path) > 0
|
||||
end,
|
||||
fileInfo = function(path)
|
||||
path = normalize(path)
|
||||
if files[path] ~= nil then return { size = #files[path], mtime = 0, isDir = false } end
|
||||
if #directory(path) > 0 then return { size = 0, mtime = 0, isDir = true } end
|
||||
return nil, "missing"
|
||||
end,
|
||||
listDir = function(path)
|
||||
local values = directory(normalize(path))
|
||||
if #values > 0 then return values, nil end
|
||||
return nil, "missing"
|
||||
end,
|
||||
readFile = function(path)
|
||||
path = normalize(path)
|
||||
readCounts[path] = (readCounts[path] or 0) + 1
|
||||
local content = files[path]
|
||||
if content ~= nil then return content, nil end
|
||||
return nil, "missing"
|
||||
end,
|
||||
writeFile = function(path, content)
|
||||
path = normalize(path)
|
||||
files[path] = content
|
||||
writeCounts[path] = (writeCounts[path] or 0) + 1
|
||||
return true
|
||||
end,
|
||||
renameFile = function(from, to)
|
||||
from = normalize(from)
|
||||
to = normalize(to)
|
||||
if files[from] == nil then return false, "missing" end
|
||||
files[to] = files[from]
|
||||
files[from] = nil
|
||||
return true
|
||||
end,
|
||||
removeFile = function(path)
|
||||
files[normalize(path)] = nil
|
||||
return true
|
||||
end,
|
||||
pluginDir = function() return "/plugin" end,
|
||||
pluginDataDir = function() return "/data" end,
|
||||
commandExists = function(name) return availableCommands[name] == true end,
|
||||
getConfig = function(key) return configValues[key] end,
|
||||
outputs = function() return {} end,
|
||||
focusedOutputName = function() return nil end,
|
||||
clipboardText = function() return nil end,
|
||||
openColorPicker = function() return false end,
|
||||
notify = function() end,
|
||||
notifyError = function() errorNotifications += 1 end,
|
||||
log = function() end,
|
||||
togglePanel = function() end,
|
||||
setUpdateInterval = function(value) updateInterval = value end,
|
||||
runAsync = function(command, callback, timeoutMs)
|
||||
table.insert(asyncRequests, { command = command, callback = callback, timeoutMs = timeoutMs, completed = false })
|
||||
return true
|
||||
end,
|
||||
string = { trim = function(value) return (value:gsub("^%s+", ""):gsub("%s+$", "")) end },
|
||||
json = {
|
||||
decode = function(raw)
|
||||
local value = encodedValues[raw]
|
||||
if value == nil then return nil, "invalid mock JSON" end
|
||||
return copy(value), nil
|
||||
end,
|
||||
encode = function(value)
|
||||
encodedIndex += 1
|
||||
local token = "__mock_json_" .. tostring(encodedIndex)
|
||||
encodedValues[token] = copy(value)
|
||||
return token, nil
|
||||
end,
|
||||
},
|
||||
state = {
|
||||
get = function(key) return copy(stateValues[key]) end,
|
||||
set = function(key, value)
|
||||
stateValues[key] = copy(value)
|
||||
for _, watcher in ipairs(stateWatchers[key] or {}) do watcher(copy(value)) end
|
||||
end,
|
||||
watch = function(key, callback)
|
||||
stateWatchers[key] = stateWatchers[key] or {}
|
||||
table.insert(stateWatchers[key], callback)
|
||||
end,
|
||||
},
|
||||
}
|
||||
|
||||
local function node(kind)
|
||||
return function(props, children) return { type = kind, props = props or {}, children = children or {} } end
|
||||
end
|
||||
|
||||
local ui = {}
|
||||
for _, kind in ipairs({ "column", "row", "box", "label", "glyph", "image", "separator", "spacer", "progress", "button", "graph", "input", "select", "slider", "toggle", "scroll" }) do
|
||||
ui[kind] = node(kind)
|
||||
end
|
||||
|
||||
return {
|
||||
noctalia = noctalia,
|
||||
ui = ui,
|
||||
panel = {
|
||||
render = function(_tree)
|
||||
renderCount += 1
|
||||
renderedTree = _tree
|
||||
end,
|
||||
close = function() end,
|
||||
setWantsSecondTicks = function(value)
|
||||
wantsSecondTicks = value == true
|
||||
end,
|
||||
},
|
||||
setFiles = function(value)
|
||||
files = {}
|
||||
fileExistsOverrides = {}
|
||||
for path, content in pairs(value) do files[normalize(path)] = content end
|
||||
end,
|
||||
setFileExists = function(path, value)
|
||||
fileExistsOverrides[normalize(path)] = value
|
||||
end,
|
||||
resetRuntime = function()
|
||||
stateValues = {}
|
||||
configValues = {}
|
||||
availableCommands = {}
|
||||
asyncRequests = {}
|
||||
renderCount = 0
|
||||
renderedTree = nil
|
||||
readCounts = {}
|
||||
writeCounts = {}
|
||||
wantsSecondTicks = false
|
||||
errorNotifications = 0
|
||||
updateInterval = nil
|
||||
end,
|
||||
setFile = function(path, content)
|
||||
files[normalize(path)] = content
|
||||
end,
|
||||
setConfig = function(values) configValues = values or {} end,
|
||||
setAvailableCommands = function(values) availableCommands = values or {} end,
|
||||
asyncLaunchCount = function() return #asyncRequests end,
|
||||
completeAsync = function(index, result)
|
||||
local request = asyncRequests[index]
|
||||
if request == nil or request.completed then return false end
|
||||
request.completed = true
|
||||
if request.callback ~= nil then request.callback(result) end
|
||||
return true
|
||||
end,
|
||||
renderCount = function() return renderCount end,
|
||||
renderedTree = function() return renderedTree end,
|
||||
readCount = function(path) return readCounts[normalize(path)] or 0 end,
|
||||
writeCount = function(path) return writeCounts[normalize(path)] or 0 end,
|
||||
fileContent = function(path) return files[normalize(path)] end,
|
||||
wantsSecondTicks = function() return wantsSecondTicks end,
|
||||
errorNotificationCount = function() return errorNotifications end,
|
||||
stateValue = function(key) return copy(stateValues[key]) end,
|
||||
updateInterval = function() return updateInterval end,
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"title": "Keybind Cheatsheet",
|
||||
"widget_tooltip": "Open keybind cheatsheet",
|
||||
"loading": "Reading keybindings...",
|
||||
"search_placeholder": "Search keys, descriptions, and actions",
|
||||
"refresh": "Refresh",
|
||||
"appearance": "Appearance",
|
||||
"back": "Back",
|
||||
"clear": "Clear search",
|
||||
"edit_bindings": "Edit bindings",
|
||||
"finish_editing": "Finish editing",
|
||||
"edit_description": "Edit description",
|
||||
"hide_binding": "Hide binding",
|
||||
"show_binding": "Show binding",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"reset": "Reset",
|
||||
"paste": "Paste color",
|
||||
"choose_color": "Choose color",
|
||||
"restore_hidden": "Restore hidden bindings",
|
||||
"reset_colors": "Reset all colors",
|
||||
"no_results": "No keybindings match this search.",
|
||||
"without_description": "Without Description",
|
||||
"other": "Other",
|
||||
"source_warning": "Some configuration files could not be read.",
|
||||
"unsupported": "No supported compositor was detected.",
|
||||
"missing_config": "The configured keybind file could not be read.",
|
||||
"hyprctl_missing": "hyprctl is required for Hyprland Lua keybindings.",
|
||||
"hyprctl_failed": "Hyprland did not return its active keybindings.",
|
||||
"customize_colors": "Key colors",
|
||||
"background": "Background",
|
||||
"text": "Text",
|
||||
"hidden_count": "{count} hidden",
|
||||
"binding_count": "{count} bindings",
|
||||
"settings": {
|
||||
"compositor": {
|
||||
"label": "Compositor",
|
||||
"description": "Detect the active compositor or force a parser.",
|
||||
"options": {
|
||||
"auto": "Automatic",
|
||||
"mango": "Mango",
|
||||
"hyprland": "Hyprland",
|
||||
"niri": "Niri"
|
||||
}
|
||||
},
|
||||
"mango_config": {
|
||||
"label": "Mango config",
|
||||
"description": "Main Mango configuration file. Source directives are followed."
|
||||
},
|
||||
"hyprland_config": {
|
||||
"label": "Hyprland config",
|
||||
"description": "Main classic Hyprland configuration file."
|
||||
},
|
||||
"hyprland_lua_config": {
|
||||
"label": "Hyprland Lua config",
|
||||
"description": "Lua configuration used to recover categories for live binds."
|
||||
},
|
||||
"hyprland_parser": {
|
||||
"label": "Hyprland parser",
|
||||
"description": "Use the live Lua bind list, classic config, or automatic detection.",
|
||||
"options": {
|
||||
"auto": "Automatic",
|
||||
"lua": "Lua / hyprctl",
|
||||
"conf": "Classic config"
|
||||
}
|
||||
},
|
||||
"niri_config": {
|
||||
"label": "Niri config",
|
||||
"description": "Main Niri KDL configuration file. Include directives are followed."
|
||||
},
|
||||
"columns": {
|
||||
"label": "Columns",
|
||||
"description": "Maximum number of balanced columns in the panel."
|
||||
},
|
||||
"show_undescribed": {
|
||||
"label": "Show undescribed bindings",
|
||||
"description": "Put bindings without descriptions in a separate section."
|
||||
},
|
||||
"show_actions": {
|
||||
"label": "Show actions",
|
||||
"description": "Show compositor commands below binding descriptions."
|
||||
},
|
||||
"glyph": {
|
||||
"label": "Glyph",
|
||||
"description": "Icon shown in the bar."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
--!nonstrict
|
||||
-- Static, event-only bar entry. Parsing and cache ownership belong to the data
|
||||
-- service; this widget only toggles the panel or requests a refresh.
|
||||
|
||||
local PANEL_ID = "kenn/keybind-cheatsheet:cheatsheet"
|
||||
local REFRESH_REQUEST_KEY = "keybind-cheatsheet.refresh-request"
|
||||
|
||||
local function render()
|
||||
barWidget.setGlyph(noctalia.getConfig("glyph") or "keyboard")
|
||||
barWidget.setTooltip(noctalia.tr("widget_tooltip"))
|
||||
end
|
||||
|
||||
function onClick()
|
||||
noctalia.togglePanel(PANEL_ID)
|
||||
end
|
||||
|
||||
function onIpc(event, _payload)
|
||||
if event == "toggle" then
|
||||
noctalia.togglePanel(PANEL_ID)
|
||||
elseif event == "refresh" then
|
||||
local generation = tonumber(noctalia.state.get(REFRESH_REQUEST_KEY)) or 0
|
||||
noctalia.state.set(REFRESH_REQUEST_KEY, generation + 1)
|
||||
end
|
||||
end
|
||||
|
||||
render()
|
||||
Reference in New Issue
Block a user