feat(keybinds): redesign cheatsheet with category navigation (#3534)

* feat(keybinds): redesign cheatsheet with category navigation, M3 styling, and click-to-execute

- Add category sidebar with Material Symbols icons and badge counts
- Move search field to top of sidebar with Ctrl+F shortcut
- Implement 2-column masonry layout for keybind items
- Deduplicate duplicate actions and stack multi-key combos vertically
- Collapse consecutive workspace keybindings (1-9) into range entries
- Add media & hardware icons for volume, mic, brightness, and PrtSc keycaps
- Preserve Niri window filter properties and distinguish same-app Alt-Tab binds
- Enable click-to-execute for keybind actions across supported compositors

* fix(keybinds): preserve niri action properties and disable execution on range rows

* fix(keybinds): convert niri action properties to CLI flags and disable IPC execution for window switcher binds

* fix(keybinds): pass skip-confirmation as boolean switch in niri executeAction

* fix(keybinds): handle niri spawn-sh actions in executeAction
This commit is contained in:
Huỳnh Thiện Lộc authored and GitHub committed 2026-09-23 09:09:28 -04:00
1 parent 80060ee925
commit 9d1884fb19
9 files changed
+977 -216

No files matched your search

+4 -2
View File
@@ -24,13 +24,15 @@ type niriActionPart struct {
quoted bool
}
var niriActionPropertyOrder = []string{"focus", "show-pointer", "write-to-disk", "skip-confirmation", "delay-ms"}
var niriActionPropertyOrder = []string{"focus", "show-pointer", "write-to-disk", "skip-confirmation", "delay-ms", "filter", "scope"}
var niriActionProperties = map[string]struct{}{
"focus": {},
"show-pointer": {},
"write-to-disk": {},
"skip-confirmation": {},
"delay-ms": {},
"filter": {},
"scope": {},
}
func NewNiriProvider(configDir string) *NiriProvider {
@@ -466,7 +468,7 @@ func (n *NiriProvider) buildActionNode(action string) *document.Node {
}
func (n *NiriProvider) isNiriActionPropertyToken(part niriActionPart) bool {
if part.quoted || !strings.Contains(part.value, "=") {
if !strings.Contains(part.value, "=") {
return false
}
key, _, ok := strings.Cut(part.value, "=")
@@ -483,7 +483,7 @@ func (p *NiriParser) parseKeybindNode(node *document.Node, _ string) *NiriKeyBin
args = append(args, arg.ValueString())
}
if actionNode.Properties != nil {
for _, propName := range []string{"focus", "show-pointer", "write-to-disk", "skip-confirmation", "delay-ms"} {
for _, propName := range []string{"focus", "show-pointer", "write-to-disk", "skip-confirmation", "delay-ms", "filter", "scope"} {
if val, ok := actionNode.Properties.Get(propName); ok {
args = append(args, propName+"="+val.String())
}
@@ -811,6 +811,7 @@ func TestNiriParseActionWithProperties(t *testing.T) {
Mod+Shift+1 hotkey-overlay-title="Move to Workspace 1" { move-column-to-workspace 1 focus=false; }
Mod+Shift+2 hotkey-overlay-title="Move to Workspace 2" { move-column-to-workspace 2 focus=false; }
Alt+Tab { next-window scope="output"; }
Alt+grave { next-window filter="app-id"; }
}
`
if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil {
@@ -822,8 +823,8 @@ func TestNiriParseActionWithProperties(t *testing.T) {
t.Fatalf("ParseNiriKeys failed: %v", err)
}
if len(result.Section.Keybinds) != 3 {
t.Errorf("Expected 3 keybinds, got %d", len(result.Section.Keybinds))
if len(result.Section.Keybinds) != 4 {
t.Errorf("Expected 4 keybinds, got %d", len(result.Section.Keybinds))
}
for _, kb := range result.Section.Keybinds {
@@ -849,8 +850,16 @@ func TestNiriParseActionWithProperties(t *testing.T) {
t.Errorf("move-column-to-workspace missing focus=false arg")
}
case "next-window":
if kb.Key != "Tab" {
t.Errorf("next-window key = %q, want 'Tab'", kb.Key)
if kb.Key == "Tab" {
if len(kb.Args) != 1 || kb.Args[0] != "scope=\"output\"" {
t.Errorf("next-window Tab args = %v, want [scope=\"output\"]", kb.Args)
}
} else if kb.Key == "grave" {
if len(kb.Args) != 1 || kb.Args[0] != "filter=\"app-id\"" {
t.Errorf("next-window grave args = %v, want [filter=\"app-id\"]", kb.Args)
}
} else {
t.Errorf("unexpected next-window key: %q", kb.Key)
}
}
}
@@ -711,3 +711,30 @@ func TestNiriGenerateWorkspaceBindsRoundTrip(t *testing.T) {
t.Error("set-column-width -10% not found after round-trip")
}
}
func TestNiriGenerateActionWithFilterAndScope(t *testing.T) {
provider := NewNiriProvider("")
binds := map[string]*overrideBind{
"Alt+grave": {
Key: "Alt+grave",
Action: `next-window filter="app-id"`,
Description: "Next Window (Same Application)",
},
"Alt+Tab": {
Key: "Alt+Tab",
Action: `next-window scope="output"`,
Description: "Next Window",
},
}
content := provider.generateBindsContent(binds)
expected := `binds {
Alt+Tab hotkey-overlay-title="Next Window" { next-window scope="output"; }
Alt+grave hotkey-overlay-title="Next Window (Same Application)" { next-window filter="app-id"; }
}
`
if content != expected {
t.Errorf("Content mismatch.\nGot:\n%s\nWant:\n%s", content, expected)
}
}
+142
View File
@@ -343,3 +343,145 @@ function eventMatchesCombo(event, combo) {
return false;
return event.key === qtKeyFromName(keyName);
}
const KEY_GLYPH_MAP = {
"shift": "⇧",
"ctrl": "⌃",
"control": "⌃",
"alt": "⌥",
"mod": "⌘",
"super": "⌘",
"meta": "⌘",
"win": "⌘",
"return": "⏎",
"enter": "⏎",
"tab": "⇥",
"backspace": "⌫",
"space": "␣",
"escape": "Esc",
"esc": "Esc",
"up": "↑",
"down": "↓",
"left": "←",
"right": "→",
"page_up": "⇞",
"pageup": "⇞",
"page_down": "⇟",
"pagedown": "⇟",
"home": "Home",
"end": "End",
"delete": "Del",
"del": "Del",
"insert": "Ins",
"print": "PrtSc",
"printscreen": "PrtSc",
"prtscr": "PrtSc",
"prtsc": "PrtSc",
"sysrq": "PrtSc",
"plus": "+",
"minus": "-",
"equal": "=",
"slash": "/",
"backslash": "\\",
"bracketleft": "[",
"bracketright": "]",
"semicolon": ";",
"apostrophe": "'",
"comma": ",",
"period": ".",
"grave": "`",
"wheelscrolldown": "󰍽 ↓",
"wheelscrollup": "󰍽 ↑",
"wheelscrollleft": "󰍽 ←",
"wheelscrollright": "󰍽 →",
"mousescrolldown": "󰍽 ↓",
"mousescrollup": "󰍽 ↑",
"mouse_down": "󰍽 ↓",
"mouse_up": "󰍽 ↑",
"mouse_left": "󰍽 ←",
"mouse_right": "󰍽 →",
"scrolldown": "󰍽 ↓",
"scrollup": "󰍽 ↑",
// Audio & Volume
"xf86audioraisevolume": "󰕾 +",
"audioraisevolume": "󰕾 +",
"xf86audiolowervolume": "󰕾 -",
"audiolowervolume": "󰕾 -",
"xf86audiomute": "󰖁",
"audiomute": "󰖁",
"xf86audiomicmute": "󰍭",
"audiomicmute": "󰍭",
// Media Playback
"xf86audioplay": "󰐊",
"audioplay": "󰐊",
"xf86audiopause": "󰏤",
"audiopause": "󰏤",
"xf86audioplaypause": "󰐎",
"audioplaypause": "󰐎",
"xf86audionext": "󰒭",
"audionext": "󰒭",
"xf86audioprev": "󰒮",
"audioprev": "󰒮",
"xf86audiostop": "󰓛",
"audiostop": "󰓛",
"xf86audiorecord": "Rec",
"audiorecord": "Rec",
"xf86audiomedia": "Media",
"audiomedia": "Media",
// Display & Brightness
"xf86monbrightnessup": "󰃠 +",
"monbrightnessup": "󰃠 +",
"xf86monbrightnessdown": "󰃠 -",
"monbrightnessdown": "󰃠 -",
"xf86kbdbrightnessup": "󰌌 +",
"kbdbrightnessup": "󰌌 +",
"xf86kbdbrightnessdown": "󰌌 -",
"kbdbrightnessdown": "󰌌 -",
// Hardware & System
"xf86calculator": "Calc",
"xf86search": "Search",
"xf86mail": "Mail",
"xf86homepage": "Web",
"xf86explorer": "Files",
"xf86poweroff": "Power",
"xf86sleep": "Sleep",
"xf86wakeup": "Wake",
"xf86eject": "Eject",
"xf86launch0": "Launch 0",
"xf86launch1": "PrtSc",
"launch1": "PrtSc"
};
function formatKeyTokens(keyString) {
if (!keyString)
return [];
var str = keyString;
var plusEnds = false;
if (str.endsWith("++")) {
plusEnds = true;
str = str.slice(0, -2);
}
var parts = str.split("+");
var tokens = [];
for (var i = 0; i < parts.length; i++) {
var part = parts[i].trim();
if (!part)
continue;
var lower = part.toLowerCase();
if (KEY_GLYPH_MAP[lower] !== undefined) {
tokens.push(KEY_GLYPH_MAP[lower]);
} else if (part.length === 1) {
tokens.push(part.toUpperCase());
} else {
tokens.push(part);
}
}
if (plusEnds) {
tokens.push("+");
}
return tokens;
}
+21 -3
View File
@@ -227,7 +227,9 @@ const NIRI_ACTIONS = {
],
"Alt-Tab": [
{ id: "next-window", label: "Next Window" },
{ id: "previous-window", label: "Previous Window" }
{ id: "previous-window", label: "Previous Window" },
{ id: "next-window filter=\"app-id\"", label: "Next Window (Same Application)" },
{ id: "previous-window filter=\"app-id\"", label: "Previous Window (Same Application)" }
]
};
@@ -932,8 +934,16 @@ function getActionLabel(action, compositor) {
return compAct.label;
var base = action.split(" ")[0];
compAct = findCompositorAction(compositor, base);
if (compAct)
if (compAct) {
var arg = action.slice(base.length).trim();
if (arg && compAct.label.includes("(by index)")) {
return compAct.label.replace("(by index)", arg).trim();
}
if (arg.includes("filter=") && arg.includes("app-id")) {
return compAct.label + " (Same Application)";
}
return compAct.label;
}
}
if (action.startsWith("spawn sh -c "))
@@ -948,7 +958,7 @@ function getActionType(action) {
return "compositor";
if (isDmsAction(action))
return "dms";
if (/^spawn \w+ -c /.test(action) || action.startsWith("spawn_shell "))
if (/^spawn \w+ -c /.test(action) || action.startsWith("spawn_shell ") || action.startsWith("spawn-sh "))
return "shell";
if (action.startsWith("spawn "))
return "spawn";
@@ -971,6 +981,8 @@ function isValidAction(action) {
case "spawn sh -c ''":
case "spawn_shell":
case "spawn_shell ":
case "spawn-sh":
case "spawn-sh ":
return false;
}
return true;
@@ -1027,6 +1039,12 @@ function parseShellCommand(action) {
}
if (action.startsWith("spawn_shell "))
return action.slice(12);
if (action.startsWith("spawn-sh ")) {
var cmd = action.slice(9).trim();
if ((cmd.startsWith('"') && cmd.endsWith('"')) || (cmd.startsWith("'") && cmd.endsWith("'")))
cmd = cmd.slice(1, -1);
return cmd;
}
return "";
}
+693 -206
View File
@@ -4,15 +4,129 @@ import QtQuick.Layouts
import qs.Common
import qs.Services
import qs.Widgets
import "../Common/KeyUtils.js" as KeyUtils
import "../Common/KeybindActions.js" as Actions
FocusScope {
id: content
focus: true
function getBindLabel(bind) {
if (!bind)
return "";
var label = bind.desc || "";
if (!label && bind.action) {
label = Actions.getActionLabel(bind.action, KeybindsService.currentProvider) || bind.action;
}
if (!label)
return "";
if (label.includes("(by index)") && bind.action) {
var parts = bind.action.trim().split(/\s+/);
if (parts.length > 1) {
var arg = parts[parts.length - 1];
label = label.replace("(by index)", arg).trim();
}
}
if ((bind.action === "next-window" || bind.action === "previous-window") && (bind.key || "").toLowerCase().includes("grave")) {
if (label === "Next Window")
label = "Next Window (Same Application)";
else if (label === "Previous Window")
label = "Previous Window (Same Application)";
}
if (label.startsWith("Media: "))
label = label.slice(7).trim();
return label;
}
Component {
id: keybindItemDelegate
DankListItem {
id: keybindRow
required property var modelData
readonly property bool canExecute: !keybindRow.modelData.isRange && KeybindsService.canExecuteAction(keybindRow.modelData.action)
Layout.fillWidth: true
Layout.preferredHeight: implicitHeight
implicitHeight: Math.max(Theme.menuItemHeight, keycapsCol.implicitHeight + Theme.spacingS * 2)
firstInGroup: true
lastInGroup: true
radius: Theme.groupedListOuterRadius
Accessible.name: keybindRow.modelData.label || content.getBindLabel(keybindRow.modelData)
Accessible.description: (keybindRow.modelData.allKeys ? keybindRow.modelData.allKeys.join(", ") : (keybindRow.modelData.key || "")) + " • " + (keybindRow.modelData.action || "")
onClicked: {
if (keybindRow.canExecute && KeybindsService.executeAction(keybindRow.modelData.action))
content.closeRequested();
}
RowLayout {
id: rowLayout
anchors.fill: parent
anchors.leftMargin: Theme.spacingM
anchors.rightMargin: Theme.spacingS
anchors.topMargin: Theme.spacingXS
anchors.bottomMargin: Theme.spacingXS
spacing: Theme.spacingS
// Description / Action (Leading)
StyledText {
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
text: keybindRow.modelData.label || content.getBindLabel(keybindRow.modelData)
font.pixelSize: Theme.fontSizeSmall
color: keybindRow.contentColor
elide: Text.ElideRight
wrapMode: Text.NoWrap
}
// DankKeycap items (Trailing - stacked vertically if multiple combos)
Column {
id: keycapsCol
Layout.alignment: Qt.AlignVCenter | Qt.AlignRight
spacing: Theme.spacingXS
Repeater {
model: keybindRow.modelData.keyCombos || (keybindRow.modelData.key ? [KeyUtils.formatKeyTokens(keybindRow.modelData.key)] : [])
Row {
anchors.right: parent ? parent.right : undefined
spacing: Theme.spacingXXS
Repeater {
model: modelData
DankKeycap {
text: modelData
textColor: Theme.primary
}
}
}
}
}
}
MouseArea {
id: rowHoverArea
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.NoButton
cursorShape: keybindRow.canExecute ? Qt.PointingHandCursor : Qt.ArrowCursor
}
DankTooltipHost {
text: keybindRow.canExecute ? ((keybindRow.modelData.action || keybindRow.modelData.desc || "") + " • " + I18n.tr("Click to run", "cheatsheet action tooltip suffix")) : (keybindRow.modelData.label || keybindRow.modelData.action || keybindRow.modelData.desc || "")
target: keybindRow
hoverArea: rowHoverArea
}
}
}
property real scrollStep: 60
property var activeFlickable: mainFlickable
property var activeFlickable: rightFlickable
property bool showFloatingToggle: true
property bool floating: false
property alias searchField: searchField
property string selectedCategory: "All"
signal closeRequested
signal floatingToggleRequested
@@ -21,7 +135,7 @@ FocusScope {
if (!activeFlickable)
return;
let newY = activeFlickable.contentY + scrollStep;
newY = Math.min(newY, activeFlickable.contentHeight - activeFlickable.height);
newY = Math.min(newY, Math.max(0, activeFlickable.contentHeight - activeFlickable.height));
activeFlickable.contentY = newY;
}
@@ -33,7 +147,24 @@ FocusScope {
activeFlickable.contentY = newY;
}
function focusSearch() {
if (searchField) {
searchField.forceActiveFocus();
searchField.selectAll();
}
}
Shortcut {
sequence: "Ctrl+F"
onActivated: content.focusSearch()
}
Keys.onPressed: event => {
if ((event.modifiers & Qt.ControlModifier) && event.key === Qt.Key_F) {
focusSearch();
event.accepted = true;
return;
}
switch (event.key) {
case Qt.Key_J:
if (event.modifiers & Qt.ControlModifier) {
@@ -58,269 +189,595 @@ FocusScope {
}
}
Column {
anchors.fill: parent
anchors.margins: Theme.spacingL
spacing: Theme.spacingL
readonly property var categoryPriority: ({
"window": 1,
"windows": 1,
"window management": 1,
"workspace": 2,
"workspaces": 2,
"applications": 3,
"apps": 3,
"audio": 4,
"sound": 4,
"media": 5,
"brightness": 6,
"dms": 7,
"compositor": 8,
"custom": 9,
"system": 10
})
RowLayout {
width: parent.width
spacing: Theme.spacingM
function getCategoryIcon(catName) {
if (!catName)
return "keyboard";
const lower = catName.toLowerCase();
if (lower === "all")
return "apps";
if (lower.includes("window"))
return "desktop_windows";
if (lower.includes("workspace"))
return "grid_view";
if (lower.includes("alt-tab") || lower.includes("alt_tab") || lower.includes("switcher"))
return "swap_horiz";
if (lower.includes("screenshot") || lower.includes("capture"))
return "screenshot_region";
if (lower.includes("overview"))
return "overview";
if (lower.includes("monitor") || lower.includes("display"))
return "monitor";
if (lower.includes("exec") || lower.includes("terminal") || lower.includes("spawn") || lower.includes("command"))
return "terminal";
if (lower.includes("scratchpad"))
return "sticky_note_2";
if (lower.includes("layout"))
return "view_quilt";
if (lower.includes("gap"))
return "border_inner";
if (lower.includes("tag"))
return "tag";
if (lower.includes("app"))
return "open_in_new";
if (lower.includes("audio") || lower.includes("sound") || lower.includes("media"))
return "volume_up";
if (lower.includes("bright"))
return "brightness_6";
if (lower.includes("dms"))
return "widgets";
if (lower.includes("system") || lower.includes("power"))
return "power_settings_new";
if (lower.includes("compositor"))
return "tune";
if (lower.includes("custom"))
return "edit";
if (lower.includes("other"))
return "more_horiz";
return "keyboard";
}
StyledText {
Layout.alignment: Qt.AlignLeft
text: KeybindsService.cheatsheet.title || I18n.tr("Keybinds")
font.pixelSize: Theme.fontSizeLarge
font.weight: Theme.fontWeightMedium
color: Theme.primary
}
property var rawBinds: KeybindsService.cheatsheet.binds || ({})
Item {
Layout.fillWidth: true
}
function collapseSequentialBinds(list) {
if (!list || list.length < 3)
return list || [];
const numberPattern = /^(.*?)(\s+)(\d+)$/;
const clusters = {};
DankActionButton {
visible: content.showFloatingToggle
iconName: content.floating ? "close_fullscreen" : "open_in_new"
tooltipText: content.floating ? I18n.tr("Dock window") : I18n.tr("Open as window")
onClicked: content.floatingToggleRequested()
}
DankSearchField {
id: searchField
Layout.alignment: Qt.AlignRight
keyForwardTargets: [content]
onTextEdited: searchDebounce.restart()
Keys.onEscapePressed: event => {
content.closeRequested();
event.accepted = true;
for (let i = 0; i < list.length; i++) {
const item = list[i];
const match = (item.label || "").match(numberPattern);
const combo = item.keyCombos && item.keyCombos[0];
if (match && combo && combo.length > 0) {
const lastToken = combo[combo.length - 1];
const num = parseInt(match[3], 10);
if (!isNaN(num) && lastToken === String(num)) {
const baseLabel = match[1];
const mods = combo.slice(0, -1);
const modSig = mods.join("+");
const clusterKey = (baseLabel + "||" + modSig).toLowerCase();
if (!clusters[clusterKey])
clusters[clusterKey] = [];
clusters[clusterKey].push({
item: item,
num: num,
baseLabel: baseLabel,
mods: mods,
index: i
});
}
}
}
Timer {
id: searchDebounce
interval: 50
repeat: false
onTriggered: {
mainFlickable.categories = mainFlickable.generateCategories(searchField.text);
const collapsedMap = {};
const skipIndices = {};
for (const cKey in clusters) {
const cluster = clusters[cKey];
if (cluster.length >= 3) {
cluster.sort((a, b) => a.num - b.num);
let isSeq = true;
for (let k = 1; k < cluster.length; k++) {
if (cluster[k].num !== cluster[k - 1].num + 1) {
isSeq = false;
break;
}
}
if (isSeq) {
const first = cluster[0];
const last = cluster[cluster.length - 1];
const rangeStr = first.num + "–" + last.num;
const combinedLabel = first.baseLabel + " " + rangeStr;
const allKeys = [];
for (let k = 0; k < cluster.length; k++) {
if (cluster[k].item.allKeys) {
for (let ak = 0; ak < cluster[k].item.allKeys.length; ak++) {
allKeys.push(cluster[k].item.allKeys[ak]);
}
} else if (cluster[k].item.key) {
allKeys.push(cluster[k].item.key);
}
if (k > 0) {
skipIndices[cluster[k].index] = true;
}
}
collapsedMap[first.index] = {
action: "",
desc: combinedLabel,
label: combinedLabel,
key: first.item.key,
allKeys: allKeys,
tokenSigs: {},
keyCombos: [[...first.mods, rangeStr]],
isRange: true
};
}
}
}
DankFlickable {
id: mainFlickable
width: parent.width
height: parent.height - parent.spacing - 40
contentWidth: rowLayout.implicitWidth
contentHeight: rowLayout.implicitHeight
clip: true
const result = [];
for (let i = 0; i < list.length; i++) {
if (collapsedMap[i]) {
result.push(collapsedMap[i]);
} else if (!skipIndices[i]) {
result.push(list[i]);
}
}
return result;
}
property var rawBinds: KeybindsService.cheatsheet.binds || {}
function generateCategories(query) {
const lowerQuery = query ? query.toLowerCase().trim() : "";
const lowerQueryWords = lowerQuery ? lowerQuery.split(/\s+/) : [];
const processed = {};
let totalCount = 0;
function generateCategories(query) {
const lowerQuery = query ? query.toLowerCase().trim() : "";
const lowerQueryWords = query.split(/\s+/);
const processed = {};
for (const cat in rawBinds) {
const binds = rawBinds[cat];
if (!Array.isArray(binds))
continue;
const catLower = cat.toLowerCase();
const subcatMap = {};
let hasBinds = false;
for (const cat in rawBinds) {
const binds = rawBinds[cat];
const catLower = cat.toLowerCase();
const subcats = {};
let hasSubcats = false;
for (let i = 0; i < binds.length; i++) {
const bind = binds[i];
const keyLower = (bind.key || "").toLowerCase();
const descLower = (bind.desc || "").toLowerCase();
const actionLower = (bind.action || "").toLowerCase();
for (let i = 0; i < binds.length; i++) {
const bind = binds[i];
if (bind.hideOnOverlay)
continue;
if (bind.hideOnOverlay)
continue;
let shouldContinue = false;
for (let j = 0; j < lowerQueryWords.length; j++) {
const word = lowerQueryWords[j];
if (!(word.length === 0 || keyLower.includes(word) || descLower.includes(word) || catLower.includes(word) || actionLower.includes(word))) {
shouldContinue = true;
break;
}
}
if (shouldContinue)
continue;
const label = content.getBindLabel(bind);
const labelLower = label.toLowerCase();
const keyTokens = KeyUtils.formatKeyTokens(bind.key);
const tokenSig = keyTokens.join("+");
const keyLower = (bind.key || "").toLowerCase();
const descLower = (bind.desc || "").toLowerCase();
const actionLower = (bind.action || "").toLowerCase();
if (bind.subcat) {
hasSubcats = true;
if (!subcats[bind.subcat])
subcats[bind.subcat] = [];
subcats[bind.subcat].push(bind);
} else {
if (!subcats["_root"])
subcats["_root"] = [];
subcats["_root"].push(bind);
}
}
if (Object.keys(subcats).length === 0)
let matched = true;
for (let j = 0; j < lowerQueryWords.length; j++) {
const word = lowerQueryWords[j];
if (!word)
continue;
if (!keyLower.includes(word) && !labelLower.includes(word) && !descLower.includes(word) && !catLower.includes(word) && !actionLower.includes(word) && !tokenSig.toLowerCase().includes(word)) {
matched = false;
break;
}
}
if (!matched)
continue;
const subcatName = bind.subcat || "_root";
if (!subcatMap[subcatName])
subcatMap[subcatName] = {};
const isGraveAltTab = (bind.action === "next-window" || bind.action === "previous-window") && (bind.key || "").toLowerCase().includes("grave") && !bind.action.includes("filter=");
const bindAction = isGraveAltTab ? (bind.action + ' filter="app-id"') : (bind.action || "");
const groupKey = (label + "||" + bindAction).toLowerCase();
if (subcatMap[subcatName][groupKey]) {
const existing = subcatMap[subcatName][groupKey];
if (tokenSig && !existing.tokenSigs[tokenSig] && keyTokens.length > 0) {
existing.tokenSigs[tokenSig] = true;
existing.keyCombos.push(keyTokens);
existing.allKeys.push(bind.key);
}
} else {
const sigs = {};
if (tokenSig && keyTokens.length > 0)
sigs[tokenSig] = true;
subcatMap[subcatName][groupKey] = {
action: bindAction,
desc: bind.desc,
label: label,
key: bind.key,
allKeys: [bind.key],
tokenSigs: sigs,
keyCombos: keyTokens.length > 0 ? [keyTokens] : []
};
hasBinds = true;
}
}
if (hasBinds) {
const subcats = {};
let catBindCount = 0;
for (const subcatName in subcatMap) {
const groups = subcatMap[subcatName];
const rawList = [];
for (const gKey in groups) {
rawList.push(groups[gKey]);
}
const collapsedList = content.collapseSequentialBinds(rawList);
subcats[subcatName] = collapsedList;
catBindCount += collapsedList.length;
}
if (catBindCount > 0) {
processed[cat] = {
hasSubcats: hasSubcats,
name: cat,
count: catBindCount,
subcats: subcats,
subcatKeys: Object.keys(subcats)
};
totalCount += catBindCount;
}
return processed;
}
}
property var categories: generateCategories("")
const keys = Object.keys(processed).sort((a, b) => {
const pA = categoryPriority[a.toLowerCase()] || 99;
const pB = categoryPriority[b.toLowerCase()] || 99;
if (pA !== pB)
return pA - pB;
return a.localeCompare(b);
});
function estimateCategoryHeight(catName) {
const catData = categories[catName];
if (!catData)
return 0;
let bindCount = 0;
for (const key of catData.subcatKeys) {
bindCount += catData.subcats[key]?.length || 0;
if (key !== "_root")
bindCount += 1;
}
return 40 + bindCount * 28;
return {
byCategory: processed,
sortedKeys: keys,
totalCount: totalCount
};
}
property string activeSearchQuery: ""
property var dataModel: generateCategories("")
Connections {
target: KeybindsService
function onCheatsheetLoaded() {
content.dataModel = content.generateCategories(content.activeSearchQuery);
}
}
Timer {
id: searchDebounce
interval: 120
repeat: false
onTriggered: {
content.activeSearchQuery = searchField.text;
content.dataModel = content.generateCategories(searchField.text);
if (searchField.text.trim() !== "" && content.selectedCategory !== "All") {
if (!content.dataModel.byCategory[content.selectedCategory])
content.selectedCategory = "All";
}
}
}
property var categoryKeys: Object.keys(categories)
Item {
anchors.fill: parent
anchors.margins: Theme.spacingL
function distributeCategories(cols) {
const columns = [];
const heights = [];
for (let i = 0; i < cols; i++) {
columns.push([]);
heights.push(0);
}
const sorted = [...categoryKeys].sort((a, b) => estimateCategoryHeight(b) - estimateCategoryHeight(a));
for (const cat of sorted) {
let minIdx = 0;
for (let i = 1; i < cols; i++) {
if (heights[i] < heights[minIdx])
minIdx = i;
// Sidebar
Item {
id: sidebar
anchors.left: parent.left
anchors.top: parent.top
anchors.bottom: parent.bottom
width: 260
// Search Bar + Float Window Toggle at top of sidebar
RowLayout {
id: searchRow
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
spacing: Theme.spacingS
DankSearchField {
id: searchField
Layout.fillWidth: true
placeholderText: I18n.tr("Search keybinds...", "keybinds cheatsheet search placeholder")
keyForwardTargets: [content]
onTextChanged: {
if (text.trim() === "") {
searchDebounce.stop();
content.activeSearchQuery = "";
content.dataModel = content.generateCategories("");
} else {
searchDebounce.restart();
}
}
Keys.onEscapePressed: event => {
content.closeRequested();
event.accepted = true;
}
columns[minIdx].push(cat);
heights[minIdx] += estimateCategoryHeight(cat);
}
return columns;
DankActionButton {
visible: content.showFloatingToggle
buttonSize: Theme.iconButtonSize
iconName: content.floating ? "close_fullscreen" : "open_in_new"
tooltipText: content.floating ? I18n.tr("Dock window") : I18n.tr("Open as window")
onClicked: content.floatingToggleRequested()
Layout.alignment: Qt.AlignVCenter
}
}
Row {
id: rowLayout
width: mainFlickable.width
spacing: Theme.spacingM
DankFlickable {
id: sidebarFlickable
anchors.left: parent.left
anchors.right: parent.right
anchors.top: searchRow.bottom
anchors.bottom: parent.bottom
anchors.topMargin: Theme.spacingM
contentWidth: width
contentHeight: sidebarCol.implicitHeight
clip: true
property int numColumns: Math.max(1, Math.min(3, Math.floor(width / 350)))
property var columnCategories: mainFlickable.distributeCategories(numColumns)
Column {
id: sidebarCol
width: parent.width
spacing: Theme.spacingXXS
Repeater {
model: rowLayout.numColumns
// "All" Category Tab
DankListItem {
id: allTab
width: sidebarCol.width
implicitHeight: Theme.menuItemHeight
isSelected: content.selectedCategory === "All"
surfaceColor: "transparent"
border.width: 0
radius: Theme.cornerRadiusFull
topLeftRadius: Theme.cornerRadiusFull
topRightRadius: Theme.cornerRadiusFull
bottomLeftRadius: Theme.cornerRadiusFull
bottomRightRadius: Theme.cornerRadiusFull
Accessible.name: I18n.tr("All")
onClicked: content.selectedCategory = "All"
Column {
id: masonryColumn
width: (rowLayout.width - rowLayout.spacing * (rowLayout.numColumns - 1)) / rowLayout.numColumns
spacing: Theme.spacingXL
RowLayout {
anchors.fill: parent
anchors.leftMargin: Theme.spacingL
anchors.rightMargin: Theme.spacingL
spacing: Theme.spacingS
Repeater {
model: rowLayout.columnCategories[index] || []
DankIcon {
name: "apps"
size: Theme.iconSizeSmall
color: allTab.contentColor
}
Column {
id: categoryColumn
width: parent.width
spacing: Theme.spacingXS
StyledText {
text: I18n.tr("All")
color: allTab.contentColor
font.weight: allTab.isSelected ? Theme.fontWeightMedium : Theme.fontWeightNormal
Layout.fillWidth: true
elide: Text.ElideRight
}
property string catName: modelData
property var catData: mainFlickable.categories[catName]
DankBadge {
text: content.dataModel.totalCount.toString()
color: allTab.isSelected ? Theme.primary : Theme.surfaceVariant
textColor: allTab.isSelected ? Theme.onPrimary : Theme.surfaceVariantText
Layout.alignment: Qt.AlignVCenter
}
}
}
// Divider between All and specific categories
Rectangle {
width: sidebarCol.width - Theme.spacingS * 2
height: Theme.dividerWidth
anchors.horizontalCenter: parent.horizontalCenter
color: Theme.outlineVariant
opacity: 0.3
}
// Category Tabs
Repeater {
model: content.dataModel.sortedKeys
DankListItem {
id: catTab
required property var modelData
readonly property string catName: modelData
readonly property var catInfo: content.dataModel.byCategory[catName]
width: sidebarCol.width
implicitHeight: Theme.menuItemHeight
isSelected: content.selectedCategory === catTab.catName
surfaceColor: "transparent"
border.width: 0
radius: Theme.cornerRadiusFull
topLeftRadius: Theme.cornerRadiusFull
topRightRadius: Theme.cornerRadiusFull
bottomLeftRadius: Theme.cornerRadiusFull
bottomRightRadius: Theme.cornerRadiusFull
Accessible.name: I18n.tr(catTab.catName)
onClicked: content.selectedCategory = catTab.catName
RowLayout {
anchors.fill: parent
anchors.leftMargin: Theme.spacingL
anchors.rightMargin: Theme.spacingL
spacing: Theme.spacingS
DankIcon {
name: content.getCategoryIcon(catTab.catName)
size: Theme.iconSizeSmall
color: catTab.contentColor
}
StyledText {
text: categoryColumn.catName
text: I18n.tr(catTab.catName)
color: catTab.contentColor
font.weight: catTab.isSelected ? Theme.fontWeightMedium : Theme.fontWeightNormal
Layout.fillWidth: true
elide: Text.ElideRight
}
DankBadge {
text: (catTab.catInfo?.count || 0).toString()
color: catTab.isSelected ? Theme.primary : Theme.surfaceVariant
textColor: catTab.isSelected ? Theme.onPrimary : Theme.surfaceVariantText
Layout.alignment: Qt.AlignVCenter
}
}
}
}
}
}
}
// Vertical Divider between Sidebar and Main Content
Rectangle {
id: vDivider
anchors.left: sidebar.right
anchors.leftMargin: Theme.spacingM
anchors.top: parent.top
anchors.bottom: parent.bottom
width: Theme.dividerWidth
color: Theme.outlineVariant
opacity: 0.4
}
// Main Content Area
Item {
id: mainArea
anchors.left: vDivider.right
anchors.leftMargin: Theme.spacingM
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
DankFlickable {
id: rightFlickable
anchors.fill: parent
contentWidth: width
contentHeight: contentCol.implicitHeight
clip: true
Column {
id: contentCol
width: rightFlickable.width - Theme.spacingS
spacing: Theme.spacingL
// Categories Renderer
Repeater {
model: content.selectedCategory === "All" ? content.dataModel.sortedKeys : (content.dataModel.byCategory[content.selectedCategory] ? [content.selectedCategory] : [])
Column {
id: sectionCol
width: contentCol.width
spacing: Theme.spacingM
readonly property string sectionCatName: modelData
readonly property var sectionCatInfo: content.dataModel.byCategory[sectionCatName]
// Section Header
RowLayout {
width: parent.width
spacing: Theme.spacingS
DankIcon {
name: content.getCategoryIcon(sectionCol.sectionCatName)
size: Theme.iconSizeMedium
color: Theme.primary
}
StyledText {
text: I18n.tr(sectionCol.sectionCatName)
font.pixelSize: Theme.fontSizeMedium
font.weight: Theme.fontWeightMedium
color: Theme.primary
}
Rectangle {
width: parent.width
height: 1
color: Theme.primary
Layout.fillWidth: true
height: Theme.dividerWidth
color: Theme.outlineVariant
opacity: 0.3
}
}
Item {
width: 1
height: Theme.spacingXS
}
// Subcategories Repeater
Repeater {
model: sectionCol.sectionCatInfo?.subcatKeys || []
Column {
width: parent.width
spacing: Theme.spacingM
id: subcatCol
width: sectionCol.width
spacing: Theme.spacingXS
Repeater {
model: categoryColumn.catData?.subcatKeys || []
readonly property string subcatName: modelData
readonly property var subcatBinds: sectionCol.sectionCatInfo?.subcats?.[subcatName] || []
Column {
width: parent.width
// Subcategory Title (if not _root)
StyledText {
visible: subcatCol.subcatName !== "_root"
text: I18n.tr(subcatCol.subcatName)
font.pixelSize: Theme.fontSizeSmall
font.weight: Theme.fontWeightMedium
color: Theme.surfaceVariantText
}
readonly property bool isTwoColumn: rightFlickable.width > 550
// 2-Column Responsive Masonry Layout
RowLayout {
id: keybindsMasonry
width: parent.width
spacing: Theme.spacingM
// Left Column
ColumnLayout {
Layout.fillWidth: true
Layout.alignment: Qt.AlignTop
spacing: Theme.spacingXS
property string subcatName: modelData
property var subcatBinds: categoryColumn.catData?.subcats?.[subcatName] || []
StyledText {
visible: parent.subcatName !== "_root"
text: parent.subcatName
font.pixelSize: Theme.fontSizeSmall
font.weight: Theme.fontWeightMedium
color: Theme.primary
opacity: 0.7
Repeater {
model: subcatCol.subcatBinds ? subcatCol.subcatBinds.filter((_, idx) => !subcatCol.isTwoColumn || idx % 2 === 0) : []
delegate: keybindItemDelegate
}
}
Column {
width: parent.width
spacing: Theme.spacingXS
// Right Column
ColumnLayout {
visible: subcatCol.isTwoColumn
Layout.fillWidth: true
Layout.alignment: Qt.AlignTop
spacing: Theme.spacingXS
Repeater {
model: parent.parent.subcatBinds
Item {
width: parent.width
height: 24
StyledRect {
id: keyBadge
width: Math.min(keyText.implicitWidth + 12, 160)
height: 22
radius: Theme.cornerRadiusXS
anchors.verticalCenter: parent.verticalCenter
StyledText {
id: keyText
anchors.centerIn: parent
color: Theme.secondary
text: (modelData.key || "").replace(/\+/g, " + ")
font.pixelSize: Theme.fontSizeSmall
font.weight: Theme.fontWeightMedium
isMonospace: true
elide: Text.ElideRight
width: Math.min(implicitWidth, 148)
}
}
StyledText {
anchors.left: parent.left
anchors.leftMargin: 170
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: modelData.desc || modelData.action || ""
font.pixelSize: Theme.fontSizeSmall
opacity: 0.9
elide: Text.ElideRight
wrapMode: Text.NoWrap
}
}
}
Repeater {
model: subcatCol.isTwoColumn && subcatCol.subcatBinds ? subcatCol.subcatBinds.filter((_, idx) => idx % 2 === 1) : []
delegate: keybindItemDelegate
}
}
}
@@ -330,6 +787,36 @@ FocusScope {
}
}
}
// Empty State
Column {
anchors.centerIn: parent
spacing: Theme.spacingM
visible: content.dataModel.totalCount === 0
DankIcon {
anchors.horizontalCenter: parent.horizontalCenter
name: content.activeSearchQuery.trim() !== "" ? "search_off" : "keyboard"
size: Theme.iconSizeLarge + Theme.spacingL
color: Theme.surfaceVariantText
}
StyledText {
anchors.horizontalCenter: parent.horizontalCenter
text: content.activeSearchQuery.trim() !== "" ? I18n.tr("No results found") : I18n.tr("No keybinds found")
font.pixelSize: Theme.fontSizeMedium
font.weight: Theme.fontWeightMedium
color: Theme.surfaceText
}
StyledText {
anchors.horizontalCenter: parent.horizontalCenter
text: I18n.tr("Try a different search")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
visible: content.activeSearchQuery.trim() !== ""
}
}
}
}
}
@@ -29,6 +29,7 @@ DankModal {
content: Component {
KeybindsContent {
anchors.fill: parent
showFloatingToggle: true
floating: false
onCloseRequested: overlay.close()
+75
View File
@@ -322,6 +322,81 @@ Singleton {
cheatsheetProcess.running = true;
}
function canExecuteAction(action) {
if (!action)
return false;
if (action.startsWith("spawn ") || action.startsWith("spawn_shell ") || action.startsWith("spawn-sh ") || action.startsWith("exec "))
return true;
const provider = currentProvider || cheatsheetProvider;
if (provider === "niri") {
const base = action.trim().split(/\s+/)[0];
if (base === "next-window" || base === "previous-window")
return false;
}
return provider === "niri" || provider === "hyprland" || provider === "mangowc";
}
function executeAction(action) {
if (!action)
return false;
log.info("Executing keybind action:", action);
if (action.startsWith("spawn ") || action.startsWith("spawn_shell ") || action.startsWith("spawn-sh ") || action.startsWith("exec ")) {
let cmd = action;
if (cmd.startsWith("spawn "))
cmd = cmd.slice(6).trim();
else if (cmd.startsWith("spawn_shell "))
cmd = cmd.slice(12).trim();
else if (cmd.startsWith("spawn-sh ")) {
cmd = cmd.slice(9).trim();
if ((cmd.startsWith('"') && cmd.endsWith('"')) || (cmd.startsWith("'") && cmd.endsWith("'")))
cmd = cmd.slice(1, -1);
} else if (cmd.startsWith("exec "))
cmd = cmd.slice(5).trim();
Quickshell.execDetached(["sh", "-c", cmd]);
return true;
}
const provider = currentProvider || cheatsheetProvider;
if (provider === "niri") {
const parts = action.trim().split(/\s+/);
if (parts.length === 0 || parts[0] === "next-window" || parts[0] === "previous-window")
return false;
const cmdParts = [];
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
const eqIdx = part.indexOf("=");
if (eqIdx !== -1) {
const key = part.slice(0, eqIdx);
const val = part.slice(eqIdx + 1).replace(/^["']|["']$/g, "");
if (key === "skip-confirmation") {
if (val === "true")
cmdParts.push("--skip-confirmation");
} else {
cmdParts.push("--" + key + "=" + val);
}
} else {
cmdParts.push(part);
}
}
Quickshell.execDetached(["sh", "-c", "niri msg action " + cmdParts.join(" ")]);
return true;
}
if (provider === "hyprland") {
Quickshell.execDetached(["sh", "-c", "hyprctl dispatch " + action]);
return true;
}
if (provider === "mangowc") {
const mmsgParams = action.trim().split(/\s+/).join(",");
Quickshell.execDetached(["sh", "-c", "mmsg -d " + mmsgParams]);
return true;
}
return false;
}
function loadBinds(showLoading) {
if (currentProvider === "aqueous") {
_loadPending = true;