Merge pull request #365 from bnema/feat/native-vim-mode-tranche-1-2a
feat(vim): add native key sequence grammar and accessibility spike
35 files · +6227 -52
M internal/application/port/webview.go +7 -0
| @@ -437,3 +437,10 @@ type PageScrollable interface { | |||
| 437 | 437 | type PageScrollCanceler interface { | |
| 438 | 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 | 81 | // VimModeConfig holds vim scroll mode shortcut configuration. | |
| 82 | 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 line | |
| 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"` | |
| 83 | + | 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"` | |
| 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 | 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 | 156 | wv.handlers = handlers | |
| 157 | 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 | 167 | // Build a CEF client backed by our handlerSet. | |
| 160 | 168 | // Store on WebView to prevent GC collection before CEF AddRef's it. | |
| 161 | 169 | wv.client = purecef.NewClient(wv.handlers) | |
| @@ -164,10 +172,8 @@ func (f *WebViewFactory) newWebView(ctx context.Context) (*WebView, error) { | |||
| 164 | 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() | |
| 168 | - | if !viewportHooksResult.Completed() { | |
| 169 | - | wv.destroyViewBridgeOnGTKAsync() | |
| 170 | - | return nil, errGTKSyncDispatchIncomplete("install CEF viewport sync hooks", viewportHooksResult) | |
| 175 | + | if err := wv.installNewWebViewViewportHooksAfterCapture(); err != nil { | |
| 176 | + | return nil, err | |
| 171 | 177 | } | |
| 172 | 178 | wv.startRenderStallWatchdog() | |
| 173 | 179 | return wv, nil | |
M internal/infrastructure/cef/render_handler_adapter.go +11 -2
| @@ -19,6 +19,7 @@ type dumberRenderHandler struct { | |||
| 19 | 19 | main purecef.RenderHandler | |
| 20 | 20 | popup purecef.RenderHandler | |
| 21 | 21 | popupSurface popupSurface | |
| 22 | + | a11y purecef.AccessibilityHandler | |
| 22 | 23 | } | |
| 23 | 24 | ||
| 24 | 25 | var _ purecef.RenderHandler = (*dumberRenderHandler)(nil) | |
| @@ -48,6 +49,14 @@ func newDumberRenderHandler(wv *WebView) purecef.RenderHandler { | |||
| 48 | 49 | return nil | |
| 49 | 50 | } | |
| 50 | 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 | 61 | var unsupportedPaintOnce sync.Once | |
| 53 | 62 | hooks := startupPresentationHooks(activeStartupTrace()) | |
| @@ -110,10 +119,10 @@ func (h *dumberRenderHandler) renderTargetForPaint(elementType purecef.PaintElem | |||
| 110 | 119 | } | |
| 111 | 120 | ||
| 112 | 121 | func (h *dumberRenderHandler) GetAccessibilityHandler() purecef.AccessibilityHandler { | |
| 113 | - | if h == nil || h.main == nil { | |
| 122 | + | if h == nil { | |
| 114 | 123 | return nil | |
| 115 | 124 | } | |
| 116 | - | return h.main.GetAccessibilityHandler() | |
| 125 | + | return h.a11y | |
| 117 | 126 | } | |
| 118 | 127 | ||
| 119 | 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 | 37 | _ port.OAuthCallbackCapable = (*WebView)(nil) | |
| 38 | 38 | _ port.PageScrollable = (*WebView)(nil) | |
| 39 | 39 | _ port.PageScrollCanceler = (*WebView)(nil) | |
| 40 | + | _ port.AccessibilityEnabler = (*WebView)(nil) | |
| 40 | 41 | ) | |
| 41 | 42 | ||
| 42 | 43 | // errDestroyed is returned when an operation is attempted on a destroyed WebView. | |
| @@ -152,6 +153,19 @@ type WebView struct { | |||
| 152 | 153 | ||
| 153 | 154 | 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 | 169 | // beginFrameTick drives CEF external BeginFrame requests while the GTK | |
| 156 | 170 | // widget is visible. Access is guarded by mu. | |
| 157 | 171 | beginFrameTick *gtk.TickCallback | |
| @@ -1222,6 +1236,7 @@ func (wv *WebView) Destroy() { | |||
| 1222 | 1236 | if !wv.destroyed.CompareAndSwap(false, true) { | |
| 1223 | 1237 | return | |
| 1224 | 1238 | } | |
| 1239 | + | wv.shutdownAccessibilityCapture() | |
| 1225 | 1240 | wv.resetPageScrollQueue() | |
| 1226 | 1241 | wv.syntheticPopupMu.Lock() | |
| 1227 | 1242 | wv.syntheticPopups = nil | |
| @@ -2534,6 +2549,37 @@ func (wv *WebView) CancelPageScroll(_ context.Context) { | |||
| 2534 | 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 | 2583 | func (wv *WebView) resetPageScrollQueue() { | |
| 2538 | 2584 | q := &wv.pageScrollQueue | |
| 2539 | 2585 | q.mu.Lock() | |
M internal/infrastructure/cef/webview_handlers.go +1 -0
| @@ -955,6 +955,7 @@ func (h *handlerSet) finishAfterCreated( | |||
| 955 | 955 | } | |
| 956 | 956 | h.wv.scheduleStartAdaptiveFrameRatePolling() | |
| 957 | 957 | h.wv.scheduleStartBeginFrameLoop() | |
| 958 | + | h.wv.applyAccessibilityIfReady() | |
| 958 | 959 | h.wv.fireReadyToShow() | |
| 959 | 960 | } | |
| 960 | 961 | ||
M internal/infrastructure/config/defaults.go +31 -18
| @@ -38,22 +38,23 @@ const ( | |||
| 38 | 38 | defaultOmniboxAutoOpenOnNewPane = false | |
| 39 | 39 | ||
| 40 | 40 | // Workspace defaults | |
| 41 | - | defaultPaneActivationShortcut = "ctrl+p" | |
| 42 | - | defaultPaneTimeoutMilliseconds = 3000 | |
| 43 | - | defaultTabActivationShortcut = "ctrl+t" | |
| 44 | - | defaultTabTimeoutMilliseconds = 3000 | |
| 45 | - | defaultResizeActivationShortcut = "ctrl+n" | |
| 46 | - | defaultResizeTimeoutMilliseconds = 3000 | |
| 47 | - | defaultPageActivationShortcut = "ctrl+y" | |
| 48 | - | defaultPageTimeoutMilliseconds = 0 | |
| 49 | - | defaultResizeStepPercent = 5.0 | |
| 50 | - | defaultResizeMinPanePercent = 10.0 | |
| 51 | - | defaultTabBarPosition = "bottom" | |
| 52 | - | defaultPopupPlacement = "right" | |
| 53 | - | defaultExternalLinkBehavior = ExternalLinkBehaviorWindowed | |
| 54 | - | defaultExternalLinkPlacement = ExternalLinkPlacementRight | |
| 55 | - | defaultFloatingPaneWidthPct = 0.82 | |
| 56 | - | defaultFloatingPaneHeightPct = 0.72 | |
| 41 | + | defaultPaneActivationShortcut = "ctrl+p" | |
| 42 | + | defaultPaneTimeoutMilliseconds = 3000 | |
| 43 | + | defaultTabActivationShortcut = "ctrl+t" | |
| 44 | + | defaultTabTimeoutMilliseconds = 3000 | |
| 45 | + | defaultResizeActivationShortcut = "ctrl+n" | |
| 46 | + | defaultResizeTimeoutMilliseconds = 3000 | |
| 47 | + | defaultPageActivationShortcut = "ctrl+y" | |
| 48 | + | defaultPageTimeoutMilliseconds = 0 | |
| 49 | + | defaultPageSequenceTimeoutMilliseconds = 500 | |
| 50 | + | defaultResizeStepPercent = 5.0 | |
| 51 | + | defaultResizeMinPanePercent = 10.0 | |
| 52 | + | defaultTabBarPosition = "bottom" | |
| 53 | + | defaultPopupPlacement = "right" | |
| 54 | + | defaultExternalLinkBehavior = ExternalLinkBehaviorWindowed | |
| 55 | + | defaultExternalLinkPlacement = ExternalLinkPlacementRight | |
| 56 | + | defaultFloatingPaneWidthPct = 0.82 | |
| 57 | + | defaultFloatingPaneHeightPct = 0.72 | |
| 57 | 58 | ||
| 58 | 59 | // Session defaults | |
| 59 | 60 | defaultSessionActivationShortcut = "ctrl+o" | |
| @@ -305,8 +306,9 @@ func DefaultConfig() *Config { | |||
| 305 | 306 | }, | |
| 306 | 307 | }, | |
| 307 | 308 | VimMode: VimModeConfig{ | |
| 308 | - | ActivationShortcut: defaultPageActivationShortcut, | |
| 309 | - | TimeoutMilliseconds: defaultPageTimeoutMilliseconds, | |
| 309 | + | ActivationShortcut: defaultPageActivationShortcut, | |
| 310 | + | TimeoutMilliseconds: defaultPageTimeoutMilliseconds, | |
| 311 | + | SequenceTimeoutMilliseconds: defaultPageSequenceTimeoutMilliseconds, | |
| 310 | 312 | Actions: map[string]ActionBinding{ | |
| 311 | 313 | "vim-scroll-left": {Keys: []string{"h"}, Desc: "Scroll page left"}, | |
| 312 | 314 | "vim-scroll-down": {Keys: []string{"j"}, Desc: "Scroll page down"}, | |
| @@ -316,6 +318,17 @@ func DefaultConfig() *Config { | |||
| 316 | 318 | "vim-scroll-up-fast": {Keys: []string{"shift+k"}, Desc: "Scroll page up fast"}, | |
| 317 | 319 | "confirm": {Keys: []string{"enter"}, Desc: "Confirm action"}, | |
| 318 | 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 | 334 | ResizeMode: ResizeModeConfig{ | |
M internal/infrastructure/config/loader.go +2 -0
| @@ -708,6 +708,8 @@ func (m *Manager) setWorkspaceDefaults(defaults *Config) { | |||
| 708 | 708 | m.viper.SetDefault("workspace.tab_mode.actions", defaults.Workspace.TabMode.Actions) | |
| 709 | 709 | m.viper.SetDefault("workspace.vim_mode.activation_shortcut", defaults.Workspace.VimMode.ActivationShortcut) | |
| 710 | 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 | 713 | m.viper.SetDefault("workspace.vim_mode.actions", defaults.Workspace.VimMode.Actions) | |
| 712 | 714 | m.viper.SetDefault("workspace.resize_mode.activation_shortcut", defaults.Workspace.ResizeMode.ActivationShortcut) | |
| 713 | 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 | 488 | Range: ">=0", | |
| 489 | 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 | 507 | Key: "workspace.vim_mode.actions.<action>", | |
| 493 | 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 | 19 | var missing []string | |
| 20 | 20 | for _, key := range NewSchemaProvider().GetSchema() { | |
| 21 | + | if _, pending := schemaKeysPendingProductDocs[key.Key]; pending { | |
| 22 | + | continue | |
| 23 | + | } | |
| 21 | 24 | if configurationReferenceCoversKey(docKeys, key.Key) { | |
| 22 | 25 | continue | |
| 23 | 26 | } | |
| @@ -28,6 +31,13 @@ func TestConfigurationReferenceCoversSchemaKeys(t *testing.T) { | |||
| 28 | 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 | 41 | func configurationReferenceKeys(t *testing.T) map[string]struct{} { | |
| 32 | 42 | t.Helper() | |
| 33 | 43 | ||
M internal/infrastructure/config/validation.go +85 -7
| @@ -11,6 +11,7 @@ import ( | |||
| 11 | 11 | ||
| 12 | 12 | domainurl "github.com/bnema/dumber/internal/domain/url" | |
| 13 | 13 | domainvalidation "github.com/bnema/dumber/internal/domain/validation" | |
| 14 | + | "github.com/bnema/dumber/internal/domain/vimkeys" | |
| 14 | 15 | ) | |
| 15 | 16 | ||
| 16 | 17 | const cefLogSeverityDisabled = 99 | |
| @@ -243,12 +244,31 @@ func validateTabMode(config *Config) []string { | |||
| 243 | 244 | } | |
| 244 | 245 | ||
| 245 | 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 | 260 | // validateModalModeActions shared timeout/empty-actions/duplicate-key checks for | |
| 250 | 261 | // pane, tab, and page modal modes. Messages stay path-exact via modePath. | |
| 251 | 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 | 272 | var validationErrors []string | |
| 253 | 273 | if timeoutMS < 0 { | |
| 254 | 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 | 277 | validationErrors = append(validationErrors, fmt.Sprintf("workspace.%s.actions cannot be empty", modePath)) | |
| 258 | 278 | } | |
| 259 | 279 | ||
| 260 | - | seenKeys := make(map[string]string) | |
| 261 | - | for action, binding := range actions { | |
| 280 | + | if keyCanon == nil { | |
| 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 | 307 | if len(binding.Keys) == 0 { | |
| 263 | 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 { | |
| 266 | - | if existingAction, exists := seenKeys[key]; exists { | |
| 311 | + | keys := append([]string(nil), binding.Keys...) | |
| 312 | + | sort.Strings(keys) | |
| 313 | + | for _, key := range keys { | |
| 314 | + | canonical, err := keyCanon(key) | |
| 315 | + | if err != nil { | |
| 267 | 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 | 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 | 329 | modePath, | |
| 271 | 330 | existingAction, | |
| 272 | 331 | action, | |
| 273 | 332 | )) | |
| 274 | 333 | } | |
| 275 | - | seenKeys[key] = action | |
| 334 | + | seenSequence[canonical] = action | |
| 276 | 335 | } | |
| 277 | 336 | } | |
| 278 | 337 | 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 | 359 | func validateFloatingPane(config *Config) []string { | |
| 282 | 360 | var validationErrors []string | |
| 283 | 361 | seenProfileKeys := make(map[string]string) | |
M internal/infrastructure/config/vim_mode_config_test.go +264 -2
| @@ -3,10 +3,36 @@ package config | |||
| 3 | 3 | import ( | |
| 4 | 4 | "testing" | |
| 5 | 5 | ||
| 6 | + | "github.com/spf13/viper" | |
| 6 | 7 | "github.com/stretchr/testify/assert" | |
| 7 | 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 | 36 | func TestDefaultConfig_VimModeDefaults(t *testing.T) { | |
| 11 | 37 | cfg := DefaultConfig() | |
| 12 | 38 | ||
| @@ -14,9 +40,12 @@ func TestDefaultConfig_VimModeDefaults(t *testing.T) { | |||
| 14 | 40 | vimMode := cfg.Workspace.VimMode | |
| 15 | 41 | assert.Equal(t, "ctrl+y", vimMode.ActivationShortcut) | |
| 16 | 42 | assert.Equal(t, 0, vimMode.TimeoutMilliseconds) | |
| 43 | + | assert.Equal(t, 500, vimMode.SequenceTimeoutMilliseconds) | |
| 17 | 44 | ||
| 18 | 45 | // All required actions must exist with exact key bindings. | |
| 19 | 46 | require.NotNil(t, vimMode.Actions) | |
| 47 | + | require.Len(t, legacyVimModeActions, 8) | |
| 48 | + | require.Len(t, vimMode.Actions, len(legacyVimModeActions)+len(sequenceVimModeActions)) | |
| 20 | 49 | requireActionBinding(t, vimMode.Actions, "vim-scroll-left", []string{"h"}) | |
| 21 | 50 | requireActionBinding(t, vimMode.Actions, "vim-scroll-down", []string{"j"}) | |
| 22 | 51 | requireActionBinding(t, vimMode.Actions, "vim-scroll-up", []string{"k"}) | |
| @@ -25,6 +54,54 @@ func TestDefaultConfig_VimModeDefaults(t *testing.T) { | |||
| 25 | 54 | requireActionBinding(t, vimMode.Actions, "vim-scroll-up-fast", []string{"shift+k"}) | |
| 26 | 55 | requireActionBinding(t, vimMode.Actions, "confirm", []string{"enter"}) | |
| 27 | 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 | 107 | func TestVimModeConfig_GetKeyBindings(t *testing.T) { | |
| @@ -39,7 +116,18 @@ func TestVimModeConfig_GetKeyBindings(t *testing.T) { | |||
| 39 | 116 | assert.Equal(t, "vim-scroll-up-fast", bindings["shift+k"]) | |
| 40 | 117 | assert.Equal(t, "confirm", bindings["enter"]) | |
| 41 | 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 | 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 | 220 | func TestValidateVimMode_NilActionsFail(t *testing.T) { | |
| 89 | 221 | cfg := DefaultConfig() | |
| 90 | 222 | cfg.Workspace.VimMode.Actions = nil | |
| @@ -110,6 +242,106 @@ func TestValidateVimMode_DuplicateKeyBindings(t *testing.T) { | |||
| 110 | 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 | 345 | func TestValidateVimMode_EmptyActionKeysFail(t *testing.T) { | |
| 114 | 346 | cfg := DefaultConfig() | |
| 115 | 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 | 432 | func TestSchemaProvider_VimModeKeys(t *testing.T) { | |
| 196 | 433 | provider := &SchemaProvider{} | |
| 197 | 434 | schema := provider.GetSchema() | |
| 198 | 435 | ||
| 199 | - | var foundActivation, foundTimeout, foundActions bool | |
| 436 | + | var foundActivation, foundTimeout, foundSequenceTimeout, foundActions bool | |
| 200 | 437 | for _, key := range schema { | |
| 201 | 438 | switch key.Key { | |
| 202 | 439 | case "workspace.vim_mode.activation_shortcut": | |
| @@ -210,6 +447,12 @@ func TestSchemaProvider_VimModeKeys(t *testing.T) { | |||
| 210 | 447 | assert.Equal(t, "0", key.Default) | |
| 211 | 448 | assert.Equal(t, ">=0", key.Range) | |
| 212 | 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 | 456 | case "workspace.vim_mode.actions.<action>": | |
| 214 | 457 | foundActions = true | |
| 215 | 458 | assert.Equal(t, "object", key.Type) | |
| @@ -219,5 +462,24 @@ func TestSchemaProvider_VimModeKeys(t *testing.T) { | |||
| 219 | 462 | ||
| 220 | 463 | assert.True(t, foundActivation, "missing workspace.vim_mode.activation_shortcut schema key") | |
| 221 | 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 | 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 | 1100 | a.activateBrowserWindow(bw) | |
| 1101 | 1101 | a.handleModeChange(ctx, bw, from, to) | |
| 1102 | 1102 | }) | |
| 1103 | + | a.bindVimModeSequenceToaster(ctx, bw) | |
| 1103 | 1104 | bw.keyboardHandler.SetRouteKey(func(kc input.KeyContext) input.KeyRoute { | |
| 1104 | 1105 | if bw.sessionManager != nil && bw.sessionManager.IsVisible() { | |
| 1105 | 1106 | 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 | 4103 | // getModeToastClass returns the CSS class for the given mode's toast styling. | |
| 4058 | 4104 | func getModeToastClass(mode input.Mode) string { | |
| 4059 | 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 | 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 | 970 | func newTestShellToaster(t *testing.T) (*component.Toaster, *layoutmocks.MockBoxWidget, *layoutmocks.MockLabelWidget) { | |
| 967 | 971 | t.Helper() | |
| 968 | 972 | ||
| @@ -995,6 +999,9 @@ func TestApp_ShowFilterStatusUsesLastFocusedBrowserWindowToaster(t *testing.T) { | |||
| 995 | 999 | toaster, box, label := newTestShellToaster(t) | |
| 996 | 1000 | box.EXPECT().SetVisible(true).Once() | |
| 997 | 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 | 1006 | bw := &browserWindow{id: "window-1", appToaster: toaster} | |
| 1000 | 1007 | app := &App{ | |
| @@ -1003,17 +1010,23 @@ func TestApp_ShowFilterStatusUsesLastFocusedBrowserWindowToaster(t *testing.T) { | |||
| 1003 | 1010 | } | |
| 1004 | 1011 | ||
| 1005 | 1012 | app.showFilterStatus(ctx, port.FilterStatus{State: port.FilterStateLoading, Message: "Ad blocker loading"}) | |
| 1013 | + | toaster.Hide() | |
| 1006 | 1014 | } | |
| 1007 | 1015 | ||
| 1008 | 1016 | func TestTestShellToasterCleanupCancelsAutoDismiss(t *testing.T) { | |
| 1009 | 1017 | toaster, box, label := newTestShellToaster(t) | |
| 1010 | 1018 | box.EXPECT().SetVisible(true).Once() | |
| 1011 | 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 | 1026 | toaster.CancelAutoDismiss() | |
| 1014 | 1027 | ||
| 1015 | 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 | 1030 | for time.Now().Before(deadline) { | |
| 1018 | 1031 | for mainContext.Pending() { | |
| 1019 | 1032 | mainContext.Iteration(false) | |
| @@ -1028,6 +1041,9 @@ func TestApp_CheckConfigMigrationUsesLastFocusedBrowserWindowToaster(t *testing. | |||
| 1028 | 1041 | toaster, box, label := newTestShellToaster(t) | |
| 1029 | 1042 | box.EXPECT().SetVisible(true).Once() | |
| 1030 | 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 | 1048 | migrator := portmocks.NewMockConfigMigrator(t) | |
| 1033 | 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 | 1064 | app.checkConfigMigration(ctx) | |
| 1065 | + | toaster.Hide() | |
| 1049 | 1066 | } | |
| 1050 | 1067 | ||
| 1051 | 1068 | func TestApp_FinalizeActivationStartsBrowserLaunchRelayOnceAndClosesOnShutdown(t *testing.T) { | |
M internal/ui/input/keyboard.go +49 -12
| @@ -114,6 +114,8 @@ type KeyboardHandler struct { | |||
| 114 | 114 | vimScrollRepeatAdd func(intervalMS uint, cb *glib.SourceFunc) uint | |
| 115 | 115 | vimScrollRepeatRemove func(id uint) bool | |
| 116 | 116 | vimScrollNow func() time.Time | |
| 117 | + | ||
| 118 | + | seq vimModeSequenceState | |
| 117 | 119 | } | |
| 118 | 120 | ||
| 119 | 121 | // NewKeyboardHandler creates a new keyboard handler. | |
| @@ -122,8 +124,13 @@ func NewKeyboardHandler(ctx context.Context, workspace *entity.WorkspaceConfig, | |||
| 122 | 124 | ||
| 123 | 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 | 132 | h := &KeyboardHandler{ | |
| 126 | - | shortcuts: NewShortcutSet(ctx, workspace, session), | |
| 133 | + | shortcuts: shortcuts, | |
| 127 | 134 | modal: NewModalState(ctx), | |
| 128 | 135 | workspace: workspace, | |
| 129 | 136 | session: session, | |
| @@ -134,6 +141,7 @@ func NewKeyboardHandler(ctx context.Context, workspace *entity.WorkspaceConfig, | |||
| 134 | 141 | }, | |
| 135 | 142 | vimScrollRepeatRemove: glib.SourceRemove, | |
| 136 | 143 | vimScrollNow: time.Now, | |
| 144 | + | seq: newVimModeSequenceState(shortcuts.VimModeSequences(), seqTimeout), | |
| 137 | 145 | } | |
| 138 | 146 | h.SetOnModeChange(nil) | |
| 139 | 147 | ||
| @@ -168,7 +176,6 @@ func (h *KeyboardHandler) SetVimModeActivationPassthrough(fn func() bool) { | |||
| 168 | 176 | func (h *KeyboardHandler) ReloadShortcuts(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) { | |
| 169 | 177 | h.stopVimScrollRepeat() | |
| 170 | 178 | h.mu.Lock() | |
| 171 | - | defer h.mu.Unlock() | |
| 172 | 179 | ||
| 173 | 180 | log := logging.FromContext(ctx) | |
| 174 | 181 | log.Debug().Msg("reloading shortcuts from config") | |
| @@ -176,6 +183,14 @@ func (h *KeyboardHandler) ReloadShortcuts(ctx context.Context, workspace *entity | |||
| 176 | 183 | h.shortcuts = NewShortcutSet(ctx, workspace, session) | |
| 177 | 184 | h.workspace = workspace | |
| 178 | 185 | 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 | 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 | 207 | if to != ModeVim { | |
| 193 | 208 | h.stopVimScrollRepeat() | |
| 209 | + | // Silent reset under ModalState lock: no pending callback (avoids Mode() re-entry). | |
| 210 | + | h.resetPendingSequence(false) | |
| 194 | 211 | } | |
| 195 | 212 | // Forward to app-level callback | |
| 196 | 213 | if fn != nil { | |
| @@ -270,16 +287,10 @@ func (h *KeyboardHandler) AttachTo(window *gtk.ApplicationWindow) { | |||
| 270 | 287 | // so app shortcuts still work. | |
| 271 | 288 | h.controller.SetPropagationPhase(gtk.PhaseBubbleValue) | |
| 272 | 289 | ||
| 273 | - | // Wire GTK main thread scheduler for modal timeouts. Timer goroutines | |
| 274 | - | // must dispatch ExitMode to the GTK thread because onModeChange may | |
| 275 | - | // call setControllerPhase (a GTK operation). | |
| 276 | - | h.modal.SetMainThreadScheduler(func(fn func()) { | |
| 277 | - | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 278 | - | fn() | |
| 279 | - | return false | |
| 280 | - | }) | |
| 281 | - | glib.IdleAdd(&cb, 0) | |
| 282 | - | }) | |
| 290 | + | // Wire GTK main thread schedulers for modal and sequence ambiguity timeouts. | |
| 291 | + | // Timer goroutines must dispatch back to the GTK thread because callbacks | |
| 292 | + | // may touch GTK widgets / EventController phase. | |
| 293 | + | h.installGTKMainThreadSchedulers() | |
| 283 | 294 | ||
| 284 | 295 | // Connect key pressed handler (retain callback to prevent GC). | |
| 285 | 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 | 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 | 333 | // Detach removes the keyboard handler from a live GTK window and releases its | |
| 305 | 334 | // retained GTK signal callbacks. | |
| 306 | 335 | func (h *KeyboardHandler) Detach() { | |
| @@ -316,6 +345,9 @@ func (h *KeyboardHandler) DetachForDestroy() { | |||
| 316 | 345 | ||
| 317 | 346 | func (h *KeyboardHandler) detach(removeController bool) { | |
| 318 | 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 | 351 | h.mu.Lock() | |
| 320 | 352 | if h.controller != nil && h.keyPressedHandlerID != 0 { | |
| 321 | 353 | h.controller.DisconnectSignal(h.keyPressedHandlerID) | |
| @@ -405,6 +437,11 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie | |||
| 405 | 437 | // 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 | 445 | // --- Shortcut processing path --- | |
| 409 | 446 | ||
| 410 | 447 | // Normalize uppercase letters for consistent binding lookup | |
M internal/ui/input/shortcuts.go +13 -2
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | "strings" | |
| 9 | 9 | ||
| 10 | 10 | "github.com/bnema/dumber/internal/domain/entity" | |
| 11 | + | "github.com/bnema/dumber/internal/domain/vimkeys" | |
| 11 | 12 | "github.com/bnema/dumber/internal/logging" | |
| 12 | 13 | "github.com/bnema/puregotk/v4/gdk" | |
| 13 | 14 | ) | |
| @@ -233,6 +234,8 @@ type ShortcutSet struct { | |||
| 233 | 234 | ResizeMode ShortcutTable | |
| 234 | 235 | // VimMode shortcuts are only active in vim mode. | |
| 235 | 236 | VimMode ShortcutTable | |
| 237 | + | // vimModeSequences holds multi-key Vim mode bindings. | |
| 238 | + | vimModeSequences *vimkeys.Trie | |
| 236 | 239 | } | |
| 237 | 240 | ||
| 238 | 241 | // NewShortcutSet creates a ShortcutSet from workspace and session configuration. | |
| @@ -302,10 +305,18 @@ func (s *ShortcutSet) buildResizeModeShortcuts(ctx context.Context, cfg *entity. | |||
| 302 | 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 | 310 | func (s *ShortcutSet) buildVimModeShortcuts(ctx context.Context, cfg *entity.WorkspaceConfig) { | |
| 307 | 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 | 320 | if binding, ok := ParseKeyString(cfg.VimMode.ActivationShortcut); ok { | |
| 310 | 321 | s.VimMode[binding] = ActionEnterVimMode | |
| 311 | 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 | + | } | |