diff --git a/core/internal/keybinds/providers/niri.go b/core/internal/keybinds/providers/niri.go index 19424ffda..a89468a67 100644 --- a/core/internal/keybinds/providers/niri.go +++ b/core/internal/keybinds/providers/niri.go @@ -55,6 +55,7 @@ func (n *NiriProvider) GetCheatSheet() (*keybinds.CheatSheet, error) { sheet := &keybinds.CheatSheet{ Title: "Niri Keybinds", Provider: n.Name(), + ModKey: result.ModKey, Binds: categorizedBinds, DMSBindsIncluded: result.DMSBindsIncluded, } diff --git a/core/internal/keybinds/providers/niri_parser.go b/core/internal/keybinds/providers/niri_parser.go index 4cd818f02..af6dc10bc 100644 --- a/core/internal/keybinds/providers/niri_parser.go +++ b/core/internal/keybinds/providers/niri_parser.go @@ -33,6 +33,7 @@ type NiriSection struct { type NiriParser struct { configDir string + modKey string processedFiles map[string]bool bindMap map[string]*NiriKeyBinding bindOrder []string @@ -237,6 +238,7 @@ func isBraceAdjacentSpace(b byte) bool { func NewNiriParser(configDir string) *NiriParser { return &NiriParser{ configDir: configDir, + modKey: "Super", processedFiles: make(map[string]bool), bindMap: make(map[string]*NiriKeyBinding), bindOrder: []string{}, @@ -377,6 +379,8 @@ func (p *NiriParser) processNodes(nodes []*document.Node, section *NiriSection, switch name { case "include": p.handleInclude(node, section, baseDir) + case "input": + p.handleInput(node) case "binds": p.extractBinds(node, section, "") case "recent-windows": @@ -385,6 +389,19 @@ func (p *NiriParser) processNodes(nodes []*document.Node, section *NiriSection, } } +func (p *NiriParser) handleInput(node *document.Node) { + for _, child := range node.Children { + if child.Name.String() != "mod-key" || len(child.Arguments) == 0 { + continue + } + + modKey := strings.Trim(strings.TrimSpace(child.Arguments[0].String()), "\"") + if modKey != "" { + p.modKey = modKey + } + } +} + func (p *NiriParser) handleInclude(node *document.Node, section *NiriSection, baseDir string) { if len(node.Arguments) == 0 { return @@ -534,6 +551,7 @@ func (p *NiriParser) parseKeyCombo(combo string) ([]string, string) { type NiriParseResult struct { Section *NiriSection + ModKey string DMSBindsIncluded bool DMSStatus *DMSBindsStatusInfo ConflictingConfigs map[string]*NiriKeyBinding @@ -586,6 +604,7 @@ func ParseNiriKeys(configDir string) (*NiriParseResult, error) { } return &NiriParseResult{ Section: section, + ModKey: parser.modKey, DMSBindsIncluded: parser.HasDMSBindsIncluded(), DMSStatus: parser.buildDMSStatus(), ConflictingConfigs: parser.conflictingConfigs, diff --git a/core/internal/keybinds/providers/niri_parser_test.go b/core/internal/keybinds/providers/niri_parser_test.go index 7b0f10330..e0fbc9d98 100644 --- a/core/internal/keybinds/providers/niri_parser_test.go +++ b/core/internal/keybinds/providers/niri_parser_test.go @@ -7,6 +7,28 @@ import ( "testing" ) +func TestNiriParseModKey(t *testing.T) { + config := `input { + mod-key "Alt" +} +binds { + Mod+T { spawn "kitty"; } +} +` + tmpDir := t.TempDir() + if err := os.WriteFile(filepath.Join(tmpDir, "config.kdl"), []byte(config), 0o644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + result, err := ParseNiriKeys(tmpDir) + if err != nil { + t.Fatalf("ParseNiriKeys failed: %v", err) + } + if result.ModKey != "Alt" { + t.Errorf("ModKey = %q, want %q", result.ModKey, "Alt") + } +} + func TestNiriParse_NoSpaceBeforeBrace(t *testing.T) { config := `recent-windows { binds { diff --git a/core/internal/keybinds/providers/niri_test.go b/core/internal/keybinds/providers/niri_test.go index 0976e8cd5..1dc1b1fb2 100644 --- a/core/internal/keybinds/providers/niri_test.go +++ b/core/internal/keybinds/providers/niri_test.go @@ -17,7 +17,10 @@ func TestNiriProviderGetCheatSheet(t *testing.T) { tmpDir := t.TempDir() configFile := filepath.Join(tmpDir, "config.kdl") - content := `binds { + content := `input { + mod-key "Alt" +} +binds { Mod+Q { close-window; } Mod+F { fullscreen-window; } Mod+T hotkey-overlay-title="Open Terminal" { spawn "kitty"; } @@ -45,6 +48,10 @@ func TestNiriProviderGetCheatSheet(t *testing.T) { t.Errorf("Provider = %q, want %q", cheatSheet.Provider, "niri") } + if cheatSheet.ModKey != "Alt" { + t.Errorf("ModKey = %q, want %q", cheatSheet.ModKey, "Alt") + } + windowBinds := cheatSheet.Binds["Window"] if len(windowBinds) < 2 { t.Errorf("Expected at least 2 Window binds, got %d", len(windowBinds)) diff --git a/core/internal/keybinds/types.go b/core/internal/keybinds/types.go index 6cf53f5d7..0c4abbb07 100644 --- a/core/internal/keybinds/types.go +++ b/core/internal/keybinds/types.go @@ -32,6 +32,7 @@ type DMSBindsStatus struct { type CheatSheet struct { Title string `json:"title"` Provider string `json:"provider"` + ModKey string `json:"modKey,omitempty"` Binds map[string][]Keybind `json:"binds"` DMSBindsIncluded bool `json:"dmsBindsIncluded"` DMSStatus *DMSBindsStatus `json:"dmsStatus,omitempty"` diff --git a/quickshell/Common/KeyUtils.js b/quickshell/Common/KeyUtils.js index 3f9078dd5..d61148a4b 100644 --- a/quickshell/Common/KeyUtils.js +++ b/quickshell/Common/KeyUtils.js @@ -192,23 +192,42 @@ function formatToken(mods, key) { return (mods.length ? mods.join("+") + "+" : "") + key; } -function normalizeKeyCombo(keyCombo) { - if (!keyCombo) - return ""; - return keyCombo.toLowerCase().replace(/\bmod\b/g, "super").replace(/\bsuper\b/g, "super"); +function canonicalModifier(modifier) { + var normalized = (modifier || "").toLowerCase(); + if (normalized === "control") + return "ctrl"; + if (normalized === "win") + return "super"; + return normalized; } -function getConflictingBinds(keyCombo, currentAction, allBinds) { +function withSymbolicMod(mods, modKey) { + var configuredMod = canonicalModifier(modKey); + if (!configuredMod) + return mods; + return mods.map(function (modifier) { + return canonicalModifier(modifier) === configuredMod ? "Mod" : modifier; + }); +} + +function normalizeKeyCombo(keyCombo, modKey) { + if (!keyCombo) + return ""; + var configuredMod = canonicalModifier(modKey) || "super"; + return keyCombo.toLowerCase().replace(/\bmod\b/g, configuredMod).replace(/\bcontrol\b/g, "ctrl").replace(/\bwin\b/g, "super"); +} + +function getConflictingBinds(keyCombo, currentAction, allBinds, modKey) { if (!keyCombo) return []; var conflicts = []; - var normalizedKey = normalizeKeyCombo(keyCombo); + var normalizedKey = normalizeKeyCombo(keyCombo, modKey); for (var i = 0; i < allBinds.length; i++) { var bind = allBinds[i]; if (bind.action === currentAction) continue; for (var k = 0; k < bind.keys.length; k++) { - if (normalizeKeyCombo(bind.keys[k].key) === normalizedKey) { + if (normalizeKeyCombo(bind.keys[k].key, modKey) === normalizedKey) { conflicts.push({ action: bind.action, desc: bind.desc || bind.action diff --git a/quickshell/Services/KeybindsService.qml b/quickshell/Services/KeybindsService.qml index f0a8e94e8..127029aeb 100644 --- a/quickshell/Services/KeybindsService.qml +++ b/quickshell/Services/KeybindsService.qml @@ -42,6 +42,7 @@ Singleton { property bool saving: false property bool fixing: false property string lastError: "" + property string modKey: "Super" property bool dmsBindsIncluded: true property var dmsStatus: ({ @@ -348,6 +349,7 @@ Singleton { function _processData() { keybinds = _rawData || {}; + modKey = currentProvider === "niri" ? (_rawData?.modKey || "Super") : "Super"; dmsBindsIncluded = _rawData?.dmsBindsIncluded ?? true; const status = _rawData?.dmsStatus; if (status) { diff --git a/quickshell/Widgets/KeybindItem.qml b/quickshell/Widgets/KeybindItem.qml index 72194bf32..a156ba007 100644 --- a/quickshell/Widgets/KeybindItem.qml +++ b/quickshell/Widgets/KeybindItem.qml @@ -56,7 +56,7 @@ Item { readonly property bool hasConfigConflict: configConflict !== null readonly property string _originalKey: editingKeyIndex >= 0 && editingKeyIndex < keys.length ? keys[editingKeyIndex].key : "" readonly property string _selectedDesc: editingKeyIndex >= 0 && editingKeyIndex < keys.length ? (keys[editingKeyIndex].desc || bindData.desc || "") : (bindData.desc || "") - readonly property var _conflicts: editKey ? KeyUtils.getConflictingBinds(editKey, bindData.action, KeybindsService.getFlatBinds()) : [] + readonly property var _conflicts: editKey ? KeyUtils.getConflictingBinds(editKey, bindData.action, KeybindsService.getFlatBinds(), KeybindsService.currentProvider === "niri" ? KeybindsService.modKey : "Super") : [] readonly property bool hasConflict: _conflicts.length > 0 readonly property real _inputHeight: Math.round(Theme.fontSizeMedium * 3) @@ -725,6 +725,8 @@ Item { if (!mods.includes("Shift")) mods.push("Shift"); } + if (KeybindsService.currentProvider === "niri") + mods = KeyUtils.withSymbolicMod(mods, KeybindsService.modKey); const key = KeyUtils.xkbKeyFromQtKey(qtKey, !!(event.modifiers & Qt.KeypadModifier)); if (!key) { @@ -756,7 +758,7 @@ Item { } wheel.accepted = true; - const mods = []; + let mods = []; if (wheel.modifiers & Qt.ControlModifier) mods.push("Ctrl"); if (wheel.modifiers & Qt.ShiftModifier) @@ -765,6 +767,8 @@ Item { mods.push("Alt"); if (wheel.modifiers & Qt.MetaModifier) mods.push("Super"); + if (KeybindsService.currentProvider === "niri") + mods = KeyUtils.withSymbolicMod(mods, KeybindsService.modKey); let wheelKey = ""; if (wheel.angleDelta.y > 0)