diff --git a/keybind-cheatsheet/README.md b/keybind-cheatsheet/README.md index 5cf48e103..0ada00df8 100644 --- a/keybind-cheatsheet/README.md +++ b/keybind-cheatsheet/README.md @@ -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 @@ -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. diff --git a/keybind-cheatsheet/panel.luau b/keybind-cheatsheet/panel.luau index f7b6d9d58..f3f91afcf 100644 --- a/keybind-cheatsheet/panel.luau +++ b/keybind-cheatsheet/panel.luau @@ -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 @@ -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 @@ -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 @@ -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) @@ -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")) diff --git a/keybind-cheatsheet/plugin.toml b/keybind-cheatsheet/plugin.toml index 980d2f632..5f23d0547 100644 --- a/keybind-cheatsheet/plugin.toml +++ b/keybind-cheatsheet/plugin.toml @@ -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" @@ -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" diff --git a/keybind-cheatsheet/service.luau b/keybind-cheatsheet/service.luau index 2bbf83dd7..18fc0f587 100644 --- a/keybind-cheatsheet/service.luau +++ b/keybind-cheatsheet/service.luau @@ -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 diff --git a/keybind-cheatsheet/tests/expected.json b/keybind-cheatsheet/tests/expected.json index 3e3b1a42e..f0d47d976 100644 --- a/keybind-cheatsheet/tests/expected.json +++ b/keybind-cheatsheet/tests/expected.json @@ -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" + ] } } diff --git a/keybind-cheatsheet/tests/fixtures/hypr/binds.json b/keybind-cheatsheet/tests/fixtures/hypr/binds.json index a848a139b..08e8fa00c 100644 --- a/keybind-cheatsheet/tests/fixtures/hypr/binds.json +++ b/keybind-cheatsheet/tests/fixtures/hypr/binds.json @@ -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":""} ] diff --git a/keybind-cheatsheet/tests/fixtures/hypr/parts/media.lua b/keybind-cheatsheet/tests/fixtures/hypr/parts/media.lua index 367a4e12e..d20bd2161 100644 --- a/keybind-cheatsheet/tests/fixtures/hypr/parts/media.lua +++ b/keybind-cheatsheet/tests/fixtures/hypr/parts/media.lua @@ -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" }) diff --git a/keybind-cheatsheet/translations/en.json b/keybind-cheatsheet/translations/en.json index 070023272..abcb86f27 100644 --- a/keybind-cheatsheet/translations/en.json +++ b/keybind-cheatsheet/translations/en.json @@ -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",