diff --git a/core/internal/keybinds/providers/niri.go b/core/internal/keybinds/providers/niri.go index 102568261..205fc1542 100644 --- a/core/internal/keybinds/providers/niri.go +++ b/core/internal/keybinds/providers/niri.go @@ -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, "=") diff --git a/core/internal/keybinds/providers/niri_parser.go b/core/internal/keybinds/providers/niri_parser.go index 2c50ecb2a..347b7508f 100644 --- a/core/internal/keybinds/providers/niri_parser.go +++ b/core/internal/keybinds/providers/niri_parser.go @@ -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()) } diff --git a/core/internal/keybinds/providers/niri_parser_test.go b/core/internal/keybinds/providers/niri_parser_test.go index 13a7984d1..7b285fa30 100644 --- a/core/internal/keybinds/providers/niri_parser_test.go +++ b/core/internal/keybinds/providers/niri_parser_test.go @@ -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) } } } diff --git a/core/internal/keybinds/providers/niri_test.go b/core/internal/keybinds/providers/niri_test.go index 149560ce3..227bc0127 100644 --- a/core/internal/keybinds/providers/niri_test.go +++ b/core/internal/keybinds/providers/niri_test.go @@ -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) + } +} diff --git a/quickshell/Common/KeyUtils.js b/quickshell/Common/KeyUtils.js index 5f2d24546..ebfa658fd 100644 --- a/quickshell/Common/KeyUtils.js +++ b/quickshell/Common/KeyUtils.js @@ -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; +} diff --git a/quickshell/Common/KeybindActions.js b/quickshell/Common/KeybindActions.js index 620d2af09..aa5156f62 100644 --- a/quickshell/Common/KeybindActions.js +++ b/quickshell/Common/KeybindActions.js @@ -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 ""; } diff --git a/quickshell/Modals/KeybindsContent.qml b/quickshell/Modals/KeybindsContent.qml index fbd098790..6c512f5a6 100644 --- a/quickshell/Modals/KeybindsContent.qml +++ b/quickshell/Modals/KeybindsContent.qml @@ -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() !== "" + } + } } } } diff --git a/quickshell/Modals/KeybindsModalOverlay.qml b/quickshell/Modals/KeybindsModalOverlay.qml index c298da4c1..224704840 100644 --- a/quickshell/Modals/KeybindsModalOverlay.qml +++ b/quickshell/Modals/KeybindsModalOverlay.qml @@ -29,6 +29,7 @@ DankModal { content: Component { KeybindsContent { + anchors.fill: parent showFloatingToggle: true floating: false onCloseRequested: overlay.close() diff --git a/quickshell/Services/KeybindsService.qml b/quickshell/Services/KeybindsService.qml index 094567857..5ae9ac161 100644 --- a/quickshell/Services/KeybindsService.qml +++ b/quickshell/Services/KeybindsService.qml @@ -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;