diff --git a/core/internal/keybinds/providers/hyprland.go b/core/internal/keybinds/providers/hyprland.go index 130c7235..e5cd6733 100644 --- a/core/internal/keybinds/providers/hyprland.go +++ b/core/internal/keybinds/providers/hyprland.go @@ -190,9 +190,13 @@ func (h *HyprlandProvider) formatRawAction(dispatcher, params string) string { } func (h *HyprlandProvider) formatKey(kb *HyprlandKeyBinding) string { + key := kb.Key + if canonical, ok := hyprlandScrollToCanonical(key); ok { + key = canonical + } parts := make([]string, 0, len(kb.Mods)+1) parts = append(parts, kb.Mods...) - parts = append(parts, kb.Key) + parts = append(parts, key) return strings.Join(parts, "+") } @@ -411,6 +415,9 @@ func normalizeLuaBindKeyPart(part string) string { case "alt", "mod1": return "ALT" } + if native, ok := hyprlandScrollToNative(part); ok { + return native + } if len(part) == 1 { return strings.ToUpper(part) } @@ -1130,6 +1137,11 @@ func parseLuaUnbindLine(line string) (string, bool) { func luaKeyComboToInternalKey(combo string) string { parts := strings.Fields(strings.ReplaceAll(strings.ReplaceAll(combo, "+", " "), " ", " ")) + for i, part := range parts { + if canonical, ok := hyprlandScrollToCanonical(part); ok { + parts[i] = canonical + } + } return strings.Join(parts, "+") } diff --git a/core/internal/keybinds/providers/hyprland_parser.go b/core/internal/keybinds/providers/hyprland_parser.go index a0cf3288..57d8efc5 100644 --- a/core/internal/keybinds/providers/hyprland_parser.go +++ b/core/internal/keybinds/providers/hyprland_parser.go @@ -347,9 +347,13 @@ func (p *HyprlandParser) buildDMSStatus() *HyprlandDMSStatus { } func (p *HyprlandParser) formatBindKey(kb *HyprlandKeyBinding) string { + key := kb.Key + if canonical, ok := hyprlandScrollToCanonical(key); ok { + key = canonical + } parts := make([]string, 0, len(kb.Mods)+1) parts = append(parts, kb.Mods...) - parts = append(parts, kb.Key) + parts = append(parts, key) return strings.Join(parts, "+") } diff --git a/core/internal/keybinds/providers/hyprland_parser_test.go b/core/internal/keybinds/providers/hyprland_parser_test.go index d3def7ca..afaff44e 100644 --- a/core/internal/keybinds/providers/hyprland_parser_test.go +++ b/core/internal/keybinds/providers/hyprland_parser_test.go @@ -486,6 +486,61 @@ hl.bind("SUPER + 1", hl.dsp.exec_cmd("hyprctl dispatch workspace 1")) } } +func TestHyprlandSetBindTranslatesScrollWheelToMouse(t *testing.T) { + tmpDir := t.TempDir() + dmsDir := filepath.Join(tmpDir, "dms") + if err := os.MkdirAll(dmsDir, 0o755); err != nil { + t.Fatal(err) + } + bindsUser := filepath.Join(dmsDir, "binds-user.lua") + if err := os.WriteFile(bindsUser, []byte("-- DMS user keybind overrides\n"), 0o644); err != nil { + t.Fatal(err) + } + + provider := NewHyprlandProvider(tmpDir) + if err := provider.SetBind("SUPER + WheelScrollDown", "workspace 1", "", nil); err != nil { + t.Fatal(err) + } + + got := readFile(t, bindsUser) + if !strings.Contains(got, `hl.bind("SUPER + mouse_down"`) { + t.Fatalf("expected scroll key translated to mouse_down, got:\n%s", got) + } + if strings.Contains(got, "WheelScroll") { + t.Fatalf("expected no raw niri scroll keysym in hyprland output, got:\n%s", got) + } + + if err := provider.SetBind("SUPER + WheelScrollDown", "workspace 2", "", nil); err != nil { + t.Fatal(err) + } + got = readFile(t, bindsUser) + if strings.Count(got, `hl.bind("SUPER + mouse_down"`) != 1 { + t.Fatalf("expected exactly one mouse_down bind after re-save, got:\n%s", got) + } +} + +func TestHyprlandScrollWheelRoundTrips(t *testing.T) { + for native, canonical := range map[string]string{ + "mouse_up": "WheelScrollUp", + "mouse_down": "WheelScrollDown", + "mouse_left": "WheelScrollLeft", + "mouse_right": "WheelScrollRight", + } { + if got := luaKeyComboToInternalKey("SUPER + " + native); got != "SUPER+"+canonical { + t.Errorf("luaKeyComboToInternalKey(%q) = %q, want SUPER+%s", native, got, canonical) + } + } +} + +func readFile(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return string(data) +} + func TestHyprlandRemoveBindReplacesExistingOverrideWithNegativeOverride(t *testing.T) { tmpDir := t.TempDir() dmsDir := filepath.Join(tmpDir, "dms") diff --git a/core/internal/keybinds/providers/mangowc.go b/core/internal/keybinds/providers/mangowc.go index 41e8d06c..647618e7 100644 --- a/core/internal/keybinds/providers/mangowc.go +++ b/core/internal/keybinds/providers/mangowc.go @@ -236,6 +236,9 @@ func (m *MangoWCProvider) SetBind(key, action, description string, options map[s if optionPrefix := m.bindPrefixFromOptions(options); optionPrefix != "" { prefix = optionPrefix } + if _, leaf := m.parseKeyString(key); isScrollKey(leaf) { + prefix = mangowcAxisBindPrefix + } existingBinds[normalizedKey] = &mangowcOverrideBind{ Key: key, @@ -346,6 +349,12 @@ func (m *MangoWCProvider) parseOverrideBindLine(line, precedingComment string) ( keyName := strings.TrimSpace(fields[1]) command := strings.TrimSpace(fields[2]) + if prefix == mangowcAxisBindPrefix { + if canonical, ok := mangowcDirectionToScroll(keyName); ok { + keyName = canonical + } + } + var params string if len(fields) > 3 { params = strings.TrimSpace(fields[3]) @@ -365,6 +374,9 @@ func (m *MangoWCProvider) parseOverrideBindLine(line, precedingComment string) ( } func (m *MangoWCProvider) isBindPrefix(prefix string) bool { + if prefix == mangowcAxisBindPrefix { + return true + } if !strings.HasPrefix(prefix, "bind") { return false } @@ -591,6 +603,11 @@ func (m *MangoWCProvider) writeBindLine(sb *strings.Builder, bind *mangowcOverri if prefix == "" { prefix = "bind" } + if prefix == mangowcAxisBindPrefix { + if direction, ok := mangowcScrollToDirection(key); ok { + key = direction + } + } sb.WriteString(prefix) sb.WriteString("=") if mods == "" { diff --git a/core/internal/keybinds/providers/mangowc_parser.go b/core/internal/keybinds/providers/mangowc_parser.go index 31a437c3..35f1bbd5 100644 --- a/core/internal/keybinds/providers/mangowc_parser.go +++ b/core/internal/keybinds/providers/mangowc_parser.go @@ -244,7 +244,7 @@ func (p *MangoWCParser) ParseKeys() []MangoWCKeyBinding { } continue } - if !strings.HasPrefix(trimmed, "bind") { + if !strings.HasPrefix(trimmed, "bind") && !strings.HasPrefix(trimmed, mangowcAxisBindPrefix) { pendingComment = "" continue } @@ -427,7 +427,7 @@ func (p *MangoWCParser) parseFileWithSource(filePath string) ([]MangoWCKeyBindin continue } - if !strings.HasPrefix(trimmed, "bind") { + if !strings.HasPrefix(trimmed, "bind") && !strings.HasPrefix(trimmed, mangowcAxisBindPrefix) { pendingComment = "" continue } @@ -493,7 +493,7 @@ func (p *MangoWCParser) parseDMSBindsDirectly(dmsBindsPath string) []MangoWCKeyB // line directly above) is the description: mango feeds inline comments to spawn // as argv, so DMS keeps descriptions on the line above; inline `#` is a fallback. func (p *MangoWCParser) getKeybindAtLineContent(line string, precedingComment string) *MangoWCKeyBinding { - bindMatch := regexp.MustCompile(`^(bind[lsrp]*)\s*=\s*(.+)$`) + bindMatch := regexp.MustCompile(`^(bind[lsrp]*|axisbind)\s*=\s*(.+)$`) matches := bindMatch.FindStringSubmatch(line) if len(matches) < 3 { return nil @@ -527,6 +527,12 @@ func (p *MangoWCParser) getKeybindAtLineContent(line string, precedingComment st key := strings.TrimSpace(keyFields[1]) command := strings.TrimSpace(keyFields[2]) + if matches[1] == mangowcAxisBindPrefix { + if canonical, ok := mangowcDirectionToScroll(key); ok { + key = canonical + } + } + var params string if len(keyFields) > 3 { params = strings.TrimSpace(keyFields[3]) diff --git a/core/internal/keybinds/providers/mangowc_parser_test.go b/core/internal/keybinds/providers/mangowc_parser_test.go index ac370791..a8d4ee0d 100644 --- a/core/internal/keybinds/providers/mangowc_parser_test.go +++ b/core/internal/keybinds/providers/mangowc_parser_test.go @@ -6,6 +6,29 @@ import ( "testing" ) +func TestMangoWCParseAxisBindToScrollKey(t *testing.T) { + tmpDir := t.TempDir() + cfg := filepath.Join(tmpDir, "config.conf") + content := "axisbind=SUPER,UP,spawn,dms ipc call test\n" + if err := os.WriteFile(cfg, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + binds, err := ParseMangoWCKeys(cfg) + if err != nil { + t.Fatalf("ParseMangoWCKeys failed: %v", err) + } + if len(binds) != 1 { + t.Fatalf("expected 1 bind, got %d", len(binds)) + } + if binds[0].Key != "WheelScrollUp" { + t.Fatalf("expected axis direction parsed as WheelScrollUp, got %q", binds[0].Key) + } + if len(binds[0].Mods) != 1 || binds[0].Mods[0] != "SUPER" { + t.Fatalf("expected SUPER mod, got %v", binds[0].Mods) + } +} + func TestMangoWCAutogenerateComment(t *testing.T) { tests := []struct { command string diff --git a/core/internal/keybinds/providers/mangowc_test.go b/core/internal/keybinds/providers/mangowc_test.go index 16b6cc36..84f86460 100644 --- a/core/internal/keybinds/providers/mangowc_test.go +++ b/core/internal/keybinds/providers/mangowc_test.go @@ -417,6 +417,40 @@ bind=SUPER,3,view,3 } } +func TestMangoWCSetBindTranslatesScrollWheelToAxisBind(t *testing.T) { + tmpDir := t.TempDir() + dmsDir := filepath.Join(tmpDir, "dms") + if err := os.MkdirAll(dmsDir, 0o755); err != nil { + t.Fatalf("failed to create dms dir: %v", err) + } + bindsPath := filepath.Join(dmsDir, "binds.conf") + seed := "# === Custom Keybinds ===\nbind=SUPER,t,spawn,ghostty\ngesturebind=none,left,3,focusdir,left\n" + if err := os.WriteFile(bindsPath, []byte(seed), 0o644); err != nil { + t.Fatalf("failed to write seed binds: %v", err) + } + + provider := NewMangoWCProvider(tmpDir) + if err := provider.SetBind("SUPER+WheelScrollDown", "spawn dms ipc call test", "Scroll down", nil); err != nil { + t.Fatalf("SetBind failed: %v", err) + } + + content := readFile(t, bindsPath) + if !strings.Contains(content, "axisbind=SUPER,DOWN,spawn,dms ipc call test") { + t.Fatalf("expected scroll bind written as axisbind direction, got:\n%s", content) + } + if strings.Contains(content, "WheelScroll") { + t.Fatalf("expected no raw niri scroll keysym in mango output, got:\n%s", content) + } + + if err := provider.SetBind("SUPER+WheelScrollDown", "spawn dms ipc call test2", "Scroll down", nil); err != nil { + t.Fatalf("SetBind failed: %v", err) + } + content = readFile(t, bindsPath) + if strings.Count(content, "axisbind=SUPER,DOWN,") != 1 { + t.Fatalf("expected exactly one axisbind after re-save, got:\n%s", content) + } +} + func TestMangoWCRemoveBindPreservesNonBindLines(t *testing.T) { tmpDir := t.TempDir() dmsDir := filepath.Join(tmpDir, "dms") diff --git a/core/internal/keybinds/providers/scroll.go b/core/internal/keybinds/providers/scroll.go new file mode 100644 index 00000000..547a4577 --- /dev/null +++ b/core/internal/keybinds/providers/scroll.go @@ -0,0 +1,74 @@ +package providers + +import "strings" + +// Scroll-wheel binds are captured by the shell as niri's keysym names +// (WheelScrollUp/Down/Left/Right) regardless of the active compositor. Niri +// consumes them natively; every other provider speaks a different dialect, so the +// raw niri token must be translated on write and back again on read. Without this +// the token is emitted verbatim and the compositor rejects the bind (issue #2683). + +var canonicalScrollKeys = map[string]string{ + "wheelscrollup": "WheelScrollUp", + "wheelscrolldown": "WheelScrollDown", + "wheelscrollleft": "WheelScrollLeft", + "wheelscrollright": "WheelScrollRight", +} + +func isScrollKey(token string) bool { + _, ok := canonicalScrollKeys[strings.ToLower(token)] + return ok +} + +// Hyprland binds the wheel inside a regular bind using mouse_up/down/left/right. +var hyprlandScrollNative = map[string]string{ + "wheelscrollup": "mouse_up", + "wheelscrolldown": "mouse_down", + "wheelscrollleft": "mouse_left", + "wheelscrollright": "mouse_right", +} + +var hyprlandScrollCanonical = map[string]string{ + "mouse_up": "WheelScrollUp", + "mouse_down": "WheelScrollDown", + "mouse_left": "WheelScrollLeft", + "mouse_right": "WheelScrollRight", +} + +func hyprlandScrollToNative(token string) (string, bool) { + v, ok := hyprlandScrollNative[strings.ToLower(token)] + return v, ok +} + +func hyprlandScrollToCanonical(token string) (string, bool) { + v, ok := hyprlandScrollCanonical[strings.ToLower(token)] + return v, ok +} + +// MangoWC binds the wheel through a dedicated axisbind directive whose key field +// is a direction (UP/DOWN/LEFT/RIGHT) rather than a keysym. +const mangowcAxisBindPrefix = "axisbind" + +var mangowcScrollDirection = map[string]string{ + "wheelscrollup": "UP", + "wheelscrolldown": "DOWN", + "wheelscrollleft": "LEFT", + "wheelscrollright": "RIGHT", +} + +var mangowcScrollCanonical = map[string]string{ + "up": "WheelScrollUp", + "down": "WheelScrollDown", + "left": "WheelScrollLeft", + "right": "WheelScrollRight", +} + +func mangowcScrollToDirection(token string) (string, bool) { + v, ok := mangowcScrollDirection[strings.ToLower(token)] + return v, ok +} + +func mangowcDirectionToScroll(direction string) (string, bool) { + v, ok := mangowcScrollCanonical[strings.ToLower(direction)] + return v, ok +}