detached 21d72f7

Merge pull request #365 from bnema/feat/native-vim-mode-tranche-1-2a

feat(vim): add native key sequence grammar and accessibility spike
commit
21d72f7ee4ef168cfdbd7b0ba0482e7d16b48476
author
bnema · · 2026-08-14 03:37 UTC
committer
GitHub · · 2026-08-14 03:37 UTC
merge of
d47730b + fc65432 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
35 files · +6227 -52
M internal/application/port/webview.go +7 -0
@@ -437,3 +437,10 @@ type PageScrollable interface {
437 type PageScrollCanceler interface {437 type PageScrollCanceler interface {
438 CancelPageScroll(ctx context.Context)438 CancelPageScroll(ctx context.Context)
439 }439 }
440+
441+// AccessibilityEnabler is an optional capability for adapters that can enable
442+// browser accessibility tooling once per WebView. Engines that do not support
443+// accessibility leave this unimplemented so callers can type-assert safely.
444+type AccessibilityEnabler interface {
445+ EnableAccessibility()
446+}
M internal/domain/entity/config_types.go +5 -3
@@ -80,9 +80,11 @@ func (t *TabModeConfig) GetKeyBindings() map[string]string {
80 80
81 // VimModeConfig holds vim scroll mode shortcut configuration.81 // VimModeConfig holds vim scroll mode shortcut configuration.
82 type VimModeConfig struct {82 type VimModeConfig struct {
83- ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line83+ ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line
84- TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"`84+ TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"`
85- Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`85+ SequenceTimeoutMilliseconds int `mapstructure:"sequence_timeout_ms" yaml:"sequence_timeout_ms" toml:"sequence_timeout_ms" json:"sequence_timeout_ms"` //nolint:lll // struct tags must stay on one line
86+ PreloadAccessibility bool `mapstructure:"preload_accessibility" yaml:"preload_accessibility" toml:"preload_accessibility" json:"preload_accessibility"` //nolint:lll // struct tags must stay on one line
87+ Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`
86 }88 }
87 89
88 // GetKeyBindings returns a map from key string to action name.90 // GetKeyBindings returns a map from key string to action name.
A internal/domain/vimkeys/key.go +169 -0
@@ -0,0 +1,169 @@
1+package vimkeys
2+
3+import (
4+ "errors"
5+ "strings"
6+)
7+
8+// Mods holds keyboard modifier flags for a parsed key.
9+type Mods uint8
10+
11+const (
12+ ModCtrl Mods = 1 << iota
13+ ModShift
14+ ModAlt
15+)
16+
17+const (
18+ symCR = "CR"
19+ symEsc = "Esc"
20+)
21+
22+// Key is a single key event with symbol and modifiers.
23+type Key struct {
24+ Sym string
25+ Mods Mods
26+}
27+
28+// Sequence is an ordered list of keys forming a Vim-style binding.
29+type Sequence []Key
30+
31+var (
32+ ErrEmptyBinding = errors.New("vimkeys: empty binding")
33+ ErrBadBinding = errors.New("vimkeys: malformed binding")
34+)
35+
36+// String renders a key in canonical form.
37+func (k Key) String() string {
38+ if k.Sym == "" {
39+ return ""
40+ }
41+
42+ if k.Mods == ModShift && len(k.Sym) == 1 {
43+ if c := k.Sym[0]; c >= 'a' && c <= 'z' {
44+ return strings.ToUpper(k.Sym)
45+ }
46+ }
47+
48+ if k.Mods == 0 {
49+ if k.Sym == "+" {
50+ return "<Plus>"
51+ }
52+ if alias := angleAliasForSym(k.Sym); alias != "" {
53+ return "<" + alias + ">"
54+ }
55+ return k.Sym
56+ }
57+
58+ return formatAngleKey(k)
59+}
60+
61+// String renders the full binding sequence in canonical form.
62+// When the naive concatenation would case-insensitively equal an exact legacy
63+// atom (enter/return/escape/esc), the first key is forced to an angle literal
64+// so ParseBinding cannot reabsorb the sequence as that atom.
65+func (s Sequence) String() string {
66+ rendered := s.render(false)
67+ if collidesWithLegacyAtom(rendered) {
68+ return s.render(true)
69+ }
70+ return rendered
71+}
72+
73+func (s Sequence) render(forceAngleFirst bool) string {
74+ var b strings.Builder
75+ for i, key := range s {
76+ if forceAngleFirst && i == 0 {
77+ b.WriteString(key.angleLiteralString())
78+ continue
79+ }
80+ b.WriteString(key.String())
81+ }
82+ return b.String()
83+}
84+
85+func collidesWithLegacyAtom(s string) bool {
86+ switch strings.ToLower(s) {
87+ case "enter", "return", "escape", "esc":
88+ return true
89+ default:
90+ return false
91+ }
92+}
93+
94+// angleLiteralString forces angle-bracket form for keys that Key.String would
95+// otherwise emit as bare or shifted letters (e.g. "e" / "E").
96+func (k Key) angleLiteralString() string {
97+ if len(k.Sym) == 1 {
98+ c := k.Sym[0]
99+ if c >= 'a' && c <= 'z' {
100+ switch k.Mods {
101+ case 0:
102+ return "<" + k.Sym + ">"
103+ case ModShift:
104+ return formatAngleKey(k)
105+ }
106+ }
107+ }
108+ return k.String()
109+}
110+
111+// Equal reports whether two sequences contain the same keys.
112+func (s Sequence) Equal(other Sequence) bool {
113+ if len(s) != len(other) {
114+ return false
115+ }
116+ for i := range s {
117+ if s[i] != other[i] {
118+ return false
119+ }
120+ }
121+ return true
122+}
123+
124+func angleAliasForSym(sym string) string {
125+ switch sym {
126+ case symCR:
127+ return "Return"
128+ case symEsc:
129+ return "Escape"
130+ case "Space", "Tab", "BackSpace", "Delete",
131+ "Left", "Right", "Up", "Down",
132+ "Home", "End", "PageUp", "PageDown":
133+ return sym
134+ case "<":
135+ return "lt"
136+ case ">":
137+ return "gt"
138+ default:
139+ return ""
140+ }
141+}
142+
143+func formatAngleKey(k Key) string {
144+ var b strings.Builder
145+ b.WriteByte('<')
146+ if k.Mods&ModCtrl != 0 {
147+ b.WriteByte('C')
148+ }
149+ if k.Mods&ModShift != 0 {
150+ if b.Len() > 1 {
151+ b.WriteByte('-')
152+ }
153+ b.WriteByte('S')
154+ }
155+ if k.Mods&ModAlt != 0 {
156+ if b.Len() > 1 {
157+ b.WriteByte('-')
158+ }
159+ b.WriteByte('A')
160+ }
161+ b.WriteByte('-')
162+ if alias := angleAliasForSym(k.Sym); alias != "" {
163+ b.WriteString(alias)
164+ } else {
165+ b.WriteString(k.Sym)
166+ }
167+ b.WriteByte('>')
168+ return b.String()
169+}
A internal/domain/vimkeys/key_test.go +100 -0
@@ -0,0 +1,100 @@
1+package vimkeys
2+
3+import (
4+ "testing"
5+)
6+
7+func TestKeyString(t *testing.T) {
8+ tests := []struct {
9+ name string
10+ key Key
11+ want string
12+ }{
13+ {name: "letter", key: Key{Sym: "j"}, want: "j"},
14+ {name: "shift letter", key: Key{Sym: "j", Mods: ModShift}, want: "J"},
15+ {name: "ctrl letter", key: Key{Sym: "d", Mods: ModCtrl}, want: "<C-d>"},
16+ {name: "ctrl plus", key: Key{Sym: "+", Mods: ModCtrl}, want: "<C-+>"},
17+ {name: "bare plus", key: Key{Sym: "+"}, want: "<Plus>"},
18+ {name: "shift glyph", key: Key{Sym: "!"}, want: "!"},
19+ {name: "special CR", key: Key{Sym: "CR"}, want: "<Return>"},
20+ {name: "special Esc", key: Key{Sym: "Esc"}, want: "<Escape>"},
21+ {name: "special Space", key: Key{Sym: "Space"}, want: "<Space>"},
22+ {name: "special Tab", key: Key{Sym: "Tab"}, want: "<Tab>"},
23+ {name: "special BackSpace", key: Key{Sym: "BackSpace"}, want: "<BackSpace>"},
24+ {name: "special Delete", key: Key{Sym: "Delete"}, want: "<Delete>"},
25+ {name: "special Left", key: Key{Sym: "Left"}, want: "<Left>"},
26+ {name: "special Right", key: Key{Sym: "Right"}, want: "<Right>"},
27+ {name: "special Up", key: Key{Sym: "Up"}, want: "<Up>"},
28+ {name: "special Down", key: Key{Sym: "Down"}, want: "<Down>"},
29+ {name: "special Home", key: Key{Sym: "Home"}, want: "<Home>"},
30+ {name: "special End", key: Key{Sym: "End"}, want: "<End>"},
31+ {name: "special PageUp", key: Key{Sym: "PageUp"}, want: "<PageUp>"},
32+ {name: "special PageDown", key: Key{Sym: "PageDown"}, want: "<PageDown>"},
33+ {name: "ctrl Tab", key: Key{Sym: "Tab", Mods: ModCtrl}, want: "<C-Tab>"},
34+ {name: "shift BackSpace", key: Key{Sym: "BackSpace", Mods: ModShift}, want: "<S-BackSpace>"},
35+ {name: "alt Delete", key: Key{Sym: "Delete", Mods: ModAlt}, want: "<A-Delete>"},
36+ {name: "ctrl Left", key: Key{Sym: "Left", Mods: ModCtrl}, want: "<C-Left>"},
37+ {name: "literal lt", key: Key{Sym: "<"}, want: "<lt>"},
38+ {name: "literal gt", key: Key{Sym: ">"}, want: "<gt>"},
39+ {name: "ctrl gt", key: Key{Sym: ">", Mods: ModCtrl}, want: "<C-gt>"},
40+ {name: "alt only", key: Key{Sym: "b", Mods: ModAlt}, want: "<A-b>"},
41+ {name: "ctrl alt", key: Key{Sym: "c", Mods: ModCtrl | ModAlt}, want: "<C-A-c>"},
42+ {name: "ctrl shift", key: Key{Sym: "a", Mods: ModCtrl | ModShift}, want: "<C-S-a>"},
43+ }
44+ for _, tt := range tests {
45+ t.Run(tt.name, func(t *testing.T) {
46+ if got := tt.key.String(); got != tt.want {
47+ t.Errorf("Key.String() = %q, want %q", got, tt.want)
48+ }
49+ })
50+ }
51+}
52+
53+func TestSequenceString(t *testing.T) {
54+ tests := []struct {
55+ name string
56+ seq Sequence
57+ want string
58+ }{
59+ {name: "yah", seq: Sequence{{Sym: "y"}, {Sym: "a"}, {Sym: "h"}}, want: "yah"},
60+ {name: "double bracket", seq: Sequence{{Sym: "]"}, {Sym: "]"}}, want: "]]"},
61+ {name: "gO", seq: Sequence{{Sym: "g"}, {Sym: "o", Mods: ModShift}}, want: "gO"},
62+ }
63+ for _, tt := range tests {
64+ t.Run(tt.name, func(t *testing.T) {
65+ if got := tt.seq.String(); got != tt.want {
66+ t.Errorf("Sequence.String() = %q, want %q", got, tt.want)
67+ }
68+ })
69+ }
70+}
71+
72+func TestSequenceEqual(t *testing.T) {
73+ a := Sequence{{Sym: "j"}, {Sym: "k"}}
74+ b := Sequence{{Sym: "j"}, {Sym: "k"}}
75+ c := Sequence{{Sym: "j"}}
76+ if !a.Equal(b) {
77+ t.Fatal("equal sequences reported unequal")
78+ }
79+ if a.Equal(c) {
80+ t.Fatal("unequal length sequences reported equal")
81+ }
82+ if a.Equal(Sequence{{Sym: "j"}, {Sym: "x"}}) {
83+ t.Fatal("different keys reported equal")
84+ }
85+}
86+
87+func TestKeyStringEmpty(t *testing.T) {
88+ if got := (Key{}).String(); got != "" {
89+ t.Fatalf("empty Key.String() = %q, want empty", got)
90+ }
91+}
92+
93+func TestErrorSentinels(t *testing.T) {
94+ if ErrEmptyBinding.Error() != "vimkeys: empty binding" {
95+ t.Errorf("ErrEmptyBinding = %q, want %q", ErrEmptyBinding.Error(), "vimkeys: empty binding")
96+ }
97+ if ErrBadBinding.Error() != "vimkeys: malformed binding" {
98+ t.Errorf("ErrBadBinding = %q, want %q", ErrBadBinding.Error(), "vimkeys: malformed binding")
99+ }
100+}
A internal/domain/vimkeys/matcher.go +197 -0
@@ -0,0 +1,197 @@
1+package vimkeys
2+
3+import "strconv"
4+
5+// ResultKind classifies matcher feed outcomes.
6+type ResultKind int
7+
8+const (
9+ ResultPending ResultKind = iota
10+ ResultComplete
11+ ResultInvalid
12+)
13+
14+// Result is the outcome of feeding a key into the matcher.
15+type Result struct {
16+ Kind ResultKind
17+ Count int
18+ Action string
19+ Pending string
20+}
21+
22+// Matcher incrementally matches key sequences against a trie.
23+type Matcher struct {
24+ trie *Trie
25+ node *trieNode
26+ pending Sequence
27+ count int
28+ inCount bool
29+ ambiguous bool
30+}
31+
32+// NewMatcher creates a matcher backed by trie.
33+func NewMatcher(trie *Trie) *Matcher {
34+ return &Matcher{trie: trie}
35+}
36+
37+// Reset clears matcher state.
38+func (m *Matcher) Reset() {
39+ if m == nil {
40+ return
41+ }
42+ m.node = nil
43+ m.pending = nil
44+ m.count = 0
45+ m.inCount = false
46+ m.ambiguous = false
47+}
48+
49+// Pending returns the count prefix and keys matched so far.
50+func (m *Matcher) Pending() string {
51+ if !m.hasTrie() {
52+ return ""
53+ }
54+ return formatPending(m.count, m.pending)
55+}
56+
57+// Ambiguous reports whether the current prefix is both exact and extendable.
58+func (m *Matcher) Ambiguous() bool {
59+ return m.hasTrie() && m.ambiguous
60+}
61+
62+// Feed consumes one key and returns the current match state.
63+func (m *Matcher) Feed(key Key) Result {
64+ if !m.hasTrie() {
65+ m.Reset()
66+ return Result{Kind: ResultInvalid}
67+ }
68+
69+ if m.node == nil && len(m.pending) == 0 && m.count == 0 && !m.inCount {
70+ m.beginSequence()
71+ }
72+
73+ if m.inCount {
74+ return m.feedCountDigit(key)
75+ }
76+
77+ if len(m.pending) == 0 && isCountStartDigit(key) {
78+ m.inCount = true
79+ return m.feedCountDigit(key)
80+ }
81+
82+ return m.feedSequenceKey(key)
83+}
84+
85+func (m *Matcher) hasTrie() bool {
86+ return m != nil && m.trie != nil && m.trie.root != nil
87+}
88+
89+func (m *Matcher) beginSequence() {
90+ if !m.hasTrie() {
91+ m.Reset()
92+ return
93+ }
94+ m.node = m.trie.root
95+ m.pending = nil
96+ m.count = 0
97+ m.inCount = false
98+ m.ambiguous = false
99+}
100+
101+func (m *Matcher) feedCountDigit(key Key) Result {
102+ if !isCountDigit(key) {
103+ m.inCount = false
104+ m.node = m.trie.root
105+ return m.feedSequenceKey(key)
106+ }
107+
108+ digit := int(key.Sym[0] - '0')
109+ next := m.count*10 + digit
110+ if next > maxCount {
111+ next = maxCount
112+ }
113+ m.count = next
114+ return Result{Kind: ResultPending, Count: normalizedCount(m.count), Pending: m.Pending()}
115+}
116+
117+func (m *Matcher) feedSequenceKey(key Key) Result {
118+ if m.node == nil {
119+ result := Result{Kind: ResultInvalid, Count: normalizedCount(m.count)}
120+ m.Reset()
121+ return result
122+ }
123+
124+ child, ok := m.node.children[key]
125+ if !ok {
126+ result := Result{Kind: ResultInvalid, Count: normalizedCount(m.count)}
127+ m.Reset()
128+ return result
129+ }
130+
131+ m.pending = append(m.pending, key)
132+ m.node = child
133+
134+ exact := m.node.hasAction
135+ children := len(m.node.children) > 0
136+ m.ambiguous = exact && children
137+
138+ switch {
139+ case m.ambiguous:
140+ return Result{Kind: ResultPending, Count: normalizedCount(m.count), Pending: m.Pending()}
141+ case exact:
142+ result := Result{
143+ Kind: ResultComplete,
144+ Count: normalizedCount(m.count),
145+ Action: m.node.action,
146+ }
147+ m.Reset()
148+ return result
149+ case children:
150+ return Result{Kind: ResultPending, Count: normalizedCount(m.count), Pending: m.Pending()}
151+ default:
152+ result := Result{Kind: ResultInvalid, Count: normalizedCount(m.count)}
153+ m.Reset()
154+ return result
155+ }
156+}
157+
158+// ResolveAmbiguity completes the current short exact binding.
159+func (m *Matcher) ResolveAmbiguity() (Result, bool) {
160+ if !m.hasTrie() || !m.ambiguous || m.node == nil || !m.node.hasAction {
161+ return Result{}, false
162+ }
163+
164+ result := Result{
165+ Kind: ResultComplete,
166+ Count: normalizedCount(m.count),
167+ Action: m.node.action,
168+ }
169+ m.Reset()
170+ return result, true
171+}
172+
173+func isCountStartDigit(key Key) bool {
174+ return key.Mods == 0 && len(key.Sym) == 1 && key.Sym[0] >= '1' && key.Sym[0] <= '9'
175+}
176+
177+func isCountDigit(key Key) bool {
178+ return key.Mods == 0 && len(key.Sym) == 1 && key.Sym[0] >= '0' && key.Sym[0] <= '9'
179+}
180+
181+func normalizedCount(count int) int {
182+ if count <= 0 {
183+ return 0
184+ }
185+ return count
186+}
187+
188+func formatPending(count int, pending Sequence) string {
189+ var out string
190+ if count > 0 {
191+ out = strconv.Itoa(count)
192+ }
193+ if len(pending) > 0 {
194+ out += pending.String()
195+ }
196+ return out
197+}
A internal/domain/vimkeys/matcher_test.go +393 -0
@@ -0,0 +1,393 @@
1+package vimkeys
2+
3+import (
4+ "testing"
5+)
6+
7+func testTrie(t *testing.T) *Trie {
8+ t.Helper()
9+ trie := NewTrie()
10+ bindings := []struct {
11+ seq Sequence
12+ action string
13+ }{
14+ {seq: Sequence{{Sym: "]"}, {Sym: "]"}}, action: "double-bracket"},
15+ {seq: Sequence{{Sym: "]"}, {Sym: "c"}}, action: "bracket-c"},
16+ {seq: Sequence{{Sym: "g"}, {Sym: "o", Mods: ModShift}}, action: "gO"},
17+ {seq: Sequence{{Sym: "y"}, {Sym: "a"}, {Sym: "h"}}, action: "yah"},
18+ {seq: Sequence{{Sym: "]"}}, action: "single-bracket"},
19+ {seq: Sequence{{Sym: "j"}}, action: "down"},
20+ {seq: Sequence{{Sym: "0"}}, action: "zero"},
21+ }
22+ for _, binding := range bindings {
23+ if err := trie.Insert(binding.seq, binding.action); err != nil {
24+ t.Fatalf("Insert(%v) error = %v", binding.seq, err)
25+ }
26+ }
27+ return trie
28+}
29+
30+func TestMatcher_FeedPrefixesPending(t *testing.T) {
31+ m := NewMatcher(testTrie(t))
32+
33+ cases := []struct {
34+ name string
35+ keys []Key
36+ want ResultKind
37+ pending string
38+ }{
39+ {
40+ name: "]] first bracket",
41+ keys: []Key{{Sym: "]"}},
42+ want: ResultPending,
43+ pending: "]",
44+ },
45+ {
46+ name: "gO prefix g",
47+ keys: []Key{{Sym: "g"}},
48+ want: ResultPending,
49+ pending: "g",
50+ },
51+ {
52+ name: "yah prefix ya",
53+ keys: []Key{{Sym: "y"}, {Sym: "a"}},
54+ want: ResultPending,
55+ pending: "ya",
56+ },
57+ }
58+ for _, tt := range cases {
59+ t.Run(tt.name, func(t *testing.T) {
60+ m.Reset()
61+ var result Result
62+ for _, key := range tt.keys {
63+ result = m.Feed(key)
64+ }
65+ if result.Kind != tt.want {
66+ t.Fatalf("Feed kind = %v, want %v", result.Kind, tt.want)
67+ }
68+ if result.Pending != tt.pending {
69+ t.Fatalf("Pending = %q, want %q", result.Pending, tt.pending)
70+ }
71+ if m.Pending() != tt.pending {
72+ t.Fatalf("Matcher.Pending() = %q, want %q", m.Pending(), tt.pending)
73+ }
74+ if result.Count != 0 {
75+ t.Fatalf("Count = %d, want 0 (absent)", result.Count)
76+ }
77+ })
78+ }
79+}
80+
81+func TestMatcher_FeedComplete(t *testing.T) {
82+ m := NewMatcher(testTrie(t))
83+
84+ cases := []struct {
85+ name string
86+ keys []Key
87+ action string
88+ }{
89+ {name: "]]", keys: []Key{{Sym: "]"}, {Sym: "]"}}, action: "double-bracket"},
90+ {name: "gO", keys: []Key{{Sym: "g"}, {Sym: "o", Mods: ModShift}}, action: "gO"},
91+ {name: "yah", keys: []Key{{Sym: "y"}, {Sym: "a"}, {Sym: "h"}}, action: "yah"},
92+ }
93+ for _, tt := range cases {
94+ t.Run(tt.name, func(t *testing.T) {
95+ m.Reset()
96+ var result Result
97+ for _, key := range tt.keys {
98+ result = m.Feed(key)
99+ }
100+ if result.Kind != ResultComplete {
101+ t.Fatalf("Feed kind = %v, want %v", result.Kind, ResultComplete)
102+ }
103+ if result.Action != tt.action {
104+ t.Fatalf("Action = %q, want %q", result.Action, tt.action)
105+ }
106+ if result.Count != 0 {
107+ t.Fatalf("Count = %d, want 0", result.Count)
108+ }
109+ })
110+ }
111+}
112+
113+func TestMatcher_Counts(t *testing.T) {
114+ m := NewMatcher(testTrie(t))
115+
116+ cases := []struct {
117+ name string
118+ keys []Key
119+ count int
120+ action string
121+ }{
122+ {
123+ name: "2]]",
124+ keys: []Key{{Sym: "2"}, {Sym: "]"}, {Sym: "]"}},
125+ count: 2,
126+ action: "double-bracket",
127+ },
128+ {
129+ name: "3j",
130+ keys: []Key{{Sym: "3"}, {Sym: "j"}},
131+ count: 3,
132+ action: "down",
133+ },
134+ {
135+ name: "10j",
136+ keys: []Key{{Sym: "1"}, {Sym: "0"}, {Sym: "j"}},
137+ count: 10,
138+ action: "down",
139+ },
140+ {
141+ name: "03j",
142+ keys: []Key{{Sym: "0"}, {Sym: "3"}, {Sym: "j"}},
143+ count: 3,
144+ action: "down",
145+ },
146+ {
147+ name: "0 binding",
148+ keys: []Key{{Sym: "0"}},
149+ count: 0,
150+ action: "zero",
151+ },
152+ }
153+ for _, tt := range cases {
154+ t.Run(tt.name, func(t *testing.T) {
155+ m.Reset()
156+ var result Result
157+ for _, key := range tt.keys {
158+ result = m.Feed(key)
159+ }
160+ if result.Kind != ResultComplete {
161+ t.Fatalf("Feed kind = %v, want %v", result.Kind, ResultComplete)
162+ }
163+ if result.Count != tt.count {
164+ t.Fatalf("Count = %d, want %d", result.Count, tt.count)
165+ }
166+ if result.Action != tt.action {
167+ t.Fatalf("Action = %q, want %q", result.Action, tt.action)
168+ }
169+ })
170+ }
171+}
172+
173+func TestMatcher_CountPendingString(t *testing.T) {
174+ m := NewMatcher(testTrie(t))
175+ m.Reset()
176+ result := m.Feed(Key{Sym: "3"})
177+ if result.Kind != ResultPending {
178+ t.Fatalf("Feed kind = %v, want %v", result.Kind, ResultPending)
179+ }
180+ if result.Pending != "3" {
181+ t.Fatalf("Pending = %q, want %q", result.Pending, "3")
182+ }
183+ if m.Pending() != "3" {
184+ t.Fatalf("Matcher.Pending() = %q, want %q", m.Pending(), "3")
185+ }
186+}
187+
188+func TestMatcher_CountCap9999(t *testing.T) {
189+ m := NewMatcher(testTrie(t))
190+ digits := make([]Key, 0, 5)
191+ for _, ch := range "10000" {
192+ digits = append(digits, Key{Sym: string(ch)})
193+ }
194+ digits = append(digits, Key{Sym: "j"})
195+
196+ m.Reset()
197+ var result Result
198+ for _, key := range digits {
199+ result = m.Feed(key)
200+ }
201+ if result.Kind != ResultComplete {
202+ t.Fatalf("Feed kind = %v, want %v", result.Kind, ResultComplete)
203+ }
204+ if result.Count != 9999 {
205+ t.Fatalf("Count = %d, want 9999", result.Count)
206+ }
207+}
208+
209+func TestMatcher_AutomaticReset(t *testing.T) {
210+ m := NewMatcher(testTrie(t))
211+
212+ m.Reset()
213+ complete := m.Feed(Key{Sym: "j"})
214+ if complete.Kind != ResultComplete {
215+ t.Fatalf("first Feed kind = %v, want %v", complete.Kind, ResultComplete)
216+ }
217+
218+ next := m.Feed(Key{Sym: "g"})
219+ if next.Kind != ResultPending {
220+ t.Fatalf("after complete Feed kind = %v, want %v", next.Kind, ResultPending)
221+ }
222+ if m.Pending() != "g" {
223+ t.Fatalf("Pending after reset = %q, want g", m.Pending())
224+ }
225+
226+ m.Reset()
227+ invalid := m.Feed(Key{Sym: "x"})
228+ if invalid.Kind != ResultInvalid {
229+ t.Fatalf("invalid Feed kind = %v, want %v", invalid.Kind, ResultInvalid)
230+ }
231+
232+ afterInvalid := m.Feed(Key{Sym: "j"})
233+ if afterInvalid.Kind != ResultComplete {
234+ t.Fatalf("after invalid Feed kind = %v, want %v", afterInvalid.Kind, ResultComplete)
235+ }
236+}
237+
238+func TestMatcher_AmbiguityClearsOnTransition(t *testing.T) {
239+ trie := NewTrie()
240+ for _, binding := range []struct {
241+ seq Sequence
242+ action string
243+ }{
244+ {seq: Sequence{{Sym: "]"}}, action: "single-bracket"},
245+ {seq: Sequence{{Sym: "]"}, {Sym: "]"}, {Sym: "x"}}, action: "triple-bracket"},
246+ } {
247+ if err := trie.Insert(binding.seq, binding.action); err != nil {
248+ t.Fatalf("Insert(%v) error = %v", binding.seq, err)
249+ }
250+ }
251+
252+ m := NewMatcher(trie)
253+ m.Reset()
254+
255+ result := m.Feed(Key{Sym: "]"})
256+ if result.Kind != ResultPending {
257+ t.Fatalf("after ] Feed kind = %v, want %v", result.Kind, ResultPending)
258+ }
259+ if !m.Ambiguous() {
260+ t.Fatal("Ambiguous() after ] = false, want true")
261+ }
262+
263+ result = m.Feed(Key{Sym: "]"})
264+ if result.Kind != ResultPending {
265+ t.Fatalf("after ]] Feed kind = %v, want %v", result.Kind, ResultPending)
266+ }
267+ if m.Ambiguous() {
268+ t.Fatal("Ambiguous() after ]] = true, want false")
269+ }
270+}
271+
272+func TestMatcher_AmbiguousShortLong(t *testing.T) {
273+ m := NewMatcher(testTrie(t))
274+
275+ m.Reset()
276+ result := m.Feed(Key{Sym: "]"})
277+ if result.Kind != ResultPending {
278+ t.Fatalf("Feed ] kind = %v, want %v", result.Kind, ResultPending)
279+ }
280+ if !m.Ambiguous() {
281+ t.Fatal("Ambiguous() = false, want true after ]")
282+ }
283+
284+ short, ok := m.ResolveAmbiguity()
285+ if !ok {
286+ t.Fatal("ResolveAmbiguity() ok = false, want true")
287+ }
288+ if short.Kind != ResultComplete {
289+ t.Fatalf("ResolveAmbiguity() kind = %v, want %v", short.Kind, ResultComplete)
290+ }
291+ if short.Action != "single-bracket" {
292+ t.Fatalf("short action = %q, want %q", short.Action, "single-bracket")
293+ }
294+
295+ m.Reset()
296+ _ = m.Feed(Key{Sym: "]"})
297+ finish := m.Feed(Key{Sym: "]"})
298+ if finish.Kind != ResultComplete {
299+ t.Fatalf("finish ]] kind = %v, want %v", finish.Kind, ResultComplete)
300+ }
301+ if finish.Action != "double-bracket" {
302+ t.Fatalf("long action = %q, want %q", finish.Action, "double-bracket")
303+ }
304+}
305+
306+func TestMatcher_Reset(t *testing.T) {
307+ m := NewMatcher(testTrie(t))
308+ _ = m.Feed(Key{Sym: "y"})
309+ _ = m.Feed(Key{Sym: "a"})
310+ m.Reset()
311+ if m.Pending() != "" {
312+ t.Fatalf("Pending after Reset = %q, want empty", m.Pending())
313+ }
314+ if m.Ambiguous() {
315+ t.Fatal("Ambiguous() after Reset = true, want false")
316+ }
317+}
318+
319+func TestMatcher_InvalidMidSequence(t *testing.T) {
320+ m := NewMatcher(testTrie(t))
321+ m.Reset()
322+ _ = m.Feed(Key{Sym: "y"})
323+ result := m.Feed(Key{Sym: "x"})
324+ if result.Kind != ResultInvalid {
325+ t.Fatalf("Feed kind = %v, want %v", result.Kind, ResultInvalid)
326+ }
327+}
328+
329+func TestMatcher_ResolveAmbiguityInvalid(t *testing.T) {
330+ m := NewMatcher(testTrie(t))
331+ m.Reset()
332+ _, ok := m.ResolveAmbiguity()
333+ if ok {
334+ t.Fatal("ResolveAmbiguity without prefix ok = true, want false")
335+ }
336+}
337+
338+func TestMatcher_NilTriePendingEmpty(t *testing.T) {
339+ m := NewMatcher(nil)
340+ if got := m.Pending(); got != "" {
341+ t.Fatalf("Pending() = %q, want empty", got)
342+ }
343+}
344+
345+func TestMatcher_NilTrieAmbiguousFalse(t *testing.T) {
346+ m := NewMatcher(nil)
347+ if m.Ambiguous() {
348+ t.Fatal("Ambiguous() = true, want false")
349+ }
350+}
351+
352+func TestMatcher_NilTrieFeedInvalid(t *testing.T) {
353+ m := NewMatcher(nil)
354+
355+ for _, key := range []Key{{Sym: "x"}, {Sym: "2"}} {
356+ result := m.Feed(key)
357+ if result.Kind != ResultInvalid {
358+ t.Fatalf("Feed(%v) kind = %v, want %v", key, result.Kind, ResultInvalid)
359+ }
360+ if result.Pending != "" {
361+ t.Fatalf("Feed(%v) pending = %q, want empty", key, result.Pending)
362+ }
363+ if got := m.Pending(); got != "" {
364+ t.Fatalf("after Feed(%v) Pending() = %q, want empty", key, got)
365+ }
366+ if m.Ambiguous() {
367+ t.Fatalf("after Feed(%v) Ambiguous() = true, want false", key)
368+ }
369+ }
370+}
371+
372+func TestMatcher_InvalidDeadEnd(t *testing.T) {
373+ trie := NewTrie()
374+ trie.root.children[Key{Sym: "x"}] = newTrieNode()
375+ m := NewMatcher(trie)
376+ result := m.Feed(Key{Sym: "x"})
377+ if result.Kind != ResultInvalid {
378+ t.Fatalf("Feed kind = %v, want %v", result.Kind, ResultInvalid)
379+ }
380+}
381+
382+func TestMatcher_CountExactCap(t *testing.T) {
383+ m := NewMatcher(testTrie(t))
384+ m.Reset()
385+ var result Result
386+ for _, ch := range "9999" {
387+ result = m.Feed(Key{Sym: string(ch)})
388+ }
389+ result = m.Feed(Key{Sym: "j"})
390+ if result.Count != 9999 {
391+ t.Fatalf("Count = %d, want 9999", result.Count)
392+ }
393+}
A internal/domain/vimkeys/parse.go +412 -0
@@ -0,0 +1,412 @@
1+package vimkeys
2+
3+import (
4+ "strings"
5+ "unicode"
6+ "unicode/utf8"
7+)
8+
9+func ParseBinding(s string) (Sequence, error) {
10+ s = strings.TrimSpace(s)
11+ if s == "" {
12+ return nil, ErrEmptyBinding
13+ }
14+
15+ if key, ok := tryParseLegacyAtom(s); ok {
16+ return Sequence{key}, nil
17+ }
18+
19+ if key, ok, err := tryParseAsLegacyChord(s); ok {
20+ if err != nil {
21+ return nil, err
22+ }
23+ return Sequence{key}, nil
24+ }
25+ return parseRawSequence(s)
26+}
27+
28+// tryParseLegacyAtom treats raw UI key names enter/escape (and aliases) as single
29+// keys so they remain compatible with the GTK shortcut table contract.
30+func tryParseLegacyAtom(s string) (Key, bool) {
31+ switch strings.ToLower(s) {
32+ case "enter", "return":
33+ return Key{Sym: symCR}, true
34+ case "escape", "esc":
35+ return Key{Sym: symEsc}, true
36+ default:
37+ return Key{}, false
38+ }
39+}
40+
41+func legacyChordParts(s string) []string {
42+ parts := strings.Split(s, "+")
43+ tokens := make([]string, 0, len(parts))
44+ for _, part := range parts {
45+ if part = strings.TrimSpace(part); part != "" {
46+ tokens = append(tokens, part)
47+ }
48+ }
49+ return tokens
50+}
51+
52+func isModifierName(part string) bool {
53+ switch strings.ToLower(part) {
54+ case "ctrl", "control", "shift", "alt":
55+ return true
56+ default:
57+ return false
58+ }
59+}
60+
61+func tryParseAsLegacyChord(s string) (Key, bool, error) {
62+ if s == "+" {
63+ return Key{Sym: "+"}, true, nil
64+ }
65+
66+ parts := legacyChordParts(s)
67+ if !legacyChordLooksLikeAttempt(s, parts) {
68+ return Key{}, false, nil
69+ }
70+
71+ key, err := buildLegacyChordKey(s, parts)
72+ return key, true, err
73+}
74+
75+func legacyChordLooksLikeAttempt(s string, parts []string) bool {
76+ if strings.HasSuffix(s, "++") {
77+ return true
78+ }
79+ for _, part := range parts {
80+ if isModifierName(part) {
81+ return true
82+ }
83+ }
84+ return false
85+}
86+
87+func validateLegacyChordLayout(s string, parts []string) error {
88+ if !strings.Contains(s, "+") {
89+ return ErrBadBinding
90+ }
91+ if strings.HasSuffix(s, "++") {
92+ return nil
93+ }
94+ for i, part := range parts {
95+ if isModifierName(part) {
96+ continue
97+ }
98+ if i != len(parts)-1 {
99+ return ErrBadBinding
100+ }
101+ }
102+ return nil
103+}
104+
105+func modsFromLegacyPart(part string) (Mods, bool) {
106+ switch strings.ToLower(part) {
107+ case "ctrl", "control":
108+ return ModCtrl, true
109+ case "shift":
110+ return ModShift, true
111+ case "alt":
112+ return ModAlt, true
113+ default:
114+ return 0, false
115+ }
116+}
117+
118+func collectLegacyChordModsAndKey(s string, parts []string) (Mods, string, error) {
119+ if err := validateLegacyChordLayout(s, parts); err != nil {
120+ return 0, "", err
121+ }
122+
123+ var mods Mods
124+ keyPart := ""
125+ for _, part := range parts {
126+ if mod, ok := modsFromLegacyPart(part); ok {
127+ mods |= mod
128+ continue
129+ }
130+ if keyPart != "" {
131+ return 0, "", ErrBadBinding
132+ }
133+ keyPart = part
134+ }
135+
136+ if keyPart == "" && strings.HasSuffix(s, "++") {
137+ keyPart = "+"
138+ }
139+ if keyPart == "" {
140+ return 0, "", ErrBadBinding
141+ }
142+ return mods, keyPart, nil
143+}
144+
145+func legacyKeyPartWithShift(keyPart string, mods Mods) (string, Mods) {
146+ if len(keyPart) == 1 && keyPart[0] >= 'A' && keyPart[0] <= 'Z' {
147+ return strings.ToLower(keyPart), mods | ModShift
148+ }
149+ return keyPart, mods
150+}
151+
152+func buildLegacyChordKey(s string, parts []string) (Key, error) {
153+ mods, keyPart, err := collectLegacyChordModsAndKey(s, parts)
154+ if err != nil {
155+ return Key{}, err
156+ }
157+ keyPart, mods = legacyKeyPartWithShift(keyPart, mods)
158+ sym, err := symFromToken(keyPart, false)
159+ if err != nil {
160+ return Key{}, err
161+ }
162+ return Key{Sym: sym, Mods: mods}, nil
163+}
164+
165+func parseAngleToken(inner string) (Key, error) {
166+ if !strings.Contains(inner, "-") {
167+ sym, err := symFromAlias(inner)
168+ if err != nil {
169+ return Key{}, err
170+ }
171+ return Key{Sym: sym}, nil
172+ }
173+
174+ mods, symPart, err := splitAngleModsAndSym(inner)
175+ if err != nil {
176+ return Key{}, err
177+ }
178+
179+ sym, err := symFromToken(symPart, true)
180+ if err != nil {
181+ return Key{}, err
182+ }
183+ return Key{Sym: sym, Mods: mods}, nil
184+}
185+
186+func splitAngleModsAndSym(inner string) (Mods, string, error) {
187+ var modInner, symPart string
188+ if strings.HasSuffix(inner, "--") {
189+ modInner = inner[:len(inner)-2]
190+ symPart = "-"
191+ } else {
192+ lastDash := strings.LastIndexByte(inner, '-')
193+ if lastDash < 0 {
194+ return 0, "", ErrBadBinding
195+ }
196+ modInner = inner[:lastDash]
197+ symPart = inner[lastDash+1:]
198+ }
199+ if symPart == "" || modInner == "" {
200+ return 0, "", ErrBadBinding
201+ }
202+
203+ var mods Mods
204+ for _, modPart := range strings.Split(modInner, "-") {
205+ modPart = strings.TrimSpace(modPart)
206+ if modPart == "" {
207+ return 0, "", ErrBadBinding
208+ }
209+ partMods, err := modsFromCompactModString(modPart)
210+ if err != nil {
211+ return 0, "", err
212+ }
213+ mods |= partMods
214+ }
215+ return mods, symPart, nil
216+}
217+
218+func modsFromCompactModString(modPart string) (Mods, error) {
219+ var mods Mods
220+ for _, ch := range modPart {
221+ switch ch {
222+ case 'C', 'c':
223+ mods |= ModCtrl
224+ case 'S', 's':
225+ mods |= ModShift
226+ case 'A', 'a':
227+ mods |= ModAlt
228+ default:
229+ return 0, ErrBadBinding
230+ }
231+ }
232+ return mods, nil
233+}
234+
235+func parseRawSequence(s string) (Sequence, error) {
236+ seq := make(Sequence, 0, len(s))
237+ for i := 0; i < len(s); {
238+ if s[i] == '<' {
239+ key, width, err := parseAngleAt(s, i)
240+ if err != nil {
241+ return nil, err
242+ }
243+ seq = append(seq, key)
244+ i += width
245+ continue
246+ }
247+ // Raw text is runes plus <...> tokens (and whole-string legacy atoms/chords
248+ // handled above). Bare C-/S-/A- must not steal printable runs like "C-d".
249+ r, width := utf8.DecodeRuneInString(s[i:])
250+ key, err := keyFromRune(r)
251+ if err != nil {
252+ return nil, err
253+ }
254+ seq = append(seq, key)
255+ i += width
256+ }
257+ return seq, nil
258+}
259+
260+func parseAngleAt(s string, i int) (Key, int, error) {
261+ if i >= len(s) || s[i] != '<' {
262+ return Key{}, 0, ErrBadBinding
263+ }
264+ closeIdx := strings.IndexByte(s[i+1:], '>')
265+ if closeIdx < 0 {
266+ return Key{}, 0, ErrBadBinding
267+ }
268+ closeIdx += i + 1
269+ inner := s[i+1 : closeIdx]
270+ if inner == "" {
271+ return Key{}, 0, ErrBadBinding
272+ }
273+ key, err := parseAngleToken(inner)
274+ if err != nil {
275+ return Key{}, 0, err
276+ }
277+ return key, closeIdx - i + 1, nil
278+}
279+
280+func keyFromRune(r rune) (Key, error) {
281+ if r >= 'A' && r <= 'Z' {
282+ return Key{Sym: strings.ToLower(string(r)), Mods: ModShift}, nil
283+ }
284+ if shiftedGlyph, ok := shiftedGlyphForRune(r); ok {
285+ return Key{Sym: shiftedGlyph}, nil
286+ }
287+ if r >= 'a' && r <= 'z' {
288+ return Key{Sym: string(r)}, nil
289+ }
290+ if unicode.IsPrint(r) {
291+ return Key{Sym: string(r)}, nil
292+ }
293+ return Key{}, ErrBadBinding
294+}
295+
296+func shiftedGlyphForRune(r rune) (string, bool) {
297+ switch r {
298+ case '!', '@', '#', '$', '%', '^', '&', '*', '(', ')', '_', '+', '{', '}', '|', ':', '"', '~', '<', '>', '?':
299+ return string(r), true
300+ default:
301+ return "", false
302+ }
303+}
304+
305+func symFromSpecialAlias(token string) (string, bool) {
306+ lower := strings.ToLower(token)
307+ switch lower {
308+ case "cr", "return":
309+ return symCR, true
310+ case "esc", "escape":
311+ return symEsc, true
312+ case "space":
313+ return "Space", true
314+ case "lt":
315+ return "<", true
316+ case "gt":
317+ return ">", true
318+ case "plus":
319+ return "+", true
320+ default:
321+ return namedSymFromAlias(lower)
322+ }
323+}
324+
325+func namedSymFromAlias(lower string) (string, bool) {
326+ switch lower {
327+ case "tab":
328+ return "Tab", true
329+ case "backspace":
330+ return "BackSpace", true
331+ case "delete":
332+ return "Delete", true
333+ case "left":
334+ return "Left", true
335+ case "right":
336+ return "Right", true
337+ case "up":
338+ return "Up", true
339+ case "down":
340+ return "Down", true
341+ case "home":
342+ return "Home", true
343+ case "end":
344+ return "End", true
345+ case "pageup":
346+ return "PageUp", true
347+ case "pagedown":
348+ return "PageDown", true
349+ default:
350+ return "", false
351+ }
352+}
353+
354+func symFromToken(token string, allowSingleLetter bool) (string, error) {
355+ if sym, ok := symFromSpecialAlias(token); ok {
356+ return sym, nil
357+ }
358+ if allowSingleLetter {
359+ if sym, ok := asciiLetterSym(token); ok {
360+ return sym, nil
361+ }
362+ }
363+ if sym, ok := singlePrintableRuneSym(token); ok {
364+ return sym, nil
365+ }
366+ return "", ErrBadBinding
367+}
368+
369+func symFromAlias(token string) (string, error) {
370+ if sym, ok := symFromSpecialAlias(token); ok {
371+ return sym, nil
372+ }
373+ // One-character / one-rune literal angle tokens (<e>, <é>) disambiguate
374+ // legacy-atom collisions. Multi-rune unknowns such as <Nope> remain invalid.
375+ if sym, ok := asciiLetterSym(token); ok {
376+ return sym, nil
377+ }
378+ if sym, ok := singlePrintableRuneSym(token); ok {
379+ return sym, nil
380+ }
381+ return "", ErrBadBinding
382+}
383+
384+// asciiLetterSym normalizes a single ASCII letter token (A-Z -> a-z).
385+func asciiLetterSym(token string) (string, bool) {
386+ if len(token) != 1 {
387+ return "", false
388+ }
389+ ch := token[0]
390+ if ch >= 'A' && ch <= 'Z' {
391+ return strings.ToLower(token), true
392+ }
393+ if ch >= 'a' && ch <= 'z' {
394+ return token, true
395+ }
396+ return "", false
397+}
398+
399+// singlePrintableRuneSym accepts exactly one valid UTF-8 printable rune.
400+func singlePrintableRuneSym(token string) (string, bool) {
401+ if token == "" || !utf8.ValidString(token) {
402+ return "", false
403+ }
404+ r, size := utf8.DecodeRuneInString(token)
405+ if size != len(token) {
406+ return "", false
407+ }
408+ if !unicode.IsPrint(r) {
409+ return "", false
410+ }
411+ return token, true
412+}
A internal/domain/vimkeys/parse_test.go +1243 -0
@@ -0,0 +1,1243 @@
1+package vimkeys
2+
3+import (
4+ "errors"
5+ "strings"
6+ "testing"
7+)
8+
9+func TestParseBinding_SingleKeys(t *testing.T) {
10+ tests := []struct {
11+ input string
12+ want Sequence
13+ }{
14+ {input: "j", want: Sequence{{Sym: "j"}}},
15+ {input: "shift+j", want: Sequence{{Sym: "j", Mods: ModShift}}},
16+ {input: "ctrl++", want: Sequence{{Sym: "+", Mods: ModCtrl}}},
17+ {input: "ctrl+-", want: Sequence{{Sym: "-", Mods: ModCtrl}}},
18+ {input: "<C-d>", want: Sequence{{Sym: "d", Mods: ModCtrl}}},
19+ {input: "<CR>", want: Sequence{{Sym: "CR"}}},
20+ {input: "<Return>", want: Sequence{{Sym: "CR"}}},
21+ {input: "<Esc>", want: Sequence{{Sym: "Esc"}}},
22+ {input: "<Escape>", want: Sequence{{Sym: "Esc"}}},
23+ {input: "<Space>", want: Sequence{{Sym: "Space"}}},
24+ {input: "<Tab>", want: Sequence{{Sym: "Tab"}}},
25+ {input: "<BackSpace>", want: Sequence{{Sym: "BackSpace"}}},
26+ {input: "<Delete>", want: Sequence{{Sym: "Delete"}}},
27+ {input: "<Left>", want: Sequence{{Sym: "Left"}}},
28+ {input: "<Right>", want: Sequence{{Sym: "Right"}}},
29+ {input: "<Up>", want: Sequence{{Sym: "Up"}}},
30+ {input: "<Down>", want: Sequence{{Sym: "Down"}}},
31+ {input: "<Home>", want: Sequence{{Sym: "Home"}}},
32+ {input: "<End>", want: Sequence{{Sym: "End"}}},
33+ {input: "<PageUp>", want: Sequence{{Sym: "PageUp"}}},
34+ {input: "<PageDown>", want: Sequence{{Sym: "PageDown"}}},
35+ {input: "<C-Tab>", want: Sequence{{Sym: "Tab", Mods: ModCtrl}}},
36+ {input: "<S-BackSpace>", want: Sequence{{Sym: "BackSpace", Mods: ModShift}}},
37+ {input: "<A-Delete>", want: Sequence{{Sym: "Delete", Mods: ModAlt}}},
38+ {input: "<C-Left>", want: Sequence{{Sym: "Left", Mods: ModCtrl}}},
39+ {input: "<lt>", want: Sequence{{Sym: "<"}}},
40+ {input: "<gt>", want: Sequence{{Sym: ">"}}},
41+ {input: "<C-gt>", want: Sequence{{Sym: ">", Mods: ModCtrl}}},
42+ {input: "<Plus>", want: Sequence{{Sym: "+"}}},
43+ {input: "enter", want: Sequence{{Sym: "CR"}}},
44+ {input: "escape", want: Sequence{{Sym: "Esc"}}},
45+ {input: "Enter", want: Sequence{{Sym: "CR"}}},
46+ {input: "Escape", want: Sequence{{Sym: "Esc"}}},
47+ }
48+ for _, tt := range tests {
49+ t.Run(tt.input, func(t *testing.T) {
50+ got, err := ParseBinding(tt.input)
51+ if err != nil {
52+ t.Fatalf("ParseBinding(%q) error = %v", tt.input, err)
53+ }
54+ if !got.Equal(tt.want) {
55+ t.Fatalf("ParseBinding(%q) = %v, want %v", tt.input, got, tt.want)
56+ }
57+ })
58+ }
59+}
60+
61+func TestParseBinding_Sequences(t *testing.T) {
62+ tests := []struct {
63+ input string
64+ want Sequence
65+ }{
66+ {input: "]]", want: Sequence{{Sym: "]"}, {Sym: "]"}}},
67+ {input: "yah", want: Sequence{{Sym: "y"}, {Sym: "a"}, {Sym: "h"}}},
68+ {input: "gO", want: Sequence{{Sym: "g"}, {Sym: "o", Mods: ModShift}}},
69+ }
70+ for _, tt := range tests {
71+ t.Run(tt.input, func(t *testing.T) {
72+ got, err := ParseBinding(tt.input)
73+ if err != nil {
74+ t.Fatalf("ParseBinding(%q) error = %v", tt.input, err)
75+ }
76+ if !got.Equal(tt.want) {
77+ t.Fatalf("ParseBinding(%q) = %v, want %v", tt.input, got, tt.want)
78+ }
79+ })
80+ }
81+}
82+
83+func TestParseBinding_Malformed(t *testing.T) {
84+ tests := []struct {
85+ input string
86+ want error
87+ }{
88+ {input: "", want: ErrEmptyBinding},
89+ {input: " ", want: ErrEmptyBinding},
90+ {input: "<", want: ErrBadBinding},
91+ {input: "<>", want: ErrBadBinding},
92+ {input: "<Bad>", want: ErrBadBinding},
93+ {input: "shift+", want: ErrBadBinding},
94+ {input: "ctrl+", want: ErrBadBinding},
95+ }
96+ for _, tt := range tests {
97+ t.Run(tt.input, func(t *testing.T) {
98+ _, err := ParseBinding(tt.input)
99+ if err == nil {
100+ t.Fatalf("ParseBinding(%q) expected error", tt.input)
101+ }
102+ if !errors.Is(err, tt.want) {
103+ t.Fatalf("ParseBinding(%q) error = %v, want %v", tt.input, err, tt.want)
104+ }
105+ })
106+ }
107+}
108+
109+func TestParseBinding_CanonicalString(t *testing.T) {
110+ tests := []struct {
111+ input string
112+ want string
113+ }{
114+ {input: "j", want: "j"},
115+ {input: "shift+j", want: "J"},
116+ {input: "ctrl++", want: "<C-+>"},
117+ {input: "ctrl+-", want: "<C-->"},
118+ {input: "<C-->", want: "<C-->"},
119+ {input: "<C-d>", want: "<C-d>"},
120+ {input: "<Plus>", want: "<Plus>"},
121+ {input: "+", want: "<Plus>"},
122+ {input: "<CR>", want: "<Return>"},
123+ {input: "<Return>", want: "<Return>"},
124+ {input: "<Esc>", want: "<Escape>"},
125+ {input: "<Escape>", want: "<Escape>"},
126+ {input: "<Space>", want: "<Space>"},
127+ {input: "<Tab>", want: "<Tab>"},
128+ {input: "<BackSpace>", want: "<BackSpace>"},
129+ {input: "<Delete>", want: "<Delete>"},
130+ {input: "<Left>", want: "<Left>"},
131+ {input: "<Right>", want: "<Right>"},
132+ {input: "<Up>", want: "<Up>"},
133+ {input: "<Down>", want: "<Down>"},
134+ {input: "<Home>", want: "<Home>"},
135+ {input: "<End>", want: "<End>"},
136+ {input: "<PageUp>", want: "<PageUp>"},
137+ {input: "<PageDown>", want: "<PageDown>"},
138+ {input: "<C-Tab>", want: "<C-Tab>"},
139+ {input: "<S-BackSpace>", want: "<S-BackSpace>"},
140+ {input: "<A-Delete>", want: "<A-Delete>"},
141+ {input: "<C-Home>", want: "<C-Home>"},
142+ {input: "<A-PageDown>", want: "<A-PageDown>"},
143+ {input: "<lt>", want: "<lt>"},
144+ {input: "<gt>", want: "<gt>"},
145+ {input: ">", want: "<gt>"},
146+ {input: "ctrl+>", want: "<C-gt>"},
147+ {input: "<C-gt>", want: "<C-gt>"},
148+ {input: "gO", want: "gO"},
149+ }
150+ for _, tt := range tests {
151+ t.Run(tt.input, func(t *testing.T) {
152+ seq, err := ParseBinding(tt.input)
153+ if err != nil {
154+ t.Fatalf("ParseBinding(%q) error = %v", tt.input, err)
155+ }
156+ if got := seq.String(); got != tt.want {
157+ t.Fatalf("ParseBinding(%q).String() = %q, want %q", tt.input, got, tt.want)
158+ }
159+ })
160+ }
161+}
162+
163+func TestParseBinding_LegacyChords(t *testing.T) {
164+ tests := []struct {
165+ input string
166+ want Sequence
167+ }{
168+ {input: "ctrl+d", want: Sequence{{Sym: "d", Mods: ModCtrl}}},
169+ {input: "control+j", want: Sequence{{Sym: "j", Mods: ModCtrl}}},
170+ {input: "alt+k", want: Sequence{{Sym: "k", Mods: ModAlt}}},
171+ {input: "ctrl+J", want: Sequence{{Sym: "j", Mods: ModCtrl | ModShift}}},
172+ }
173+ for _, tt := range tests {
174+ t.Run(tt.input, func(t *testing.T) {
175+ got, err := ParseBinding(tt.input)
176+ if err != nil {
177+ t.Fatalf("ParseBinding(%q) error = %v", tt.input, err)
178+ }
179+ if !got.Equal(tt.want) {
180+ t.Fatalf("ParseBinding(%q) = %v, want %v", tt.input, got, tt.want)
181+ }
182+ })
183+ }
184+}
185+
186+func TestParseBinding_MoreMalformed(t *testing.T) {
187+ tests := []string{
188+ "ctrl+shift+j+k",
189+ "<C->",
190+ "<C--d>",
191+ "<Bad>",
192+ "ctrl+",
193+ }
194+ for _, input := range tests {
195+ t.Run(input, func(t *testing.T) {
196+ _, err := ParseBinding(input)
197+ if err == nil {
198+ t.Fatalf("ParseBinding(%q) expected error", input)
199+ }
200+ if !errors.Is(err, ErrBadBinding) {
201+ t.Fatalf("ParseBinding(%q) error = %v, want %v", input, err, ErrBadBinding)
202+ }
203+ })
204+ }
205+}
206+
207+func TestParseBinding_PlusAlone(t *testing.T) {
208+ got, err := ParseBinding("+")
209+ if err != nil {
210+ t.Fatalf("ParseBinding error = %v", err)
211+ }
212+ want := Sequence{{Sym: "+"}}
213+ if !got.Equal(want) {
214+ t.Fatalf("ParseBinding = %v, want %v", got, want)
215+ }
216+}
217+
218+func TestParseBinding_LooksLikeChordButRaw(t *testing.T) {
219+ got, err := ParseBinding("]c")
220+ if err != nil {
221+ t.Fatalf("ParseBinding error = %v", err)
222+ }
223+ want := Sequence{{Sym: "]"}, {Sym: "c"}}
224+ if !got.Equal(want) {
225+ t.Fatalf("ParseBinding = %v, want %v", got, want)
226+ }
227+}
228+
229+func TestParseBinding_BareModifierLikeIsLiteral(t *testing.T) {
230+ // Modifiers are canonical only inside <...>; bare "C-d" is Shift+c, '-', d.
231+ tests := []struct {
232+ input string
233+ want Sequence
234+ }{
235+ {
236+ input: "C-d",
237+ want: Sequence{
238+ {Sym: "c", Mods: ModShift},
239+ {Sym: "-"},
240+ {Sym: "d"},
241+ },
242+ },
243+ {
244+ input: "S-j",
245+ want: Sequence{
246+ {Sym: "s", Mods: ModShift},
247+ {Sym: "-"},
248+ {Sym: "j"},
249+ },
250+ },
251+ {
252+ input: "A-k",
253+ want: Sequence{
254+ {Sym: "a", Mods: ModShift},
255+ {Sym: "-"},
256+ {Sym: "k"},
257+ },
258+ },
259+ }
260+ for _, tt := range tests {
261+ t.Run(tt.input, func(t *testing.T) {
262+ got, err := ParseBinding(tt.input)
263+ if err != nil {
264+ t.Fatalf("ParseBinding(%q) error = %v", tt.input, err)
265+ }
266+ if !got.Equal(tt.want) {
267+ t.Fatalf("ParseBinding(%q) = %v, want %v", tt.input, got, tt.want)
268+ }
269+ if got := got.String(); got != tt.input {
270+ t.Fatalf("ParseBinding(%q).String() = %q, want %q", tt.input, got, tt.input)
271+ }
272+ })
273+ }
274+}
275+
276+func TestParseBinding_CtrlGtRoundTrip(t *testing.T) {
277+ tests := []struct {
278+ input string
279+ want Sequence
280+ wantString string
281+ }{
282+ {
283+ input: "ctrl+>",
284+ want: Sequence{{Sym: ">", Mods: ModCtrl}},
285+ wantString: "<C-gt>",
286+ },
287+ {
288+ input: "<C-gt>",
289+ want: Sequence{{Sym: ">", Mods: ModCtrl}},
290+ wantString: "<C-gt>",
291+ },
292+ {
293+ input: "<S-gt>",
294+ want: Sequence{{Sym: ">", Mods: ModShift}},
295+ wantString: "<S-gt>",
296+ },
297+ {
298+ input: "<A-gt>",
299+ want: Sequence{{Sym: ">", Mods: ModAlt}},
300+ wantString: "<A-gt>",
301+ },
302+ {
303+ input: ">",
304+ want: Sequence{{Sym: ">"}},
305+ wantString: "<gt>",
306+ },
307+ {
308+ input: "<gt>",
309+ want: Sequence{{Sym: ">"}},
310+ wantString: "<gt>",
311+ },
312+ }
313+ for _, tt := range tests {
314+ t.Run(tt.input, func(t *testing.T) {
315+ first, err := ParseBinding(tt.input)
316+ if err != nil {
317+ t.Fatalf("ParseBinding(%q) error = %v", tt.input, err)
318+ }
319+ if !first.Equal(tt.want) {
320+ t.Fatalf("ParseBinding(%q) = %v, want %v", tt.input, first, tt.want)
321+ }
322+ if got := first.String(); got != tt.wantString {
323+ t.Fatalf("ParseBinding(%q).String() = %q, want %q", tt.input, got, tt.wantString)
324+ }
325+ second, err := ParseBinding(first.String())
326+ if err != nil {
327+ t.Fatalf("ParseBinding(%q) from canonical %q error = %v", tt.input, first.String(), err)
328+ }
329+ if !first.Equal(second) {
330+ t.Fatalf("round trip %q -> %q -> %v, want %v", tt.input, first.String(), second, first)
331+ }
332+ })
333+ }
334+}
335+
336+func TestParseBinding_ShiftCHyphenDRoundTrip(t *testing.T) {
337+ input := "<S-c>-d"
338+ want := Sequence{
339+ {Sym: "c", Mods: ModShift},
340+ {Sym: "-"},
341+ {Sym: "d"},
342+ }
343+ first, err := ParseBinding(input)
344+ if err != nil {
345+ t.Fatalf("ParseBinding(%q) error = %v", input, err)
346+ }
347+ if !first.Equal(want) {
348+ t.Fatalf("ParseBinding(%q) = %v, want %v", input, first, want)
349+ }
350+ canonical := first.String()
351+ if canonical != "C-d" {
352+ t.Fatalf("String() = %q, want %q", canonical, "C-d")
353+ }
354+ second, err := ParseBinding(canonical)
355+ if err != nil {
356+ t.Fatalf("ParseBinding(%q) error = %v", canonical, err)
357+ }
358+ if !first.Equal(second) {
359+ t.Fatalf("round trip %q -> %q -> %v, want %v", input, canonical, second, first)
360+ }
361+}
362+
363+func TestParseBinding_SpecialNamesInSequence(t *testing.T) {
364+ tests := []struct {
365+ input string
366+ want Sequence
367+ }{
368+ {input: "j<Esc>", want: Sequence{{Sym: "j"}, {Sym: "Esc"}}},
369+ {input: "<Space>j", want: Sequence{{Sym: "Space"}, {Sym: "j"}}},
370+ {input: "<CR>j", want: Sequence{{Sym: "CR"}, {Sym: "j"}}},
371+ {input: "<lt>j", want: Sequence{{Sym: "<"}, {Sym: "j"}}},
372+ {input: "<gt>j", want: Sequence{{Sym: ">"}, {Sym: "j"}}},
373+ {input: "<Plus>j", want: Sequence{{Sym: "+"}, {Sym: "j"}}},
374+ {input: "j<Tab>", want: Sequence{{Sym: "j"}, {Sym: "Tab"}}},
375+ {input: "<BackSpace>j", want: Sequence{{Sym: "BackSpace"}, {Sym: "j"}}},
376+ }
377+ for _, tt := range tests {
378+ t.Run(tt.input, func(t *testing.T) {
379+ got, err := ParseBinding(tt.input)
380+ if err != nil {
381+ t.Fatalf("ParseBinding(%q) error = %v", tt.input, err)
382+ }
383+ if !got.Equal(tt.want) {
384+ t.Fatalf("ParseBinding(%q) = %v, want %v", tt.input, got, tt.want)
385+ }
386+ })
387+ }
388+}
389+
390+func TestParseBinding_BareNamedPrefixIsLiteral(t *testing.T) {
391+ // Named keys are canonical only in <...>; bare "Spacej" is Shift+s + p + a + c + e + j.
392+ seq := Sequence{
393+ {Sym: "s", Mods: ModShift},
394+ {Sym: "p"},
395+ {Sym: "a"},
396+ {Sym: "c"},
397+ {Sym: "e"},
398+ {Sym: "j"},
399+ }
400+ if got := seq.String(); got != "Spacej" {
401+ t.Fatalf("seq.String() = %q, want %q", got, "Spacej")
402+ }
403+ got, err := ParseBinding("Spacej")
404+ if err != nil {
405+ t.Fatalf("ParseBinding(%q) error = %v", "Spacej", err)
406+ }
407+ if !got.Equal(seq) {
408+ t.Fatalf("ParseBinding(%q) = %v, want %v", "Spacej", got, seq)
409+ }
410+}
411+
412+func TestParseBinding_PrintableGlyphs(t *testing.T) {
413+ got, err := ParseBinding("!@#")
414+ if err != nil {
415+ t.Fatalf("ParseBinding error = %v", err)
416+ }
417+ want := Sequence{{Sym: "!"}, {Sym: "@"}, {Sym: "#"}}
418+ if !got.Equal(want) {
419+ t.Fatalf("ParseBinding = %v, want %v", got, want)
420+ }
421+}
422+
423+func TestParseBinding_MixedAngleSequences(t *testing.T) {
424+ tests := []struct {
425+ input string
426+ want Sequence
427+ }{
428+ {
429+ input: "<C-d>j",
430+ want: Sequence{{Sym: "d", Mods: ModCtrl}, {Sym: "j"}},
431+ },
432+ {
433+ input: "<Escape>j",
434+ want: Sequence{{Sym: "Esc"}, {Sym: "j"}},
435+ },
436+ }
437+ for _, tt := range tests {
438+ t.Run(tt.input, func(t *testing.T) {
439+ got, err := ParseBinding(tt.input)
440+ if err != nil {
441+ t.Fatalf("ParseBinding(%q) error = %v", tt.input, err)
442+ }
443+ if !got.Equal(tt.want) {
444+ t.Fatalf("ParseBinding(%q) = %v, want %v", tt.input, got, tt.want)
445+ }
446+ })
447+ }
448+}
449+
450+func TestParseBinding_CtrlHyphenRoundTrip(t *testing.T) {
451+ tests := []struct {
452+ input string
453+ want Sequence
454+ wantString string
455+ }{
456+ {
457+ input: "ctrl+-",
458+ want: Sequence{{Sym: "-", Mods: ModCtrl}},
459+ wantString: "<C-->",
460+ },
461+ {
462+ input: "<C-->",
463+ want: Sequence{{Sym: "-", Mods: ModCtrl}},
464+ wantString: "<C-->",
465+ },
466+ {
467+ input: "<C-S-->",
468+ want: Sequence{{Sym: "-", Mods: ModCtrl | ModShift}},
469+ wantString: "<C-S-->",
470+ },
471+ {
472+ input: "<C-A-->",
473+ want: Sequence{{Sym: "-", Mods: ModCtrl | ModAlt}},
474+ wantString: "<C-A-->",
475+ },
476+ {
477+ input: "<C-S-A-->",
478+ want: Sequence{{Sym: "-", Mods: ModCtrl | ModShift | ModAlt}},
479+ wantString: "<C-S-A-->",
480+ },
481+ }
482+ for _, tt := range tests {
483+ t.Run(tt.input, func(t *testing.T) {
484+ first, err := ParseBinding(tt.input)
485+ if err != nil {
486+ t.Fatalf("ParseBinding(%q) error = %v", tt.input, err)
487+ }
488+ if !first.Equal(tt.want) {
489+ t.Fatalf("ParseBinding(%q) = %v, want %v", tt.input, first, tt.want)
490+ }
491+ if got := first.String(); got != tt.wantString {
492+ t.Fatalf("ParseBinding(%q).String() = %q, want %q", tt.input, got, tt.wantString)
493+ }
494+ second, err := ParseBinding(first.String())
495+ if err != nil {
496+ t.Fatalf("ParseBinding(%q) from canonical %q error = %v", tt.input, first.String(), err)
497+ }
498+ if !first.Equal(second) {
499+ t.Fatalf("round trip %q -> %q -> %v, want %v", tt.input, first.String(), second, first)
500+ }
501+ })
502+ }
503+}
504+
505+func TestParseBinding_CtrlPlusRoundTrip(t *testing.T) {
506+ tests := []struct {
507+ input string
508+ want Sequence
509+ wantString string
510+ }{
511+ {
512+ input: "ctrl++",
513+ want: Sequence{{Sym: "+", Mods: ModCtrl}},
514+ wantString: "<C-+>",
515+ },
516+ {
517+ input: "<C-+>",
518+ want: Sequence{{Sym: "+", Mods: ModCtrl}},
519+ wantString: "<C-+>",
520+ },
521+ {
522+ input: "<C-S-+>",
523+ want: Sequence{{Sym: "+", Mods: ModCtrl | ModShift}},
524+ wantString: "<C-S-+>",
525+ },
526+ {
527+ input: "<C-A-+>",
528+ want: Sequence{{Sym: "+", Mods: ModCtrl | ModAlt}},
529+ wantString: "<C-A-+>",
530+ },
531+ {
532+ input: "<C-S-A-+>",
533+ want: Sequence{{Sym: "+", Mods: ModCtrl | ModShift | ModAlt}},
534+ wantString: "<C-S-A-+>",
535+ },
536+ }
537+ for _, tt := range tests {
538+ t.Run(tt.input, func(t *testing.T) {
539+ first, err := ParseBinding(tt.input)
540+ if err != nil {
541+ t.Fatalf("ParseBinding(%q) error = %v", tt.input, err)
542+ }
543+ if !first.Equal(tt.want) {
544+ t.Fatalf("ParseBinding(%q) = %v, want %v", tt.input, first, tt.want)
545+ }
546+ if got := first.String(); got != tt.wantString {
547+ t.Fatalf("ParseBinding(%q).String() = %q, want %q", tt.input, got, tt.wantString)
548+ }
549+ second, err := ParseBinding(first.String())
550+ if err != nil {
551+ t.Fatalf("ParseBinding(%q) from canonical %q error = %v", tt.input, first.String(), err)
552+ }
553+ if !first.Equal(second) {
554+ t.Fatalf("round trip %q -> %q -> %v, want %v", tt.input, first.String(), second, first)
555+ }
556+ })
557+ }
558+}
559+
560+func TestParseBinding_MultiModifierRoundTrip(t *testing.T) {
561+ input := "<C-S-a>"
562+ first, err := ParseBinding(input)
563+ if err != nil {
564+ t.Fatalf("ParseBinding(%q) error = %v", input, err)
565+ }
566+ want := Sequence{{Sym: "a", Mods: ModCtrl | ModShift}}
567+ if !first.Equal(want) {
568+ t.Fatalf("ParseBinding(%q) = %v, want %v", input, first, want)
569+ }
570+
571+ canonical := first.String()
572+ if canonical != "<C-S-a>" {
573+ t.Fatalf("String() = %q, want %q", canonical, "<C-S-a>")
574+ }
575+
576+ second, err := ParseBinding(canonical)
577+ if err != nil {
578+ t.Fatalf("ParseBinding(%q) error = %v", canonical, err)
579+ }
580+ if !first.Equal(second) {
581+ t.Fatalf("round trip %q -> %q -> %v, want %v", input, canonical, second, first)
582+ }
583+}
584+
585+func TestParseBinding_RoundTrip(t *testing.T) {
586+ inputs := []string{
587+ "j",
588+ "shift+j",
589+ "ctrl++",
590+ "ctrl+-",
591+ "<C-->",
592+ "<C-S-->",
593+ "<C-A-->",
594+ "]]",
595+ "yah",
596+ "gO",
597+ "<C-d>",
598+ "<CR>",
599+ "<Esc>",
600+ "<Space>",
601+ "<Tab>",
602+ "<BackSpace>",
603+ "<Delete>",
604+ "<Left>",
605+ "<Right>",
606+ "<Up>",
607+ "<Down>",
608+ "<Home>",
609+ "<End>",
610+ "<PageUp>",
611+ "<PageDown>",
612+ "<C-Tab>",
613+ "<S-BackSpace>",
614+ "<A-Delete>",
615+ "<C-Left>",
616+ "<S-Home>",
617+ "<A-PageUp>",
618+ "<lt>",
619+ "<gt>",
620+ ">",
621+ "ctrl+>",
622+ "<C-gt>",
623+ "<C-d>j",
624+ "<Escape>j",
625+ "<C-S-a>",
626+ "<S-c>-d",
627+ "C-d",
628+ "S-j",
629+ "A-k",
630+ "Spacej",
631+ "Escj",
632+ "Tabx",
633+ "enter",
634+ "escape",
635+ "return",
636+ "esc",
637+ }
638+ for _, input := range inputs {
639+ t.Run(input, func(t *testing.T) {
640+ first, err := ParseBinding(input)
641+ if err != nil {
642+ t.Fatalf("ParseBinding(%q) error = %v", input, err)
643+ }
644+ canonical := first.String()
645+ second, err := ParseBinding(canonical)
646+ if err != nil {
647+ t.Fatalf("ParseBinding(%q) from canonical %q error = %v", input, canonical, err)
648+ }
649+ if !first.Equal(second) {
650+ t.Fatalf("round trip %q -> %q -> %v, want %v", input, canonical, second, first)
651+ }
652+ })
653+ }
654+}
655+
656+// TestParseBinding_DeterministicRoundTripProperty asserts ParseBinding(seq.String()) == seq
657+// over a fixed representative set of printable, named, and modifier sequences.
658+// The corpus is deterministic (no flaky randomness).
659+func TestParseBinding_DeterministicRoundTripProperty(t *testing.T) {
660+ corpus := []Sequence{
661+ {{Sym: "j"}},
662+ {{Sym: "j", Mods: ModShift}},
663+ {{Sym: "]"}, {Sym: "]"}},
664+ {{Sym: "y"}, {Sym: "a"}, {Sym: "h"}},
665+ {{Sym: "g"}, {Sym: "o", Mods: ModShift}},
666+ {{Sym: "d", Mods: ModCtrl}},
667+ {{Sym: "a", Mods: ModCtrl | ModShift}},
668+ {{Sym: "k", Mods: ModAlt}},
669+ {{Sym: "CR"}},
670+ {{Sym: "Esc"}},
671+ {{Sym: "Space"}},
672+ {{Sym: "Tab"}},
673+ {{Sym: "BackSpace"}},
674+ {{Sym: "Delete"}},
675+ {{Sym: "Left"}},
676+ {{Sym: "Right"}},
677+ {{Sym: "Up"}},
678+ {{Sym: "Down"}},
679+ {{Sym: "Home"}},
680+ {{Sym: "End"}},
681+ {{Sym: "PageUp"}},
682+ {{Sym: "PageDown"}},
683+ {{Sym: "Tab", Mods: ModCtrl}},
684+ {{Sym: "BackSpace", Mods: ModShift}},
685+ {{Sym: "Delete", Mods: ModAlt}},
686+ {{Sym: "<"}},
687+ {{Sym: ">"}},
688+ {{Sym: ">", Mods: ModCtrl}},
689+ {{Sym: "+"}},
690+ {{Sym: "+", Mods: ModCtrl}},
691+ {{Sym: "+", Mods: ModCtrl | ModShift}},
692+ {{Sym: "-", Mods: ModCtrl}},
693+ {{Sym: "-", Mods: ModCtrl | ModShift}},
694+ {{Sym: "-", Mods: ModCtrl | ModAlt}},
695+ {{Sym: "!"}},
696+ {{Sym: "@"}},
697+ {{Sym: "#"}},
698+ {{Sym: "Space"}, {Sym: "j"}},
699+ {{Sym: "d", Mods: ModCtrl}, {Sym: "j"}},
700+ {{Sym: "Esc"}, {Sym: "j"}},
701+ // Literal printable run that formerly collided with greedy "Space" recognition.
702+ {
703+ {Sym: "s", Mods: ModShift},
704+ {Sym: "p"},
705+ {Sym: "a"},
706+ {Sym: "c"},
707+ {Sym: "e"},
708+ {Sym: "j"},
709+ },
710+ {
711+ {Sym: "e", Mods: ModShift},
712+ {Sym: "s"},
713+ {Sym: "c"},
714+ {Sym: "j"},
715+ },
716+ {
717+ {Sym: "t", Mods: ModShift},
718+ {Sym: "a"},
719+ {Sym: "b"},
720+ {Sym: "x"},
721+ },
722+ // Bare modifier-like text must stay literal (no C-/S-/A- stealing).
723+ {
724+ {Sym: "c", Mods: ModShift},
725+ {Sym: "-"},
726+ {Sym: "d"},
727+ },
728+ // Representative multi-byte printable runes (accented BMP + emoji).
729+ {{Sym: "é"}},
730+ {{Sym: "é", Mods: ModCtrl}},
731+ {{Sym: "ñ", Mods: ModAlt}},
732+ {{Sym: "😀"}},
733+ {{Sym: "😀", Mods: ModCtrl}},
734+ }
735+ for _, seq := range corpus {
736+ canonical := seq.String()
737+ t.Run(canonical, func(t *testing.T) {
738+ got, err := ParseBinding(canonical)
739+ if err != nil {
740+ t.Fatalf("ParseBinding(%q) error = %v", canonical, err)
741+ }
742+ if !got.Equal(seq) {
743+ t.Fatalf("ParseBinding(%q) = %v, want %v", canonical, got, seq)
744+ }
745+ })
746+ }
747+}
748+
749+// TestParseBinding_LegacyAtomCollisionRoundTrip ensures rune sequences whose
750+// naive Key.String concatenation would equal a legacy atom are emitted with
751+// enough angle-literal tokens to round-trip without becoming CR/Esc.
752+func TestParseBinding_LegacyAtomCollisionRoundTrip(t *testing.T) {
753+ tests := []struct {
754+ name string
755+ seq Sequence
756+ wantString string
757+ }{
758+ {
759+ name: "Shift+e n t e r",
760+ seq: Sequence{
761+ {Sym: "e", Mods: ModShift},
762+ {Sym: "n"}, {Sym: "t"}, {Sym: "e"}, {Sym: "r"},
763+ },
764+ wantString: "<S-e>nter",
765+ },
766+ {
767+ name: "e n t e r",
768+ seq: Sequence{
769+ {Sym: "e"}, {Sym: "n"}, {Sym: "t"}, {Sym: "e"}, {Sym: "r"},
770+ },
771+ wantString: "<e>nter",
772+ },
773+ {
774+ name: "Shift+r e t u r n",
775+ seq: Sequence{
776+ {Sym: "r", Mods: ModShift},
777+ {Sym: "e"}, {Sym: "t"}, {Sym: "u"}, {Sym: "r"}, {Sym: "n"},
778+ },
779+ wantString: "<S-r>eturn",
780+ },
781+ {
782+ name: "r e t u r n",
783+ seq: Sequence{
784+ {Sym: "r"}, {Sym: "e"}, {Sym: "t"}, {Sym: "u"}, {Sym: "r"}, {Sym: "n"},
785+ },
786+ wantString: "<r>eturn",
787+ },
788+ {
789+ name: "Shift+e s c",
790+ seq: Sequence{
791+ {Sym: "e", Mods: ModShift},
792+ {Sym: "s"}, {Sym: "c"},
793+ },
794+ wantString: "<S-e>sc",
795+ },
796+ {
797+ name: "e s c",
798+ seq: Sequence{{Sym: "e"}, {Sym: "s"}, {Sym: "c"}},
799+ wantString: "<e>sc",
800+ },
801+ {
802+ name: "Shift+e s c a p e",
803+ seq: Sequence{
804+ {Sym: "e", Mods: ModShift},
805+ {Sym: "s"}, {Sym: "c"}, {Sym: "a"}, {Sym: "p"}, {Sym: "e"},
806+ },
807+ wantString: "<S-e>scape",
808+ },
809+ {
810+ name: "e s c a p e",
811+ seq: Sequence{
812+ {Sym: "e"}, {Sym: "s"}, {Sym: "c"}, {Sym: "a"}, {Sym: "p"}, {Sym: "e"},
813+ },
814+ wantString: "<e>scape",
815+ },
816+ }
817+ for _, tt := range tests {
818+ t.Run(tt.name, func(t *testing.T) {
819+ if got := tt.seq.String(); got != tt.wantString {
820+ t.Fatalf("Sequence.String() = %q, want %q", got, tt.wantString)
821+ }
822+ got, err := ParseBinding(tt.wantString)
823+ if err != nil {
824+ t.Fatalf("ParseBinding(%q) error = %v", tt.wantString, err)
825+ }
826+ if !got.Equal(tt.seq) {
827+ t.Fatalf("ParseBinding(%q) = %v, want %v", tt.wantString, got, tt.seq)
828+ }
829+ if again := got.String(); again != tt.wantString {
830+ t.Fatalf("canonical unstable %q -> %q", tt.wantString, again)
831+ }
832+ })
833+ }
834+}
835+
836+// TestParseBinding_LegacyChordCollisionRoundTrip ensures a rune sequence that
837+// would spell a legacy chord via a literal '+' is emitted with <Plus> so it
838+// cannot reparse as that chord.
839+func TestParseBinding_LegacyChordCollisionRoundTrip(t *testing.T) {
840+ tests := []struct {
841+ name string
842+ seq Sequence
843+ wantString string
844+ }{
845+ {
846+ name: "ctrl + d runes",
847+ seq: Sequence{
848+ {Sym: "c"}, {Sym: "t"}, {Sym: "r"}, {Sym: "l"},
849+ {Sym: "+"},
850+ {Sym: "d"},
851+ },
852+ wantString: "ctrl<Plus>d",
853+ },
854+ {
855+ name: "Ctrl + d mixed case start",
856+ seq: Sequence{
857+ {Sym: "c", Mods: ModShift},
858+ {Sym: "t"}, {Sym: "r"}, {Sym: "l"},
859+ {Sym: "+"},
860+ {Sym: "d"},
861+ },
862+ wantString: "Ctrl<Plus>d",
863+ },
864+ {
865+ name: "control + j runes",
866+ seq: Sequence{
867+ {Sym: "c"}, {Sym: "o"}, {Sym: "n"}, {Sym: "t"}, {Sym: "r"}, {Sym: "o"}, {Sym: "l"},
868+ {Sym: "+"},
869+ {Sym: "j"},
870+ },
871+ wantString: "control<Plus>j",
872+ },
873+ {
874+ name: "shift + j runes",
875+ seq: Sequence{
876+ {Sym: "s"}, {Sym: "h"}, {Sym: "i"}, {Sym: "f"}, {Sym: "t"},
877+ {Sym: "+"},
878+ {Sym: "j"},
879+ },
880+ wantString: "shift<Plus>j",
881+ },
882+ {
883+ name: "alt + k runes",
884+ seq: Sequence{
885+ {Sym: "a"}, {Sym: "l"}, {Sym: "t"},
886+ {Sym: "+"},
887+ {Sym: "k"},
888+ },
889+ wantString: "alt<Plus>k",
890+ },
891+ }
892+ for _, tt := range tests {
893+ t.Run(tt.name, func(t *testing.T) {
894+ if got := tt.seq.String(); got != tt.wantString {
895+ t.Fatalf("Sequence.String() = %q, want %q", got, tt.wantString)
896+ }
897+ got, err := ParseBinding(tt.wantString)
898+ if err != nil {
899+ t.Fatalf("ParseBinding(%q) error = %v", tt.wantString, err)
900+ }
901+ if !got.Equal(tt.seq) {
902+ t.Fatalf("ParseBinding(%q) = %v, want %v", tt.wantString, got, tt.seq)
903+ }
904+ })
905+ }
906+}
907+
908+// TestParseBinding_LegacyAtomsAndChordsStillAccepted preserves exact raw
909+// legacy config inputs as named atoms / chords.
910+func TestParseBinding_LegacyAtomsAndChordsStillAccepted(t *testing.T) {
911+ tests := []struct {
912+ input string
913+ want Sequence
914+ }{
915+ {input: "enter", want: Sequence{{Sym: "CR"}}},
916+ {input: "return", want: Sequence{{Sym: "CR"}}},
917+ {input: "Enter", want: Sequence{{Sym: "CR"}}},
918+ {input: "Return", want: Sequence{{Sym: "CR"}}},
919+ {input: "ENTER", want: Sequence{{Sym: "CR"}}},
920+ {input: "escape", want: Sequence{{Sym: "Esc"}}},
921+ {input: "esc", want: Sequence{{Sym: "Esc"}}},
922+ {input: "Escape", want: Sequence{{Sym: "Esc"}}},
923+ {input: "Esc", want: Sequence{{Sym: "Esc"}}},
924+ {input: "ESC", want: Sequence{{Sym: "Esc"}}},
925+ {input: "ctrl+d", want: Sequence{{Sym: "d", Mods: ModCtrl}}},
926+ {input: "control+j", want: Sequence{{Sym: "j", Mods: ModCtrl}}},
927+ {input: "shift+j", want: Sequence{{Sym: "j", Mods: ModShift}}},
928+ {input: "alt+k", want: Sequence{{Sym: "k", Mods: ModAlt}}},
929+ }
930+ for _, tt := range tests {
931+ t.Run(tt.input, func(t *testing.T) {
932+ got, err := ParseBinding(tt.input)
933+ if err != nil {
934+ t.Fatalf("ParseBinding(%q) error = %v", tt.input, err)
935+ }
936+ if !got.Equal(tt.want) {
937+ t.Fatalf("ParseBinding(%q) = %v, want %v", tt.input, got, tt.want)
938+ }
939+ })
940+ }
941+}
942+
943+// TestParseBinding_OneCharAngleLiteral accepts <e>/<S-e> while rejecting <Nope>.
944+func TestParseBinding_OneCharAngleLiteral(t *testing.T) {
945+ got, err := ParseBinding("<e>")
946+ if err != nil {
947+ t.Fatalf("ParseBinding(<e>) error = %v", err)
948+ }
949+ if want := (Sequence{{Sym: "e"}}); !got.Equal(want) {
950+ t.Fatalf("ParseBinding(<e>) = %v, want %v", got, want)
951+ }
952+ got, err = ParseBinding("<S-e>")
953+ if err != nil {
954+ t.Fatalf("ParseBinding(<S-e>) error = %v", err)
955+ }
956+ if want := (Sequence{{Sym: "e", Mods: ModShift}}); !got.Equal(want) {
957+ t.Fatalf("ParseBinding(<S-e>) = %v, want %v", got, want)
958+ }
959+ if _, err := ParseBinding("<Nope>"); !errors.Is(err, ErrBadBinding) {
960+ t.Fatalf("ParseBinding(<Nope>) error = %v, want %v", err, ErrBadBinding)
961+ }
962+}
963+
964+// TestParseBinding_CtrlEAcuteRoundTrip covers the runtime Unicode canonical
965+// invariant: ctrl+é / <C-é> parse to Key{Sym:"é", ModCtrl} and String
966+// re-parses identically.
967+func TestParseBinding_CtrlEAcuteRoundTrip(t *testing.T) {
968+ want := Sequence{{Sym: "é", Mods: ModCtrl}}
969+ for _, input := range []string{"ctrl+é", "<C-é>"} {
970+ t.Run(input, func(t *testing.T) {
971+ got, err := ParseBinding(input)
972+ if err != nil {
973+ t.Fatalf("ParseBinding(%q) error = %v", input, err)
974+ }
975+ if !got.Equal(want) {
976+ t.Fatalf("ParseBinding(%q) = %v, want %v", input, got, want)
977+ }
978+ canonical := got.String()
979+ if canonical != "<C-é>" {
980+ t.Fatalf("String() = %q, want %q", canonical, "<C-é>")
981+ }
982+ again, err := ParseBinding(canonical)
983+ if err != nil {
984+ t.Fatalf("ParseBinding(%q) error = %v", canonical, err)
985+ }
986+ if !again.Equal(want) {
987+ t.Fatalf("round trip %q -> %q -> %v, want %v", input, canonical, again, want)
988+ }
989+ })
990+ }
991+}
992+
993+// TestParseBinding_ReplacementCharRoundTrip ensures valid printable U+FFFD is not
994+// rejected by the single-rune token path (r == utf8.RuneError is U+FFFD itself).
995+func TestParseBinding_ReplacementCharRoundTrip(t *testing.T) {
996+ const repl = "\uFFFD"
997+ want := Sequence{{Sym: repl, Mods: ModCtrl}}
998+ for _, input := range []string{"ctrl+" + repl, "<C-" + repl + ">"} {
999+ t.Run(input, func(t *testing.T) {
1000+ got, err := ParseBinding(input)
1001+ if err != nil {
1002+ t.Fatalf("ParseBinding(%q) error = %v", input, err)
1003+ }
1004+ if !got.Equal(want) {
1005+ t.Fatalf("ParseBinding(%q) = %v, want %v", input, got, want)
1006+ }
1007+ canonical := got.String()
1008+ wantCanonical := "<C-" + repl + ">"
1009+ if canonical != wantCanonical {
1010+ t.Fatalf("String() = %q, want %q", canonical, wantCanonical)
1011+ }
1012+ again, err := ParseBinding(canonical)
1013+ if err != nil {
1014+ t.Fatalf("ParseBinding(%q) error = %v", canonical, err)
1015+ }
1016+ if !again.Equal(want) {
1017+ t.Fatalf("round trip %q -> %q -> %v, want %v", input, canonical, again, want)
1018+ }
1019+ })
1020+ }
1021+ modifiedWant := Sequence{{Sym: repl}}
1022+ got, err := ParseBinding("<" + repl + ">")
1023+ if err != nil {
1024+ t.Fatalf("ParseBinding angle literal error = %v", err)
1025+ }
1026+ if !got.Equal(modifiedWant) {
1027+ t.Fatalf("ParseBinding(<U+FFFD>) = %v, want %v", got, modifiedWant)
1028+ }
1029+}
1030+
1031+// TestParseBinding_LegacyCollisionModel systematically covers every exact
1032+// legacy atom alias, modifier+plus chord spellings, case variants, and
1033+// adjacent non-colliding sequences while retaining round-trip.
1034+func TestParseBinding_LegacyCollisionModel(t *testing.T) {
1035+ type atomCase struct {
1036+ alias string
1037+ seq Sequence
1038+ }
1039+ atoms := []atomCase{
1040+ {alias: "enter", seq: Sequence{{Sym: "e"}, {Sym: "n"}, {Sym: "t"}, {Sym: "e"}, {Sym: "r"}}},
1041+ {alias: "Enter", seq: Sequence{{Sym: "e", Mods: ModShift}, {Sym: "n"}, {Sym: "t"}, {Sym: "e"}, {Sym: "r"}}},
1042+ {alias: "ENTER", seq: Sequence{
1043+ {Sym: "e", Mods: ModShift}, {Sym: "n", Mods: ModShift}, {Sym: "t", Mods: ModShift},
1044+ {Sym: "e", Mods: ModShift}, {Sym: "r", Mods: ModShift},
1045+ }},
1046+ {alias: "return", seq: Sequence{{Sym: "r"}, {Sym: "e"}, {Sym: "t"}, {Sym: "u"}, {Sym: "r"}, {Sym: "n"}}},
1047+ {alias: "Return", seq: Sequence{{Sym: "r", Mods: ModShift}, {Sym: "e"}, {Sym: "t"}, {Sym: "u"}, {Sym: "r"}, {Sym: "n"}}},
1048+ {alias: "escape", seq: Sequence{{Sym: "e"}, {Sym: "s"}, {Sym: "c"}, {Sym: "a"}, {Sym: "p"}, {Sym: "e"}}},
1049+ {alias: "Escape", seq: Sequence{{Sym: "e", Mods: ModShift}, {Sym: "s"}, {Sym: "c"}, {Sym: "a"}, {Sym: "p"}, {Sym: "e"}}},
1050+ {alias: "esc", seq: Sequence{{Sym: "e"}, {Sym: "s"}, {Sym: "c"}}},
1051+ {alias: "Esc", seq: Sequence{{Sym: "e", Mods: ModShift}, {Sym: "s"}, {Sym: "c"}}},
1052+ {alias: "ESC", seq: Sequence{{Sym: "e", Mods: ModShift}, {Sym: "s", Mods: ModShift}, {Sym: "c", Mods: ModShift}}},
1053+ }
1054+ for _, tt := range atoms {
1055+ t.Run("atom/"+tt.alias, func(t *testing.T) {
1056+ canonical := tt.seq.String()
1057+ if strings.EqualFold(canonical, tt.alias) {
1058+ t.Fatalf("Sequence.String() still collides with legacy atom %q", tt.alias)
1059+ }
1060+ got, err := ParseBinding(canonical)
1061+ if err != nil {
1062+ t.Fatalf("ParseBinding(%q) error = %v", canonical, err)
1063+ }
1064+ if !got.Equal(tt.seq) {
1065+ t.Fatalf("ParseBinding(%q) = %v, want %v", canonical, got, tt.seq)
1066+ }
1067+ raw, err := ParseBinding(tt.alias)
1068+ if err != nil {
1069+ t.Fatalf("ParseBinding(%q) error = %v", tt.alias, err)
1070+ }
1071+ if raw.Equal(tt.seq) {
1072+ t.Fatalf("legacy atom %q must not equal rune sequence", tt.alias)
1073+ }
1074+ })
1075+ }
1076+
1077+ chordSpellings := []struct {
1078+ legacy string
1079+ seq Sequence
1080+ }{
1081+ {
1082+ legacy: "ctrl+d",
1083+ seq: Sequence{
1084+ {Sym: "c"}, {Sym: "t"}, {Sym: "r"}, {Sym: "l"}, {Sym: "+"}, {Sym: "d"},
1085+ },
1086+ },
1087+ {
1088+ legacy: "control+j",
1089+ seq: Sequence{
1090+ {Sym: "c"}, {Sym: "o"}, {Sym: "n"}, {Sym: "t"}, {Sym: "r"}, {Sym: "o"}, {Sym: "l"},
1091+ {Sym: "+"}, {Sym: "j"},
1092+ },
1093+ },
1094+ {
1095+ legacy: "shift+a",
1096+ seq: Sequence{
1097+ {Sym: "s"}, {Sym: "h"}, {Sym: "i"}, {Sym: "f"}, {Sym: "t"},
1098+ {Sym: "+"}, {Sym: "a"},
1099+ },
1100+ },
1101+ {
1102+ legacy: "alt+b",
1103+ seq: Sequence{
1104+ {Sym: "a"}, {Sym: "l"}, {Sym: "t"}, {Sym: "+"}, {Sym: "b"},
1105+ },
1106+ },
1107+ }
1108+ for _, tt := range chordSpellings {
1109+ t.Run("chord/"+tt.legacy, func(t *testing.T) {
1110+ canonical := tt.seq.String()
1111+ if canonical == tt.legacy {
1112+ t.Fatalf("Sequence.String() still equals legacy chord %q", tt.legacy)
1113+ }
1114+ got, err := ParseBinding(canonical)
1115+ if err != nil {
1116+ t.Fatalf("ParseBinding(%q) error = %v", canonical, err)
1117+ }
1118+ if !got.Equal(tt.seq) {
1119+ t.Fatalf("ParseBinding(%q) = %v, want %v", canonical, got, tt.seq)
1120+ }
1121+ chord, err := ParseBinding(tt.legacy)
1122+ if err != nil {
1123+ t.Fatalf("ParseBinding(%q) error = %v", tt.legacy, err)
1124+ }
1125+ if chord.Equal(tt.seq) {
1126+ t.Fatalf("legacy chord %q must not equal rune sequence", tt.legacy)
1127+ }
1128+ })
1129+ }
1130+
1131+ // Adjacent sequences that share prefixes but are not exact atoms.
1132+ adjacent := []Sequence{
1133+ {{Sym: "e"}, {Sym: "n"}, {Sym: "t"}, {Sym: "e"}, {Sym: "r"}, {Sym: "j"}},
1134+ {{Sym: "e", Mods: ModShift}, {Sym: "s"}, {Sym: "c"}, {Sym: "j"}},
1135+ {{Sym: "c"}, {Sym: "t"}, {Sym: "r"}, {Sym: "l"}, {Sym: "+"}, {Sym: "d"}, {Sym: "j"}},
1136+ }
1137+ for _, seq := range adjacent {
1138+ canonical := seq.String()
1139+ t.Run("adjacent/"+canonical, func(t *testing.T) {
1140+ got, err := ParseBinding(canonical)
1141+ if err != nil {
1142+ t.Fatalf("ParseBinding(%q) error = %v", canonical, err)
1143+ }
1144+ if !got.Equal(seq) {
1145+ t.Fatalf("ParseBinding(%q) = %v, want %v", canonical, got, seq)
1146+ }
1147+ })
1148+ }
1149+}
1150+
1151+// TestParseBinding_ExhaustiveCanonicalModelRoundTrip covers the full legal Key
1152+// surface that Sequence.String can emit: letters × modifier combos, printable
1153+// ASCII punctuation in runtime-canonical modifier states, named symbols ×
1154+// modifier combos, and adjacent pairs that can spell modifier/named prefixes.
1155+func TestParseBinding_ExhaustiveCanonicalModelRoundTrip(t *testing.T) {
1156+ allMods := []Mods{
1157+ 0,
1158+ ModCtrl,
1159+ ModShift,
1160+ ModAlt,
1161+ ModCtrl | ModShift,
1162+ ModCtrl | ModAlt,
1163+ ModShift | ModAlt,
1164+ ModCtrl | ModShift | ModAlt,
1165+ }
1166+
1167+ var corpus []Sequence
1168+
1169+ for c := 'a'; c <= 'z'; c++ {
1170+ sym := string(c)
1171+ for _, mods := range allMods {
1172+ corpus = append(corpus, Sequence{{Sym: sym, Mods: mods}})
1173+ }
1174+ }
1175+
1176+ punctuation := []string{
1177+ "!", `"`, "#", "$", "%", "&", "'", "(", ")", "*", "+", ",", "-", ".", "/",
1178+ ":", ";", "<", "=", ">", "?", "@", "[", `\`, "]", "^", "_", "`", "{", "|", "}", "~",
1179+ }
1180+ for _, sym := range punctuation {
1181+ for _, mods := range allMods {
1182+ // Unmodified punctuation is always legal; modified forms are emitted
1183+ // as angle keys and must round-trip.
1184+ corpus = append(corpus, Sequence{{Sym: sym, Mods: mods}})
1185+ }
1186+ }
1187+
1188+ named := []string{
1189+ "CR", "Esc", "Space", "Tab", "BackSpace", "Delete",
1190+ "Left", "Right", "Up", "Down", "Home", "End", "PageUp", "PageDown",
1191+ }
1192+ for _, sym := range named {
1193+ for _, mods := range allMods {
1194+ corpus = append(corpus, Sequence{{Sym: sym, Mods: mods}})
1195+ }
1196+ }
1197+
1198+ // Representative adjacent pairs that can spell modifier/named prefixes,
1199+ // including exact legacy atom/chord collision spellings.
1200+ adjacentPairs := []Sequence{
1201+ {{Sym: "c", Mods: ModShift}, {Sym: "-"}, {Sym: "d"}},
1202+ {{Sym: "s", Mods: ModShift}, {Sym: "-"}, {Sym: "j"}},
1203+ {{Sym: "a", Mods: ModShift}, {Sym: "-"}, {Sym: "k"}},
1204+ {{Sym: "c", Mods: ModShift}, {Sym: "-"}, {Sym: "s", Mods: ModShift}, {Sym: "-"}, {Sym: "a"}},
1205+ {{Sym: "s", Mods: ModShift}, {Sym: "p"}, {Sym: "a"}, {Sym: "c"}, {Sym: "e"}, {Sym: "j"}},
1206+ {{Sym: "e", Mods: ModShift}, {Sym: "s"}, {Sym: "c"}, {Sym: "j"}},
1207+ {{Sym: "t", Mods: ModShift}, {Sym: "a"}, {Sym: "b"}, {Sym: "x"}},
1208+ {{Sym: "r", Mods: ModShift}, {Sym: "e"}, {Sym: "t"}, {Sym: "u"}, {Sym: "r"}, {Sym: "n"}, {Sym: "x"}},
1209+ {{Sym: "e"}, {Sym: "n"}, {Sym: "t"}, {Sym: "e"}, {Sym: "r"}},
1210+ {{Sym: "e", Mods: ModShift}, {Sym: "n"}, {Sym: "t"}, {Sym: "e"}, {Sym: "r"}},
1211+ {{Sym: "e"}, {Sym: "s"}, {Sym: "c"}},
1212+ {{Sym: "e", Mods: ModShift}, {Sym: "s"}, {Sym: "c"}, {Sym: "a"}, {Sym: "p"}, {Sym: "e"}},
1213+ {{Sym: "r"}, {Sym: "e"}, {Sym: "t"}, {Sym: "u"}, {Sym: "r"}, {Sym: "n"}},
1214+ {{Sym: "c"}, {Sym: "t"}, {Sym: "r"}, {Sym: "l"}, {Sym: "+"}, {Sym: "d"}},
1215+ {{Sym: "d", Mods: ModCtrl}, {Sym: "j"}},
1216+ {{Sym: ">"}, {Sym: "j"}},
1217+ {{Sym: "<"}, {Sym: "j"}},
1218+ {{Sym: "g"}, {Sym: "o", Mods: ModShift}},
1219+ {{Sym: "]"}, {Sym: "]"}},
1220+ }
1221+ corpus = append(corpus, adjacentPairs...)
1222+
1223+ seen := make(map[string]struct{}, len(corpus))
1224+ for _, seq := range corpus {
1225+ canonical := seq.String()
1226+ if _, ok := seen[canonical]; ok {
1227+ continue
1228+ }
1229+ seen[canonical] = struct{}{}
1230+ t.Run(canonical, func(t *testing.T) {
1231+ got, err := ParseBinding(canonical)
1232+ if err != nil {
1233+ t.Fatalf("ParseBinding(%q) error = %v", canonical, err)
1234+ }
1235+ if !got.Equal(seq) {
1236+ t.Fatalf("ParseBinding(%q) = %v, want %v", canonical, got, seq)
1237+ }
1238+ if again := got.String(); again != canonical {
1239+ t.Fatalf("canonical unstable %q -> %q", canonical, again)
1240+ }
1241+ })
1242+ }
1243+}
A internal/domain/vimkeys/trie.go +84 -0
@@ -0,0 +1,84 @@
1+package vimkeys
2+
3+import "errors"
4+
5+// Node describes the trie state at the end of a walked sequence.
6+type Node struct {
7+ Action string
8+ Exact bool
9+ HasChildren bool
10+}
11+
12+// ErrConflict is returned when inserting a duplicate binding.
13+var ErrConflict = errors.New("vimkeys: duplicate binding")
14+
15+const maxCount = 9999
16+
17+type trieNode struct {
18+ children map[Key]*trieNode
19+ action string
20+ hasAction bool
21+}
22+
23+// Trie stores Vim key sequence bindings.
24+type Trie struct {
25+ root *trieNode
26+ depth int
27+}
28+
29+// NewTrie creates an empty binding trie.
30+func NewTrie() *Trie {
31+ return &Trie{root: newTrieNode()}
32+}
33+
34+func newTrieNode() *trieNode {
35+ return &trieNode{children: make(map[Key]*trieNode)}
36+}
37+
38+// Insert adds a binding for seq. Duplicate sequences return ErrConflict.
39+// Nil or empty sequences return ErrEmptyBinding.
40+func (t *Trie) Insert(seq Sequence, action string) error {
41+ if len(seq) == 0 {
42+ return ErrEmptyBinding
43+ }
44+ node := t.root
45+ for _, key := range seq {
46+ child, ok := node.children[key]
47+ if !ok {
48+ child = newTrieNode()
49+ node.children[key] = child
50+ }
51+ node = child
52+ }
53+ if node.hasAction {
54+ return ErrConflict
55+ }
56+ node.hasAction = true
57+ node.action = action
58+ if depth := len(seq); depth > t.depth {
59+ t.depth = depth
60+ }
61+ return nil
62+}
63+
64+// Walk reports the trie node reached by seq and whether the path exists.
65+func (t *Trie) Walk(seq Sequence) (Node, bool) {
66+ node := t.root
67+ for _, key := range seq {
68+ child, ok := node.children[key]
69+ if !ok {
70+ return Node{}, false
71+ }
72+ node = child
73+ }
74+ return Node{
75+ Action: node.action,
76+ Exact: node.hasAction,
77+ HasChildren: len(node.children) > 0,
78+ }, true
79+}
80+
81+// MaxDepth returns the longest inserted sequence length.
82+func (t *Trie) MaxDepth() int {
83+ return t.depth
84+}
A internal/domain/vimkeys/trie_test.go +118 -0
@@ -0,0 +1,118 @@
1+package vimkeys
2+
3+import (
4+ "errors"
5+ "testing"
6+)
7+
8+func TestTrie_InsertWalkPrefixes(t *testing.T) {
9+ trie := NewTrie()
10+ bindings := []struct {
11+ seq Sequence
12+ action string
13+ }{
14+ {seq: Sequence{{Sym: "]"}, {Sym: "]"}}, action: "double-bracket"},
15+ {seq: Sequence{{Sym: "]"}, {Sym: "c"}}, action: "bracket-c"},
16+ {seq: Sequence{{Sym: "g"}, {Sym: "o", Mods: ModShift}}, action: "gO"},
17+ {seq: Sequence{{Sym: "y"}, {Sym: "a"}, {Sym: "h"}}, action: "yah"},
18+ }
19+ for _, binding := range bindings {
20+ if err := trie.Insert(binding.seq, binding.action); err != nil {
21+ t.Fatalf("Insert(%v) error = %v", binding.seq, err)
22+ }
23+ }
24+
25+ prefixCases := []struct {
26+ name string
27+ seq Sequence
28+ wantExact bool
29+ wantChildren bool
30+ wantAction string
31+ }{
32+ {name: "]] prefix ]", seq: Sequence{{Sym: "]"}}, wantChildren: true},
33+ {name: "]] full", seq: Sequence{{Sym: "]"}, {Sym: "]"}}, wantExact: true, wantAction: "double-bracket"},
34+ {name: "]c prefix ]", seq: Sequence{{Sym: "]"}}, wantChildren: true},
35+ {name: "]c full", seq: Sequence{{Sym: "]"}, {Sym: "c"}}, wantExact: true, wantAction: "bracket-c"},
36+ {name: "gO prefix g", seq: Sequence{{Sym: "g"}}, wantChildren: true},
37+ {name: "gO full", seq: Sequence{{Sym: "g"}, {Sym: "o", Mods: ModShift}}, wantExact: true, wantAction: "gO"},
38+ {name: "yah prefix y", seq: Sequence{{Sym: "y"}}, wantChildren: true},
39+ {name: "yah prefix ya", seq: Sequence{{Sym: "y"}, {Sym: "a"}}, wantChildren: true},
40+ {name: "yah full", seq: Sequence{{Sym: "y"}, {Sym: "a"}, {Sym: "h"}}, wantExact: true, wantAction: "yah"},
41+ }
42+ for _, tt := range prefixCases {
43+ t.Run(tt.name, func(t *testing.T) {
44+ node, ok := trie.Walk(tt.seq)
45+ if !ok {
46+ t.Fatalf("Walk(%v) ok = false, want true", tt.seq)
47+ }
48+ if node.Exact != tt.wantExact {
49+ t.Errorf("Walk(%v) Exact = %v, want %v", tt.seq, node.Exact, tt.wantExact)
50+ }
51+ if node.HasChildren != tt.wantChildren {
52+ t.Errorf("Walk(%v) HasChildren = %v, want %v", tt.seq, node.HasChildren, tt.wantChildren)
53+ }
54+ if tt.wantExact && node.Action != tt.wantAction {
55+ t.Errorf("Walk(%v) Action = %q, want %q", tt.seq, node.Action, tt.wantAction)
56+ }
57+ })
58+ }
59+}
60+
61+func TestTrie_InsertDuplicateReturnsConflict(t *testing.T) {
62+ trie := NewTrie()
63+ seq := Sequence{{Sym: "j"}}
64+ if err := trie.Insert(seq, "first"); err != nil {
65+ t.Fatalf("first Insert error = %v", err)
66+ }
67+ err := trie.Insert(seq, "second")
68+ if !errors.Is(err, ErrConflict) {
69+ t.Fatalf("duplicate Insert error = %v, want %v", err, ErrConflict)
70+ }
71+}
72+
73+func TestTrie_InsertEmptySequenceReturnsErrEmptyBinding(t *testing.T) {
74+ trie := NewTrie()
75+ for _, seq := range []Sequence{nil, {}} {
76+ err := trie.Insert(seq, "root-action")
77+ if !errors.Is(err, ErrEmptyBinding) {
78+ t.Fatalf("Insert(%v) error = %v, want %v", seq, err, ErrEmptyBinding)
79+ }
80+ }
81+ node, ok := trie.Walk(nil)
82+ if !ok {
83+ t.Fatal("Walk(nil) ok = false, want true for empty path")
84+ }
85+ if node.Exact || node.Action != "" {
86+ t.Fatalf("Walk(nil) after rejected empty Insert = %+v, want no root action", node)
87+ }
88+ if trie.MaxDepth() != 0 {
89+ t.Fatalf("MaxDepth() = %d after rejected empty Insert, want 0", trie.MaxDepth())
90+ }
91+}
92+
93+func TestTrie_MaxDepth(t *testing.T) {
94+ trie := NewTrie()
95+ if got := trie.MaxDepth(); got != 0 {
96+ t.Fatalf("empty MaxDepth() = %d, want 0", got)
97+ }
98+ if err := trie.Insert(Sequence{{Sym: "y"}, {Sym: "a"}, {Sym: "h"}}, "yah"); err != nil {
99+ t.Fatalf("Insert(yah) error = %v", err)
100+ }
101+ if err := trie.Insert(Sequence{{Sym: "]"}, {Sym: "]"}}, "brackets"); err != nil {
102+ t.Fatalf("Insert(]]) error = %v", err)
103+ }
104+ if got := trie.MaxDepth(); got != 3 {
105+ t.Fatalf("MaxDepth() = %d, want 3", got)
106+ }
107+}
108+
109+func TestTrie_WalkMissingPath(t *testing.T) {
110+ trie := NewTrie()
111+ if err := trie.Insert(Sequence{{Sym: "j"}}, "down"); err != nil {
112+ t.Fatalf("Insert(j) error = %v", err)
113+ }
114+ _, ok := trie.Walk(Sequence{{Sym: "k"}})
115+ if ok {
116+ t.Fatal("Walk missing path ok = true, want false")
117+ }
118+}
A internal/infrastructure/cef/accessibility_capture.go +433 -0
@@ -0,0 +1,433 @@
1+package cef
2+
3+import (
4+ "fmt"
5+ "os"
6+ "path/filepath"
7+ "strconv"
8+ "strings"
9+ "sync"
10+ "sync/atomic"
11+ "syscall"
12+
13+ "github.com/bnema/dumber/internal/application/port"
14+ "github.com/bnema/dumber/internal/infrastructure/config"
15+ "github.com/bnema/dumber/internal/logging"
16+ "github.com/bnema/dumber/internal/shared/syncdispatch"
17+)
18+
19+const (
20+ accessibilityCaptureQueueCapacity = 64
21+ accessibilityCaptureDirPerm = 0o700
22+ accessibilityCaptureFilePerm = 0o600
23+ accessibilityCaptureEnvName = "DUMBER_A11Y_CAPTURE"
24+ accessibilityCaptureEnvValue = "1"
25+ accessibilityCaptureKindTree = "tree"
26+ accessibilityCaptureKindLocation = "location"
27+
28+ // Per-WebView capture budgets for the durable .../a11y-capture/webview-%d
29+ // namespace. Sized to cover an ~80s measurement campaign with headroom for
30+ // frequent tree+location updates, while capping unbounded growth when the
31+ // same WebView-ID directory is reused across sessions. Existing strict
32+ // capture files count toward both budgets; writes reject before create
33+ // when either limit would be exceeded. Tests may inject smaller limits.
34+ accessibilityCaptureDefaultMaxFiles = 10_000
35+ accessibilityCaptureDefaultMaxBytes = 512 << 20 // 512 MiB
36+)
37+
38+type accessibilityStatsSnapshot struct {
39+ Count int
40+ TreeCount int
41+ LocationCount int
42+ TotalBytes int64
43+ MinBytes int
44+ MaxBytes int
45+ AverageBytes float64
46+ TotalSerializeNanos int64
47+ MaxSerializeNanos int64
48+ AverageSerializeNanos float64
49+}
50+
51+type accessibilityStats struct {
52+ mu sync.Mutex
53+ count int
54+ treeCount int
55+ locationCount int
56+ totalBytes int64
57+ minBytes int
58+ maxBytes int
59+ totalSerializeNanos int64
60+ maxSerializeNanos int64
61+}
62+
63+func (s *accessibilityStats) Observe(p accessibilityPayload) {
64+ if s == nil {
65+ return
66+ }
67+ s.mu.Lock()
68+ defer s.mu.Unlock()
69+ s.count++
70+ switch p.Kind {
71+ case accessibilityCaptureKindTree:
72+ s.treeCount++
73+ case accessibilityCaptureKindLocation:
74+ s.locationCount++
75+ }
76+ s.totalBytes += int64(p.Bytes)
77+ if s.count == 1 || p.Bytes < s.minBytes {
78+ s.minBytes = p.Bytes
79+ }
80+ if p.Bytes > s.maxBytes {
81+ s.maxBytes = p.Bytes
82+ }
83+ s.totalSerializeNanos += p.SerializeNanos
84+ if p.SerializeNanos > s.maxSerializeNanos {
85+ s.maxSerializeNanos = p.SerializeNanos
86+ }
87+}
88+
89+func (s *accessibilityStats) Snapshot() accessibilityStatsSnapshot {
90+ if s == nil {
91+ return accessibilityStatsSnapshot{}
92+ }
93+ s.mu.Lock()
94+ defer s.mu.Unlock()
95+ if s.count == 0 {
96+ return accessibilityStatsSnapshot{}
97+ }
98+ return accessibilityStatsSnapshot{
99+ Count: s.count,
100+ TreeCount: s.treeCount,
101+ LocationCount: s.locationCount,
102+ TotalBytes: s.totalBytes,
103+ MinBytes: s.minBytes,
104+ MaxBytes: s.maxBytes,
105+ AverageBytes: float64(s.totalBytes) / float64(s.count),
106+ TotalSerializeNanos: s.totalSerializeNanos,
107+ MaxSerializeNanos: s.maxSerializeNanos,
108+ AverageSerializeNanos: float64(s.totalSerializeNanos) / float64(s.count),
109+ }
110+}
111+
112+type accessibilityCaptureWorker struct {
113+ mu sync.RWMutex
114+ closed bool
115+ queue chan accessibilityPayload
116+ wg sync.WaitGroup
117+ consume func(accessibilityPayload)
118+ dropped atomic.Uint64
119+}
120+
121+func newAccessibilityCaptureWorker(capacity int, consume func(accessibilityPayload)) *accessibilityCaptureWorker {
122+ if capacity < 1 {
123+ capacity = 1
124+ }
125+ w := &accessibilityCaptureWorker{
126+ queue: make(chan accessibilityPayload, capacity),
127+ consume: consume,
128+ }
129+ w.wg.Add(1)
130+ go w.loop()
131+ return w
132+}
133+
134+func (w *accessibilityCaptureWorker) loop() {
135+ defer w.wg.Done()
136+ for p := range w.queue {
137+ if w.consume != nil {
138+ w.consume(p)
139+ }
140+ }
141+}
142+
143+func (w *accessibilityCaptureWorker) Submit(p accessibilityPayload) bool {
144+ if w == nil {
145+ return false
146+ }
147+ w.mu.RLock()
148+ defer w.mu.RUnlock()
149+ if w.closed {
150+ return false
151+ }
152+ select {
153+ case w.queue <- p:
154+ return true
155+ default:
156+ w.dropped.Add(1)
157+ return false
158+ }
159+}
160+
161+func (w *accessibilityCaptureWorker) Dropped() uint64 {
162+ if w == nil {
163+ return 0
164+ }
165+ return w.dropped.Load()
166+}
167+
168+func (w *accessibilityCaptureWorker) Close() {
169+ if w == nil {
170+ return
171+ }
172+ w.mu.Lock()
173+ if w.closed {
174+ w.mu.Unlock()
175+ return
176+ }
177+ w.closed = true
178+ close(w.queue)
179+ w.mu.Unlock()
180+ w.wg.Wait()
181+}
182+
183+type accessibilityCapture struct {
184+ mu sync.Mutex
185+ dir string
186+ seq uint64
187+ fileCount int
188+ totalBytes int64
189+ maxFiles int
190+ maxBytes int64
191+}
192+
193+func accessibilityCaptureEnabled() bool {
194+ return os.Getenv(accessibilityCaptureEnvName) == accessibilityCaptureEnvValue
195+}
196+
197+func accessibilityCaptureDir(webViewID port.WebViewID) (string, error) {
198+ logDir, err := config.GetLogDir()
199+ if err != nil {
200+ return "", err
201+ }
202+ return filepath.Join(logDir, "a11y-capture", fmt.Sprintf("webview-%d", webViewID)), nil
203+}
204+
205+func validateAccessibilityCaptureDir(dir string) error {
206+ if dir == "" || dir == "." {
207+ return fmt.Errorf("accessibility capture dir rejected: %q", dir)
208+ }
209+ for _, part := range strings.Split(filepath.ToSlash(dir), "/") {
210+ if part == ".." {
211+ return fmt.Errorf("accessibility capture dir rejected: %q", dir)
212+ }
213+ }
214+ return nil
215+}
216+
217+// parseAccessibilityCaptureName accepts only strict numbered capture names
218+// produced by Write: NNNNNN-(tree|location).json with a non-zero sequence.
219+func parseAccessibilityCaptureName(name string) (seq uint64, ok bool) {
220+ if !strings.HasSuffix(name, ".json") {
221+ return 0, false
222+ }
223+ base := strings.TrimSuffix(name, ".json")
224+ seqPart, kind, cut := strings.Cut(base, "-")
225+ if !cut || len(seqPart) != 6 || !accessibilityCaptureKindAllowed(kind) {
226+ return 0, false
227+ }
228+ for _, r := range seqPart {
229+ if r < '0' || r > '9' {
230+ return 0, false
231+ }
232+ }
233+ n, err := strconv.ParseUint(seqPart, 10, 64)
234+ if err != nil || n == 0 {
235+ return 0, false
236+ }
237+ return n, true
238+}
239+
240+// scanAccessibilityCaptureDir accounts only strict regular capture files.
241+// Symlinks and non-regular entries are ignored (never followed). Gaps are
242+// allowed; the returned sequence is the maximum numbered name present.
243+func scanAccessibilityCaptureDir(dir string) (maxSeq uint64, fileCount int, totalBytes int64, err error) {
244+ entries, err := os.ReadDir(dir)
245+ if err != nil {
246+ return 0, 0, 0, fmt.Errorf("read accessibility capture dir: %w", err)
247+ }
248+ for _, entry := range entries {
249+ name := entry.Name()
250+ seq, ok := parseAccessibilityCaptureName(name)
251+ if !ok {
252+ continue
253+ }
254+ path := filepath.Join(dir, name)
255+ info, lstatErr := os.Lstat(path)
256+ if lstatErr != nil {
257+ return 0, 0, 0, fmt.Errorf("lstat accessibility capture %s: %w", name, lstatErr)
258+ }
259+ if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
260+ continue
261+ }
262+ fileCount++
263+ totalBytes += info.Size()
264+ if seq > maxSeq {
265+ maxSeq = seq
266+ }
267+ }
268+ return maxSeq, fileCount, totalBytes, nil
269+}
270+
271+func newAccessibilityCapture(dir string) (*accessibilityCapture, error) {
272+ if err := validateAccessibilityCaptureDir(dir); err != nil {
273+ return nil, err
274+ }
275+ if err := os.MkdirAll(dir, accessibilityCaptureDirPerm); err != nil {
276+ return nil, fmt.Errorf("mkdir accessibility capture dir: %w", err)
277+ }
278+ if err := os.Chmod(dir, accessibilityCaptureDirPerm); err != nil {
279+ return nil, fmt.Errorf("chmod accessibility capture dir: %w", err)
280+ }
281+ maxSeq, fileCount, totalBytes, err := scanAccessibilityCaptureDir(dir)
282+ if err != nil {
283+ return nil, err
284+ }
285+ return &accessibilityCapture{
286+ dir: dir,
287+ seq: maxSeq,
288+ fileCount: fileCount,
289+ totalBytes: totalBytes,
290+ maxFiles: accessibilityCaptureDefaultMaxFiles,
291+ maxBytes: accessibilityCaptureDefaultMaxBytes,
292+ }, nil
293+}
294+
295+func accessibilityCaptureKindAllowed(kind string) bool {
296+ switch kind {
297+ case accessibilityCaptureKindTree, accessibilityCaptureKindLocation:
298+ return true
299+ default:
300+ return false
301+ }
302+}
303+
304+func (c *accessibilityCapture) Write(p accessibilityPayload) error {
305+ if c == nil || p.JSON == "" {
306+ return nil
307+ }
308+ if !accessibilityCaptureKindAllowed(p.Kind) {
309+ return fmt.Errorf("accessibility capture kind rejected: %q", p.Kind)
310+ }
311+ payloadBytes := int64(len(p.JSON))
312+ c.mu.Lock()
313+ defer c.mu.Unlock()
314+ if c.fileCount >= c.maxFiles {
315+ return fmt.Errorf("accessibility capture file budget exceeded (%d files)", c.maxFiles)
316+ }
317+ if c.totalBytes+payloadBytes > c.maxBytes {
318+ return fmt.Errorf("accessibility capture byte budget exceeded (%d bytes)", c.maxBytes)
319+ }
320+ next := c.seq + 1
321+ name := fmt.Sprintf("%06d-%s.json", next, p.Kind)
322+ path := filepath.Join(c.dir, name)
323+ file, err := openFileNoFollow(path, syscall.O_CREAT|syscall.O_EXCL|syscall.O_WRONLY, accessibilityCaptureFilePerm)
324+ if err != nil {
325+ return err
326+ }
327+ c.seq = next
328+ c.fileCount++
329+ c.totalBytes += payloadBytes
330+ _, writeErr := file.WriteString(p.JSON)
331+ chmodErr := file.Chmod(accessibilityCaptureFilePerm)
332+ closeErr := file.Close()
333+ if writeErr != nil {
334+ return writeErr
335+ }
336+ if chmodErr != nil {
337+ return chmodErr
338+ }
339+ return closeErr
340+}
341+
342+func (wv *WebView) enableAccessibilityCaptureIfRequested() error {
343+ if wv == nil || !accessibilityCaptureEnabled() {
344+ return nil
345+ }
346+ dir, err := accessibilityCaptureDir(wv.id)
347+ if err != nil {
348+ return err
349+ }
350+ capture, err := newAccessibilityCapture(dir)
351+ if err != nil {
352+ return err
353+ }
354+ wv.a11yCapture = capture
355+ wv.a11yWorker = newAccessibilityCaptureWorker(accessibilityCaptureQueueCapacity, wv.consumeAccessibilityPayload)
356+ logAccessibilityCaptureEnabled(wv.ctx, wv.id, dir)
357+ return nil
358+}
359+
360+func (wv *WebView) consumeAccessibilityPayload(p accessibilityPayload) {
361+ if wv == nil {
362+ return
363+ }
364+ wv.a11yStats.Observe(p)
365+ logAccessibilityUpdate(wv.ctx, wv.id, p)
366+ if wv.a11yCapture == nil {
367+ return
368+ }
369+ if err := wv.a11yCapture.Write(p); err != nil {
370+ logging.FromContext(wv.ctx).Warn().Err(err).
371+ Uint64("webview_id", uint64(wv.id)).
372+ Msg("cef: accessibility capture write failed")
373+ }
374+}
375+
376+// abortAccessibilityCaptureSetup closes a capture worker started during
377+// newWebView without emitting Destroy's summary. Safe to call when unset.
378+func (wv *WebView) abortAccessibilityCaptureSetup() {
379+ wv.finalizeAccessibilityCapture(false)
380+}
381+
382+// failNewWebViewAfterAccessibilityCapture centralizes cleanup for every
383+// newWebView error return after enableAccessibilityCaptureIfRequested may have
384+// started the worker. Closes the worker exactly once without Destroy summary.
385+func (wv *WebView) failNewWebViewAfterAccessibilityCapture(
386+ operation string,
387+ result syncdispatch.SyncDispatchResult,
388+) error {
389+ wv.abortAccessibilityCaptureSetup()
390+ wv.destroyViewBridgeOnGTKAsync()
391+ return errGTKSyncDispatchIncomplete(operation, result)
392+}
393+
394+// newWebViewInstallViewportSyncHooks is the viewport-hook install used by
395+// newWebView. Tests replace it to force incomplete GTK dispatch after capture
396+// init without a live display.
397+var newWebViewInstallViewportSyncHooks = func(wv *WebView) syncdispatch.SyncDispatchResult {
398+ return wv.installViewportSyncHooks()
399+}
400+
401+// installNewWebViewViewportHooksAfterCapture installs viewport sync hooks and
402+// aborts capture setup if GTK dispatch does not complete.
403+func (wv *WebView) installNewWebViewViewportHooksAfterCapture() error {
404+ result := newWebViewInstallViewportSyncHooks(wv)
405+ if result.Completed() {
406+ return nil
407+ }
408+ return wv.failNewWebViewAfterAccessibilityCapture("install CEF viewport sync hooks", result)
409+}
410+
411+func (wv *WebView) shutdownAccessibilityCapture() {
412+ wv.finalizeAccessibilityCapture(true)
413+}
414+
415+// finalizeAccessibilityCapture claims one-shot ownership of worker close.
416+// Pointers stay published so concurrent enqueueAccessibilityPayload reads are
417+// race-free; Submit observes the closed worker and returns false.
418+func (wv *WebView) finalizeAccessibilityCapture(emitSummary bool) {
419+ if wv == nil {
420+ return
421+ }
422+ if !wv.a11yCaptureFinalized.CompareAndSwap(false, true) {
423+ return
424+ }
425+ worker := wv.a11yWorker
426+ if worker == nil {
427+ return
428+ }
429+ worker.Close()
430+ if emitSummary {
431+ logAccessibilitySummary(wv.ctx, wv.id, wv.a11yStats.Snapshot(), worker.Dropped())
432+ }
433+}
A internal/infrastructure/cef/accessibility_capture_test.go +649 -0
@@ -0,0 +1,649 @@
1+package cef
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "os"
8+ "path/filepath"
9+ "sync"
10+ "sync/atomic"
11+ "testing"
12+ "time"
13+
14+ "github.com/bnema/dumber/internal/application/port"
15+ "github.com/bnema/dumber/internal/logging"
16+ "github.com/bnema/dumber/internal/shared/syncdispatch"
17+ purecef "github.com/bnema/purego-cef/cef"
18+ "github.com/rs/zerolog"
19+ "github.com/stretchr/testify/assert"
20+ "github.com/stretchr/testify/require"
21+)
22+
23+func TestAccessibilityCapture_WritesNumberedJSONWithSecurePerms(t *testing.T) {
24+ dir := t.TempDir()
25+ capture, err := newAccessibilityCapture(dir)
26+ require.NoError(t, err)
27+
28+ require.NoError(t, capture.Write(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `{"a":1}`}))
29+ require.NoError(t, capture.Write(accessibilityPayload{Kind: accessibilityCaptureKindLocation, JSON: `{"b":2}`}))
30+ require.NoError(t, capture.Write(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `{"c":3}`}))
31+
32+ want := []string{"000001-tree.json", "000002-location.json", "000003-tree.json"}
33+ entries, err := os.ReadDir(dir)
34+ require.NoError(t, err)
35+ require.Len(t, entries, 3)
36+ for i, name := range want {
37+ assert.Equal(t, name, entries[i].Name())
38+ info, err := entries[i].Info()
39+ require.NoError(t, err)
40+ assert.Equal(t, os.FileMode(0o600), info.Mode().Perm())
41+ }
42+}
43+
44+func TestAccessibilityCapture_IgnoresEmptyPayload(t *testing.T) {
45+ dir := t.TempDir()
46+ capture, err := newAccessibilityCapture(dir)
47+ require.NoError(t, err)
48+ require.NoError(t, capture.Write(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: ""}))
49+ entries, err := os.ReadDir(dir)
50+ require.NoError(t, err)
51+ assert.Empty(t, entries)
52+}
53+
54+func TestAccessibilityCapture_RejectsUnsafePaths(t *testing.T) {
55+ for _, dir := range []string{"", ".", "foo/../bar", "../escape", "a/../../b"} {
56+ _, err := newAccessibilityCapture(dir)
57+ require.Error(t, err, "dir=%q", dir)
58+ }
59+}
60+
61+func TestAccessibilityCapture_RejectsPathLikeAndUnknownKinds(t *testing.T) {
62+ dir := t.TempDir()
63+ capture, err := newAccessibilityCapture(dir)
64+ require.NoError(t, err)
65+
66+ for _, kind := range []string{
67+ "../escape",
68+ "tree/../../etc/passwd",
69+ "tree/foo",
70+ "/tmp/x",
71+ "unknown",
72+ "TREE",
73+ "",
74+ "tree\x00",
75+ } {
76+ writeErr := capture.Write(accessibilityPayload{Kind: kind, JSON: `{"x":1}`})
77+ require.Error(t, writeErr, "kind=%q", kind)
78+ }
79+
80+ entries, err := os.ReadDir(dir)
81+ require.NoError(t, err)
82+ assert.Empty(t, entries)
83+}
84+
85+func TestAccessibilityCapture_NeverOverwritesExistingNumberedFile(t *testing.T) {
86+ dir := t.TempDir()
87+ preexisting := filepath.Join(dir, "000001-tree.json")
88+ const original = `{"keep":true}`
89+ require.NoError(t, os.WriteFile(preexisting, []byte(original), 0o600))
90+
91+ capture, err := newAccessibilityCapture(dir)
92+ require.NoError(t, err)
93+ require.NoError(t, capture.Write(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `{"append":true}`}))
94+
95+ got, err := os.ReadFile(preexisting)
96+ require.NoError(t, err)
97+ assert.Equal(t, original, string(got))
98+
99+ appended, err := os.ReadFile(filepath.Join(dir, "000002-tree.json"))
100+ require.NoError(t, err)
101+ assert.JSONEq(t, `{"append":true}`, string(appended))
102+
103+ entries, err := os.ReadDir(dir)
104+ require.NoError(t, err)
105+ require.Len(t, entries, 2)
106+}
107+
108+func TestAccessibilityCapture_ReusedDirInitializesMaxSequence(t *testing.T) {
109+ dir := t.TempDir()
110+ require.NoError(t, os.WriteFile(filepath.Join(dir, "000001-tree.json"), []byte(`{"a":1}`), 0o600))
111+ require.NoError(t, os.WriteFile(filepath.Join(dir, "000003-location.json"), []byte(`{"b":2}`), 0o600))
112+ require.NoError(t, os.WriteFile(filepath.Join(dir, "readme.txt"), []byte("ignore"), 0o600))
113+ require.NoError(t, os.WriteFile(filepath.Join(dir, "000002-other.json"), []byte(`{}`), 0o600))
114+
115+ capture, err := newAccessibilityCapture(dir)
116+ require.NoError(t, err)
117+ require.Equal(t, uint64(3), capture.seq)
118+ require.Equal(t, 2, capture.fileCount)
119+ require.Equal(t, int64(len(`{"a":1}`)+len(`{"b":2}`)), capture.totalBytes)
120+
121+ require.NoError(t, capture.Write(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `{"c":3}`}))
122+ _, err = os.Stat(filepath.Join(dir, "000004-tree.json"))
123+ require.NoError(t, err)
124+ _, err = os.Stat(filepath.Join(dir, "000002-tree.json"))
125+ require.Error(t, err)
126+}
127+
128+func TestAccessibilityCapture_BudgetsIncludeExistingAndRejectBeforeCreate(t *testing.T) {
129+ dir := t.TempDir()
130+ existing := []byte(`{"keep":true}`)
131+ require.NoError(t, os.WriteFile(filepath.Join(dir, "000001-tree.json"), existing, 0o600))
132+
133+ capture, err := newAccessibilityCapture(dir)
134+ require.NoError(t, err)
135+ capture.maxFiles = 2
136+ capture.maxBytes = int64(len(existing) + 8)
137+
138+ require.NoError(t, capture.Write(accessibilityPayload{Kind: accessibilityCaptureKindLocation, JSON: `{"ok":1}`}))
139+ require.Equal(t, 2, capture.fileCount)
140+ require.FileExists(t, filepath.Join(dir, "000002-location.json"))
141+
142+ before, err := os.ReadDir(dir)
143+ require.NoError(t, err)
144+ require.Len(t, before, 2)
145+
146+ require.Error(t, capture.Write(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `x`}))
147+ afterFiles, err := os.ReadDir(dir)
148+ require.NoError(t, err)
149+ require.Len(t, afterFiles, 2, "file-budget reject must not create a file")
150+ require.Equal(t, uint64(2), capture.seq, "file-budget reject must not advance sequence")
151+
152+ capture.maxFiles = 10
153+ require.Error(t, capture.Write(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `too-large`}))
154+ afterBytes, err := os.ReadDir(dir)
155+ require.NoError(t, err)
156+ require.Len(t, afterBytes, 2, "byte-budget reject must not create a file")
157+ require.Equal(t, uint64(2), capture.seq, "byte-budget reject must not advance sequence")
158+}
159+
160+func TestAccessibilityCapture_ScanIgnoresSymlinkAndNonRegular(t *testing.T) {
161+ dir := t.TempDir()
162+ require.NoError(t, os.WriteFile(filepath.Join(dir, "000001-tree.json"), []byte(`{"ok":1}`), 0o600))
163+ require.NoError(t, os.Symlink(filepath.Join(dir, "000001-tree.json"), filepath.Join(dir, "000009-tree.json")))
164+ require.NoError(t, os.Mkdir(filepath.Join(dir, "000008-location.json"), 0o700))
165+
166+ capture, err := newAccessibilityCapture(dir)
167+ require.NoError(t, err)
168+ require.Equal(t, uint64(1), capture.seq)
169+ require.Equal(t, 1, capture.fileCount)
170+ require.Equal(t, int64(len(`{"ok":1}`)), capture.totalBytes)
171+
172+ require.NoError(t, capture.Write(accessibilityPayload{Kind: accessibilityCaptureKindLocation, JSON: `{"n":2}`}))
173+ require.FileExists(t, filepath.Join(dir, "000002-location.json"))
174+ info, err := os.Lstat(filepath.Join(dir, "000009-tree.json"))
175+ require.NoError(t, err)
176+ assert.NotZero(t, info.Mode()&os.ModeSymlink)
177+}
178+
179+func TestAccessibilityCapture_Namespace0700AndChmodExisting(t *testing.T) {
180+ root := t.TempDir()
181+ dir := filepath.Join(root, "webview-9")
182+ require.NoError(t, os.MkdirAll(dir, 0o755))
183+ info, err := os.Stat(dir)
184+ require.NoError(t, err)
185+ require.Equal(t, os.FileMode(0o755), info.Mode().Perm())
186+
187+ capture, err := newAccessibilityCapture(dir)
188+ require.NoError(t, err)
189+ require.NotNil(t, capture)
190+
191+ info, err = os.Stat(dir)
192+ require.NoError(t, err)
193+ assert.Equal(t, os.FileMode(0o700), info.Mode().Perm())
194+}
195+
196+func TestAccessibilityCapture_ConcurrentCounterNoCollision(t *testing.T) {
197+ dir := t.TempDir()
198+ capture, err := newAccessibilityCapture(dir)
199+ require.NoError(t, err)
200+
201+ const n = 32
202+ var wg sync.WaitGroup
203+ var writeErrs atomic.Int64
204+ wg.Add(n)
205+ for i := 0; i < n; i++ {
206+ go func() {
207+ defer wg.Done()
208+ if writeErr := capture.Write(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `{}`}); writeErr != nil {
209+ writeErrs.Add(1)
210+ }
211+ }()
212+ }
213+ wg.Wait()
214+ require.Equal(t, int64(0), writeErrs.Load())
215+
216+ entries, err := os.ReadDir(dir)
217+ require.NoError(t, err)
218+ require.Len(t, entries, n)
219+ seen := make(map[string]struct{}, n)
220+ for _, e := range entries {
221+ _, dup := seen[e.Name()]
222+ assert.False(t, dup, "duplicate %s", e.Name())
223+ seen[e.Name()] = struct{}{}
224+ }
225+}
226+
227+func TestAccessibilityCapture_TwoWebViewIDsIsolated(t *testing.T) {
228+ root := t.TempDir()
229+ dir1 := filepath.Join(root, "webview-1")
230+ dir2 := filepath.Join(root, "webview-2")
231+ c1, err := newAccessibilityCapture(dir1)
232+ require.NoError(t, err)
233+ c2, err := newAccessibilityCapture(dir2)
234+ require.NoError(t, err)
235+
236+ var wg sync.WaitGroup
237+ var writeErrs atomic.Int64
238+ wg.Add(2)
239+ go func() {
240+ defer wg.Done()
241+ if writeErr := c1.Write(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `{"id":1}`}); writeErr != nil {
242+ writeErrs.Add(1)
243+ }
244+ }()
245+ go func() {
246+ defer wg.Done()
247+ if writeErr := c2.Write(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `{"id":2}`}); writeErr != nil {
248+ writeErrs.Add(1)
249+ }
250+ }()
251+ wg.Wait()
252+ require.Equal(t, int64(0), writeErrs.Load())
253+
254+ b1, err := os.ReadFile(filepath.Join(dir1, "000001-tree.json"))
255+ require.NoError(t, err)
256+ b2, err := os.ReadFile(filepath.Join(dir2, "000001-tree.json"))
257+ require.NoError(t, err)
258+ assert.JSONEq(t, `{"id":1}`, string(b1))
259+ assert.JSONEq(t, `{"id":2}`, string(b2))
260+}
261+
262+func TestAccessibilityCaptureDir_UsesLogDirNamespace(t *testing.T) {
263+ home := t.TempDir()
264+ t.Setenv("ENV", "")
265+ t.Setenv("HOME", home)
266+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
267+ t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
268+ t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
269+ t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
270+
271+ dir, err := accessibilityCaptureDir(42)
272+ require.NoError(t, err)
273+ assert.Equal(t, filepath.Join(home, ".local", "state", "dumber", "logs", "a11y-capture", "webview-42"), dir)
274+}
275+
276+func TestAccessibilityCaptureEnabled_RequiresExactOne(t *testing.T) {
277+ t.Setenv("DUMBER_A11Y_CAPTURE", "")
278+ assert.False(t, accessibilityCaptureEnabled())
279+ t.Setenv("DUMBER_A11Y_CAPTURE", "0")
280+ assert.False(t, accessibilityCaptureEnabled())
281+ t.Setenv("DUMBER_A11Y_CAPTURE", "true")
282+ assert.False(t, accessibilityCaptureEnabled())
283+ t.Setenv("DUMBER_A11Y_CAPTURE", "1")
284+ assert.True(t, accessibilityCaptureEnabled())
285+}
286+
287+func TestAccessibilityCaptureWorker_DropsWhenFullThenCloseDrains(t *testing.T) {
288+ var (
289+ mu sync.Mutex
290+ got []accessibilityPayload
291+ )
292+ w := &accessibilityCaptureWorker{
293+ queue: make(chan accessibilityPayload, 2),
294+ consume: func(p accessibilityPayload) {
295+ mu.Lock()
296+ got = append(got, p)
297+ mu.Unlock()
298+ },
299+ }
300+ require.True(t, w.Submit(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: "1"}))
301+ require.True(t, w.Submit(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: "2"}))
302+ require.False(t, w.Submit(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: "3"}))
303+ assert.Equal(t, uint64(1), w.Dropped())
304+
305+ w.wg.Add(1)
306+ go w.loop()
307+ w.Close()
308+
309+ mu.Lock()
310+ defer mu.Unlock()
311+ require.Len(t, got, 2)
312+ assert.Equal(t, "1", got[0].JSON)
313+ assert.Equal(t, "2", got[1].JSON)
314+ require.False(t, w.Submit(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: "4"}))
315+}
316+
317+func TestAccessibilityCaptureWorker_NonBlockingUnderBackpressure(t *testing.T) {
318+ block := make(chan struct{})
319+ started := make(chan struct{})
320+ var consumed atomic.Int64
321+ w := newAccessibilityCaptureWorker(2, func(accessibilityPayload) {
322+ select {
323+ case <-started:
324+ default:
325+ close(started)
326+ }
327+ <-block
328+ consumed.Add(1)
329+ })
330+
331+ require.True(t, w.Submit(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: "a"}))
332+ <-started
333+ require.True(t, w.Submit(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: "b"}))
334+ require.True(t, w.Submit(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: "c"}))
335+
336+ done := make(chan struct{})
337+ go func() {
338+ defer close(done)
339+ assert.False(t, w.Submit(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: "d"}))
340+ }()
341+ select {
342+ case <-done:
343+ case <-time.After(2 * time.Second):
344+ t.Fatal("Submit blocked under backpressure")
345+ }
346+ assert.Equal(t, uint64(1), w.Dropped())
347+
348+ close(block)
349+ w.Close()
350+ assert.Equal(t, int64(3), consumed.Load())
351+}
352+
353+func TestAccessibilityStats_ExactAverages(t *testing.T) {
354+ var stats accessibilityStats
355+ stats.Observe(accessibilityPayload{Kind: accessibilityCaptureKindTree, Bytes: 1000, SerializeNanos: 2_000_000})
356+ stats.Observe(accessibilityPayload{Kind: accessibilityCaptureKindTree, Bytes: 3000, SerializeNanos: 4_000_000})
357+ stats.Observe(accessibilityPayload{Kind: accessibilityCaptureKindLocation, Bytes: 100, SerializeNanos: 500_000})
358+
359+ snap := stats.Snapshot()
360+ assert.Equal(t, 3, snap.Count)
361+ assert.Equal(t, 2, snap.TreeCount)
362+ assert.Equal(t, 1, snap.LocationCount)
363+ assert.Equal(t, int64(4100), snap.TotalBytes)
364+ assert.Equal(t, 100, snap.MinBytes)
365+ assert.Equal(t, 3000, snap.MaxBytes)
366+ assert.InDelta(t, 1366.6666666666667, snap.AverageBytes, 1e-9)
367+ assert.Equal(t, int64(6_500_000), snap.TotalSerializeNanos)
368+ assert.InDelta(t, 2_166_666.6666666665, snap.AverageSerializeNanos, 1e-9)
369+ assert.Equal(t, int64(4_000_000), snap.MaxSerializeNanos)
370+}
371+
372+func TestAccessibilityStats_EmptySnapshotZero(t *testing.T) {
373+ var stats accessibilityStats
374+ snap := stats.Snapshot()
375+ assert.Equal(t, accessibilityStatsSnapshot{}, snap)
376+}
377+
378+func TestAccessibilityCapture_InactiveWithoutEnvZeroWork(t *testing.T) {
379+ t.Setenv("DUMBER_A11Y_CAPTURE", "")
380+ var output bytes.Buffer
381+ logger := zerolog.New(&output).Level(zerolog.DebugLevel)
382+ ctx := logging.WithContext(context.Background(), logger)
383+
384+ wv := &WebView{ctx: ctx, id: port.WebViewID(7), viewBridge: &Cef2gtkAdapter{}}
385+ require.Nil(t, wv.a11yWorker)
386+ require.Nil(t, wv.a11yCapture)
387+
388+ rh := newDumberRenderHandler(wv)
389+ require.NotNil(t, rh)
390+ handler, ok := rh.GetAccessibilityHandler().(*accessibilityHandler)
391+ require.True(t, ok)
392+ require.Nil(t, handler.sink)
393+
394+ writeCalls := 0
395+ handler.writeJSON = func(purecef.Value, purecef.JsonWriterOptions) string {
396+ writeCalls++
397+ return `{"x":1}`
398+ }
399+ handler.OnAccessibilityTreeChange(stubCEFValue{})
400+ handler.OnAccessibilityLocationChange(stubCEFValue{})
401+ assert.Equal(t, 0, writeCalls)
402+ assert.Equal(t, accessibilityStatsSnapshot{}, wv.a11yStats.Snapshot())
403+ assert.Empty(t, output.String())
404+ assert.False(t, wv.enqueueAccessibilityPayload(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `{}`}))
405+}
406+
407+func TestAccessibilityCapture_ConsumeLogsAndWritesOffCallback(t *testing.T) {
408+ dir := t.TempDir()
409+ capture, err := newAccessibilityCapture(dir)
410+ require.NoError(t, err)
411+
412+ var output bytes.Buffer
413+ logger := zerolog.New(&output).Level(zerolog.InfoLevel)
414+ ctx := logging.WithContext(context.Background(), logger)
415+
416+ wv := &WebView{
417+ ctx: ctx,
418+ id: port.WebViewID(3),
419+ a11yCapture: capture,
420+ }
421+ wv.a11yWorker = newAccessibilityCaptureWorker(8, wv.consumeAccessibilityPayload)
422+
423+ require.True(t, wv.enqueueAccessibilityPayload(accessibilityPayload{
424+ Kind: accessibilityCaptureKindTree, JSON: `{"ok":true}`, Bytes: 11, SerializeNanos: 123,
425+ }))
426+ require.Eventually(t, func() bool {
427+ _, err := os.Stat(filepath.Join(dir, "000001-tree.json"))
428+ return err == nil
429+ }, 2*time.Second, 10*time.Millisecond)
430+ wv.a11yWorker.Close()
431+
432+ snap := wv.a11yStats.Snapshot()
433+ assert.Equal(t, 1, snap.Count)
434+ assert.Equal(t, 1, snap.TreeCount)
435+ assert.Equal(t, 11, snap.MaxBytes)
436+
437+ var found bool
438+ dec := json.NewDecoder(bytes.NewReader(output.Bytes()))
439+ for dec.More() {
440+ var rec map[string]any
441+ require.NoError(t, dec.Decode(&rec))
442+ if rec["message"] == "cef: accessibility update" {
443+ found = true
444+ assert.InDelta(t, float64(3), rec["webview_id"], 0)
445+ assert.Equal(t, accessibilityCaptureKindTree, rec["kind"])
446+ assert.InDelta(t, float64(11), rec["bytes"], 0)
447+ assert.InDelta(t, float64(123), rec["serialize_ns"], 0)
448+ }
449+ }
450+ assert.True(t, found)
451+}
452+
453+func TestAccessibilityCapture_DestroyEmitsSummary(t *testing.T) {
454+ dir := t.TempDir()
455+ capture, err := newAccessibilityCapture(dir)
456+ require.NoError(t, err)
457+
458+ var output bytes.Buffer
459+ logger := zerolog.New(&output).Level(zerolog.InfoLevel)
460+ ctx := logging.WithContext(context.Background(), logger)
461+
462+ wv := &WebView{
463+ ctx: ctx,
464+ id: port.WebViewID(5),
465+ a11yCapture: capture,
466+ }
467+ wv.a11yWorker = newAccessibilityCaptureWorker(4, wv.consumeAccessibilityPayload)
468+ require.True(t, wv.enqueueAccessibilityPayload(accessibilityPayload{
469+ Kind: accessibilityCaptureKindLocation, JSON: `{}`, Bytes: 2, SerializeNanos: 50,
470+ }))
471+ require.Eventually(t, func() bool {
472+ return wv.a11yStats.Snapshot().Count == 1
473+ }, 2*time.Second, 10*time.Millisecond)
474+
475+ worker := wv.a11yWorker
476+ wv.shutdownAccessibilityCapture()
477+
478+ require.True(t, wv.a11yCaptureFinalized.Load())
479+ require.Same(t, worker, wv.a11yWorker)
480+ require.NotNil(t, wv.a11yCapture)
481+
482+ var found bool
483+ dec := json.NewDecoder(bytes.NewReader(output.Bytes()))
484+ for dec.More() {
485+ var rec map[string]any
486+ require.NoError(t, dec.Decode(&rec))
487+ if rec["message"] == "cef: accessibility summary" {
488+ found = true
489+ assert.InDelta(t, float64(5), rec["webview_id"], 0)
490+ assert.InDelta(t, float64(1), rec["count"], 0)
491+ assert.InDelta(t, float64(1), rec["location_count"], 0)
492+ assert.InDelta(t, float64(0), rec["dropped"], 0)
493+ }
494+ }
495+ assert.True(t, found)
496+ require.False(t, wv.enqueueAccessibilityPayload(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `{}`}))
497+}
498+
499+func TestInstallNewWebViewViewportHooksAfterCapture_FailureClosesWorkerOnce(t *testing.T) {
500+ home := t.TempDir()
501+ t.Setenv("HOME", home)
502+ t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
503+ t.Setenv("DUMBER_A11Y_CAPTURE", "1")
504+
505+ prev := newWebViewInstallViewportSyncHooks
506+ t.Cleanup(func() { newWebViewInstallViewportSyncHooks = prev })
507+ newWebViewInstallViewportSyncHooks = func(*WebView) syncdispatch.SyncDispatchResult {
508+ return syncdispatch.SyncDispatchResult{
509+ Label: "cef.install_viewport_sync_hooks",
510+ Status: syncdispatch.SyncDispatchTimedOut,
511+ }
512+ }
513+
514+ var output bytes.Buffer
515+ logger := zerolog.New(&output).Level(zerolog.InfoLevel)
516+ ctx := logging.WithContext(context.Background(), logger)
517+
518+ wv := &WebView{
519+ ctx: ctx,
520+ id: port.WebViewID(99),
521+ gtkSyncDispatch: func(fn func()) {
522+ if fn != nil {
523+ fn()
524+ }
525+ },
526+ gtkSyncIsOwner: func() bool { return true },
527+ viewBridge: &Cef2gtkAdapter{},
528+ }
529+ require.NoError(t, wv.enableAccessibilityCaptureIfRequested())
530+ worker := wv.a11yWorker
531+ require.NotNil(t, worker)
532+
533+ err := wv.installNewWebViewViewportHooksAfterCapture()
534+ require.Error(t, err)
535+ require.Contains(t, err.Error(), "install CEF viewport sync hooks")
536+ require.True(t, wv.a11yCaptureFinalized.Load())
537+ require.Same(t, worker, wv.a11yWorker, "published worker pointer must stay immutable")
538+ require.NotNil(t, wv.a11yCapture, "published capture pointer must stay immutable")
539+ require.True(t, worker.closed)
540+ require.False(t, worker.Submit(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `{}`}))
541+ require.False(t, wv.enqueueAccessibilityPayload(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `{}`}))
542+
543+ wv.shutdownAccessibilityCapture()
544+ wv.Destroy()
545+
546+ summaryCount := 0
547+ dec := json.NewDecoder(bytes.NewReader(output.Bytes()))
548+ for dec.More() {
549+ var rec map[string]any
550+ require.NoError(t, dec.Decode(&rec))
551+ if rec["message"] == "cef: accessibility summary" {
552+ summaryCount++
553+ }
554+ }
555+ assert.Equal(t, 0, summaryCount, "failed init must not emit Destroy summary")
556+}
557+
558+func TestShutdownAccessibilityCapture_IdempotentAfterAbort(t *testing.T) {
559+ dir := t.TempDir()
560+ capture, err := newAccessibilityCapture(dir)
561+ require.NoError(t, err)
562+
563+ var output bytes.Buffer
564+ logger := zerolog.New(&output).Level(zerolog.InfoLevel)
565+ ctx := logging.WithContext(context.Background(), logger)
566+
567+ wv := &WebView{ctx: ctx, id: port.WebViewID(7), a11yCapture: capture}
568+ wv.a11yWorker = newAccessibilityCaptureWorker(2, wv.consumeAccessibilityPayload)
569+
570+ wv.abortAccessibilityCaptureSetup()
571+ require.True(t, wv.a11yCaptureFinalized.Load())
572+ require.NotNil(t, wv.a11yWorker)
573+ require.True(t, wv.a11yWorker.closed)
574+ require.NotNil(t, wv.a11yCapture)
575+ wv.shutdownAccessibilityCapture()
576+ wv.shutdownAccessibilityCapture()
577+
578+ summaryCount := 0
579+ dec := json.NewDecoder(bytes.NewReader(output.Bytes()))
580+ for dec.More() {
581+ var rec map[string]any
582+ require.NoError(t, dec.Decode(&rec))
583+ if rec["message"] == "cef: accessibility summary" {
584+ summaryCount++
585+ }
586+ }
587+ assert.Equal(t, 0, summaryCount)
588+}
589+
590+func TestEnqueueAccessibilityPayload_ConcurrentWithShutdownRaceFree(t *testing.T) {
591+ dir := t.TempDir()
592+ capture, err := newAccessibilityCapture(dir)
593+ require.NoError(t, err)
594+
595+ var output bytes.Buffer
596+ logger := zerolog.New(&output).Level(zerolog.InfoLevel)
597+ ctx := logging.WithContext(context.Background(), logger)
598+
599+ wv := &WebView{ctx: ctx, id: port.WebViewID(11), a11yCapture: capture}
600+ wv.a11yWorker = newAccessibilityCaptureWorker(64, wv.consumeAccessibilityPayload)
601+ worker := wv.a11yWorker
602+
603+ const producers = 8
604+ const perProducer = 200
605+ var wg sync.WaitGroup
606+ start := make(chan struct{})
607+ for range producers {
608+ wg.Add(1)
609+ go func() {
610+ defer wg.Done()
611+ <-start
612+ for range perProducer {
613+ _ = wv.enqueueAccessibilityPayload(accessibilityPayload{
614+ Kind: accessibilityCaptureKindTree, JSON: `{}`, Bytes: 2, SerializeNanos: 1,
615+ })
616+ }
617+ }()
618+ }
619+ wg.Add(1)
620+ go func() {
621+ defer wg.Done()
622+ <-start
623+ wv.shutdownAccessibilityCapture()
624+ }()
625+ close(start)
626+ wg.Wait()
627+
628+ require.True(t, wv.a11yCaptureFinalized.Load())
629+ require.Same(t, worker, wv.a11yWorker)
630+ require.NotNil(t, wv.a11yCapture)
631+ require.True(t, worker.closed)
632+ assert.False(t, wv.enqueueAccessibilityPayload(accessibilityPayload{
633+ Kind: accessibilityCaptureKindTree, JSON: `{}`,
634+ }))
635+
636+ wv.shutdownAccessibilityCapture()
637+ wv.abortAccessibilityCaptureSetup()
638+
639+ summaryCount := 0
640+ dec := json.NewDecoder(bytes.NewReader(output.Bytes()))
641+ for dec.More() {
642+ var rec map[string]any
643+ require.NoError(t, dec.Decode(&rec))
644+ if rec["message"] == "cef: accessibility summary" {
645+ summaryCount++
646+ }
647+ }
648+ assert.Equal(t, 1, summaryCount, "successful shutdown must emit exactly one summary")
649+}
A internal/infrastructure/cef/accessibility_enable_test.go +174 -0
@@ -0,0 +1,174 @@
1+package cef
2+
3+import (
4+ "context"
5+ "sync"
6+ "testing"
7+
8+ purecef "github.com/bnema/purego-cef/cef"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/dumber/internal/application/port"
13+)
14+
15+type accessibilityStateHost struct {
16+ purecef.BrowserHost
17+
18+ mu sync.Mutex
19+ states []purecef.State
20+}
21+
22+func (h *accessibilityStateHost) SetAccessibilityState(state purecef.State) {
23+ h.mu.Lock()
24+ defer h.mu.Unlock()
25+ h.states = append(h.states, state)
26+}
27+
28+func (h *accessibilityStateHost) recordedStates() []purecef.State {
29+ h.mu.Lock()
30+ defer h.mu.Unlock()
31+ out := make([]purecef.State, len(h.states))
32+ copy(out, h.states)
33+ return out
34+}
35+
36+func TestWebView_EnableAccessibility_NoOpWhenDestroyedOrHostNil(t *testing.T) {
37+ t.Parallel()
38+
39+ destroyed := &WebView{}
40+ destroyed.destroyed.Store(true)
41+ host := &accessibilityStateHost{}
42+ destroyed.host = host
43+ destroyed.EnableAccessibility()
44+ assert.Empty(t, host.recordedStates())
45+ assert.False(t, destroyed.a11yRequested.Load())
46+
47+ noHost := &WebView{}
48+ noHost.EnableAccessibility()
49+ assert.False(t, noHost.a11yEnabled.Load())
50+ assert.True(t, noHost.a11yRequested.Load(), "early request must persist while host is nil")
51+}
52+
53+func TestWebView_EnableAccessibility_IdempotentOncePerWebView(t *testing.T) {
54+ t.Parallel()
55+
56+ host := &accessibilityStateHost{}
57+ wv := &WebView{host: host}
58+
59+ wv.EnableAccessibility()
60+ wv.EnableAccessibility()
61+ wv.EnableAccessibility()
62+
63+ states := host.recordedStates()
64+ require.Len(t, states, 1)
65+ assert.Equal(t, purecef.StateStateEnabled, states[0])
66+ assert.True(t, wv.a11yEnabled.Load())
67+}
68+
69+func TestWebView_EnableAccessibility_IndependentPerWebView(t *testing.T) {
70+ t.Parallel()
71+
72+ hostA := &accessibilityStateHost{}
73+ hostB := &accessibilityStateHost{}
74+ wvA := &WebView{host: hostA}
75+ wvB := &WebView{host: hostB}
76+
77+ wvA.EnableAccessibility()
78+ wvA.EnableAccessibility()
79+ wvB.EnableAccessibility()
80+
81+ require.Len(t, hostA.recordedStates(), 1)
82+ require.Len(t, hostB.recordedStates(), 1)
83+ assert.Equal(t, purecef.StateStateEnabled, hostA.recordedStates()[0])
84+ assert.Equal(t, purecef.StateStateEnabled, hostB.recordedStates()[0])
85+}
86+
87+func TestWebView_EnableAccessibility_PersistsUntilHostAttachment(t *testing.T) {
88+ t.Parallel()
89+
90+ wv := &WebView{}
91+ wv.EnableAccessibility()
92+ assert.True(t, wv.a11yRequested.Load())
93+ assert.False(t, wv.a11yEnabled.Load())
94+
95+ host := &accessibilityStateHost{}
96+ wv.host = host
97+ wv.applyAccessibilityIfReady()
98+
99+ require.Len(t, host.recordedStates(), 1)
100+ assert.Equal(t, purecef.StateStateEnabled, host.recordedStates()[0])
101+ assert.True(t, wv.a11yEnabled.Load())
102+}
103+
104+func TestWebView_EnableAccessibility_ConcurrentCallsEnableOnce(t *testing.T) {
105+ t.Parallel()
106+
107+ host := &accessibilityStateHost{}
108+ wv := &WebView{host: host}
109+
110+ var wg sync.WaitGroup
111+ for range 32 {
112+ wg.Add(1)
113+ go func() {
114+ defer wg.Done()
115+ wv.EnableAccessibility()
116+ }()
117+ }
118+ wg.Wait()
119+
120+ require.Len(t, host.recordedStates(), 1)
121+ assert.Equal(t, purecef.StateStateEnabled, host.recordedStates()[0])
122+}
123+
124+func TestWebView_EnableAccessibility_RequestThenDestroyBeforeAttachmentNeverEnables(t *testing.T) {
125+ t.Parallel()
126+
127+ wv := &WebView{}
128+ wv.EnableAccessibility()
129+ assert.True(t, wv.a11yRequested.Load())
130+
131+ wv.Destroy()
132+
133+ host := &accessibilityStateHost{}
134+ wv.host = host
135+ wv.applyAccessibilityIfReady()
136+ wv.EnableAccessibility()
137+
138+ assert.Empty(t, host.recordedStates())
139+ assert.False(t, wv.a11yEnabled.Load())
140+}
141+
142+func TestFinishAfterCreated_AppliesPendingAccessibilityOnce(t *testing.T) {
143+ t.Parallel()
144+
145+ wv := &WebView{ctx: context.Background()}
146+ wv.EnableAccessibility()
147+ assert.False(t, wv.a11yEnabled.Load())
148+
149+ host := &accessibilityStateHost{}
150+ wv.host = host
151+ h := &handlerSet{wv: wv}
152+ h.finishAfterCreated(nil, host, afterCreatedState{})
153+ h.finishAfterCreated(nil, host, afterCreatedState{})
154+
155+ require.Len(t, host.recordedStates(), 1)
156+ assert.Equal(t, purecef.StateStateEnabled, host.recordedStates()[0])
157+}
158+
159+func TestFinishAfterCreated_SkipsWhenNoPendingRequest(t *testing.T) {
160+ t.Parallel()
161+
162+ host := &accessibilityStateHost{}
163+ wv := &WebView{ctx: context.Background(), host: host}
164+ h := &handlerSet{wv: wv}
165+ h.finishAfterCreated(nil, host, afterCreatedState{})
166+
167+ assert.Empty(t, host.recordedStates())
168+ assert.False(t, wv.a11yEnabled.Load())
169+}
170+
171+func TestWebView_ImplementsAccessibilityEnabler(t *testing.T) {
172+ t.Parallel()
173+ var _ port.AccessibilityEnabler = (*WebView)(nil)
174+}
A internal/infrastructure/cef/accessibility_handler.go +62 -0
@@ -0,0 +1,62 @@
1+package cef
2+
3+import (
4+ "time"
5+
6+ purecef "github.com/bnema/purego-cef/cef"
7+)
8+
9+// accessibilityPayload is the UI-thread serialization result handed to a
10+// non-blocking sink. Capture, stats, and I/O happen off the CEF UI thread.
11+type accessibilityPayload struct {
12+ Kind string
13+ JSON string
14+ Bytes int
15+ SerializeNanos int64
16+}
17+
18+type accessibilityHandler struct {
19+ sink func(accessibilityPayload) bool
20+ writeJSON func(purecef.Value, purecef.JsonWriterOptions) string
21+}
22+
23+var _ purecef.AccessibilityHandler = (*accessibilityHandler)(nil)
24+
25+func newAccessibilityHandler(sink func(accessibilityPayload) bool) purecef.AccessibilityHandler {
26+ return &accessibilityHandler{
27+ sink: sink,
28+ writeJSON: purecef.WriteJson,
29+ }
30+}
31+
32+func (h *accessibilityHandler) OnAccessibilityTreeChange(value purecef.Value) {
33+ h.emit(accessibilityCaptureKindTree, value)
34+}
35+
36+func (h *accessibilityHandler) OnAccessibilityLocationChange(value purecef.Value) {
37+ h.emit(accessibilityCaptureKindLocation, value)
38+}
39+
40+func (h *accessibilityHandler) emit(kind string, value purecef.Value) {
41+ if h == nil || h.sink == nil {
42+ return
43+ }
44+ if value == nil {
45+ return
46+ }
47+ started := time.Now()
48+ jsonText := h.writeJSON(value, purecef.JsonWriterOptionsJsonWriterDefault)
49+ h.sink(accessibilityPayload{
50+ Kind: kind,
51+ JSON: jsonText,
52+ Bytes: len(jsonText),
53+ SerializeNanos: time.Since(started).Nanoseconds(),
54+ })
55+}
56+
57+func (wv *WebView) enqueueAccessibilityPayload(p accessibilityPayload) bool {
58+ if wv == nil || wv.a11yWorker == nil {
59+ return false
60+ }
61+ return wv.a11yWorker.Submit(p)
62+}
A internal/infrastructure/cef/accessibility_handler_test.go +153 -0
@@ -0,0 +1,153 @@
1+package cef
2+
3+import (
4+ "testing"
5+
6+ purecef "github.com/bnema/purego-cef/cef"
7+ cefmocks "github.com/bnema/purego-cef/cef/mocks"
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+type stubCEFValue struct{}
13+
14+func (stubCEFValue) IsValid() bool { return true }
15+func (stubCEFValue) IsOwned() bool { return false }
16+func (stubCEFValue) IsReadOnly() bool { return true }
17+func (stubCEFValue) IsSame(purecef.Value) bool { return false }
18+func (stubCEFValue) IsEqual(purecef.Value) bool { return false }
19+func (stubCEFValue) Copy() purecef.Value { return stubCEFValue{} }
20+func (stubCEFValue) GetType() purecef.ValueType { return 0 }
21+func (stubCEFValue) GetBool() int32 { return 0 }
22+func (stubCEFValue) GetInt() int32 { return 0 }
23+func (stubCEFValue) GetDouble() float64 { return 0 }
24+func (stubCEFValue) GetString() string { return "" }
25+func (stubCEFValue) GetBinary() purecef.BinaryValue { return nil }
26+func (stubCEFValue) GetDictionary() purecef.DictionaryValue { return nil }
27+func (stubCEFValue) GetList() purecef.ListValue { return nil }
28+func (stubCEFValue) SetNull() int32 { return 0 }
29+func (stubCEFValue) SetBool(int32) int32 { return 0 }
30+func (stubCEFValue) SetInt(int32) int32 { return 0 }
31+func (stubCEFValue) SetDouble(float64) int32 { return 0 }
32+func (stubCEFValue) SetString(string) int32 { return 0 }
33+func (stubCEFValue) SetBinary(purecef.BinaryValue) int32 { return 0 }
34+func (stubCEFValue) SetDictionary(purecef.DictionaryValue) int32 { return 0 }
35+func (stubCEFValue) SetList(purecef.ListValue) int32 { return 0 }
36+
37+func TestRenderHandlerExposesAccessibilityAtWrapTime(t *testing.T) {
38+ h := &dumberRenderHandler{a11y: newAccessibilityHandler(nil)}
39+ if h.GetAccessibilityHandler() == nil {
40+ t.Fatal("CEF would omit the accessibility override at wrap time")
41+ }
42+}
43+
44+func TestRenderHandlerExposesAccessibilityWhenMainExists(t *testing.T) {
45+ // Must fail open (nil) when a11y was not installed, even if main exists —
46+ // never fall back to h.main.GetAccessibilityHandler().
47+ mainHandler := cefmocks.NewMockRenderHandler(t)
48+ h := &dumberRenderHandler{main: mainHandler, a11y: nil}
49+ assert.Nil(t, h.GetAccessibilityHandler())
50+ mainHandler.AssertNotCalled(t, "GetAccessibilityHandler")
51+
52+ h.a11y = newAccessibilityHandler(nil)
53+ require.NotNil(t, h.GetAccessibilityHandler())
54+ mainHandler.AssertNotCalled(t, "GetAccessibilityHandler")
55+}
56+
57+func TestNewDumberRenderHandlerInstallsNonNilAccessibilityHandler(t *testing.T) {
58+ require.Nil(t, newDumberRenderHandler(nil))
59+
60+ wv := &WebView{viewBridge: &Cef2gtkAdapter{}}
61+ rh := newDumberRenderHandler(wv)
62+ require.NotNil(t, rh)
63+ require.NotNil(t, rh.GetAccessibilityHandler())
64+
65+ handler, ok := rh.GetAccessibilityHandler().(*accessibilityHandler)
66+ require.True(t, ok)
67+ assert.Nil(t, handler.sink)
68+}
69+
70+func TestAccessibilityHandler_NilSinkSkipsWriteJSON(t *testing.T) {
71+ calls := 0
72+ handler, ok := newAccessibilityHandler(nil).(*accessibilityHandler)
73+ require.True(t, ok)
74+ handler.writeJSON = func(purecef.Value, purecef.JsonWriterOptions) string {
75+ calls++
76+ return `{"unexpected":true}`
77+ }
78+
79+ handler.OnAccessibilityTreeChange(stubCEFValue{})
80+ handler.OnAccessibilityLocationChange(stubCEFValue{})
81+ assert.Equal(t, 0, calls)
82+}
83+
84+func TestAccessibilityHandler_NilValueIgnored(t *testing.T) {
85+ calls := 0
86+ var got []accessibilityPayload
87+ handler, ok := newAccessibilityHandler(func(p accessibilityPayload) bool {
88+ got = append(got, p)
89+ return true
90+ }).(*accessibilityHandler)
91+ require.True(t, ok)
92+ handler.writeJSON = func(purecef.Value, purecef.JsonWriterOptions) string {
93+ calls++
94+ return `{}`
95+ }
96+
97+ handler.OnAccessibilityTreeChange(nil)
98+ handler.OnAccessibilityLocationChange(nil)
99+ assert.Equal(t, 0, calls)
100+ assert.Empty(t, got)
101+}
102+
103+func TestAccessibilityHandler_TreeAndLocationWriteJSONOnce(t *testing.T) {
104+ var writeCalls []string
105+ var got []accessibilityPayload
106+ handler, ok := newAccessibilityHandler(func(p accessibilityPayload) bool {
107+ got = append(got, p)
108+ return true
109+ }).(*accessibilityHandler)
110+ require.True(t, ok)
111+ handler.writeJSON = func(value purecef.Value, options purecef.JsonWriterOptions) string {
112+ require.Equal(t, purecef.JsonWriterOptionsJsonWriterDefault, options)
113+ require.NotNil(t, value)
114+ writeCalls = append(writeCalls, "write")
115+ return `{"ok":true}`
116+ }
117+
118+ handler.OnAccessibilityTreeChange(stubCEFValue{})
119+ handler.OnAccessibilityLocationChange(stubCEFValue{})
120+
121+ require.Len(t, writeCalls, 2)
122+ require.Len(t, got, 2)
123+ assert.Equal(t, accessibilityCaptureKindTree, got[0].Kind)
124+ assert.Equal(t, `{"ok":true}`, got[0].JSON)
125+ assert.Equal(t, len(`{"ok":true}`), got[0].Bytes)
126+ assert.GreaterOrEqual(t, got[0].SerializeNanos, int64(0))
127+ assert.Equal(t, accessibilityCaptureKindLocation, got[1].Kind)
128+ assert.Equal(t, `{"ok":true}`, got[1].JSON)
129+ assert.Equal(t, len(`{"ok":true}`), got[1].Bytes)
130+ assert.GreaterOrEqual(t, got[1].SerializeNanos, int64(0))
131+}
132+
133+func TestAccessibilityHandler_ProductionNoCaptureZeroWriteJSON(t *testing.T) {
134+ calls := 0
135+
136+ // Factory/production path without DUMBER_A11Y_CAPTURE=1: worker stays nil,
137+ // sink is truly nil, WriteJson must not run.
138+ wv := &WebView{viewBridge: &Cef2gtkAdapter{}}
139+ rh := newDumberRenderHandler(wv)
140+ require.NotNil(t, rh)
141+
142+ handler, ok := rh.GetAccessibilityHandler().(*accessibilityHandler)
143+ require.True(t, ok)
144+ require.Nil(t, handler.sink)
145+ handler.writeJSON = func(purecef.Value, purecef.JsonWriterOptions) string {
146+ calls++
147+ return `{}`
148+ }
149+
150+ handler.OnAccessibilityTreeChange(stubCEFValue{})
151+ handler.OnAccessibilityLocationChange(stubCEFValue{})
152+ assert.Equal(t, 0, calls)
153+}
A internal/infrastructure/cef/accessibility_trace.go +41 -0
@@ -0,0 +1,41 @@
1+package cef
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/application/port"
7+ "github.com/bnema/dumber/internal/logging"
8+)
9+
10+func logAccessibilityCaptureEnabled(ctx context.Context, webViewID port.WebViewID, captureDir string) {
11+ logging.FromContext(ctx).Info().
12+ Uint64("webview_id", uint64(webViewID)).
13+ Str("capture_dir", captureDir).
14+ Msg("cef: accessibility capture enabled")
15+}
16+
17+func logAccessibilityUpdate(ctx context.Context, webViewID port.WebViewID, p accessibilityPayload) {
18+ logging.FromContext(ctx).Info().
19+ Uint64("webview_id", uint64(webViewID)).
20+ Str("kind", p.Kind).
21+ Int("bytes", p.Bytes).
22+ Int64("serialize_ns", p.SerializeNanos).
23+ Msg("cef: accessibility update")
24+}
25+
26+func logAccessibilitySummary(ctx context.Context, webViewID port.WebViewID, snap accessibilityStatsSnapshot, dropped uint64) {
27+ logging.FromContext(ctx).Info().
28+ Uint64("webview_id", uint64(webViewID)).
29+ Int("count", snap.Count).
30+ Int("tree_count", snap.TreeCount).
31+ Int("location_count", snap.LocationCount).
32+ Int64("total_bytes", snap.TotalBytes).
33+ Int("min_bytes", snap.MinBytes).
34+ Int("max_bytes", snap.MaxBytes).
35+ Float64("average_bytes", snap.AverageBytes).
36+ Int64("total_serialize_ns", snap.TotalSerializeNanos).
37+ Float64("average_serialize_ns", snap.AverageSerializeNanos).
38+ Int64("max_serialize_ns", snap.MaxSerializeNanos).
39+ Uint64("dropped", dropped).
40+ Msg("cef: accessibility summary")
41+}
A internal/infrastructure/cef/accessibility_trace_test.go +28 -0
@@ -0,0 +1,28 @@
1+package cef
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "testing"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/logging"
11+ "github.com/rs/zerolog"
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+func TestLogAccessibilityCaptureEnabled_Fields(t *testing.T) {
17+ var output bytes.Buffer
18+ logger := zerolog.New(&output).Level(zerolog.InfoLevel)
19+ ctx := logging.WithContext(context.Background(), logger)
20+
21+ logAccessibilityCaptureEnabled(ctx, port.WebViewID(9), "/tmp/a11y-capture/webview-9")
22+
23+ var rec map[string]any
24+ require.NoError(t, json.Unmarshal(output.Bytes(), &rec))
25+ assert.Equal(t, "cef: accessibility capture enabled", rec["message"])
26+ assert.InDelta(t, float64(9), rec["webview_id"], 0)
27+ assert.Equal(t, "/tmp/a11y-capture/webview-9", rec["capture_dir"])
28+}
M internal/infrastructure/cef/factory.go +10 -4
@@ -156,6 +156,14 @@ func (f *WebViewFactory) newWebView(ctx context.Context) (*WebView, error) {
156 wv.handlers = handlers156 wv.handlers = handlers
157 wv.findCtrl = newFindController()157 wv.findCtrl = newFindController()
158 158
159+ // Capture worker must exist before NewClient so the lazily wrapped render
160+ // handler can install a real sink when DUMBER_A11Y_CAPTURE=1.
161+ if err := wv.enableAccessibilityCaptureIfRequested(); err != nil {
162+ logging.FromContext(ctx).Warn().Err(err).
163+ Uint64("webview_id", uint64(id)).
164+ Msg("cef: accessibility capture setup failed")
165+ }
166+
159 // Build a CEF client backed by our handlerSet.167 // Build a CEF client backed by our handlerSet.
160 // Store on WebView to prevent GC collection before CEF AddRef's it.168 // Store on WebView to prevent GC collection before CEF AddRef's it.
161 wv.client = purecef.NewClient(wv.handlers)169 wv.client = purecef.NewClient(wv.handlers)
@@ -164,10 +172,8 @@ func (f *WebViewFactory) newWebView(ctx context.Context) (*WebView, error) {
164 logging.FromContext(ctx).Warn().Err(err).Uint64("webview_id", uint64(id)).Msg("cef2gtk: failed to enable profiling")172 logging.FromContext(ctx).Warn().Err(err).Uint64("webview_id", uint64(id)).Msg("cef2gtk: failed to enable profiling")
165 }173 }
166 }174 }
167- viewportHooksResult := wv.installViewportSyncHooks()175+ if err := wv.installNewWebViewViewportHooksAfterCapture(); err != nil {
168- if !viewportHooksResult.Completed() {176+ return nil, err
169- wv.destroyViewBridgeOnGTKAsync()
170- return nil, errGTKSyncDispatchIncomplete("install CEF viewport sync hooks", viewportHooksResult)
171 }177 }
172 wv.startRenderStallWatchdog()178 wv.startRenderStallWatchdog()
173 return wv, nil179 return wv, nil
M internal/infrastructure/cef/render_handler_adapter.go +11 -2
@@ -19,6 +19,7 @@ type dumberRenderHandler struct {
19 main purecef.RenderHandler19 main purecef.RenderHandler
20 popup purecef.RenderHandler20 popup purecef.RenderHandler
21 popupSurface popupSurface21 popupSurface popupSurface
22+ a11y purecef.AccessibilityHandler
22 }23 }
23 24
24 var _ purecef.RenderHandler = (*dumberRenderHandler)(nil)25 var _ purecef.RenderHandler = (*dumberRenderHandler)(nil)
@@ -48,6 +49,14 @@ func newDumberRenderHandler(wv *WebView) purecef.RenderHandler {
48 return nil49 return nil
49 }50 }
50 h := &dumberRenderHandler{wv: wv}51 h := &dumberRenderHandler{wv: wv}
52+ // Install a non-nil AccessibilityHandler before CEF wraps the render
53+ // handler. Outside capture the sink is truly nil so callbacks return before
54+ // WriteJson; capture workers (Task 2) wire enqueueAccessibilityPayload.
55+ var sink func(accessibilityPayload) bool
56+ if wv.a11yWorker != nil {
57+ sink = wv.enqueueAccessibilityPayload
58+ }
59+ h.a11y = newAccessibilityHandler(sink)
51 60
52 var unsupportedPaintOnce sync.Once61 var unsupportedPaintOnce sync.Once
53 hooks := startupPresentationHooks(activeStartupTrace())62 hooks := startupPresentationHooks(activeStartupTrace())
@@ -110,10 +119,10 @@ func (h *dumberRenderHandler) renderTargetForPaint(elementType purecef.PaintElem
110 }119 }
111 120
112 func (h *dumberRenderHandler) GetAccessibilityHandler() purecef.AccessibilityHandler {121 func (h *dumberRenderHandler) GetAccessibilityHandler() purecef.AccessibilityHandler {
113- if h == nil || h.main == nil {122+ if h == nil {
114 return nil123 return nil
115 }124 }
116- return h.main.GetAccessibilityHandler()125+ return h.a11y
117 }126 }
118 127
119 func (h *dumberRenderHandler) GetRootScreenRect(browser purecef.Browser, rect *purecef.Rect) int32 {128 func (h *dumberRenderHandler) GetRootScreenRect(browser purecef.Browser, rect *purecef.Rect) int32 {
M internal/infrastructure/cef/webview.go +46 -0
@@ -37,6 +37,7 @@ var (
37 _ port.OAuthCallbackCapable = (*WebView)(nil)37 _ port.OAuthCallbackCapable = (*WebView)(nil)
38 _ port.PageScrollable = (*WebView)(nil)38 _ port.PageScrollable = (*WebView)(nil)
39 _ port.PageScrollCanceler = (*WebView)(nil)39 _ port.PageScrollCanceler = (*WebView)(nil)
40+ _ port.AccessibilityEnabler = (*WebView)(nil)
40 )41 )
41 42
42 // errDestroyed is returned when an operation is attempted on a destroyed WebView.43 // errDestroyed is returned when an operation is attempted on a destroyed WebView.
@@ -152,6 +153,19 @@ type WebView struct {
152 153
153 pageScrollQueue pageScrollQueue154 pageScrollQueue pageScrollQueue
154 155
156+ // a11yWorker receives serialized accessibility payloads off the CEF UI
157+ // thread. Production leaves it nil unless DUMBER_A11Y_CAPTURE=1.
158+ // After publication, a11yWorker/a11yCapture stay immutable; teardown uses
159+ // a11yCaptureFinalized so abort/shutdown close the worker once without
160+ // racing CEF callback sinks that still read these pointers.
161+ a11yWorker *accessibilityCaptureWorker
162+ a11yCapture *accessibilityCapture
163+ a11yCaptureFinalized atomic.Bool
164+ a11yStats accessibilityStats
165+ // a11yRequested records EnableAccessibility before the browser host exists.
166+ a11yRequested atomic.Bool
167+ a11yEnabled atomic.Bool
168+
155 // beginFrameTick drives CEF external BeginFrame requests while the GTK169 // beginFrameTick drives CEF external BeginFrame requests while the GTK
156 // widget is visible. Access is guarded by mu.170 // widget is visible. Access is guarded by mu.
157 beginFrameTick *gtk.TickCallback171 beginFrameTick *gtk.TickCallback
@@ -1222,6 +1236,7 @@ func (wv *WebView) Destroy() {
1222 if !wv.destroyed.CompareAndSwap(false, true) {1236 if !wv.destroyed.CompareAndSwap(false, true) {
1223 return1237 return
1224 }1238 }
1239+ wv.shutdownAccessibilityCapture()
1225 wv.resetPageScrollQueue()1240 wv.resetPageScrollQueue()
1226 wv.syntheticPopupMu.Lock()1241 wv.syntheticPopupMu.Lock()
1227 wv.syntheticPopups = nil1242 wv.syntheticPopups = nil
@@ -2534,6 +2549,37 @@ func (wv *WebView) CancelPageScroll(_ context.Context) {
2534 q.commitMu.Unlock()2549 q.commitMu.Unlock()
2535 }2550 }
2536 2551
2552+// EnableAccessibility records a request to enable CEF accessibility and applies
2553+// it once the browser host exists. Destroyed views never enable. Calls before
2554+// host attachment remain pending until OnAfterCreated applies them.
2555+func (wv *WebView) EnableAccessibility() {
2556+ if wv == nil || wv.destroyed.Load() {
2557+ return
2558+ }
2559+ wv.a11yRequested.Store(true)
2560+ wv.applyAccessibilityIfReady()
2561+}
2562+
2563+// applyAccessibilityIfReady performs exactly one SetAccessibilityState after a
2564+// pending request and a live host are both present.
2565+func (wv *WebView) applyAccessibilityIfReady() {
2566+ if wv == nil || wv.destroyed.Load() || !wv.a11yRequested.Load() || wv.a11yEnabled.Load() {
2567+ return
2568+ }
2569+ wv.mu.RLock()
2570+ host := wv.host
2571+ wv.mu.RUnlock()
2572+ if host == nil || !wv.a11yEnabled.CompareAndSwap(false, true) {
2573+ return
2574+ }
2575+ host.SetAccessibilityState(purecef.StateStateEnabled)
2576+ if wv.ctx != nil {
2577+ logging.FromContext(wv.ctx).Debug().
2578+ Uint64("webview_id", uint64(wv.id)).
2579+ Msg("cef: accessibility enabled")
2580+ }
2581+}
2582+
2537 func (wv *WebView) resetPageScrollQueue() {2583 func (wv *WebView) resetPageScrollQueue() {
2538 q := &wv.pageScrollQueue2584 q := &wv.pageScrollQueue
2539 q.mu.Lock()2585 q.mu.Lock()
M internal/infrastructure/cef/webview_handlers.go +1 -0
@@ -955,6 +955,7 @@ func (h *handlerSet) finishAfterCreated(
955 }955 }
956 h.wv.scheduleStartAdaptiveFrameRatePolling()956 h.wv.scheduleStartAdaptiveFrameRatePolling()
957 h.wv.scheduleStartBeginFrameLoop()957 h.wv.scheduleStartBeginFrameLoop()
958+ h.wv.applyAccessibilityIfReady()
958 h.wv.fireReadyToShow()959 h.wv.fireReadyToShow()
959 }960 }
960 961
M internal/infrastructure/config/defaults.go +31 -18
@@ -38,22 +38,23 @@ const (
38 defaultOmniboxAutoOpenOnNewPane = false38 defaultOmniboxAutoOpenOnNewPane = false
39 39
40 // Workspace defaults40 // Workspace defaults
41- defaultPaneActivationShortcut = "ctrl+p"41+ defaultPaneActivationShortcut = "ctrl+p"
42- defaultPaneTimeoutMilliseconds = 300042+ defaultPaneTimeoutMilliseconds = 3000
43- defaultTabActivationShortcut = "ctrl+t"43+ defaultTabActivationShortcut = "ctrl+t"
44- defaultTabTimeoutMilliseconds = 300044+ defaultTabTimeoutMilliseconds = 3000
45- defaultResizeActivationShortcut = "ctrl+n"45+ defaultResizeActivationShortcut = "ctrl+n"
46- defaultResizeTimeoutMilliseconds = 300046+ defaultResizeTimeoutMilliseconds = 3000
47- defaultPageActivationShortcut = "ctrl+y"47+ defaultPageActivationShortcut = "ctrl+y"
48- defaultPageTimeoutMilliseconds = 048+ defaultPageTimeoutMilliseconds = 0
49- defaultResizeStepPercent = 5.049+ defaultPageSequenceTimeoutMilliseconds = 500
50- defaultResizeMinPanePercent = 10.050+ defaultResizeStepPercent = 5.0
51- defaultTabBarPosition = "bottom"51+ defaultResizeMinPanePercent = 10.0
52- defaultPopupPlacement = "right"52+ defaultTabBarPosition = "bottom"
53- defaultExternalLinkBehavior = ExternalLinkBehaviorWindowed53+ defaultPopupPlacement = "right"
54- defaultExternalLinkPlacement = ExternalLinkPlacementRight54+ defaultExternalLinkBehavior = ExternalLinkBehaviorWindowed
55- defaultFloatingPaneWidthPct = 0.8255+ defaultExternalLinkPlacement = ExternalLinkPlacementRight
56- defaultFloatingPaneHeightPct = 0.7256+ defaultFloatingPaneWidthPct = 0.82
57+ defaultFloatingPaneHeightPct = 0.72
57 58
58 // Session defaults59 // Session defaults
59 defaultSessionActivationShortcut = "ctrl+o"60 defaultSessionActivationShortcut = "ctrl+o"
@@ -305,8 +306,9 @@ func DefaultConfig() *Config {
305 },306 },
306 },307 },
307 VimMode: VimModeConfig{308 VimMode: VimModeConfig{
308- ActivationShortcut: defaultPageActivationShortcut,309+ ActivationShortcut: defaultPageActivationShortcut,
309- TimeoutMilliseconds: defaultPageTimeoutMilliseconds,310+ TimeoutMilliseconds: defaultPageTimeoutMilliseconds,
311+ SequenceTimeoutMilliseconds: defaultPageSequenceTimeoutMilliseconds,
310 Actions: map[string]ActionBinding{312 Actions: map[string]ActionBinding{
311 "vim-scroll-left": {Keys: []string{"h"}, Desc: "Scroll page left"},313 "vim-scroll-left": {Keys: []string{"h"}, Desc: "Scroll page left"},
312 "vim-scroll-down": {Keys: []string{"j"}, Desc: "Scroll page down"},314 "vim-scroll-down": {Keys: []string{"j"}, Desc: "Scroll page down"},
@@ -316,6 +318,17 @@ func DefaultConfig() *Config {
316 "vim-scroll-up-fast": {Keys: []string{"shift+k"}, Desc: "Scroll page up fast"},318 "vim-scroll-up-fast": {Keys: []string{"shift+k"}, Desc: "Scroll page up fast"},
317 "confirm": {Keys: []string{"enter"}, Desc: "Confirm action"},319 "confirm": {Keys: []string{"enter"}, Desc: "Confirm action"},
318 "cancel": {Keys: []string{"escape"}, Desc: "Cancel/exit mode"},320 "cancel": {Keys: []string{"escape"}, Desc: "Cancel/exit mode"},
321+ "heading-next": {Keys: []string{"]]"}, Desc: "Jump to next heading"},
322+ "heading-prev": {Keys: []string{"[["}, Desc: "Jump to previous heading"},
323+ "code-next": {Keys: []string{"]c"}, Desc: "Jump to next code block"},
324+ "code-prev": {Keys: []string{"[c"}, Desc: "Jump to previous code block"},
325+ "table-next": {Keys: []string{"]t"}, Desc: "Jump to next table"},
326+ "image-next": {Keys: []string{"]i"}, Desc: "Jump to next image"},
327+ "list-next": {Keys: []string{"]l"}, Desc: "Jump to next list"},
328+ "outline": {Keys: []string{"gO"}, Desc: "Show page outline"},
329+ "yank-section": {Keys: []string{"yah"}, Desc: "Yank current section"},
330+ "half-page-down": {Keys: []string{"<C-d>"}, Desc: "Scroll half page down"},
331+ "half-page-up": {Keys: []string{"<C-u>"}, Desc: "Scroll half page up"},
319 },332 },
320 },333 },
321 ResizeMode: ResizeModeConfig{334 ResizeMode: ResizeModeConfig{
M internal/infrastructure/config/loader.go +2 -0
@@ -708,6 +708,8 @@ func (m *Manager) setWorkspaceDefaults(defaults *Config) {
708 m.viper.SetDefault("workspace.tab_mode.actions", defaults.Workspace.TabMode.Actions)708 m.viper.SetDefault("workspace.tab_mode.actions", defaults.Workspace.TabMode.Actions)
709 m.viper.SetDefault("workspace.vim_mode.activation_shortcut", defaults.Workspace.VimMode.ActivationShortcut)709 m.viper.SetDefault("workspace.vim_mode.activation_shortcut", defaults.Workspace.VimMode.ActivationShortcut)
710 m.viper.SetDefault("workspace.vim_mode.timeout_ms", defaults.Workspace.VimMode.TimeoutMilliseconds)710 m.viper.SetDefault("workspace.vim_mode.timeout_ms", defaults.Workspace.VimMode.TimeoutMilliseconds)
711+ m.viper.SetDefault("workspace.vim_mode.sequence_timeout_ms", defaults.Workspace.VimMode.SequenceTimeoutMilliseconds)
712+ m.viper.SetDefault("workspace.vim_mode.preload_accessibility", defaults.Workspace.VimMode.PreloadAccessibility)
711 m.viper.SetDefault("workspace.vim_mode.actions", defaults.Workspace.VimMode.Actions)713 m.viper.SetDefault("workspace.vim_mode.actions", defaults.Workspace.VimMode.Actions)
712 m.viper.SetDefault("workspace.resize_mode.activation_shortcut", defaults.Workspace.ResizeMode.ActivationShortcut)714 m.viper.SetDefault("workspace.resize_mode.activation_shortcut", defaults.Workspace.ResizeMode.ActivationShortcut)
713 m.viper.SetDefault("workspace.resize_mode.timeout_ms", defaults.Workspace.ResizeMode.TimeoutMilliseconds)715 m.viper.SetDefault("workspace.resize_mode.timeout_ms", defaults.Workspace.ResizeMode.TimeoutMilliseconds)
M internal/infrastructure/config/schema_provider.go +15 -0
@@ -488,6 +488,21 @@ func (*SchemaProvider) getWorkspaceKeys(defaults *Config) []entity.ConfigKeyInfo
488 Range: ">=0",488 Range: ">=0",
489 Section: SectionWorkspace,489 Section: SectionWorkspace,
490 },490 },
491+ {
492+ Key: "workspace.vim_mode.sequence_timeout_ms",
493+ Type: "int",
494+ Default: fmt.Sprintf("%d", defaults.Workspace.VimMode.SequenceTimeoutMilliseconds),
495+ Description: "Vim mode multi-key sequence timeout in milliseconds (0 = immediate ambiguity resolution)",
496+ Range: ">=0",
497+ Section: SectionWorkspace,
498+ },
499+ {
500+ Key: "workspace.vim_mode.preload_accessibility",
501+ Type: "bool",
502+ Default: fmt.Sprintf("%t", defaults.Workspace.VimMode.PreloadAccessibility),
503+ Description: "Pre-enable browser accessibility when a WebView is shown (default false; enable on Vim Mode enter otherwise)",
504+ Section: SectionWorkspace,
505+ },
491 {506 {
492 Key: "workspace.vim_mode.actions.<action>",507 Key: "workspace.vim_mode.actions.<action>",
493 Type: "object",508 Type: "object",
M internal/infrastructure/config/schema_provider_test.go +10 -0
@@ -18,6 +18,9 @@ func TestConfigurationReferenceCoversSchemaKeys(t *testing.T) {
18 18
19 var missing []string19 var missing []string
20 for _, key := range NewSchemaProvider().GetSchema() {20 for _, key := range NewSchemaProvider().GetSchema() {
21+ if _, pending := schemaKeysPendingProductDocs[key.Key]; pending {
22+ continue
23+ }
21 if configurationReferenceCoversKey(docKeys, key.Key) {24 if configurationReferenceCoversKey(docKeys, key.Key) {
22 continue25 continue
23 }26 }
@@ -28,6 +31,13 @@ func TestConfigurationReferenceCoversSchemaKeys(t *testing.T) {
28 require.Empty(t, missing, "docs/reference/configuration.md should cover every SchemaProvider key")31 require.Empty(t, missing, "docs/reference/configuration.md should cover every SchemaProvider key")
29 }32 }
30 33
34+// schemaKeysPendingProductDocs lists SchemaProvider keys intentionally omitted from
35+// product docs until a dedicated docs task lands them.
36+var schemaKeysPendingProductDocs = map[string]struct{}{
37+ "workspace.vim_mode.sequence_timeout_ms": {},
38+ "workspace.vim_mode.preload_accessibility": {},
39+}
40+
31 func configurationReferenceKeys(t *testing.T) map[string]struct{} {41 func configurationReferenceKeys(t *testing.T) map[string]struct{} {
32 t.Helper()42 t.Helper()
33 43
M internal/infrastructure/config/validation.go +85 -7
@@ -11,6 +11,7 @@ import (
11 11
12 domainurl "github.com/bnema/dumber/internal/domain/url"12 domainurl "github.com/bnema/dumber/internal/domain/url"
13 domainvalidation "github.com/bnema/dumber/internal/domain/validation"13 domainvalidation "github.com/bnema/dumber/internal/domain/validation"
14+ "github.com/bnema/dumber/internal/domain/vimkeys"
14 )15 )
15 16
16 const cefLogSeverityDisabled = 9917 const cefLogSeverityDisabled = 99
@@ -243,12 +244,31 @@ func validateTabMode(config *Config) []string {
243 }244 }
244 245
245 func validateVimMode(config *Config) []string {246 func validateVimMode(config *Config) []string {
246- return validateModalModeActions("vim_mode", config.Workspace.VimMode.TimeoutMilliseconds, config.Workspace.VimMode.Actions)247+ var validationErrors []string
248+ if config.Workspace.VimMode.SequenceTimeoutMilliseconds < 0 {
249+ validationErrors = append(validationErrors, "workspace.vim_mode.sequence_timeout_ms must be non-negative")
250+ }
251+ validationErrors = append(validationErrors, validateModalModeActionsWithKeyCanon(
252+ "vim_mode",
253+ config.Workspace.VimMode.TimeoutMilliseconds,
254+ config.Workspace.VimMode.Actions,
255+ canonicalizeVimModeSequenceKey,
256+ )...)
257+ return validationErrors
247 }258 }
248 259
249 // validateModalModeActions shared timeout/empty-actions/duplicate-key checks for260 // validateModalModeActions shared timeout/empty-actions/duplicate-key checks for
250 // pane, tab, and page modal modes. Messages stay path-exact via modePath.261 // pane, tab, and page modal modes. Messages stay path-exact via modePath.
251 func validateModalModeActions(modePath string, timeoutMS int, actions map[string]ActionBinding) []string {262 func validateModalModeActions(modePath string, timeoutMS int, actions map[string]ActionBinding) []string {
263+ return validateModalModeActionsWithKeyCanon(modePath, timeoutMS, actions, nil)
264+}
265+
266+func validateModalModeActionsWithKeyCanon(
267+ modePath string,
268+ timeoutMS int,
269+ actions map[string]ActionBinding,
270+ keyCanon func(string) (string, error),
271+) []string {
252 var validationErrors []string272 var validationErrors []string
253 if timeoutMS < 0 {273 if timeoutMS < 0 {
254 validationErrors = append(validationErrors, fmt.Sprintf("workspace.%s.timeout_ms must be non-negative", modePath))274 validationErrors = append(validationErrors, fmt.Sprintf("workspace.%s.timeout_ms must be non-negative", modePath))
@@ -257,27 +277,85 @@ func validateModalModeActions(modePath string, timeoutMS int, actions map[string
257 validationErrors = append(validationErrors, fmt.Sprintf("workspace.%s.actions cannot be empty", modePath))277 validationErrors = append(validationErrors, fmt.Sprintf("workspace.%s.actions cannot be empty", modePath))
258 }278 }
259 279
260- seenKeys := make(map[string]string)280+ if keyCanon == nil {
261- for action, binding := range actions {281+ seenKeys := make(map[string]string)
282+ for action, binding := range actions {
283+ if len(binding.Keys) == 0 {
284+ validationErrors = append(validationErrors, fmt.Sprintf("workspace.%s.actions.%s must have at least one key binding", modePath, action))
285+ }
286+ for _, key := range binding.Keys {
287+ if existingAction, exists := seenKeys[key]; exists {
288+ validationErrors = append(validationErrors, fmt.Sprintf(
289+ "duplicate key binding '%s' found in %s actions '%s' and '%s'", key, modePath, existingAction, action,
290+ ))
291+ }
292+ seenKeys[key] = action
293+ }
294+ }
295+ return validationErrors
296+ }
297+
298+ actionNames := make([]string, 0, len(actions))
299+ for action := range actions {
300+ actionNames = append(actionNames, action)
301+ }
302+ sort.Strings(actionNames)
303+
304+ seenSequence := make(map[string]string)
305+ for _, action := range actionNames {
306+ binding := actions[action]
262 if len(binding.Keys) == 0 {307 if len(binding.Keys) == 0 {
263 validationErrors = append(validationErrors, fmt.Sprintf("workspace.%s.actions.%s must have at least one key binding", modePath, action))308 validationErrors = append(validationErrors, fmt.Sprintf("workspace.%s.actions.%s must have at least one key binding", modePath, action))
309+ continue
264 }310 }
265- for _, key := range binding.Keys {311+ keys := append([]string(nil), binding.Keys...)
266- if existingAction, exists := seenKeys[key]; exists {312+ sort.Strings(keys)
313+ for _, key := range keys {
314+ canonical, err := keyCanon(key)
315+ if err != nil {
267 validationErrors = append(validationErrors, fmt.Sprintf(316 validationErrors = append(validationErrors, fmt.Sprintf(
268- "duplicate key binding '%s' found in %s actions '%s' and '%s'",317+ "workspace.%s.actions.%s has invalid key binding '%s': %v",
318+ modePath,
319+ action,
269 key,320 key,
321+ err,
322+ ))
323+ continue
324+ }
325+ if existingAction, exists := seenSequence[canonical]; exists {
326+ validationErrors = append(validationErrors, fmt.Sprintf(
327+ "duplicate key binding '%s' found in %s actions '%s' and '%s'",
328+ canonical,
270 modePath,329 modePath,
271 existingAction,330 existingAction,
272 action,331 action,
273 ))332 ))
274 }333 }
275- seenKeys[key] = action334+ seenSequence[canonical] = action
276 }335 }
277 }336 }
278 return validationErrors337 return validationErrors
279 }338 }
280 339
340+func canonicalizeVimModeBinding(key string) string {
341+ switch strings.ToLower(strings.TrimSpace(key)) {
342+ case "enter":
343+ return "<Return>"
344+ case "escape":
345+ return "<Escape>"
346+ default:
347+ return key
348+ }
349+}
350+
351+func canonicalizeVimModeSequenceKey(key string) (string, error) {
352+ seq, err := vimkeys.ParseBinding(canonicalizeVimModeBinding(key))
353+ if err != nil {
354+ return "", err
355+ }
356+ return seq.String(), nil
357+}
358+
281 func validateFloatingPane(config *Config) []string {359 func validateFloatingPane(config *Config) []string {
282 var validationErrors []string360 var validationErrors []string
283 seenProfileKeys := make(map[string]string)361 seenProfileKeys := make(map[string]string)
M internal/infrastructure/config/vim_mode_config_test.go +264 -2
@@ -3,10 +3,36 @@ package config
3 import (3 import (
4 "testing"4 "testing"
5 5
6+ "github.com/spf13/viper"
6 "github.com/stretchr/testify/assert"7 "github.com/stretchr/testify/assert"
7 "github.com/stretchr/testify/require"8 "github.com/stretchr/testify/require"
8 )9 )
9 10
11+var legacyVimModeActions = []string{
12+ "vim-scroll-left",
13+ "vim-scroll-down",
14+ "vim-scroll-up",
15+ "vim-scroll-right",
16+ "vim-scroll-down-fast",
17+ "vim-scroll-up-fast",
18+ "confirm",
19+ "cancel",
20+}
21+
22+var sequenceVimModeActions = map[string]string{
23+ "heading-next": "]]",
24+ "heading-prev": "[[",
25+ "code-next": "]c",
26+ "code-prev": "[c",
27+ "table-next": "]t",
28+ "image-next": "]i",
29+ "list-next": "]l",
30+ "outline": "gO",
31+ "yank-section": "yah",
32+ "half-page-down": "<C-d>",
33+ "half-page-up": "<C-u>",
34+}
35+
10 func TestDefaultConfig_VimModeDefaults(t *testing.T) {36 func TestDefaultConfig_VimModeDefaults(t *testing.T) {
11 cfg := DefaultConfig()37 cfg := DefaultConfig()
12 38
@@ -14,9 +40,12 @@ func TestDefaultConfig_VimModeDefaults(t *testing.T) {
14 vimMode := cfg.Workspace.VimMode40 vimMode := cfg.Workspace.VimMode
15 assert.Equal(t, "ctrl+y", vimMode.ActivationShortcut)41 assert.Equal(t, "ctrl+y", vimMode.ActivationShortcut)
16 assert.Equal(t, 0, vimMode.TimeoutMilliseconds)42 assert.Equal(t, 0, vimMode.TimeoutMilliseconds)
43+ assert.Equal(t, 500, vimMode.SequenceTimeoutMilliseconds)
17 44
18 // All required actions must exist with exact key bindings.45 // All required actions must exist with exact key bindings.
19 require.NotNil(t, vimMode.Actions)46 require.NotNil(t, vimMode.Actions)
47+ require.Len(t, legacyVimModeActions, 8)
48+ require.Len(t, vimMode.Actions, len(legacyVimModeActions)+len(sequenceVimModeActions))
20 requireActionBinding(t, vimMode.Actions, "vim-scroll-left", []string{"h"})49 requireActionBinding(t, vimMode.Actions, "vim-scroll-left", []string{"h"})
21 requireActionBinding(t, vimMode.Actions, "vim-scroll-down", []string{"j"})50 requireActionBinding(t, vimMode.Actions, "vim-scroll-down", []string{"j"})
22 requireActionBinding(t, vimMode.Actions, "vim-scroll-up", []string{"k"})51 requireActionBinding(t, vimMode.Actions, "vim-scroll-up", []string{"k"})
@@ -25,6 +54,54 @@ func TestDefaultConfig_VimModeDefaults(t *testing.T) {
25 requireActionBinding(t, vimMode.Actions, "vim-scroll-up-fast", []string{"shift+k"})54 requireActionBinding(t, vimMode.Actions, "vim-scroll-up-fast", []string{"shift+k"})
26 requireActionBinding(t, vimMode.Actions, "confirm", []string{"enter"})55 requireActionBinding(t, vimMode.Actions, "confirm", []string{"enter"})
27 requireActionBinding(t, vimMode.Actions, "cancel", []string{"escape"})56 requireActionBinding(t, vimMode.Actions, "cancel", []string{"escape"})
57+ for _, action := range legacyVimModeActions {
58+ _, ok := vimMode.Actions[action]
59+ require.True(t, ok, "missing legacy vim mode action %q", action)
60+ }
61+}
62+
63+func TestVimModeSequenceDefaults(t *testing.T) {
64+ cfg := DefaultConfig()
65+ vimMode := cfg.Workspace.VimMode
66+
67+ assert.Equal(t, 500, vimMode.SequenceTimeoutMilliseconds)
68+ require.Len(t, sequenceVimModeActions, 11)
69+
70+ for action, key := range sequenceVimModeActions {
71+ requireActionBinding(t, vimMode.Actions, action, []string{key})
72+ }
73+}
74+
75+func TestVimModeConfig_DefaultsLoadThroughViper(t *testing.T) {
76+ mgr := &Manager{viper: viper.New()}
77+ mgr.setDefaults()
78+
79+ cfg, err := mgr.unmarshalConfig()
80+ require.NoError(t, err)
81+
82+ vimMode := cfg.Workspace.VimMode
83+ assert.Equal(t, "ctrl+y", vimMode.ActivationShortcut)
84+ assert.Equal(t, 0, vimMode.TimeoutMilliseconds)
85+ assert.Equal(t, 500, vimMode.SequenceTimeoutMilliseconds)
86+ require.Len(t, vimMode.Actions, len(legacyVimModeActions)+len(sequenceVimModeActions))
87+ requireActionBinding(t, vimMode.Actions, "confirm", []string{"enter"})
88+ requireActionBinding(t, vimMode.Actions, "cancel", []string{"escape"})
89+ requireActionBinding(t, vimMode.Actions, "half-page-down", []string{"<C-d>"})
90+ requireActionBinding(t, vimMode.Actions, "heading-next", []string{"]]"})
91+}
92+
93+func TestDefaultConfig_VimModeDeepClone(t *testing.T) {
94+ first := DefaultConfig()
95+ second := DefaultConfig()
96+
97+ first.Workspace.VimMode.SequenceTimeoutMilliseconds = 999
98+ first.Workspace.VimMode.Actions["confirm"] = ActionBinding{Keys: []string{"mutated"}, Desc: "mutated"}
99+ delete(first.Workspace.VimMode.Actions, "heading-next")
100+
101+ assert.Equal(t, 500, second.Workspace.VimMode.SequenceTimeoutMilliseconds)
102+ requireActionBinding(t, second.Workspace.VimMode.Actions, "confirm", []string{"enter"})
103+ requireActionBinding(t, second.Workspace.VimMode.Actions, "heading-next", []string{"]]"})
104+ require.Len(t, second.Workspace.VimMode.Actions, len(legacyVimModeActions)+len(sequenceVimModeActions))
28 }105 }
29 106
30 func TestVimModeConfig_GetKeyBindings(t *testing.T) {107 func TestVimModeConfig_GetKeyBindings(t *testing.T) {
@@ -39,7 +116,18 @@ func TestVimModeConfig_GetKeyBindings(t *testing.T) {
39 assert.Equal(t, "vim-scroll-up-fast", bindings["shift+k"])116 assert.Equal(t, "vim-scroll-up-fast", bindings["shift+k"])
40 assert.Equal(t, "confirm", bindings["enter"])117 assert.Equal(t, "confirm", bindings["enter"])
41 assert.Equal(t, "cancel", bindings["escape"])118 assert.Equal(t, "cancel", bindings["escape"])
42- assert.Len(t, bindings, 8)119+ assert.Equal(t, "heading-next", bindings["]]"])
120+ assert.Equal(t, "heading-prev", bindings["[["])
121+ assert.Equal(t, "code-next", bindings["]c"])
122+ assert.Equal(t, "code-prev", bindings["[c"])
123+ assert.Equal(t, "table-next", bindings["]t"])
124+ assert.Equal(t, "image-next", bindings["]i"])
125+ assert.Equal(t, "list-next", bindings["]l"])
126+ assert.Equal(t, "outline", bindings["gO"])
127+ assert.Equal(t, "yank-section", bindings["yah"])
128+ assert.Equal(t, "half-page-down", bindings["<C-d>"])
129+ assert.Equal(t, "half-page-up", bindings["<C-u>"])
130+ assert.Len(t, bindings, 19)
43 }131 }
44 132
45 func TestValidateVimMode_NonNegativeTimeout(t *testing.T) {133 func TestValidateVimMode_NonNegativeTimeout(t *testing.T) {
@@ -85,6 +173,50 @@ func TestValidateVimMode_NonNegativeTimeout(t *testing.T) {
85 }173 }
86 }174 }
87 175
176+func TestValidateVimMode_SequenceTimeout(t *testing.T) {
177+ tests := []struct {
178+ name string
179+ mutate func(*Config)
180+ want string
181+ }{
182+ {
183+ name: "negative sequence timeout fails",
184+ mutate: func(cfg *Config) {
185+ cfg.Workspace.VimMode.SequenceTimeoutMilliseconds = -1
186+ },
187+ want: "workspace.vim_mode.sequence_timeout_ms must be non-negative",
188+ },
189+ {
190+ name: "zero sequence timeout ok",
191+ mutate: func(cfg *Config) {
192+ cfg.Workspace.VimMode.SequenceTimeoutMilliseconds = 0
193+ },
194+ want: "",
195+ },
196+ {
197+ name: "positive sequence timeout ok",
198+ mutate: func(cfg *Config) {
199+ cfg.Workspace.VimMode.SequenceTimeoutMilliseconds = 750
200+ },
201+ want: "",
202+ },
203+ }
204+
205+ for _, tt := range tests {
206+ t.Run(tt.name, func(t *testing.T) {
207+ cfg := DefaultConfig()
208+ tt.mutate(cfg)
209+ err := validateConfig(cfg)
210+ if tt.want == "" {
211+ require.NoError(t, err)
212+ } else {
213+ require.Error(t, err)
214+ assert.Contains(t, err.Error(), tt.want)
215+ }
216+ })
217+ }
218+}
219+
88 func TestValidateVimMode_NilActionsFail(t *testing.T) {220 func TestValidateVimMode_NilActionsFail(t *testing.T) {
89 cfg := DefaultConfig()221 cfg := DefaultConfig()
90 cfg.Workspace.VimMode.Actions = nil222 cfg.Workspace.VimMode.Actions = nil
@@ -110,6 +242,106 @@ func TestValidateVimMode_DuplicateKeyBindings(t *testing.T) {
110 assert.Contains(t, err.Error(), action)242 assert.Contains(t, err.Error(), action)
111 }243 }
112 244
245+func TestValidateVimMode_MalformedBindings(t *testing.T) {
246+ malformed := []string{"<C-d", "<Nope>", "<C->"}
247+ for _, key := range malformed {
248+ t.Run(key, func(t *testing.T) {
249+ cfg := DefaultConfig()
250+ cfg.Workspace.VimMode.Actions["broken"] = ActionBinding{
251+ Keys: []string{key},
252+ Desc: "Broken binding",
253+ }
254+
255+ err := validateConfig(cfg)
256+ require.Error(t, err)
257+ assert.Contains(t, err.Error(), "broken")
258+ assert.Contains(t, err.Error(), key)
259+ })
260+ }
261+}
262+
263+func TestValidateVimMode_LegacyEnterEscapeRemainCompatible(t *testing.T) {
264+ cfg := DefaultConfig()
265+ requireActionBinding(t, cfg.Workspace.VimMode.Actions, "confirm", []string{"enter"})
266+ requireActionBinding(t, cfg.Workspace.VimMode.Actions, "cancel", []string{"escape"})
267+ require.NoError(t, validateConfig(cfg))
268+
269+ assert.Equal(t, "<Return>", canonicalizeVimModeBinding("enter"))
270+ assert.Equal(t, "<Escape>", canonicalizeVimModeBinding("escape"))
271+ assert.Equal(t, "<C-d>", canonicalizeVimModeBinding("<C-d>"))
272+}
273+
274+func TestValidateVimMode_CanonicalAliasConflicts(t *testing.T) {
275+ tests := []struct {
276+ name string
277+ actionA string
278+ keysA []string
279+ actionB string
280+ keysB []string
281+ canon string
282+ }{
283+ {
284+ name: "CR and Return",
285+ actionA: "alias-a",
286+ keysA: []string{"<CR>"},
287+ actionB: "alias-b",
288+ keysB: []string{"<Return>"},
289+ canon: "<Return>",
290+ },
291+ {
292+ name: "enter and Return",
293+ actionA: "alias-a",
294+ keysA: []string{"enter"},
295+ actionB: "alias-b",
296+ keysB: []string{"<Return>"},
297+ canon: "<Return>",
298+ },
299+ {
300+ name: "escape and Escape",
301+ actionA: "alias-a",
302+ keysA: []string{"escape"},
303+ actionB: "alias-b",
304+ keysB: []string{"<Escape>"},
305+ canon: "<Escape>",
306+ },
307+ {
308+ name: "J and shift+j",
309+ actionA: "alias-a",
310+ keysA: []string{"J"},
311+ actionB: "alias-b",
312+ keysB: []string{"shift+j"},
313+ canon: "J",
314+ },
315+ {
316+ name: "C-d and ctrl+d",
317+ actionA: "alias-a",
318+ keysA: []string{"<C-d>"},
319+ actionB: "alias-b",
320+ keysB: []string{"ctrl+d"},
321+ canon: "<C-d>",
322+ },
323+ }
324+
325+ for _, tt := range tests {
326+ t.Run(tt.name, func(t *testing.T) {
327+ cfg := DefaultConfig()
328+ // Drop defaults that would collide with the aliases under test.
329+ delete(cfg.Workspace.VimMode.Actions, "half-page-down")
330+ delete(cfg.Workspace.VimMode.Actions, "vim-scroll-down-fast")
331+ delete(cfg.Workspace.VimMode.Actions, "confirm")
332+ delete(cfg.Workspace.VimMode.Actions, "cancel")
333+ cfg.Workspace.VimMode.Actions[tt.actionA] = ActionBinding{Keys: tt.keysA, Desc: "alias a"}
334+ cfg.Workspace.VimMode.Actions[tt.actionB] = ActionBinding{Keys: tt.keysB, Desc: "alias b"}
335+
336+ err := validateConfig(cfg)
337+ require.Error(t, err)
338+ assert.Contains(t, err.Error(), "duplicate key binding '"+tt.canon+"' found in vim_mode actions")
339+ assert.Contains(t, err.Error(), tt.actionA)
340+ assert.Contains(t, err.Error(), tt.actionB)
341+ })
342+ }
343+}
344+
113 func TestValidateVimMode_EmptyActionKeysFail(t *testing.T) {345 func TestValidateVimMode_EmptyActionKeysFail(t *testing.T) {
114 cfg := DefaultConfig()346 cfg := DefaultConfig()
115 // Override an action with no key bindings.347 // Override an action with no key bindings.
@@ -192,11 +424,16 @@ func TestValidateModalModeActions_RegressionTable(t *testing.T) {
192 }424 }
193 }425 }
194 426
427+func TestValidateVimMode_DefaultsAccept(t *testing.T) {
428+ cfg := DefaultConfig()
429+ require.NoError(t, validateConfig(cfg))
430+}
431+
195 func TestSchemaProvider_VimModeKeys(t *testing.T) {432 func TestSchemaProvider_VimModeKeys(t *testing.T) {
196 provider := &SchemaProvider{}433 provider := &SchemaProvider{}
197 schema := provider.GetSchema()434 schema := provider.GetSchema()
198 435
199- var foundActivation, foundTimeout, foundActions bool436+ var foundActivation, foundTimeout, foundSequenceTimeout, foundActions bool
200 for _, key := range schema {437 for _, key := range schema {
201 switch key.Key {438 switch key.Key {
202 case "workspace.vim_mode.activation_shortcut":439 case "workspace.vim_mode.activation_shortcut":
@@ -210,6 +447,12 @@ func TestSchemaProvider_VimModeKeys(t *testing.T) {
210 assert.Equal(t, "0", key.Default)447 assert.Equal(t, "0", key.Default)
211 assert.Equal(t, ">=0", key.Range)448 assert.Equal(t, ">=0", key.Range)
212 assert.Equal(t, SectionWorkspace, key.Section)449 assert.Equal(t, SectionWorkspace, key.Section)
450+ case "workspace.vim_mode.sequence_timeout_ms":
451+ foundSequenceTimeout = true
452+ assert.Equal(t, "int", key.Type)
453+ assert.Equal(t, "500", key.Default)
454+ assert.Equal(t, ">=0", key.Range)
455+ assert.Equal(t, SectionWorkspace, key.Section)
213 case "workspace.vim_mode.actions.<action>":456 case "workspace.vim_mode.actions.<action>":
214 foundActions = true457 foundActions = true
215 assert.Equal(t, "object", key.Type)458 assert.Equal(t, "object", key.Type)
@@ -219,5 +462,24 @@ func TestSchemaProvider_VimModeKeys(t *testing.T) {
219 462
220 assert.True(t, foundActivation, "missing workspace.vim_mode.activation_shortcut schema key")463 assert.True(t, foundActivation, "missing workspace.vim_mode.activation_shortcut schema key")
221 assert.True(t, foundTimeout, "missing workspace.vim_mode.timeout_ms schema key")464 assert.True(t, foundTimeout, "missing workspace.vim_mode.timeout_ms schema key")
465+ assert.True(t, foundSequenceTimeout, "missing workspace.vim_mode.sequence_timeout_ms schema key")
222 assert.True(t, foundActions, "missing workspace.vim_mode.actions.<action> schema key")466 assert.True(t, foundActions, "missing workspace.vim_mode.actions.<action> schema key")
223 }467 }
468+
469+func TestSchemaExposesVimModeSequenceTimeout(t *testing.T) {
470+ provider := &SchemaProvider{}
471+ schema := provider.GetSchema()
472+
473+ var found bool
474+ for _, key := range schema {
475+ if key.Key != "workspace.vim_mode.sequence_timeout_ms" {
476+ continue
477+ }
478+ found = true
479+ assert.Equal(t, "int", key.Type)
480+ assert.Equal(t, "500", key.Default)
481+ assert.Equal(t, ">=0", key.Range)
482+ assert.Equal(t, SectionWorkspace, key.Section)
483+ }
484+ assert.True(t, found, "schema must expose workspace.vim_mode.sequence_timeout_ms")
485+}
M internal/ui/app.go +46 -0
@@ -1100,6 +1100,7 @@ func (a *App) initBrowserWindowInput(ctx context.Context, bw *browserWindow) {
1100 a.activateBrowserWindow(bw)1100 a.activateBrowserWindow(bw)
1101 a.handleModeChange(ctx, bw, from, to)1101 a.handleModeChange(ctx, bw, from, to)
1102 })1102 })
1103+ a.bindVimModeSequenceToaster(ctx, bw)
1103 bw.keyboardHandler.SetRouteKey(func(kc input.KeyContext) input.KeyRoute {1104 bw.keyboardHandler.SetRouteKey(func(kc input.KeyContext) input.KeyRoute {
1104 if bw.sessionManager != nil && bw.sessionManager.IsVisible() {1105 if bw.sessionManager != nil && bw.sessionManager.IsVisible() {
1105 return input.RoutePassToWidget1106 return input.RoutePassToWidget
@@ -4054,6 +4055,51 @@ func (a *App) updateModeIndicatorToaster(ctx context.Context, bw *browserWindow,
4054 )4055 )
4055 }4056 }
4056 4057
4058+// bindVimModeSequenceToaster wires pending Vim-sequence text onto this
4059+// window's modeToaster only (never appToaster / pane toasters).
4060+func (a *App) bindVimModeSequenceToaster(ctx context.Context, bw *browserWindow) {
4061+ if a == nil || bw == nil || bw.keyboardHandler == nil {
4062+ return
4063+ }
4064+ bw.keyboardHandler.SetOnPendingSequenceChange(func(pending string) {
4065+ a.showPendingSequence(ctx, bw, pending)
4066+ })
4067+ logging.FromContext(ctx).Debug().
4068+ Str("window_id", bw.id).
4069+ Msg("vim mode pending sequence toaster bound")
4070+}
4071+
4072+// showPendingSequence shows "VIM MODE · pending" on the per-window modeToaster
4073+// while a sequence is incomplete, and restores stable "VIM MODE" when pending
4074+// clears. Mode exit/hide is handled by handleModeChange/updateModeIndicatorToaster;
4075+// silent reset under ModalState avoids stale pending callbacks.
4076+func (a *App) showPendingSequence(ctx context.Context, bw *browserWindow, pending string) {
4077+ if a == nil || bw == nil || bw.modeToaster == nil {
4078+ return
4079+ }
4080+ log := logging.FromContext(ctx)
4081+ if !a.runtimeConfigSnapshot().UI.Workspace.Styling.ModeIndicatorToasterEnabled {
4082+ bw.modeToaster.Hide()
4083+ log.Debug().Str("window_id", bw.id).Msg("vim mode pending toaster hidden (disabled)")
4084+ return
4085+ }
4086+
4087+ text := input.ModeVim.DisplayName()
4088+ if pending != "" {
4089+ text = text + " · " + pending
4090+ }
4091+ bw.modeToaster.Show(ctx, text, component.ToastInfo,
4092+ component.WithDuration(0),
4093+ component.WithPosition(component.ToastPositionBottomLeft),
4094+ component.WithModeClass(getModeToastClass(input.ModeVim)),
4095+ )
4096+ log.Debug().
4097+ Str("window_id", bw.id).
4098+ Str("pending", pending).
4099+ Str("toast_text", text).
4100+ Msg("vim mode pending toaster updated")
4101+}
4102+
4057 // getModeToastClass returns the CSS class for the given mode's toast styling.4103 // getModeToastClass returns the CSS class for the given mode's toast styling.
4058 func getModeToastClass(mode input.Mode) string {4104 func getModeToastClass(mode input.Mode) string {
4059 switch mode {4105 switch mode {
M internal/ui/app_browser_launch_test.go +19 -2
@@ -963,6 +963,10 @@ func stackedViewOnActivateIsNil(t *testing.T, sv *layout.StackedView) bool {
963 return reflect.NewAt(fv.Type(), unsafe.Pointer(fv.UnsafeAddr())).Elem().IsNil()963 return reflect.NewAt(fv.Type(), unsafe.Pointer(fv.UnsafeAddr())).Elem().IsNil()
964 }964 }
965 965
966+// newTestShellToaster builds a Toaster backed by testify mocks.
967+// Callers that Show() must Hide() (or otherwise cancel the dismiss timer)
968+// before the test returns — default Show arms a GLib TimeoutAdd that can
969+// fire during a later test's MainContext.Iteration and panic on dead mocks.
966 func newTestShellToaster(t *testing.T) (*component.Toaster, *layoutmocks.MockBoxWidget, *layoutmocks.MockLabelWidget) {970 func newTestShellToaster(t *testing.T) (*component.Toaster, *layoutmocks.MockBoxWidget, *layoutmocks.MockLabelWidget) {
967 t.Helper()971 t.Helper()
968 972
@@ -995,6 +999,9 @@ func TestApp_ShowFilterStatusUsesLastFocusedBrowserWindowToaster(t *testing.T) {
995 toaster, box, label := newTestShellToaster(t)999 toaster, box, label := newTestShellToaster(t)
996 box.EXPECT().SetVisible(true).Once()1000 box.EXPECT().SetVisible(true).Once()
997 label.EXPECT().SetText("Ad blocker loading").Once()1001 label.EXPECT().SetText("Ad blocker loading").Once()
1002+ // Cancel the auto-dismiss GLib timer before mock teardown so a later test
1003+ // iterating the main context cannot fire hide() on a completed test's mocks.
1004+ box.EXPECT().SetVisible(false).Once()
998 1005
999 bw := &browserWindow{id: "window-1", appToaster: toaster}1006 bw := &browserWindow{id: "window-1", appToaster: toaster}
1000 app := &App{1007 app := &App{
@@ -1003,17 +1010,23 @@ func TestApp_ShowFilterStatusUsesLastFocusedBrowserWindowToaster(t *testing.T) {
1003 }1010 }
1004 1011
1005 app.showFilterStatus(ctx, port.FilterStatus{State: port.FilterStateLoading, Message: "Ad blocker loading"})1012 app.showFilterStatus(ctx, port.FilterStatus{State: port.FilterStateLoading, Message: "Ad blocker loading"})
1013+ toaster.Hide()
1006 }1014 }
1007 1015
1008 func TestTestShellToasterCleanupCancelsAutoDismiss(t *testing.T) {1016 func TestTestShellToasterCleanupCancelsAutoDismiss(t *testing.T) {
1009 toaster, box, label := newTestShellToaster(t)1017 toaster, box, label := newTestShellToaster(t)
1010 box.EXPECT().SetVisible(true).Once()1018 box.EXPECT().SetVisible(true).Once()
1011 label.EXPECT().SetText("will not outlive this test").Once()1019 label.EXPECT().SetText("will not outlive this test").Once()
1012- toaster.Show(context.Background(), "will not outlive this test", component.ToastInfo)1020+ toaster.Show(
1021+ context.Background(),
1022+ "will not outlive this test",
1023+ component.ToastInfo,
1024+ component.WithDuration(component.ToastBriefDurationMs),
1025+ )
1013 toaster.CancelAutoDismiss()1026 toaster.CancelAutoDismiss()
1014 1027
1015 mainContext := glib.MainContextDefault()1028 mainContext := glib.MainContextDefault()
1016- deadline := time.Now().Add(component.ToastBriefDurationMs + 600*time.Millisecond)1029+ deadline := time.Now().Add(component.ToastBriefDurationMs*time.Millisecond + 600*time.Millisecond)
1017 for time.Now().Before(deadline) {1030 for time.Now().Before(deadline) {
1018 for mainContext.Pending() {1031 for mainContext.Pending() {
1019 mainContext.Iteration(false)1032 mainContext.Iteration(false)
@@ -1028,6 +1041,9 @@ func TestApp_CheckConfigMigrationUsesLastFocusedBrowserWindowToaster(t *testing.
1028 toaster, box, label := newTestShellToaster(t)1041 toaster, box, label := newTestShellToaster(t)
1029 box.EXPECT().SetVisible(true).Once()1042 box.EXPECT().SetVisible(true).Once()
1030 label.EXPECT().SetText("Config has 1 new settings. Run 'dumber config migrate'").Once()1043 label.EXPECT().SetText("Config has 1 new settings. Run 'dumber config migrate'").Once()
1044+ // Cancel the auto-dismiss GLib timer before mock teardown so a later test
1045+ // iterating the main context cannot fire hide() on a completed test's mocks.
1046+ box.EXPECT().SetVisible(false).Once()
1031 1047
1032 migrator := portmocks.NewMockConfigMigrator(t)1048 migrator := portmocks.NewMockConfigMigrator(t)
1033 migrator.EXPECT().CheckMigration().Return(&port.MigrationResult{MissingKeys: []string{"update.notify_on_new_settings"}}, nil).Once()1049 migrator.EXPECT().CheckMigration().Return(&port.MigrationResult{MissingKeys: []string{"update.notify_on_new_settings"}}, nil).Once()
@@ -1046,6 +1062,7 @@ func TestApp_CheckConfigMigrationUsesLastFocusedBrowserWindowToaster(t *testing.
1046 }1062 }
1047 1063
1048 app.checkConfigMigration(ctx)1064 app.checkConfigMigration(ctx)
1065+ toaster.Hide()
1049 }1066 }
1050 1067
1051 func TestApp_FinalizeActivationStartsBrowserLaunchRelayOnceAndClosesOnShutdown(t *testing.T) {1068 func TestApp_FinalizeActivationStartsBrowserLaunchRelayOnceAndClosesOnShutdown(t *testing.T) {
M internal/ui/input/keyboard.go +49 -12
@@ -114,6 +114,8 @@ type KeyboardHandler struct {
114 vimScrollRepeatAdd func(intervalMS uint, cb *glib.SourceFunc) uint114 vimScrollRepeatAdd func(intervalMS uint, cb *glib.SourceFunc) uint
115 vimScrollRepeatRemove func(id uint) bool115 vimScrollRepeatRemove func(id uint) bool
116 vimScrollNow func() time.Time116 vimScrollNow func() time.Time
117+
118+ seq vimModeSequenceState
117 }119 }
118 120
119 // NewKeyboardHandler creates a new keyboard handler.121 // NewKeyboardHandler creates a new keyboard handler.
@@ -122,8 +124,13 @@ func NewKeyboardHandler(ctx context.Context, workspace *entity.WorkspaceConfig,
122 124
123 log.Debug().Msg("creating keyboard handler")125 log.Debug().Msg("creating keyboard handler")
124 126
127+ shortcuts := NewShortcutSet(ctx, workspace, session)
128+ seqTimeout := time.Duration(0)
129+ if workspace != nil {
130+ seqTimeout = time.Duration(workspace.VimMode.SequenceTimeoutMilliseconds) * time.Millisecond
131+ }
125 h := &KeyboardHandler{132 h := &KeyboardHandler{
126- shortcuts: NewShortcutSet(ctx, workspace, session),133+ shortcuts: shortcuts,
127 modal: NewModalState(ctx),134 modal: NewModalState(ctx),
128 workspace: workspace,135 workspace: workspace,
129 session: session,136 session: session,
@@ -134,6 +141,7 @@ func NewKeyboardHandler(ctx context.Context, workspace *entity.WorkspaceConfig,
134 },141 },
135 vimScrollRepeatRemove: glib.SourceRemove,142 vimScrollRepeatRemove: glib.SourceRemove,
136 vimScrollNow: time.Now,143 vimScrollNow: time.Now,
144+ seq: newVimModeSequenceState(shortcuts.VimModeSequences(), seqTimeout),
137 }145 }
138 h.SetOnModeChange(nil)146 h.SetOnModeChange(nil)
139 147
@@ -168,7 +176,6 @@ func (h *KeyboardHandler) SetVimModeActivationPassthrough(fn func() bool) {
168 func (h *KeyboardHandler) ReloadShortcuts(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) {176 func (h *KeyboardHandler) ReloadShortcuts(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) {
169 h.stopVimScrollRepeat()177 h.stopVimScrollRepeat()
170 h.mu.Lock()178 h.mu.Lock()
171- defer h.mu.Unlock()
172 179
173 log := logging.FromContext(ctx)180 log := logging.FromContext(ctx)
174 log.Debug().Msg("reloading shortcuts from config")181 log.Debug().Msg("reloading shortcuts from config")
@@ -176,6 +183,14 @@ func (h *KeyboardHandler) ReloadShortcuts(ctx context.Context, workspace *entity
176 h.shortcuts = NewShortcutSet(ctx, workspace, session)183 h.shortcuts = NewShortcutSet(ctx, workspace, session)
177 h.workspace = workspace184 h.workspace = workspace
178 h.session = session185 h.session = session
186+ seqTimeout := time.Duration(0)
187+ if workspace != nil {
188+ seqTimeout = time.Duration(workspace.VimMode.SequenceTimeoutMilliseconds) * time.Millisecond
189+ }
190+ h.mu.Unlock()
191+
192+ h.InvalidateSequenceMatcher()
193+ h.SetSequenceTimeout(seqTimeout)
179 }194 }
180 195
181 // SetOnModeChange sets the callback for mode changes (for UI updates).196 // SetOnModeChange sets the callback for mode changes (for UI updates).
@@ -191,6 +206,8 @@ func (h *KeyboardHandler) SetOnModeChange(fn func(from, to Mode)) {
191 }206 }
192 if to != ModeVim {207 if to != ModeVim {
193 h.stopVimScrollRepeat()208 h.stopVimScrollRepeat()
209+ // Silent reset under ModalState lock: no pending callback (avoids Mode() re-entry).
210+ h.resetPendingSequence(false)
194 }211 }
195 // Forward to app-level callback212 // Forward to app-level callback
196 if fn != nil {213 if fn != nil {
@@ -270,16 +287,10 @@ func (h *KeyboardHandler) AttachTo(window *gtk.ApplicationWindow) {
270 // so app shortcuts still work.287 // so app shortcuts still work.
271 h.controller.SetPropagationPhase(gtk.PhaseBubbleValue)288 h.controller.SetPropagationPhase(gtk.PhaseBubbleValue)
272 289
273- // Wire GTK main thread scheduler for modal timeouts. Timer goroutines290+ // Wire GTK main thread schedulers for modal and sequence ambiguity timeouts.
274- // must dispatch ExitMode to the GTK thread because onModeChange may291+ // Timer goroutines must dispatch back to the GTK thread because callbacks
275- // call setControllerPhase (a GTK operation).292+ // may touch GTK widgets / EventController phase.
276- h.modal.SetMainThreadScheduler(func(fn func()) {293+ h.installGTKMainThreadSchedulers()
277- cb := glib.SourceFunc(func(_ uintptr) bool {
278- fn()
279- return false
280- })
281- glib.IdleAdd(&cb, 0)
282- })
283 294
284 // Connect key pressed handler (retain callback to prevent GC).295 // Connect key pressed handler (retain callback to prevent GC).
285 // The callback receives: keyval (translated key), keycode (hardware key position), state (modifiers)296 // The callback receives: keyval (translated key), keycode (hardware key position), state (modifiers)
@@ -301,6 +312,24 @@ func (h *KeyboardHandler) AttachTo(window *gtk.ApplicationWindow) {
301 log.Debug().Msg("keyboard handler attached to window")312 log.Debug().Msg("keyboard handler attached to window")
302 }313 }
303 314
315+// installGTKMainThreadSchedulers routes modal and sequence timer callbacks
316+// through glib.IdleAdd so they run on the GTK main thread.
317+func (h *KeyboardHandler) installGTKMainThreadSchedulers() {
318+ if h == nil {
319+ return
320+ }
321+ schedule := func(fn func()) {
322+ cb := glib.SourceFunc(func(_ uintptr) bool {
323+ fn()
324+ return false
325+ })
326+ glib.IdleAdd(&cb, 0)
327+ }
328+ h.modal.SetMainThreadScheduler(schedule)
329+ h.SetSequenceMainThreadScheduler(schedule)
330+ logging.FromContext(h.ctx).Debug().Msg("gtk main-thread schedulers installed for modal and vim-mode sequences")
331+}
332+
304 // Detach removes the keyboard handler from a live GTK window and releases its333 // Detach removes the keyboard handler from a live GTK window and releases its
305 // retained GTK signal callbacks.334 // retained GTK signal callbacks.
306 func (h *KeyboardHandler) Detach() {335 func (h *KeyboardHandler) Detach() {
@@ -316,6 +345,9 @@ func (h *KeyboardHandler) DetachForDestroy() {
316 345
317 func (h *KeyboardHandler) detach(removeController bool) {346 func (h *KeyboardHandler) detach(removeController bool) {
318 h.stopVimScrollRepeat()347 h.stopVimScrollRepeat()
348+ // Sequence teardown uses seq.mu only; keep it outside h.mu to preserve
349+ // the existing h.mu → (never seq) / seq.mu standalone lock order.
350+ h.teardownSequenceState()
319 h.mu.Lock()351 h.mu.Lock()
320 if h.controller != nil && h.keyPressedHandlerID != 0 {352 if h.controller != nil && h.keyPressedHandlerID != 0 {
321 h.controller.DisconnectSignal(h.keyPressedHandlerID)353 h.controller.DisconnectSignal(h.keyPressedHandlerID)
@@ -405,6 +437,11 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie
405 // Fall through to shortcut processing below437 // Fall through to shortcut processing below
406 }438 }
407 439
440+ // Vim-mode multi-key sequences run before legacy single-chord lookup.
441+ if h.feedVimModeSequence(keyval, state) {
442+ return true
443+ }
444+
408 // --- Shortcut processing path ---445 // --- Shortcut processing path ---
409 446
410 // Normalize uppercase letters for consistent binding lookup447 // Normalize uppercase letters for consistent binding lookup
M internal/ui/input/shortcuts.go +13 -2
@@ -8,6 +8,7 @@ import (
8 "strings"8 "strings"
9 9
10 "github.com/bnema/dumber/internal/domain/entity"10 "github.com/bnema/dumber/internal/domain/entity"
11+ "github.com/bnema/dumber/internal/domain/vimkeys"
11 "github.com/bnema/dumber/internal/logging"12 "github.com/bnema/dumber/internal/logging"
12 "github.com/bnema/puregotk/v4/gdk"13 "github.com/bnema/puregotk/v4/gdk"
13 )14 )
@@ -233,6 +234,8 @@ type ShortcutSet struct {
233 ResizeMode ShortcutTable234 ResizeMode ShortcutTable
234 // VimMode shortcuts are only active in vim mode.235 // VimMode shortcuts are only active in vim mode.
235 VimMode ShortcutTable236 VimMode ShortcutTable
237+ // vimModeSequences holds multi-key Vim mode bindings.
238+ vimModeSequences *vimkeys.Trie
236 }239 }
237 240
238 // NewShortcutSet creates a ShortcutSet from workspace and session configuration.241 // NewShortcutSet creates a ShortcutSet from workspace and session configuration.
@@ -302,10 +305,18 @@ func (s *ShortcutSet) buildResizeModeShortcuts(ctx context.Context, cfg *entity.
302 s.buildModeShortcuts(ctx, cfg.ResizeMode.GetKeyBindings(), s.ResizeMode, "resize")305 s.buildModeShortcuts(ctx, cfg.ResizeMode.GetKeyBindings(), s.ResizeMode, "resize")
303 }306 }
304 307
305-// buildVimModeShortcuts populates vim mode shortcuts from config.308+// buildVimModeShortcuts populates Vim mode shortcuts from config.
309+// Trie-owned multi-key bindings are excluded from the legacy table.
306 func (s *ShortcutSet) buildVimModeShortcuts(ctx context.Context, cfg *entity.WorkspaceConfig) {310 func (s *ShortcutSet) buildVimModeShortcuts(ctx context.Context, cfg *entity.WorkspaceConfig) {
307 log := logging.FromContext(ctx)311 log := logging.FromContext(ctx)
308- s.buildModeShortcuts(ctx, cfg.VimMode.GetKeyBindings(), s.VimMode, "vim")312+ trie, owned := buildVimModeTrie(&cfg.VimMode)
313+ s.vimModeSequences = trie
314+ legacy := filterLegacyVimModeBindings(cfg.VimMode.GetKeyBindings(), owned)
315+ log.Debug().
316+ Int("owned", len(owned)).
317+ Int("legacy", len(legacy)).
318+ Msg("vim mode sequence trie built")
319+ s.buildModeShortcuts(ctx, legacy, s.VimMode, "vim")
309 if binding, ok := ParseKeyString(cfg.VimMode.ActivationShortcut); ok {320 if binding, ok := ParseKeyString(cfg.VimMode.ActivationShortcut); ok {
310 s.VimMode[binding] = ActionEnterVimMode321 s.VimMode[binding] = ActionEnterVimMode
311 log.Trace().322 log.Trace().
A internal/ui/input/vimkeys_bridge.go +94 -0
@@ -0,0 +1,94 @@
1+package input
2+
3+import (
4+ "strings"
5+
6+ "github.com/bnema/dumber/internal/domain/vimkeys"
7+ "github.com/bnema/puregotk/v4/gdk"
8+)
9+
10+// namedKeyvalSym maps a GDK named keyval onto a canonical vimkeys symbol.
11+// Immutable switch mirrors Phase 1 parser quality (no mutable package map).
12+func namedKeyvalSym(keyval uint) (string, bool) {
13+ switch keyval {
14+ case uint(gdk.KEY_Escape):
15+ return "Esc", true
16+ case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter):
17+ return "CR", true
18+ case uint(gdk.KEY_Tab), uint(gdk.KEY_ISO_Left_Tab):
19+ return "Tab", true
20+ case uint(gdk.KEY_BackSpace):
21+ return "BackSpace", true
22+ case uint(gdk.KEY_space):
23+ return "Space", true
24+ case uint(gdk.KEY_Left):
25+ return "Left", true
26+ case uint(gdk.KEY_Right):
27+ return "Right", true
28+ case uint(gdk.KEY_Up):
29+ return "Up", true
30+ case uint(gdk.KEY_Down):
31+ return "Down", true
32+ case uint(gdk.KEY_Home):
33+ return "Home", true
34+ case uint(gdk.KEY_End):
35+ return "End", true
36+ case uint(gdk.KEY_Page_Up):
37+ return "PageUp", true
38+ case uint(gdk.KEY_Page_Down):
39+ return "PageDown", true
40+ case uint(gdk.KEY_Delete):
41+ return "Delete", true
42+ default:
43+ return "", false
44+ }
45+}
46+
47+// KeyvalToVimKey converts a GDK key event into a vimkeys.Key.
48+// Named keys keep modifiers. Printable non-letter glyphs drop Shift because the
49+// glyph already encodes it; Ctrl and Alt are preserved.
50+func KeyvalToVimKey(keyval uint, state gdk.ModifierType) (vimkeys.Key, bool) {
51+ mods := vimModsFromState(state)
52+ if sym, ok := namedKeyvalSym(keyval); ok {
53+ return vimkeys.Key{Sym: sym, Mods: mods}, true
54+ }
55+
56+ r := rune(keyvalToUnicode(keyval))
57+ if r == 0 || r < 0x20 {
58+ return vimkeys.Key{}, false
59+ }
60+ if r >= 'A' && r <= 'Z' {
61+ return vimkeys.Key{Sym: strings.ToLower(string(r)), Mods: mods}, true
62+ }
63+ if r >= 'a' && r <= 'z' {
64+ return vimkeys.Key{Sym: string(r), Mods: mods}, true
65+ }
66+ return vimkeys.Key{Sym: string(r), Mods: mods &^ vimkeys.ModShift}, true
67+}
68+
69+func vimModsFromState(state gdk.ModifierType) vimkeys.Mods {
70+ var mods vimkeys.Mods
71+ if state&gdk.ControlMaskValue != 0 {
72+ mods |= vimkeys.ModCtrl
73+ }
74+ if state&gdk.ShiftMaskValue != 0 {
75+ mods |= vimkeys.ModShift
76+ }
77+ if state&gdk.AltMaskValue != 0 {
78+ mods |= vimkeys.ModAlt
79+ }
80+ return mods
81+}
82+
83+// keyvalToUnicode mirrors gdk_keyval_to_unicode for Latin-1 graphic keysyms
84+// (identity mapping) and falls back to GDK for the remaining keyvals.
85+func keyvalToUnicode(keyval uint) uint32 {
86+ // ASCII graphic and Latin-1 supplement keysyms share Unicode code points.
87+ if keyval >= 0x20 && keyval <= 0x7e {
88+ return uint32(keyval)
89+ }
90+ if keyval >= 0xa0 && keyval <= 0xff {
91+ return uint32(keyval)
92+ }
93+ return gdk.KeyvalToUnicode(keyval)
94+}
A internal/ui/input/vimkeys_bridge_test.go +207 -0
@@ -0,0 +1,207 @@
1+package input
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/dumber/internal/domain/vimkeys"
7+ "github.com/bnema/puregotk/v4/gdk"
8+)
9+
10+func TestKeyvalToVimKey(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ keyval uint
14+ state gdk.ModifierType
15+ want vimkeys.Key
16+ wantOk bool
17+ }{
18+ {
19+ name: "braceright with shift encodes glyph without shift mod",
20+ keyval: uint(gdk.KEY_braceright),
21+ state: gdk.ShiftMaskValue,
22+ want: vimkeys.Key{Sym: "}"},
23+ wantOk: true,
24+ },
25+ {
26+ name: "uppercase J with shift keeps letter and shift",
27+ keyval: uint(gdk.KEY_J),
28+ state: gdk.ShiftMaskValue,
29+ want: vimkeys.Key{Sym: "j", Mods: vimkeys.ModShift},
30+ wantOk: true,
31+ },
32+ {
33+ name: "lowercase j without mods",
34+ keyval: uint(gdk.KEY_j),
35+ state: gdk.NoModifierMaskValue,
36+ want: vimkeys.Key{Sym: "j"},
37+ wantOk: true,
38+ },
39+ {
40+ name: "named Escape keeps ctrl modifier",
41+ keyval: uint(gdk.KEY_Escape),
42+ state: gdk.ControlMaskValue,
43+ want: vimkeys.Key{Sym: "Esc", Mods: vimkeys.ModCtrl},
44+ wantOk: true,
45+ },
46+ {
47+ name: "named Return keeps shift modifier",
48+ keyval: uint(gdk.KEY_Return),
49+ state: gdk.ShiftMaskValue,
50+ want: vimkeys.Key{Sym: "CR", Mods: vimkeys.ModShift},
51+ wantOk: true,
52+ },
53+ {
54+ name: "named KP_Enter maps to CR",
55+ keyval: uint(gdk.KEY_KP_Enter),
56+ state: gdk.NoModifierMaskValue,
57+ want: vimkeys.Key{Sym: "CR"},
58+ wantOk: true,
59+ },
60+ {
61+ name: "named Space keeps alt modifier",
62+ keyval: uint(gdk.KEY_space),
63+ state: gdk.AltMaskValue,
64+ want: vimkeys.Key{Sym: "Space", Mods: vimkeys.ModAlt},
65+ wantOk: true,
66+ },
67+ {
68+ name: "named Tab maps to Tab",
69+ keyval: uint(gdk.KEY_Tab),
70+ state: gdk.NoModifierMaskValue,
71+ want: vimkeys.Key{Sym: "Tab"},
72+ wantOk: true,
73+ },
74+ {
75+ name: "named ISO_Left_Tab maps to Tab",
76+ keyval: uint(gdk.KEY_ISO_Left_Tab),
77+ state: gdk.ShiftMaskValue,
78+ want: vimkeys.Key{Sym: "Tab", Mods: vimkeys.ModShift},
79+ wantOk: true,
80+ },
81+ {
82+ name: "named BackSpace is BackSpace not BS",
83+ keyval: uint(gdk.KEY_BackSpace),
84+ state: gdk.NoModifierMaskValue,
85+ want: vimkeys.Key{Sym: "BackSpace"},
86+ wantOk: true,
87+ },
88+ {
89+ name: "named Delete is Delete not Del",
90+ keyval: uint(gdk.KEY_Delete),
91+ state: gdk.ControlMaskValue,
92+ want: vimkeys.Key{Sym: "Delete", Mods: vimkeys.ModCtrl},
93+ wantOk: true,
94+ },
95+ {
96+ name: "named Left keeps ctrl",
97+ keyval: uint(gdk.KEY_Left),
98+ state: gdk.ControlMaskValue,
99+ want: vimkeys.Key{Sym: "Left", Mods: vimkeys.ModCtrl},
100+ wantOk: true,
101+ },
102+ {
103+ name: "named Right",
104+ keyval: uint(gdk.KEY_Right),
105+ state: gdk.NoModifierMaskValue,
106+ want: vimkeys.Key{Sym: "Right"},
107+ wantOk: true,
108+ },
109+ {
110+ name: "named Up",
111+ keyval: uint(gdk.KEY_Up),
112+ state: gdk.NoModifierMaskValue,
113+ want: vimkeys.Key{Sym: "Up"},
114+ wantOk: true,
115+ },
116+ {
117+ name: "named Down",
118+ keyval: uint(gdk.KEY_Down),
119+ state: gdk.NoModifierMaskValue,
120+ want: vimkeys.Key{Sym: "Down"},
121+ wantOk: true,
122+ },
123+ {
124+ name: "named Home",
125+ keyval: uint(gdk.KEY_Home),
126+ state: gdk.NoModifierMaskValue,
127+ want: vimkeys.Key{Sym: "Home"},
128+ wantOk: true,
129+ },
130+ {
131+ name: "named End",
132+ keyval: uint(gdk.KEY_End),
133+ state: gdk.NoModifierMaskValue,
134+ want: vimkeys.Key{Sym: "End"},
135+ wantOk: true,
136+ },
137+ {
138+ name: "named PageUp",
139+ keyval: uint(gdk.KEY_Page_Up),
140+ state: gdk.NoModifierMaskValue,
141+ want: vimkeys.Key{Sym: "PageUp"},
142+ wantOk: true,
143+ },
144+ {
145+ name: "named PageDown",
146+ keyval: uint(gdk.KEY_Page_Down),
147+ state: gdk.AltMaskValue,
148+ want: vimkeys.Key{Sym: "PageDown", Mods: vimkeys.ModAlt},
149+ wantOk: true,
150+ },
151+ {
152+ name: "ctrl+d printable letter keeps ctrl",
153+ keyval: uint(gdk.KEY_d),
154+ state: gdk.ControlMaskValue,
155+ want: vimkeys.Key{Sym: "d", Mods: vimkeys.ModCtrl},
156+ wantOk: true,
157+ },
158+ {
159+ name: "ctrl+eacute Latin-1 keyval round-trips through parser",
160+ keyval: uint(gdk.KEY_eacute),
161+ state: gdk.ControlMaskValue,
162+ want: vimkeys.Key{Sym: "é", Mods: vimkeys.ModCtrl},
163+ wantOk: true,
164+ },
165+ {
166+ name: "non-printable rejected",
167+ keyval: 0,
168+ state: gdk.NoModifierMaskValue,
169+ wantOk: false,
170+ },
171+ }
172+
173+ for _, tt := range tests {
174+ t.Run(tt.name+"_roundtrip_string", func(t *testing.T) {
175+ if !tt.wantOk {
176+ return
177+ }
178+ got, ok := KeyvalToVimKey(tt.keyval, tt.state)
179+ if !ok {
180+ t.Fatalf("KeyvalToVimKey() ok = false")
181+ }
182+ canonical := got.String()
183+ parsed, err := vimkeys.ParseBinding(canonical)
184+ if err != nil {
185+ t.Fatalf("ParseBinding(%q) error = %v", canonical, err)
186+ }
187+ if !parsed.Equal(vimkeys.Sequence{got}) {
188+ t.Fatalf("round-trip %q -> %v, want %v", canonical, parsed, vimkeys.Sequence{got})
189+ }
190+ })
191+ }
192+
193+ for _, tt := range tests {
194+ t.Run(tt.name, func(t *testing.T) {
195+ got, ok := KeyvalToVimKey(tt.keyval, tt.state)
196+ if ok != tt.wantOk {
197+ t.Fatalf("KeyvalToVimKey() ok = %v, want %v", ok, tt.wantOk)
198+ }
199+ if !tt.wantOk {
200+ return
201+ }
202+ if got != tt.want {
203+ t.Fatalf("KeyvalToVimKey() = %#v, want %#v", got, tt.want)
204+ }
205+ })
206+ }
207+}
A internal/ui/input/vimkeys_dispatch.go +343 -0
@@ -0,0 +1,343 @@
1+package input
2+
3+import (
4+ "sort"
5+ "sync"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/bnema/dumber/internal/domain/vimkeys"
10+ "github.com/bnema/puregotk/v4/gdk"
11+ "github.com/rs/zerolog/log"
12+)
13+
14+// trieOwnsBinding reports whether a raw config binding belongs to the Vim
15+// sequence trie rather than the legacy single-chord Vim Mode table.
16+// Multi-key sequences and Vim-only chords that ParseKeyString cannot read are owned.
17+func trieOwnsBinding(raw string) (vimkeys.Sequence, bool) {
18+ seq, err := vimkeys.ParseBinding(raw)
19+ if err != nil {
20+ return nil, false
21+ }
22+ if len(seq) >= 2 {
23+ return seq, true
24+ }
25+ if _, ok := ParseKeyString(raw); !ok {
26+ return seq, true
27+ }
28+ return nil, false
29+}
30+
31+// buildVimModeTrie inserts trie-owned Vim mode bindings. Action names are
32+// sorted so the first sorted name wins on Insert conflicts (validation already
33+// rejects duplicates). Ownership is marked only after a successful Insert.
34+func buildVimModeTrie(cfg *entity.VimModeConfig) (*vimkeys.Trie, map[string]bool) {
35+ trie := vimkeys.NewTrie()
36+ owned := make(map[string]bool)
37+ if cfg == nil {
38+ return trie, owned
39+ }
40+
41+ actions := make([]string, 0, len(cfg.Actions))
42+ for action := range cfg.Actions {
43+ actions = append(actions, action)
44+ }
45+ sort.Strings(actions)
46+
47+ for _, action := range actions {
48+ binding := cfg.Actions[action]
49+ keys := append([]string(nil), binding.Keys...)
50+ sort.Strings(keys)
51+ for _, key := range keys {
52+ seq, ok := trieOwnsBinding(key)
53+ if !ok {
54+ continue
55+ }
56+ if err := trie.Insert(seq, action); err != nil {
57+ log.Debug().
58+ Err(err).
59+ Str("key", key).
60+ Str("action", action).
61+ Msg("vim mode trie insert skipped")
62+ continue
63+ }
64+ owned[key] = true
65+ }
66+ }
67+ return trie, owned
68+}
69+
70+// VimModeSequences returns the Vim sequence trie, if any.
71+func (s *ShortcutSet) VimModeSequences() *vimkeys.Trie {
72+ if s == nil {
73+ return nil
74+ }
75+ return s.vimModeSequences
76+}
77+
78+// filterLegacyVimModeBindings copies GetKeyBindings entries that the trie does not own.
79+func filterLegacyVimModeBindings(bindings map[string]string, owned map[string]bool) map[string]string {
80+ legacy := make(map[string]string, len(bindings))
81+ for key, action := range bindings {
82+ if owned[key] {
83+ continue
84+ }
85+ legacy[key] = action
86+ }
87+ return legacy
88+}
89+
90+// sequenceTimer is the injectable ambiguity-timeout seam.
91+type sequenceTimer interface {
92+ Stop() bool
93+}
94+
95+// vimModeSequenceState holds per-handler matcher state and ambiguity timing.
96+type vimModeSequenceState struct {
97+ mu sync.Mutex
98+ matcher *vimkeys.Matcher
99+ onPending func(string)
100+ onAction func(string, int)
101+ timeout time.Duration
102+ timer sequenceTimer
103+ afterFunc func(time.Duration, func()) sequenceTimer
104+ scheduleOnMainThread func(func())
105+ generation uint64
106+}
107+
108+func newVimModeSequenceState(trie *vimkeys.Trie, timeout time.Duration) vimModeSequenceState {
109+ return vimModeSequenceState{
110+ matcher: vimkeys.NewMatcher(trie),
111+ timeout: timeout,
112+ afterFunc: func(d time.Duration, fn func()) sequenceTimer {
113+ return time.AfterFunc(d, fn)
114+ },
115+ scheduleOnMainThread: func(fn func()) { fn() },
116+ }
117+}
118+
119+// SetOnPendingSequenceChange sets the callback for pending sequence text updates.
120+func (h *KeyboardHandler) SetOnPendingSequenceChange(fn func(string)) {
121+ h.seq.mu.Lock()
122+ defer h.seq.mu.Unlock()
123+ h.seq.onPending = fn
124+}
125+
126+// SetOnSequenceAction sets the callback for completed sequence actions.
127+func (h *KeyboardHandler) SetOnSequenceAction(fn func(string, int)) {
128+ h.seq.mu.Lock()
129+ defer h.seq.mu.Unlock()
130+ h.seq.onAction = fn
131+}
132+
133+// SetSequenceTimeout sets the ambiguity resolution timeout.
134+func (h *KeyboardHandler) SetSequenceTimeout(d time.Duration) {
135+ h.seq.mu.Lock()
136+ defer h.seq.mu.Unlock()
137+ h.seq.timeout = d
138+}
139+
140+// SetSequenceMainThreadScheduler sets the main-thread dispatcher for ambiguity
141+// resolution. Timer goroutines must not touch matcher state directly.
142+func (h *KeyboardHandler) SetSequenceMainThreadScheduler(fn func(func())) {
143+ h.seq.mu.Lock()
144+ defer h.seq.mu.Unlock()
145+ if fn == nil {
146+ return
147+ }
148+ h.seq.scheduleOnMainThread = fn
149+}
150+
151+// PendingSequence returns the current pending count+keys string.
152+func (h *KeyboardHandler) PendingSequence() string {
153+ h.seq.mu.Lock()
154+ defer h.seq.mu.Unlock()
155+ if h.seq.matcher == nil {
156+ return ""
157+ }
158+ return h.seq.matcher.Pending()
159+}
160+
161+// ResetPendingSequence clears matcher state and notifies pending listeners.
162+func (h *KeyboardHandler) ResetPendingSequence() {
163+ h.resetPendingSequence(true)
164+}
165+
166+// resetPendingSequence clears matcher/timer state. When notify is false (mode
167+// exit under ModalState lock), pending callbacks are skipped.
168+func (h *KeyboardHandler) resetPendingSequence(notify bool) {
169+ h.seq.mu.Lock()
170+ h.stopSequenceTimerLocked()
171+ h.seq.generation++
172+ if h.seq.matcher != nil {
173+ h.seq.matcher.Reset()
174+ }
175+ onPending := h.seq.onPending
176+ h.seq.mu.Unlock()
177+
178+ if notify && onPending != nil {
179+ onPending("")
180+ }
181+}
182+
183+// teardownSequenceState linearizably cancels sequence timing and clears
184+// UI-retaining callbacks without notifying. Used by Detach/DetachForDestroy.
185+// Lock order: call without holding h.mu (same as resetPendingSequence).
186+func (h *KeyboardHandler) teardownSequenceState() {
187+ h.seq.mu.Lock()
188+ defer h.seq.mu.Unlock()
189+ h.stopSequenceTimerLocked()
190+ h.seq.generation++
191+ if h.seq.matcher != nil {
192+ h.seq.matcher.Reset()
193+ }
194+ h.seq.onPending = nil
195+ h.seq.onAction = nil
196+ h.seq.scheduleOnMainThread = nil
197+ log.Debug().Msg("vim mode sequence state torn down without notify")
198+}
199+
200+// InvalidateSequenceMatcher stops timing, bumps generation, and replaces the
201+// matcher from the current VimModeSequences trie (hot reload).
202+func (h *KeyboardHandler) InvalidateSequenceMatcher() {
203+ h.mu.RLock()
204+ var trie *vimkeys.Trie
205+ if h.shortcuts != nil {
206+ trie = h.shortcuts.VimModeSequences()
207+ }
208+ h.mu.RUnlock()
209+
210+ h.seq.mu.Lock()
211+ defer h.seq.mu.Unlock()
212+ h.stopSequenceTimerLocked()
213+ h.seq.generation++
214+ h.seq.matcher = vimkeys.NewMatcher(trie)
215+ log.Debug().Msg("vim mode sequence matcher replaced")
216+}
217+
218+// feedVimModeSequence feeds a key into the vim-mode sequence matcher.
219+// Pending/Complete consume the event (true). Invalid clears and returns false
220+// so the legacy shortcut path can handle the key.
221+func (h *KeyboardHandler) feedVimModeSequence(keyval uint, state gdk.ModifierType) bool {
222+ if h.modal.Mode() != ModeVim {
223+ return false
224+ }
225+ key, ok := KeyvalToVimKey(keyval, state)
226+ if !ok {
227+ return false
228+ }
229+
230+ h.seq.mu.Lock()
231+ if h.seq.matcher == nil {
232+ h.seq.mu.Unlock()
233+ return false
234+ }
235+
236+ h.stopSequenceTimerLocked()
237+ h.seq.generation++
238+ hadPending := h.seq.matcher.Pending() != ""
239+
240+ result := h.seq.matcher.Feed(key)
241+ onPending := h.seq.onPending
242+ onAction := h.seq.onAction
243+
244+ switch result.Kind {
245+ case vimkeys.ResultPending:
246+ pending := result.Pending
247+ if pending == "" {
248+ pending = h.seq.matcher.Pending()
249+ }
250+ ambiguous := h.seq.matcher.Ambiguous()
251+ if ambiguous {
252+ h.armAmbiguityTimeoutLocked()
253+ }
254+ h.seq.mu.Unlock()
255+ if onPending != nil {
256+ onPending(pending)
257+ }
258+ log.Debug().Str("pending", pending).Bool("ambiguous", ambiguous).Msg("vim mode sequence pending")
259+ return true
260+
261+ case vimkeys.ResultComplete:
262+ action := result.Action
263+ count := result.Count
264+ h.seq.mu.Unlock()
265+ if onPending != nil {
266+ onPending("")
267+ }
268+ if onAction != nil {
269+ onAction(action, count)
270+ }
271+ log.Debug().Str("action", action).Int("count", count).Msg("vim mode sequence complete")
272+ return true
273+
274+ default: // ResultInvalid
275+ h.seq.mu.Unlock()
276+ if hadPending && onPending != nil {
277+ onPending("")
278+ }
279+ log.Debug().Msg("vim mode sequence invalid; falling through to legacy")
280+ return false
281+ }
282+}
283+
284+func (h *KeyboardHandler) stopSequenceTimerLocked() {
285+ if h.seq.timer != nil {
286+ h.seq.timer.Stop()
287+ h.seq.timer = nil
288+ }
289+}
290+
291+func (h *KeyboardHandler) armAmbiguityTimeoutLocked() {
292+ gen := h.seq.generation
293+ timeout := h.seq.timeout
294+ schedule := h.seq.scheduleOnMainThread
295+ afterFunc := h.seq.afterFunc
296+ if afterFunc == nil {
297+ afterFunc = func(d time.Duration, fn func()) sequenceTimer {
298+ return time.AfterFunc(d, fn)
299+ }
300+ }
301+ if schedule == nil {
302+ schedule = func(fn func()) { fn() }
303+ }
304+ h.seq.timer = afterFunc(timeout, func() {
305+ schedule(func() {
306+ h.resolveAmbiguity(gen)
307+ })
308+ })
309+ log.Debug().Dur("timeout", timeout).Uint64("generation", gen).Msg("vim mode ambiguity timer armed")
310+}
311+
312+func (h *KeyboardHandler) resolveAmbiguity(gen uint64) {
313+ h.seq.mu.Lock()
314+ if h.seq.generation != gen {
315+ h.seq.mu.Unlock()
316+ log.Debug().Uint64("generation", gen).Msg("stale vim mode ambiguity resolve ignored")
317+ return
318+ }
319+ if h.seq.matcher == nil {
320+ h.seq.mu.Unlock()
321+ return
322+ }
323+ result, ok := h.seq.matcher.ResolveAmbiguity()
324+ if !ok {
325+ h.seq.mu.Unlock()
326+ return
327+ }
328+ h.seq.timer = nil
329+ h.seq.generation++
330+ onPending := h.seq.onPending
331+ onAction := h.seq.onAction
332+ action := result.Action
333+ count := result.Count
334+ h.seq.mu.Unlock()
335+
336+ if onPending != nil {
337+ onPending("")
338+ }
339+ if onAction != nil {
340+ onAction(action, count)
341+ }
342+ log.Debug().Str("action", action).Int("count", count).Msg("vim mode ambiguity resolved")
343+}
A internal/ui/input/vimkeys_dispatch_test.go +713 -0
@@ -0,0 +1,713 @@
1+package input
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/bnema/dumber/internal/domain/vimkeys"
10+ "github.com/bnema/puregotk/v4/gdk"
11+)
12+
13+type stubSequenceTimer struct {
14+ onStop func()
15+}
16+
17+func (s stubSequenceTimer) Stop() bool {
18+ if s.onStop != nil {
19+ s.onStop()
20+ }
21+ return true
22+}
23+
24+func vimModeSequenceWorkspace(actions map[string]entity.ActionBinding) *entity.WorkspaceConfig {
25+ ws := newTestWorkspace()
26+ ws.VimMode = entity.VimModeConfig{
27+ ActivationShortcut: "ctrl+y",
28+ TimeoutMilliseconds: 0,
29+ SequenceTimeoutMilliseconds: 500,
30+ Actions: actions,
31+ }
32+ return ws
33+}
34+
35+func enterVimMode(t *testing.T, h *KeyboardHandler) {
36+ t.Helper()
37+ if !h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue) {
38+ t.Fatal("ctrl+y should enter vim mode")
39+ }
40+ if h.Mode() != ModeVim {
41+ t.Fatalf("mode = %v, want ModeVim", h.Mode())
42+ }
43+}
44+
45+func capturePending(h *KeyboardHandler) *[]string {
46+ pending := &[]string{}
47+ h.SetOnPendingSequenceChange(func(p string) {
48+ *pending = append(*pending, p)
49+ })
50+ return pending
51+}
52+
53+func captureSequenceActions(h *KeyboardHandler) *[]struct {
54+ action string
55+ count int
56+} {
57+ actions := &[]struct {
58+ action string
59+ count int
60+ }{}
61+ h.SetOnSequenceAction(func(action string, count int) {
62+ *actions = append(*actions, struct {
63+ action string
64+ count int
65+ }{action: action, count: count})
66+ })
67+ return actions
68+}
69+
70+func TestNewVimModeSequenceState_NilTrie(t *testing.T) {
71+ state := newVimModeSequenceState(nil, 0)
72+ if state.matcher == nil {
73+ t.Fatal("matcher is nil")
74+ }
75+ if got := state.matcher.Pending(); got != "" {
76+ t.Fatalf("Pending() = %q, want empty", got)
77+ }
78+ if state.matcher.Ambiguous() {
79+ t.Fatal("Ambiguous() = true, want false")
80+ }
81+ result := state.matcher.Feed(vimkeys.Key{Sym: "x"})
82+ if result.Kind != vimkeys.ResultInvalid {
83+ t.Fatalf("Feed kind = %v, want %v", result.Kind, vimkeys.ResultInvalid)
84+ }
85+}
86+
87+func TestTrieOwnsBinding(t *testing.T) {
88+ cases := map[string]bool{
89+ "]]": true,
90+ "yah": true,
91+ "gO": true,
92+ "<C-d>": true,
93+ "j": false,
94+ "shift+j": false,
95+ "}": false,
96+ "enter": false,
97+ "escape": false,
98+ "<Nope>": false,
99+ }
100+
101+ for raw, wantOwned := range cases {
102+ t.Run(raw, func(t *testing.T) {
103+ seq, owned := trieOwnsBinding(raw)
104+ if owned != wantOwned {
105+ t.Fatalf("trieOwnsBinding(%q) owned = %v, want %v (seq=%v)", raw, owned, wantOwned, seq)
106+ }
107+ if !wantOwned {
108+ return
109+ }
110+ if len(seq) == 0 {
111+ t.Fatalf("trieOwnsBinding(%q) returned empty sequence", raw)
112+ }
113+ })
114+ }
115+}
116+
117+func TestTrieOwnsBinding_CrossContractAtoms(t *testing.T) {
118+ tests := []struct {
119+ raw string
120+ want vimkeys.Sequence
121+ }{
122+ {raw: "}", want: vimkeys.Sequence{{Sym: "}"}}},
123+ {raw: "<C-d>", want: vimkeys.Sequence{{Sym: "d", Mods: vimkeys.ModCtrl}}},
124+ {raw: "<CR>", want: vimkeys.Sequence{{Sym: "CR"}}},
125+ {raw: "J", want: vimkeys.Sequence{{Sym: "j", Mods: vimkeys.ModShift}}},
126+ {raw: "enter", want: vimkeys.Sequence{{Sym: "CR"}}},
127+ {raw: "escape", want: vimkeys.Sequence{{Sym: "Esc"}}},
128+ }
129+
130+ for _, tt := range tests {
131+ t.Run(tt.raw, func(t *testing.T) {
132+ got, err := vimkeys.ParseBinding(tt.raw)
133+ if err != nil {
134+ t.Fatalf("ParseBinding(%q) error = %v", tt.raw, err)
135+ }
136+ if !got.Equal(tt.want) {
137+ t.Fatalf("ParseBinding(%q) = %v, want %v", tt.raw, got, tt.want)
138+ }
139+ _, owned := trieOwnsBinding(tt.raw)
140+ switch tt.raw {
141+ case "<C-d>", "<CR>":
142+ if !owned {
143+ t.Fatalf("trieOwnsBinding(%q) = false, want true", tt.raw)
144+ }
145+ default:
146+ if owned {
147+ t.Fatalf("trieOwnsBinding(%q) = true, want false", tt.raw)
148+ }
149+ }
150+ })
151+ }
152+}
153+
154+func TestBuildVimModeTrie(t *testing.T) {
155+ cfg := &entity.VimModeConfig{
156+ Actions: map[string]entity.ActionBinding{
157+ "vim-scroll-down": {Keys: []string{"j"}},
158+ "heading-next": {Keys: []string{"]]"}},
159+ "yank-section": {Keys: []string{"yah"}},
160+ "outline": {Keys: []string{"gO"}},
161+ "half-page-down": {Keys: []string{"<C-d>"}},
162+ "confirm": {Keys: []string{"enter"}},
163+ "cancel": {Keys: []string{"escape"}},
164+ },
165+ }
166+
167+ trie, owned := buildVimModeTrie(cfg)
168+ if trie == nil {
169+ t.Fatal("buildVimModeTrie returned nil trie")
170+ }
171+
172+ wantOwned := map[string]bool{
173+ "]]": true,
174+ "yah": true,
175+ "gO": true,
176+ "<C-d>": true,
177+ }
178+ for key, want := range wantOwned {
179+ if owned[key] != want {
180+ t.Fatalf("owned[%q] = %v, want %v", key, owned[key], want)
181+ }
182+ }
183+ for _, legacy := range []string{"j", "enter", "escape"} {
184+ if owned[legacy] {
185+ t.Fatalf("legacy binding %q should not be owned", legacy)
186+ }
187+ }
188+
189+ seq, err := vimkeys.ParseBinding("]]")
190+ if err != nil {
191+ t.Fatalf("ParseBinding(]]) error = %v", err)
192+ }
193+ node, ok := trie.Walk(seq)
194+ if !ok || !node.Exact || node.Action != "heading-next" {
195+ t.Fatalf("Walk(]]) = (%#v, %v), want exact heading-next", node, ok)
196+ }
197+}
198+
199+func TestBuildVimModeTrie_ConflictKeepsFirstSortedWinner(t *testing.T) {
200+ // Same sequence under two raw aliases; first sorted action wins Insert.
201+ // Losing raw key must not be marked owned (insert failed).
202+ cfg := &entity.VimModeConfig{
203+ Actions: map[string]entity.ActionBinding{
204+ "zzz-later": {Keys: []string{"<c-d>"}},
205+ "aaa-first": {Keys: []string{"<C-d>"}},
206+ "vim-scroll-down": {Keys: []string{"j"}},
207+ },
208+ }
209+
210+ trie, owned := buildVimModeTrie(cfg)
211+ if !owned["<C-d>"] {
212+ t.Fatal("winning raw key <C-d> should be owned after successful Insert")
213+ }
214+ if owned["<c-d>"] {
215+ t.Fatal("conflicting raw key <c-d> must not be marked owned when Insert fails")
216+ }
217+
218+ seq, err := vimkeys.ParseBinding("<C-d>")
219+ if err != nil {
220+ t.Fatalf("ParseBinding(<C-d>) error = %v", err)
221+ }
222+ node, ok := trie.Walk(seq)
223+ if !ok || !node.Exact || node.Action != "aaa-first" {
224+ t.Fatalf("Walk(<C-d>) = (%#v, %v), want exact aaa-first", node, ok)
225+ }
226+}
227+
228+func vimModeSequencesOwnershipFixture(t *testing.T) *ShortcutSet {
229+ t.Helper()
230+
231+ workspace := &entity.WorkspaceConfig{
232+ VimMode: entity.VimModeConfig{
233+ ActivationShortcut: "ctrl+y",
234+ Actions: map[string]entity.ActionBinding{
235+ "vim-scroll-down": {Keys: []string{"j"}},
236+ "vim-scroll-down-fast": {Keys: []string{"shift+j"}},
237+ "confirm": {Keys: []string{"enter"}},
238+ "cancel": {Keys: []string{"escape"}},
239+ "heading-next": {Keys: []string{"]]"}},
240+ "yank-section": {Keys: []string{"yah"}},
241+ "outline": {Keys: []string{"gO"}},
242+ "half-page-down": {Keys: []string{"<C-d>"}},
243+ },
244+ },
245+ }
246+
247+ set := NewShortcutSet(context.Background(), workspace, nil)
248+ if set.VimModeSequences() == nil {
249+ t.Fatal("VimModeSequences() is nil")
250+ }
251+ return set
252+}
253+
254+func assertLegacyVimModeBindings(t *testing.T, set *ShortcutSet) {
255+ t.Helper()
256+
257+ jBinding, jOK := ParseKeyString("j")
258+ if !jOK {
259+ t.Fatal("ParseKeyString(j) failed")
260+ }
261+ if action, found := set.VimMode[jBinding]; !found || action != ActionVimScrollDown {
262+ t.Fatalf("legacy j missing from VimMode: found=%v action=%q", found, action)
263+ }
264+
265+ enterBinding := KeyBinding{Keyval: uint(gdk.KEY_Return), Modifiers: ModNone}
266+ if action, found := set.VimMode[enterBinding]; !found || action != ActionExitMode {
267+ t.Fatalf("legacy enter missing from VimMode: found=%v action=%q", found, action)
268+ }
269+
270+ escapeBinding := KeyBinding{Keyval: uint(gdk.KEY_Escape), Modifiers: ModNone}
271+ if action, found := set.VimMode[escapeBinding]; !found || action != ActionExitMode {
272+ t.Fatalf("legacy escape missing from VimMode: found=%v action=%q", found, action)
273+ }
274+}
275+
276+func assertOwnedBindingsAbsentFromVimMode(t *testing.T, set *ShortcutSet) {
277+ t.Helper()
278+
279+ for _, key := range []string{"]]", "yah", "gO", "<C-d>"} {
280+ if binding, parsed := ParseKeyString(key); parsed {
281+ if action, found := set.VimMode[binding]; found {
282+ t.Fatalf("owned binding %q leaked into VimMode as %q", key, action)
283+ }
284+ }
285+ }
286+}
287+
288+func assertVimModeTrieYankSection(t *testing.T, set *ShortcutSet) {
289+ t.Helper()
290+
291+ seq, err := vimkeys.ParseBinding("yah")
292+ if err != nil {
293+ t.Fatalf("ParseBinding(yah) error = %v", err)
294+ }
295+ node, walked := set.VimModeSequences().Walk(seq)
296+ if !walked || !node.Exact || node.Action != "yank-section" {
297+ t.Fatalf("VimModeSequences Walk(yah) = (%#v, %v), want exact yank-section", node, walked)
298+ }
299+}
300+
301+func TestShortcutSet_VimModeSequencesOwnership(t *testing.T) {
302+ set := vimModeSequencesOwnershipFixture(t)
303+
304+ t.Run("legacy bindings", func(t *testing.T) {
305+ assertLegacyVimModeBindings(t, set)
306+ })
307+ t.Run("owned bindings absent from legacy table", func(t *testing.T) {
308+ assertOwnedBindingsAbsentFromVimMode(t, set)
309+ })
310+ t.Run("trie resolves owned sequence", func(t *testing.T) {
311+ assertVimModeTrieYankSection(t, set)
312+ })
313+}
314+
315+func TestFeedVimModeSequence_CompleteDoubleBracket(t *testing.T) {
316+ ctx := context.Background()
317+ ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
318+ "heading-next": {Keys: []string{"]]"}},
319+ "vim-scroll-down": {Keys: []string{"j"}},
320+ "cancel": {Keys: []string{"escape"}},
321+ })
322+ h := NewKeyboardHandler(ctx, ws, newTestSession())
323+ pending := capturePending(h)
324+ actions := captureSequenceActions(h)
325+ enterVimMode(t, h)
326+
327+ if !h.handleKeyPress(uint(']'), 0, 0) {
328+ t.Fatal("] prefix should be consumed")
329+ }
330+ if h.PendingSequence() != "]" {
331+ t.Fatalf("PendingSequence() = %q, want %q", h.PendingSequence(), "]")
332+ }
333+ if len(*pending) != 1 || (*pending)[0] != "]" {
334+ t.Fatalf("pending notifications = %#v, want [\"]\"]", *pending)
335+ }
336+
337+ if !h.handleKeyPress(uint(']'), 0, 0) {
338+ t.Fatal("]] should be consumed")
339+ }
340+ if h.PendingSequence() != "" {
341+ t.Fatalf("PendingSequence after complete = %q, want empty", h.PendingSequence())
342+ }
343+ if len(*actions) != 1 || (*actions)[0].action != "heading-next" || (*actions)[0].count != 0 {
344+ t.Fatalf("actions = %#v, want heading-next count 0", *actions)
345+ }
346+ if len(*pending) < 2 || (*pending)[len(*pending)-1] != "" {
347+ t.Fatalf("pending after complete = %#v, want trailing clear", *pending)
348+ }
349+}
350+
351+func TestFeedVimModeSequence_PendingYankNotifications(t *testing.T) {
352+ ctx := context.Background()
353+ ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
354+ "yank-section": {Keys: []string{"yah"}},
355+ "vim-scroll-down": {Keys: []string{"j"}},
356+ })
357+ h := NewKeyboardHandler(ctx, ws, newTestSession())
358+ pending := capturePending(h)
359+ actions := captureSequenceActions(h)
360+ enterVimMode(t, h)
361+
362+ for _, step := range []struct {
363+ key uint
364+ want string
365+ }{
366+ {uint('y'), "y"},
367+ {uint('a'), "ya"},
368+ {uint('h'), ""},
369+ } {
370+ if !h.handleKeyPress(step.key, 0, 0) {
371+ t.Fatalf("key %c should be consumed", step.key)
372+ }
373+ if got := h.PendingSequence(); got != step.want {
374+ t.Fatalf("after %c PendingSequence() = %q, want %q", step.key, got, step.want)
375+ }
376+ }
377+ if len(*actions) != 1 || (*actions)[0].action != "yank-section" {
378+ t.Fatalf("actions = %#v, want yank-section", *actions)
379+ }
380+ if len(*pending) < 3 || (*pending)[0] != "y" || (*pending)[1] != "ya" {
381+ t.Fatalf("pending = %#v, want y then ya", *pending)
382+ }
383+}
384+
385+func TestFeedVimModeSequence_UnknownFallsThrough(t *testing.T) {
386+ ctx := context.Background()
387+ ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
388+ "heading-next": {Keys: []string{"]]"}},
389+ "vim-scroll-down": {Keys: []string{"j"}},
390+ })
391+ h := NewKeyboardHandler(ctx, ws, newTestSession())
392+ var scrollCalls int
393+ h.SetOnAction(func(_ context.Context, action Action) error {
394+ if action == ActionVimScrollDown {
395+ scrollCalls++
396+ }
397+ return nil
398+ })
399+ enterVimMode(t, h)
400+
401+ // Unknown sequence key falls through; legacy j still works.
402+ if !h.handleKeyPress(uint('j'), 0, 0) {
403+ t.Fatal("legacy j should be handled")
404+ }
405+ if scrollCalls != 1 {
406+ t.Fatalf("scrollCalls = %d, want 1", scrollCalls)
407+ }
408+ if h.PendingSequence() != "" {
409+ t.Fatalf("PendingSequence = %q, want empty", h.PendingSequence())
410+ }
411+}
412+
413+func TestFeedVimModeSequence_IgnoresOutsideVimMode(t *testing.T) {
414+ ctx := context.Background()
415+ ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
416+ "heading-next": {Keys: []string{"]]"}},
417+ })
418+ h := NewKeyboardHandler(ctx, ws, newTestSession())
419+ pending := capturePending(h)
420+
421+ if h.feedVimModeSequence(uint(']'), 0) {
422+ t.Fatal("feed outside vim mode should return false")
423+ }
424+ if len(*pending) != 0 {
425+ t.Fatalf("pending = %#v, want none", *pending)
426+ }
427+}
428+
429+func TestFeedVimModeSequence_ModeExitResetsSilently(t *testing.T) {
430+ ctx := context.Background()
431+ ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
432+ "yank-section": {Keys: []string{"yah"}},
433+ "cancel": {Keys: []string{"escape"}},
434+ })
435+ h := NewKeyboardHandler(ctx, ws, newTestSession())
436+ pending := capturePending(h)
437+ enterVimMode(t, h)
438+
439+ if !h.handleKeyPress(uint('y'), 0, 0) {
440+ t.Fatal("y should be consumed")
441+ }
442+ before := len(*pending)
443+
444+ if !h.handleKeyPress(uint(gdk.KEY_Escape), 0, 0) {
445+ t.Fatal("escape should exit")
446+ }
447+ if h.Mode() != ModeNormal {
448+ t.Fatalf("mode = %v, want ModeNormal", h.Mode())
449+ }
450+ if h.PendingSequence() != "" {
451+ t.Fatalf("PendingSequence after exit = %q, want empty", h.PendingSequence())
452+ }
453+ if len(*pending) != before {
454+ t.Fatalf("silent exit must not notify pending clear: before=%d after=%d (%#v)", before, len(*pending), *pending)
455+ }
456+}
457+
458+func TestFeedVimModeSequence_CountPrefix(t *testing.T) {
459+ ctx := context.Background()
460+ ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
461+ "heading-next": {Keys: []string{"]]"}},
462+ })
463+ h := NewKeyboardHandler(ctx, ws, newTestSession())
464+ actions := captureSequenceActions(h)
465+ enterVimMode(t, h)
466+
467+ if !h.handleKeyPress(uint('2'), 0, 0) {
468+ t.Fatal("count digit should be consumed")
469+ }
470+ if h.PendingSequence() != "2" {
471+ t.Fatalf("PendingSequence = %q, want 2", h.PendingSequence())
472+ }
473+ if !h.handleKeyPress(uint(']'), 0, 0) {
474+ t.Fatal("first ] after count should be consumed")
475+ }
476+ if !h.handleKeyPress(uint(']'), 0, 0) {
477+ t.Fatal("2]] should complete")
478+ }
479+ if len(*actions) != 1 || (*actions)[0].action != "heading-next" || (*actions)[0].count != 2 {
480+ t.Fatalf("actions = %#v, want heading-next count 2", *actions)
481+ }
482+}
483+
484+func TestFeedVimModeSequence_AmbiguityTimeout(t *testing.T) {
485+ ctx := context.Background()
486+ ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
487+ "bracket-c": {Keys: []string{"]c"}},
488+ "bracket-cc": {Keys: []string{"]cc"}},
489+ })
490+ h := NewKeyboardHandler(ctx, ws, newTestSession())
491+ actions := captureSequenceActions(h)
492+
493+ var fired func()
494+ var stops int
495+ h.seq.afterFunc = func(d time.Duration, fn func()) sequenceTimer {
496+ if d != 500*time.Millisecond {
497+ t.Fatalf("ambiguity timeout = %v, want 500ms", d)
498+ }
499+ fired = fn
500+ return stubSequenceTimer{onStop: func() { stops++ }}
501+ }
502+ var queued []func()
503+ h.SetSequenceMainThreadScheduler(func(fn func()) {
504+ queued = append(queued, fn)
505+ })
506+
507+ enterVimMode(t, h)
508+ if !h.handleKeyPress(uint(']'), 0, 0) || !h.handleKeyPress(uint('c'), 0, 0) {
509+ t.Fatal("]c prefix should be consumed")
510+ }
511+ if !h.seq.matcher.Ambiguous() {
512+ t.Fatal("]c should be ambiguous against ]cc")
513+ }
514+ if fired == nil {
515+ t.Fatal("expected ambiguity timer arm")
516+ }
517+
518+ fired() // timer expiry queues resolve on scheduler
519+ if len(queued) != 1 {
520+ t.Fatalf("queued resolves = %d, want 1", len(queued))
521+ }
522+ queued[0]()
523+ if len(*actions) != 1 || (*actions)[0].action != "bracket-c" {
524+ t.Fatalf("actions = %#v, want bracket-c", *actions)
525+ }
526+ if h.PendingSequence() != "" {
527+ t.Fatalf("PendingSequence after resolve = %q, want empty", h.PendingSequence())
528+ }
529+ _ = stops
530+}
531+
532+func TestFeedVimModeSequence_TimeoutZeroResolvesImmediately(t *testing.T) {
533+ ctx := context.Background()
534+ ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
535+ "bracket-c": {Keys: []string{"]c"}},
536+ "bracket-cc": {Keys: []string{"]cc"}},
537+ })
538+ ws.VimMode.SequenceTimeoutMilliseconds = 0
539+ h := NewKeyboardHandler(ctx, ws, newTestSession())
540+ actions := captureSequenceActions(h)
541+
542+ var armed time.Duration = -1
543+ h.seq.afterFunc = func(d time.Duration, fn func()) sequenceTimer {
544+ armed = d
545+ fn() // AfterFunc(0) runs promptly; stub runs inline before schedule wrap
546+ return stubSequenceTimer{}
547+ }
548+ var queued []func()
549+ h.SetSequenceMainThreadScheduler(func(fn func()) {
550+ queued = append(queued, fn)
551+ })
552+
553+ enterVimMode(t, h)
554+ if !h.handleKeyPress(uint(']'), 0, 0) || !h.handleKeyPress(uint('c'), 0, 0) {
555+ t.Fatal("]c should be consumed")
556+ }
557+ if armed != 0 {
558+ t.Fatalf("armed timeout = %v, want 0", armed)
559+ }
560+ if len(queued) != 1 {
561+ t.Fatalf("queued = %d, want 1", len(queued))
562+ }
563+ queued[0]()
564+ if len(*actions) != 1 || (*actions)[0].action != "bracket-c" {
565+ t.Fatalf("actions = %#v, want bracket-c", *actions)
566+ }
567+}
568+
569+func TestFeedVimModeSequence_NextKeyStopsTimer(t *testing.T) {
570+ ctx := context.Background()
571+ ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
572+ "bracket-c": {Keys: []string{"]c"}},
573+ "bracket-cc": {Keys: []string{"]cc"}},
574+ })
575+ h := NewKeyboardHandler(ctx, ws, newTestSession())
576+ actions := captureSequenceActions(h)
577+
578+ var fired func()
579+ var stops int
580+ h.seq.afterFunc = func(_ time.Duration, fn func()) sequenceTimer {
581+ fired = fn
582+ return stubSequenceTimer{onStop: func() { stops++ }}
583+ }
584+ h.SetSequenceMainThreadScheduler(func(fn func()) { fn() })
585+
586+ enterVimMode(t, h)
587+ if !h.handleKeyPress(uint(']'), 0, 0) || !h.handleKeyPress(uint('c'), 0, 0) {
588+ t.Fatal("]c should be consumed")
589+ }
590+ if fired == nil {
591+ t.Fatal("expected timer")
592+ }
593+ if !h.handleKeyPress(uint('c'), 0, 0) {
594+ t.Fatal("]cc should complete")
595+ }
596+ if stops == 0 {
597+ t.Fatal("next key must Stop ambiguity timer")
598+ }
599+ if len(*actions) != 1 || (*actions)[0].action != "bracket-cc" {
600+ t.Fatalf("actions = %#v, want bracket-cc", *actions)
601+ }
602+ // Stale timer must no-op.
603+ fired()
604+ if len(*actions) != 1 {
605+ t.Fatalf("stale timer mutated actions: %#v", *actions)
606+ }
607+}
608+
609+func TestFeedVimModeSequence_StaleQueuedTimeoutNoOp(t *testing.T) {
610+ ctx := context.Background()
611+ ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
612+ "bracket-c": {Keys: []string{"]c"}},
613+ "bracket-cc": {Keys: []string{"]cc"}},
614+ "yank-section": {Keys: []string{"yah"}},
615+ })
616+ h := NewKeyboardHandler(ctx, ws, newTestSession())
617+ actions := captureSequenceActions(h)
618+ pending := capturePending(h)
619+
620+ var fired func()
621+ h.seq.afterFunc = func(_ time.Duration, fn func()) sequenceTimer {
622+ fired = fn
623+ return stubSequenceTimer{}
624+ }
625+ var queued []func()
626+ h.SetSequenceMainThreadScheduler(func(fn func()) {
627+ queued = append(queued, fn)
628+ })
629+
630+ enterVimMode(t, h)
631+ if !h.handleKeyPress(uint(']'), 0, 0) || !h.handleKeyPress(uint('c'), 0, 0) {
632+ t.Fatal("]c should be consumed")
633+ }
634+ fired() // enqueue stale resolve
635+ if len(queued) != 1 {
636+ t.Fatalf("queued = %d, want 1", len(queued))
637+ }
638+ stale := queued[0]
639+ queued = nil
640+
641+ // Interleave: feed/reset/reload before stale closure runs.
642+ h.ResetPendingSequence()
643+ if len(*pending) == 0 || (*pending)[len(*pending)-1] != "" {
644+ t.Fatalf("ResetPendingSequence should notify clear: %#v", *pending)
645+ }
646+ stale()
647+ if len(*actions) != 0 {
648+ t.Fatalf("stale resolve after reset must no-op, actions=%#v", *actions)
649+ }
650+
651+ // Reload path also invalidates queued resolve.
652+ fired = nil
653+ queued = nil
654+ if !h.handleKeyPress(uint(']'), 0, 0) || !h.handleKeyPress(uint('c'), 0, 0) {
655+ t.Fatal("]c again")
656+ }
657+ fired()
658+ stale = queued[0]
659+ h.ReloadShortcuts(ctx, ws, newTestSession())
660+ stale()
661+ if len(*actions) != 0 {
662+ t.Fatalf("stale resolve after reload must no-op, actions=%#v", *actions)
663+ }
664+}
665+
666+func TestFeedVimModeSequence_ReloadReplacesMatcher(t *testing.T) {
667+ ctx := context.Background()
668+ wsA := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
669+ "action-a": {Keys: []string{"yah"}},
670+ "vim-scroll-down": {Keys: []string{"j"}},
671+ })
672+ h := NewKeyboardHandler(ctx, wsA, newTestSession())
673+ actions := captureSequenceActions(h)
674+ enterVimMode(t, h)
675+
676+ if !h.handleKeyPress(uint('y'), 0, 0) {
677+ t.Fatal("y should pending under trie A")
678+ }
679+ if h.PendingSequence() != "y" {
680+ t.Fatalf("PendingSequence = %q, want y", h.PendingSequence())
681+ }
682+
683+ wsB := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
684+ "action-b": {Keys: []string{"gO"}},
685+ "vim-scroll-down": {Keys: []string{"j"}},
686+ })
687+ h.ReloadShortcuts(ctx, wsB, newTestSession())
688+ if h.PendingSequence() != "" {
689+ t.Fatalf("PendingSequence after reload = %q, want empty", h.PendingSequence())
690+ }
691+
692+ // Old A sequence must not complete; fall through / invalid.
693+ if h.feedVimModeSequence(uint('a'), 0) {
694+ t.Fatal("stale A continuation must not be consumed after reload")
695+ }
696+ if len(*actions) != 0 {
697+ t.Fatalf("no A action after reload, got %#v", *actions)
698+ }
699+
700+ // New B sequence works.
701+ if !h.handleKeyPress(uint('g'), 0, 0) {
702+ t.Fatal("g should pending under trie B")
703+ }
704+ if h.PendingSequence() != "g" {
705+ t.Fatalf("PendingSequence = %q, want g", h.PendingSequence())
706+ }
707+ if !h.handleKeyPress(uint('O'), 0, gdk.ShiftMaskValue) {
708+ t.Fatal("gO should complete under trie B")
709+ }
710+ if len(*actions) != 1 || (*actions)[0].action != "action-b" {
711+ t.Fatalf("actions = %#v, want action-b", *actions)
712+ }
713+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close