Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions keybind-cheatsheet/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,15 @@ 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.
A `--` inside a string literal (`exec_cmd("tool --flag")`) is not a comment,
so the description after it is still read.

A run of sibling binds that differ only by a number — same modifiers and
category, descriptions equal once their digits are normalised, keys all digits
or all F-keys — renders as one row (`Focus workspace N` on `1-0`,
`Focus display N` on `F1-F3`) unless `merge_sequential` is off. Edit mode
always lists every bind. Pointer buttons show by name (`LMB`, `RMB`, `MMB`,
side and back/forward) rather than by evdev code.

## Settings

Expand All @@ -125,6 +134,8 @@ literal `description` or `desc` fields. Concatenated descriptions such as
| `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. |
| `merge_sequential` | `bool` | `true` | Collapse binds that differ only by a number into one row (`Focus workspace N` on `1-0`). |
| `modifier_labels` | `string_map` | `{}` | Display names for modifiers, keyed by the compositor's name: `{ MOD3 = "Hyper" }`. |
| `glyph` | `glyph` | `keyboard` | Bar widget icon. |

Noctalia v5 owns panel dimensions and does not expose runtime auto-height.
Expand Down
99 changes: 98 additions & 1 deletion keybind-cheatsheet/panel.luau
Original file line number Diff line number Diff line change
Expand Up @@ -164,12 +164,30 @@ local KEY_LABELS = {
btn_extra = "Mouse Extra",
}

local MOUSE_BUTTONS = {
[272] = "LMB",
[273] = "RMB",
[274] = "MMB",
[275] = "Mouse 4",
[276] = "Mouse 5",
[277] = "Mouse Fwd",
[278] = "Mouse Back",
}

local function formatKey(key)
if key == nil or key == "" then return "" end
local exact = KEY_LABELS[key]
if exact ~= nil then return exact end
local lowered = KEY_LABELS[lower(key)]
if lowered ~= nil then return lowered end
-- Pointer buttons by evdev code (what Hyprland reports): the three
-- buttons everyone knows by name, side buttons and the wheel after.
local button = key:match("^mouse:(%d+)$") or key:match("^[Mm]ouse (%d+)$")
if button ~= nil then
local named = MOUSE_BUTTONS[tonumber(button)]
if named ~= nil then return named end
return "Mouse " .. button
end
return key:gsub("^mouse:", "Mouse ")
end

Expand Down Expand Up @@ -316,6 +334,20 @@ local function bucketForKey(key)
return "default"
end

-- Display name for a modifier pill. The `modifier_labels` setting maps a
-- modifier (as Hyprland names it: MOD3, MOD5, ...) to the name the user
-- actually calls it, e.g. { MOD3 = "Hyper" } for a Caps-as-Hyper setup.
local function modifierLabel(modifier)
local labels = noctalia.getConfig("modifier_labels")
if type(labels) == "table" then
local custom = labels[modifier] or labels[lower(modifier)]
if type(custom) == "string" and custom ~= "" then
return custom
end
end
return modifier:sub(1, 1) .. lower(modifier:sub(2))
end

local function bucketForModifier(modifier)
local value = lower(modifier)
if value == "super" or value == "mod4" or value == "logo" then return "super" end
Expand Down Expand Up @@ -578,9 +610,68 @@ local function restoreHiddenBindings(bindingList, hiddenPreferences)
end
end

-- Collapse a run of sibling binds that differ only by a number — "Focus
-- workspace 1" on 1, "… 2" on 2, … — into one row ("Focus workspace N" on
-- 1-0 / F1-F3). Same modifiers, same category, descriptions equal once
-- their digit runs are normalised, and keys all digits or all F-keys.
-- Never in the edit view, where each bind must stay addressable.
local function sequenceKind(key)
if key:match("^%d$") then return "digit" end
if key:match("^[Ff]%d%d?$") then return "fkey" end
return nil
end

local function keyOrdinal(key)
if key:match("^%d$") then
local n = tonumber(key)
return n == 0 and 10 or n -- the number row runs 1..9,0
end
return tonumber(key:match("%d+")) or 0
end

local function mergeSequential(list)
local out = {}
local i = 1
while i <= #list do
local first = list[i]
local kind = sequenceKind(first.key or "")
local template = kind and ((first.description or ""):gsub("%d+", "N")) or nil
local run = { first }
if kind ~= nil and template ~= nil and (first.description or "") ~= "" then
local mods = table.concat(first.modifiers or {}, "+")
local j = i + 1
while j <= #list do
local candidate = list[j]
if sequenceKind(candidate.key or "") ~= kind then break end
if table.concat(candidate.modifiers or {}, "+") ~= mods then break end
if ((candidate.description or ""):gsub("%d+", "N")) ~= template then break end
if preferences.descriptions[candidate.id] ~= nil then break end
table.insert(run, candidate)
j += 1
end
end
if #run >= 2 then
table.sort(run, function(a, b) return keyOrdinal(a.key) < keyOrdinal(b.key) end)
local merged = {}
for k, v in pairs(first) do merged[k] = v end
merged.id = first.id .. "~" .. run[#run].id
merged.key = (kind == "fkey" and "" or "") .. run[1].key .. "-" .. run[#run].key
merged.description = template
merged.merged = #run
table.insert(out, merged)
i += #run
else
table.insert(out, first)
i += 1
end
end
return out
end

local function visibleGroups()
local showUndescribed = noctalia.getConfig("show_undescribed") ~= false
local managing = view == "edit"
local mergeRuns = noctalia.getConfig("merge_sequential") ~= false and not managing
local groups = {}
local byName = {}
for index, binding in ipairs(bindings) do
Expand All @@ -599,6 +690,12 @@ local function visibleGroups()
group.weight += 1
end
end
if mergeRuns then
for _, group in ipairs(groups) do
group.bindings = mergeSequential(group.bindings)
group.weight = 1 + #group.bindings
end
end
table.sort(groups, function(a, b)
local left = categoryPriority(a.name)
local right = categoryPriority(b.name)
Expand Down Expand Up @@ -675,7 +772,7 @@ local function bindingRow(binding, occurrence)
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))
table.insert(pills, keyPill(modifierLabel(modifier), bucketForModifier(modifier), "mod-" .. index))
end
table.insert(pills, keyPill(formatKey(binding.key), bucketForKey(binding.key), "key"))

Expand Down
16 changes: 15 additions & 1 deletion keybind-cheatsheet/plugin.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
id = "kenn/keybind-cheatsheet"
name = "Keybind Cheatsheet"
version = "0.2.8"
version = "0.3.0"
plugin_api = 9
author = "kenn"
license = "MIT"
Expand Down Expand Up @@ -90,6 +90,20 @@ label_key = "settings.show_actions.label"
description_key = "settings.show_actions.description"
default = false

[[setting]]
key = "merge_sequential"
type = "bool"
label_key = "settings.merge_sequential.label"
description_key = "settings.merge_sequential.description"
default = true

[[setting]]
key = "modifier_labels"
type = "string_map"
label_key = "settings.modifier_labels.label"
description_key = "settings.modifier_labels.description"
default = {}

[[panel]]
id = "cheatsheet"
entry = "panel.luau"
Expand Down
28 changes: 27 additions & 1 deletion keybind-cheatsheet/service.luau
Original file line number Diff line number Diff line change
Expand Up @@ -911,13 +911,39 @@ local function extractLuaStringLiterals(expr)
return literals
end

-- Position of the first `--` that starts a comment, i.e. one outside a
-- string literal. A naive find() stops at the first `--` anywhere, and
-- Hyprland configs are full of `exec_cmd("tool --flag")` lines whose
-- description then never gets read (the bind lands in "Other").
local function luaCommentStart(line)
local quote = nil
local i = 1
local n = #line
while i <= n do
local ch = line:sub(i, i)
if quote ~= nil then
if ch == "\\" then
i += 1
elseif ch == quote then
quote = nil
end
elseif ch == '"' or ch == "'" then
quote = ch
elseif ch == "-" and line:sub(i + 1, i + 1) == "-" then
return i
end
i += 1
end
return nil
end

local function scanHyprLuaContent(content, sourceFile, context)
local category = ""
local includes = {}
local pendingDescription = false

for rawLine in content:gmatch("[^\r\n]+") do
local commentIndex = rawLine:find("--", 1, true)
local commentIndex = luaCommentStart(rawLine)
if commentIndex ~= nil then
local heading = rawLine:match("^%s*%-%-%s*%d+%.%s*(.-)%s*$")
if heading ~= nil and heading ~= "" then
Expand Down
32 changes: 27 additions & 5 deletions keybind-cheatsheet/tests/expected.json
Original file line number Diff line number Diff line change
@@ -1,18 +1,40 @@
{
"mango": {
"count": 9,
"descriptions": ["Terminal", "Close window", "Workspace 1", "Move window", "Focus left", "Lock on close"]
"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"]
"descriptions": [
"Terminal",
"Move window",
"Close window",
"Workspace 1"
]
},
"hypr_lua": {
"count": 4,
"categories": ["Applications", "Workspaces", "Media", "Without Description"]
"count": 5,
"categories": [
"Applications",
"Workspaces",
"Media",
"Volume",
"Without Description"
]
},
"niri": {
"count": 5,
"descriptions": ["Terminal", "Close window", "Workspace 1"]
"descriptions": [
"Terminal",
"Close window",
"Workspace 1"
]
}
}
1 change: 1 addition & 0 deletions keybind-cheatsheet/tests/fixtures/hypr/binds.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,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":true,"modmask":0,"submap":"","key":"XF86AudioRaiseVolume","keycode":0,"description":"Volume up","dispatcher":"exec","arg":"wpctl set-volume --limit 1.0 @DEFAULT_AUDIO_SINK@ 5%+"},
{"locked":false,"mouse":false,"release":false,"repeat":false,"has_description":false,"modmask":64,"submap":"","key":"Q","keycode":0,"description":"","dispatcher":"killactive","arg":""}
]
4 changes: 4 additions & 0 deletions keybind-cheatsheet/tests/fixtures/hypr/parts/media.lua
Original file line number Diff line number Diff line change
@@ -1,2 +1,6 @@
-- 3. Media
Hyprland.config.bind(", XF86AudioMute", mute, { desc = "Mute" })
-- 4. Volume
-- A `--` inside the command string is not a comment: the description after it
-- must still be read, so this bind lands in "Volume", not "Other".
Hyprland.config.bind(", XF86AudioRaiseVolume", exec("wpctl set-volume --limit 1.0 @DEFAULT_AUDIO_SINK@ 5%+"), { description = "Volume up" })
8 changes: 8 additions & 0 deletions keybind-cheatsheet/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,14 @@
"show_undescribed": {
"description": "Put bindings without descriptions in a separate section.",
"label": "Show undescribed bindings"
},
"merge_sequential": {
"label": "Merge numbered runs",
"description": "Collapse binds that differ only by a number (workspace 1, 2, 3…) into one row."
},
"modifier_labels": {
"label": "Modifier labels",
"description": "Rename modifier pills, e.g. MOD3 → Hyper. Keys are Hyprland modifier names."
}
},
"show_binding": "Show binding",
Expand Down
Loading