detached adf5a45

feat(input): dispatch page mode key sequences

commit
adf5a456972a89f667b22dbe8aa3df8cb5737117
author
brice · · 2026-07-29 06:29 UTC
committer
bnema · · 2026-08-14 02:42 UTC
parent
3f164d0
signature
unsigned
3 files · +753 -32
M internal/ui/input/keyboard.go +24 -2
@@ -114,6 +114,8 @@ type KeyboardHandler struct {
114114 vimScrollRepeatAdd func(intervalMS uint, cb *glib.SourceFunc) uint
115115 vimScrollRepeatRemove func(id uint) bool
116116 vimScrollNow func() time.Time
117+
118+ seq vimModeSequenceState
117119 }
118120
119121 // NewKeyboardHandler creates a new keyboard handler.
@@ -122,8 +124,13 @@ func NewKeyboardHandler(ctx context.Context, workspace *entity.WorkspaceConfig,
122124
123125 log.Debug().Msg("creating keyboard handler")
124126
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+ }
125132 h := &KeyboardHandler{
126- shortcuts: NewShortcutSet(ctx, workspace, session),
133+ shortcuts: shortcuts,
127134 modal: NewModalState(ctx),
128135 workspace: workspace,
129136 session: session,
@@ -134,6 +141,7 @@ func NewKeyboardHandler(ctx context.Context, workspace *entity.WorkspaceConfig,
134141 },
135142 vimScrollRepeatRemove: glib.SourceRemove,
136143 vimScrollNow: time.Now,
144+ seq: newVimModeSequenceState(shortcuts.VimModeSequences(), seqTimeout),
137145 }
138146 h.SetOnModeChange(nil)
139147
@@ -168,7 +176,6 @@ func (h *KeyboardHandler) SetVimModeActivationPassthrough(fn func() bool) {
168176 func (h *KeyboardHandler) ReloadShortcuts(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) {
169177 h.stopVimScrollRepeat()
170178 h.mu.Lock()
171- defer h.mu.Unlock()
172179
173180 log := logging.FromContext(ctx)
174181 log.Debug().Msg("reloading shortcuts from config")
@@ -176,6 +183,14 @@ func (h *KeyboardHandler) ReloadShortcuts(ctx context.Context, workspace *entity
176183 h.shortcuts = NewShortcutSet(ctx, workspace, session)
177184 h.workspace = workspace
178185 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)
179194 }
180195
181196 // SetOnModeChange sets the callback for mode changes (for UI updates).
@@ -191,6 +206,8 @@ func (h *KeyboardHandler) SetOnModeChange(fn func(from, to Mode)) {
191206 }
192207 if to != ModeVim {
193208 h.stopVimScrollRepeat()
209+ // Silent reset under ModalState lock: no pending callback (avoids Mode() re-entry).
210+ h.resetPendingSequence(false)
194211 }
195212 // Forward to app-level callback
196213 if fn != nil {
@@ -405,6 +422,11 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie
405422 // Fall through to shortcut processing below
406423 }
407424
425+ // Page-mode multi-key sequences run before legacy single-chord lookup.
426+ if h.feedVimModeSequence(keyval, state) {
427+ return true
428+ }
429+
408430 // --- Shortcut processing path ---
409431
410432 // Normalize uppercase letters for consistent binding lookup
@@ -114,6 +114,8 @@ type KeyboardHandler struct {
114 vimScrollRepeatAdd func(intervalMS uint, cb *glib.SourceFunc) uint114 vimScrollRepeatAdd func(intervalMS uint, cb *glib.SourceFunc) uint
115 vimScrollRepeatRemove func(id uint) bool115 vimScrollRepeatRemove func(id uint) bool
116 vimScrollNow func() time.Time116 vimScrollNow func() time.Time
117+
118+ seq vimModeSequenceState
117 }119 }
118 120
119 // NewKeyboardHandler creates a new keyboard handler.121 // NewKeyboardHandler creates a new keyboard handler.
@@ -122,8 +124,13 @@ func NewKeyboardHandler(ctx context.Context, workspace *entity.WorkspaceConfig,
122 124
123 log.Debug().Msg("creating keyboard handler")125 log.Debug().Msg("creating keyboard handler")
124 126
127+ shortcuts := NewShortcutSet(ctx, workspace, session)
128+ seqTimeout := time.Duration(0)
129+ if workspace != nil {
130+ seqTimeout = time.Duration(workspace.VimMode.SequenceTimeoutMilliseconds) * time.Millisecond
131+ }
125 h := &KeyboardHandler{132 h := &KeyboardHandler{
126- shortcuts: NewShortcutSet(ctx, workspace, session),133+ shortcuts: shortcuts,
127 modal: NewModalState(ctx),134 modal: NewModalState(ctx),
128 workspace: workspace,135 workspace: workspace,
129 session: session,136 session: session,
@@ -134,6 +141,7 @@ func NewKeyboardHandler(ctx context.Context, workspace *entity.WorkspaceConfig,
134 },141 },
135 vimScrollRepeatRemove: glib.SourceRemove,142 vimScrollRepeatRemove: glib.SourceRemove,
136 vimScrollNow: time.Now,143 vimScrollNow: time.Now,
144+ seq: newVimModeSequenceState(shortcuts.VimModeSequences(), seqTimeout),
137 }145 }
138 h.SetOnModeChange(nil)146 h.SetOnModeChange(nil)
139 147
@@ -168,7 +176,6 @@ func (h *KeyboardHandler) SetVimModeActivationPassthrough(fn func() bool) {
168 func (h *KeyboardHandler) ReloadShortcuts(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) {176 func (h *KeyboardHandler) ReloadShortcuts(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) {
169 h.stopVimScrollRepeat()177 h.stopVimScrollRepeat()
170 h.mu.Lock()178 h.mu.Lock()
171- defer h.mu.Unlock()
172 179
173 log := logging.FromContext(ctx)180 log := logging.FromContext(ctx)
174 log.Debug().Msg("reloading shortcuts from config")181 log.Debug().Msg("reloading shortcuts from config")
@@ -176,6 +183,14 @@ func (h *KeyboardHandler) ReloadShortcuts(ctx context.Context, workspace *entity
176 h.shortcuts = NewShortcutSet(ctx, workspace, session)183 h.shortcuts = NewShortcutSet(ctx, workspace, session)
177 h.workspace = workspace184 h.workspace = workspace
178 h.session = session185 h.session = session
186+ seqTimeout := time.Duration(0)
187+ if workspace != nil {
188+ seqTimeout = time.Duration(workspace.VimMode.SequenceTimeoutMilliseconds) * time.Millisecond
189+ }
190+ h.mu.Unlock()
191+
192+ h.InvalidateSequenceMatcher()
193+ h.SetSequenceTimeout(seqTimeout)
179 }194 }
180 195
181 // SetOnModeChange sets the callback for mode changes (for UI updates).196 // SetOnModeChange sets the callback for mode changes (for UI updates).
@@ -191,6 +206,8 @@ func (h *KeyboardHandler) SetOnModeChange(fn func(from, to Mode)) {
191 }206 }
192 if to != ModeVim {207 if to != ModeVim {
193 h.stopVimScrollRepeat()208 h.stopVimScrollRepeat()
209+ // Silent reset under ModalState lock: no pending callback (avoids Mode() re-entry).
210+ h.resetPendingSequence(false)
194 }211 }
195 // Forward to app-level callback212 // Forward to app-level callback
196 if fn != nil {213 if fn != nil {
@@ -405,6 +422,11 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie
405 // Fall through to shortcut processing below422 // Fall through to shortcut processing below
406 }423 }
407 424
425+ // Page-mode multi-key sequences run before legacy single-chord lookup.
426+ if h.feedVimModeSequence(keyval, state) {
427+ return true
428+ }
429+
408 // --- Shortcut processing path ---430 // --- Shortcut processing path ---
409 431
410 // Normalize uppercase letters for consistent binding lookup432 // Normalize uppercase letters for consistent binding lookup
M internal/ui/input/vimkeys_dispatch.go +242 -1
@@ -2,9 +2,12 @@ package input
22
33 import (
44 "sort"
5+ "sync"
6+ "time"
57
68 "github.com/bnema/dumber/internal/domain/entity"
79 "github.com/bnema/dumber/internal/domain/vimkeys"
10+ "github.com/bnema/puregotk/v4/gdk"
811 "github.com/rs/zerolog/log"
912 )
1013
@@ -55,7 +58,7 @@ func buildVimModeTrie(cfg *entity.VimModeConfig) (*vimkeys.Trie, map[string]bool
5558 Err(err).
5659 Str("key", key).
5760 Str("action", action).
58- Msg("page mode trie insert skipped")
61+ Msg("vim mode trie insert skipped")
5962 continue
6063 }
6164 owned[key] = true
@@ -83,3 +86,241 @@ func filterLegacyVimModeBindings(bindings map[string]string, owned map[string]bo
8386 }
8487 return legacy
8588 }
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+// InvalidateSequenceMatcher stops timing, bumps generation, and replaces the
184+// matcher from the current VimModeSequences trie (hot reload).
185+func (h *KeyboardHandler) InvalidateSequenceMatcher() {
186+ h.mu.RLock()
187+ var trie *vimkeys.Trie
188+ if h.shortcuts != nil {
189+ trie = h.shortcuts.VimModeSequences()
190+ }
191+ h.mu.RUnlock()
192+
193+ h.seq.mu.Lock()
194+ defer h.seq.mu.Unlock()
195+ h.stopSequenceTimerLocked()
196+ h.seq.generation++
197+ h.seq.matcher = vimkeys.NewMatcher(trie)
198+ log.Debug().Msg("vim mode sequence matcher replaced")
199+}
200+
201+// feedVimModeSequence feeds a key into the vim-mode sequence matcher.
202+// Pending/Complete consume the event (true). Invalid clears and returns false
203+// so the legacy shortcut path can handle the key.
204+func (h *KeyboardHandler) feedVimModeSequence(keyval uint, state gdk.ModifierType) bool {
205+ if h.modal.Mode() != ModeVim {
206+ return false
207+ }
208+ key, ok := KeyvalToVimKey(keyval, state)
209+ if !ok {
210+ return false
211+ }
212+
213+ h.seq.mu.Lock()
214+ if h.seq.matcher == nil {
215+ h.seq.mu.Unlock()
216+ return false
217+ }
218+
219+ h.stopSequenceTimerLocked()
220+ h.seq.generation++
221+ hadPending := h.seq.matcher.Pending() != ""
222+
223+ result := h.seq.matcher.Feed(key)
224+ onPending := h.seq.onPending
225+ onAction := h.seq.onAction
226+
227+ switch result.Kind {
228+ case vimkeys.ResultPending:
229+ pending := result.Pending
230+ if pending == "" {
231+ pending = h.seq.matcher.Pending()
232+ }
233+ ambiguous := h.seq.matcher.Ambiguous()
234+ if ambiguous {
235+ h.armAmbiguityTimeoutLocked()
236+ }
237+ h.seq.mu.Unlock()
238+ if onPending != nil {
239+ onPending(pending)
240+ }
241+ log.Debug().Str("pending", pending).Bool("ambiguous", ambiguous).Msg("vim mode sequence pending")
242+ return true
243+
244+ case vimkeys.ResultComplete:
245+ action := result.Action
246+ count := result.Count
247+ h.seq.mu.Unlock()
248+ if onPending != nil {
249+ onPending("")
250+ }
251+ if onAction != nil {
252+ onAction(action, count)
253+ }
254+ log.Debug().Str("action", action).Int("count", count).Msg("vim mode sequence complete")
255+ return true
256+
257+ default: // ResultInvalid
258+ h.seq.mu.Unlock()
259+ if hadPending && onPending != nil {
260+ onPending("")
261+ }
262+ log.Debug().Msg("vim mode sequence invalid; falling through to legacy")
263+ return false
264+ }
265+}
266+
267+func (h *KeyboardHandler) stopSequenceTimerLocked() {
268+ if h.seq.timer != nil {
269+ h.seq.timer.Stop()
270+ h.seq.timer = nil
271+ }
272+}
273+
274+func (h *KeyboardHandler) armAmbiguityTimeoutLocked() {
275+ gen := h.seq.generation
276+ timeout := h.seq.timeout
277+ schedule := h.seq.scheduleOnMainThread
278+ afterFunc := h.seq.afterFunc
279+ if afterFunc == nil {
280+ afterFunc = func(d time.Duration, fn func()) sequenceTimer {
281+ return time.AfterFunc(d, fn)
282+ }
283+ }
284+ if schedule == nil {
285+ schedule = func(fn func()) { fn() }
286+ }
287+ h.seq.timer = afterFunc(timeout, func() {
288+ schedule(func() {
289+ h.resolveAmbiguity(gen)
290+ })
291+ })
292+ log.Debug().Dur("timeout", timeout).Uint64("generation", gen).Msg("vim mode ambiguity timer armed")
293+}
294+
295+func (h *KeyboardHandler) resolveAmbiguity(gen uint64) {
296+ h.seq.mu.Lock()
297+ if h.seq.generation != gen {
298+ h.seq.mu.Unlock()
299+ log.Debug().Uint64("generation", gen).Msg("stale vim mode ambiguity resolve ignored")
300+ return
301+ }
302+ if h.seq.matcher == nil {
303+ h.seq.mu.Unlock()
304+ return
305+ }
306+ result, ok := h.seq.matcher.ResolveAmbiguity()
307+ if !ok {
308+ h.seq.mu.Unlock()
309+ return
310+ }
311+ h.seq.timer = nil
312+ h.seq.generation++
313+ onPending := h.seq.onPending
314+ onAction := h.seq.onAction
315+ action := result.Action
316+ count := result.Count
317+ h.seq.mu.Unlock()
318+
319+ if onPending != nil {
320+ onPending("")
321+ }
322+ if onAction != nil {
323+ onAction(action, count)
324+ }
325+ log.Debug().Str("action", action).Int("count", count).Msg("vim mode ambiguity resolved")
326+}
@@ -2,9 +2,12 @@ package input
2 2
3 import (3 import (
4 "sort"4 "sort"
5+ "sync"
6+ "time"
5 7
6 "github.com/bnema/dumber/internal/domain/entity"8 "github.com/bnema/dumber/internal/domain/entity"
7 "github.com/bnema/dumber/internal/domain/vimkeys"9 "github.com/bnema/dumber/internal/domain/vimkeys"
10+ "github.com/bnema/puregotk/v4/gdk"
8 "github.com/rs/zerolog/log"11 "github.com/rs/zerolog/log"
9 )12 )
10 13
@@ -55,7 +58,7 @@ func buildVimModeTrie(cfg *entity.VimModeConfig) (*vimkeys.Trie, map[string]bool
55 Err(err).58 Err(err).
56 Str("key", key).59 Str("key", key).
57 Str("action", action).60 Str("action", action).
58- Msg("page mode trie insert skipped")61+ Msg("vim mode trie insert skipped")
59 continue62 continue
60 }63 }
61 owned[key] = true64 owned[key] = true
@@ -83,3 +86,241 @@ func filterLegacyVimModeBindings(bindings map[string]string, owned map[string]bo
83 }86 }
84 return legacy87 return legacy
85 }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+// InvalidateSequenceMatcher stops timing, bumps generation, and replaces the
184+// matcher from the current VimModeSequences trie (hot reload).
185+func (h *KeyboardHandler) InvalidateSequenceMatcher() {
186+ h.mu.RLock()
187+ var trie *vimkeys.Trie
188+ if h.shortcuts != nil {
189+ trie = h.shortcuts.VimModeSequences()
190+ }
191+ h.mu.RUnlock()
192+
193+ h.seq.mu.Lock()
194+ defer h.seq.mu.Unlock()
195+ h.stopSequenceTimerLocked()
196+ h.seq.generation++
197+ h.seq.matcher = vimkeys.NewMatcher(trie)
198+ log.Debug().Msg("vim mode sequence matcher replaced")
199+}
200+
201+// feedVimModeSequence feeds a key into the vim-mode sequence matcher.
202+// Pending/Complete consume the event (true). Invalid clears and returns false
203+// so the legacy shortcut path can handle the key.
204+func (h *KeyboardHandler) feedVimModeSequence(keyval uint, state gdk.ModifierType) bool {
205+ if h.modal.Mode() != ModeVim {
206+ return false
207+ }
208+ key, ok := KeyvalToVimKey(keyval, state)
209+ if !ok {
210+ return false
211+ }
212+
213+ h.seq.mu.Lock()
214+ if h.seq.matcher == nil {
215+ h.seq.mu.Unlock()
216+ return false
217+ }
218+
219+ h.stopSequenceTimerLocked()
220+ h.seq.generation++
221+ hadPending := h.seq.matcher.Pending() != ""
222+
223+ result := h.seq.matcher.Feed(key)
224+ onPending := h.seq.onPending
225+ onAction := h.seq.onAction
226+
227+ switch result.Kind {
228+ case vimkeys.ResultPending:
229+ pending := result.Pending
230+ if pending == "" {
231+ pending = h.seq.matcher.Pending()
232+ }
233+ ambiguous := h.seq.matcher.Ambiguous()
234+ if ambiguous {
235+ h.armAmbiguityTimeoutLocked()
236+ }
237+ h.seq.mu.Unlock()
238+ if onPending != nil {
239+ onPending(pending)
240+ }
241+ log.Debug().Str("pending", pending).Bool("ambiguous", ambiguous).Msg("vim mode sequence pending")
242+ return true
243+
244+ case vimkeys.ResultComplete:
245+ action := result.Action
246+ count := result.Count
247+ h.seq.mu.Unlock()
248+ if onPending != nil {
249+ onPending("")
250+ }
251+ if onAction != nil {
252+ onAction(action, count)
253+ }
254+ log.Debug().Str("action", action).Int("count", count).Msg("vim mode sequence complete")
255+ return true
256+
257+ default: // ResultInvalid
258+ h.seq.mu.Unlock()
259+ if hadPending && onPending != nil {
260+ onPending("")
261+ }
262+ log.Debug().Msg("vim mode sequence invalid; falling through to legacy")
263+ return false
264+ }
265+}
266+
267+func (h *KeyboardHandler) stopSequenceTimerLocked() {
268+ if h.seq.timer != nil {
269+ h.seq.timer.Stop()
270+ h.seq.timer = nil
271+ }
272+}
273+
274+func (h *KeyboardHandler) armAmbiguityTimeoutLocked() {
275+ gen := h.seq.generation
276+ timeout := h.seq.timeout
277+ schedule := h.seq.scheduleOnMainThread
278+ afterFunc := h.seq.afterFunc
279+ if afterFunc == nil {
280+ afterFunc = func(d time.Duration, fn func()) sequenceTimer {
281+ return time.AfterFunc(d, fn)
282+ }
283+ }
284+ if schedule == nil {
285+ schedule = func(fn func()) { fn() }
286+ }
287+ h.seq.timer = afterFunc(timeout, func() {
288+ schedule(func() {
289+ h.resolveAmbiguity(gen)
290+ })
291+ })
292+ log.Debug().Dur("timeout", timeout).Uint64("generation", gen).Msg("vim mode ambiguity timer armed")
293+}
294+
295+func (h *KeyboardHandler) resolveAmbiguity(gen uint64) {
296+ h.seq.mu.Lock()
297+ if h.seq.generation != gen {
298+ h.seq.mu.Unlock()
299+ log.Debug().Uint64("generation", gen).Msg("stale vim mode ambiguity resolve ignored")
300+ return
301+ }
302+ if h.seq.matcher == nil {
303+ h.seq.mu.Unlock()
304+ return
305+ }
306+ result, ok := h.seq.matcher.ResolveAmbiguity()
307+ if !ok {
308+ h.seq.mu.Unlock()
309+ return
310+ }
311+ h.seq.timer = nil
312+ h.seq.generation++
313+ onPending := h.seq.onPending
314+ onAction := h.seq.onAction
315+ action := result.Action
316+ count := result.Count
317+ h.seq.mu.Unlock()
318+
319+ if onPending != nil {
320+ onPending("")
321+ }
322+ if onAction != nil {
323+ onAction(action, count)
324+ }
325+ log.Debug().Str("action", action).Int("count", count).Msg("vim mode ambiguity resolved")
326+}
M internal/ui/input/vimkeys_dispatch_test.go +487 -29
@@ -3,12 +3,70 @@ package input
33 import (
44 "context"
55 "testing"
6+ "time"
67
78 "github.com/bnema/dumber/internal/domain/entity"
89 "github.com/bnema/dumber/internal/domain/vimkeys"
910 "github.com/bnema/puregotk/v4/gdk"
1011 )
1112
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+
1270 func TestTrieOwnsBinding(t *testing.T) {
1371 cases := map[string]bool{
1472 "]]": true,
@@ -76,8 +134,8 @@ func TestTrieOwnsBinding_CrossContractAtoms(t *testing.T) {
76134 }
77135 }
78136
79-func TestBuildPageModeTrie(t *testing.T) {
80- cfg := &entity.PageModeConfig{
137+func TestBuildVimModeTrie(t *testing.T) {
138+ cfg := &entity.VimModeConfig{
81139 Actions: map[string]entity.ActionBinding{
82140 "page-scroll-down": {Keys: []string{"j"}},
83141 "heading-next": {Keys: []string{"]]"}},
@@ -89,9 +147,9 @@ func TestBuildPageModeTrie(t *testing.T) {
89147 },
90148 }
91149
92- trie, owned := buildPageModeTrie(cfg)
150+ trie, owned := buildVimModeTrie(cfg)
93151 if trie == nil {
94- t.Fatal("buildPageModeTrie returned nil trie")
152+ t.Fatal("buildVimModeTrie returned nil trie")
95153 }
96154
97155 wantOwned := map[string]bool{
@@ -121,10 +179,10 @@ func TestBuildPageModeTrie(t *testing.T) {
121179 }
122180 }
123181
124-func TestBuildPageModeTrie_ConflictKeepsFirstSortedWinner(t *testing.T) {
182+func TestBuildVimModeTrie_ConflictKeepsFirstSortedWinner(t *testing.T) {
125183 // Same sequence under two raw aliases; first sorted action wins Insert.
126184 // Losing raw key must not be marked owned (insert failed).
127- cfg := &entity.PageModeConfig{
185+ cfg := &entity.VimModeConfig{
128186 Actions: map[string]entity.ActionBinding{
129187 "zzz-later": {Keys: []string{"<c-d>"}},
130188 "aaa-first": {Keys: []string{"<C-d>"}},
@@ -132,7 +190,7 @@ func TestBuildPageModeTrie_ConflictKeepsFirstSortedWinner(t *testing.T) {
132190 },
133191 }
134192
135- trie, owned := buildPageModeTrie(cfg)
193+ trie, owned := buildVimModeTrie(cfg)
136194 if !owned["<C-d>"] {
137195 t.Fatal("winning raw key <C-d> should be owned after successful Insert")
138196 }
@@ -150,11 +208,11 @@ func TestBuildPageModeTrie_ConflictKeepsFirstSortedWinner(t *testing.T) {
150208 }
151209 }
152210
153-func pageModeSequencesOwnershipFixture(t *testing.T) *ShortcutSet {
211+func vimModeSequencesOwnershipFixture(t *testing.T) *ShortcutSet {
154212 t.Helper()
155213
156214 workspace := &entity.WorkspaceConfig{
157- PageMode: entity.PageModeConfig{
215+ VimMode: entity.VimModeConfig{
158216 ActivationShortcut: "ctrl+y",
159217 Actions: map[string]entity.ActionBinding{
160218 "page-scroll-down": {Keys: []string{"j"}},
@@ -170,69 +228,469 @@ func pageModeSequencesOwnershipFixture(t *testing.T) *ShortcutSet {
170228 }
171229
172230 set := NewShortcutSet(context.Background(), workspace, nil)
173- if set.PageModeSequences() == nil {
174- t.Fatal("PageModeSequences() is nil")
231+ if set.VimModeSequences() == nil {
232+ t.Fatal("VimModeSequences() is nil")
175233 }
176234 return set
177235 }
178236
179-func assertLegacyPageModeBindings(t *testing.T, set *ShortcutSet) {
237+func assertLegacyVimModeBindings(t *testing.T, set *ShortcutSet) {
180238 t.Helper()
181239
182240 jBinding, jOK := ParseKeyString("j")
183241 if !jOK {
184242 t.Fatal("ParseKeyString(j) failed")
185243 }
186- if action, found := set.PageMode[jBinding]; !found || action != ActionPageScrollDown {
187- t.Fatalf("legacy j missing from PageMode: found=%v action=%q", found, action)
244+ if action, found := set.VimMode[jBinding]; !found || action != ActionVimScrollDown {
245+ t.Fatalf("legacy j missing from VimMode: found=%v action=%q", found, action)
188246 }
189247
190248 enterBinding := KeyBinding{Keyval: uint(gdk.KEY_Return), Modifiers: ModNone}
191- if action, found := set.PageMode[enterBinding]; !found || action != ActionExitMode {
192- t.Fatalf("legacy enter missing from PageMode: found=%v action=%q", found, action)
249+ if action, found := set.VimMode[enterBinding]; !found || action != ActionExitMode {
250+ t.Fatalf("legacy enter missing from VimMode: found=%v action=%q", found, action)
193251 }
194252
195253 escapeBinding := KeyBinding{Keyval: uint(gdk.KEY_Escape), Modifiers: ModNone}
196- if action, found := set.PageMode[escapeBinding]; !found || action != ActionExitMode {
197- t.Fatalf("legacy escape missing from PageMode: found=%v action=%q", found, action)
254+ if action, found := set.VimMode[escapeBinding]; !found || action != ActionExitMode {
255+ t.Fatalf("legacy escape missing from VimMode: found=%v action=%q", found, action)
198256 }
199257 }
200258
201-func assertOwnedBindingsAbsentFromPageMode(t *testing.T, set *ShortcutSet) {
259+func assertOwnedBindingsAbsentFromVimMode(t *testing.T, set *ShortcutSet) {
202260 t.Helper()
203261
204262 for _, key := range []string{"]]", "yah", "gO", "<C-d>"} {
205263 if binding, parsed := ParseKeyString(key); parsed {
206- if action, found := set.PageMode[binding]; found {
207- t.Fatalf("owned binding %q leaked into PageMode as %q", key, action)
264+ if action, found := set.VimMode[binding]; found {
265+ t.Fatalf("owned binding %q leaked into VimMode as %q", key, action)
208266 }
209267 }
210268 }
211269 }
212270
213-func assertPageModeTrieYankSection(t *testing.T, set *ShortcutSet) {
271+func assertVimModeTrieYankSection(t *testing.T, set *ShortcutSet) {
214272 t.Helper()
215273
216274 seq, err := vimkeys.ParseBinding("yah")
217275 if err != nil {
218276 t.Fatalf("ParseBinding(yah) error = %v", err)
219277 }
220- node, walked := set.PageModeSequences().Walk(seq)
278+ node, walked := set.VimModeSequences().Walk(seq)
221279 if !walked || !node.Exact || node.Action != "yank-section" {
222- t.Fatalf("PageModeSequences Walk(yah) = (%#v, %v), want exact yank-section", node, walked)
280+ t.Fatalf("VimModeSequences Walk(yah) = (%#v, %v), want exact yank-section", node, walked)
223281 }
224282 }
225283
226-func TestShortcutSet_PageModeSequencesOwnership(t *testing.T) {
227- set := pageModeSequencesOwnershipFixture(t)
284+func TestShortcutSet_VimModeSequencesOwnership(t *testing.T) {
285+ set := vimModeSequencesOwnershipFixture(t)
228286
229287 t.Run("legacy bindings", func(t *testing.T) {
230- assertLegacyPageModeBindings(t, set)
288+ assertLegacyVimModeBindings(t, set)
231289 })
232290 t.Run("owned bindings absent from legacy table", func(t *testing.T) {
233- assertOwnedBindingsAbsentFromPageMode(t, set)
291+ assertOwnedBindingsAbsentFromVimMode(t, set)
234292 })
235293 t.Run("trie resolves owned sequence", func(t *testing.T) {
236- assertPageModeTrieYankSection(t, set)
294+ assertVimModeTrieYankSection(t, set)
237295 })
238296 }
297+
298+func TestFeedVimModeSequence_CompleteDoubleBracket(t *testing.T) {
299+ ctx := context.Background()
300+ ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
301+ "heading-next": {Keys: []string{"]]"}},
302+ "page-scroll-down": {Keys: []string{"j"}},
303+ "cancel": {Keys: []string{"escape"}},
304+ })
305+ h := NewKeyboardHandler(ctx, ws, newTestSession())
306+ pending := capturePending(h)
307+ actions := captureSequenceActions(h)
308+ enterVimMode(t, h)
309+
310+ if !h.handleKeyPress(uint(']'), 0, 0) {
311+ t.Fatal("] prefix should be consumed")
312+ }
313+ if h.PendingSequence() != "]" {
314+ t.Fatalf("PendingSequence() = %q, want %q", h.PendingSequence(), "]")
315+ }
316+ if len(*pending) != 1 || (*pending)[0] != "]" {
317+ t.Fatalf("pending notifications = %#v, want [\"]\"]", *pending)
318+ }
319+
320+ if !h.handleKeyPress(uint(']'), 0, 0) {
321+ t.Fatal("]] should be consumed")
322+ }
323+ if h.PendingSequence() != "" {
324+ t.Fatalf("PendingSequence after complete = %q, want empty", h.PendingSequence())
325+ }
326+ if len(*actions) != 1 || (*actions)[0].action != "heading-next" || (*actions)[0].count != 0 {
327+ t.Fatalf("actions = %#v, want heading-next count 0", *actions)
328+ }
329+ if len(*pending) < 2 || (*pending)[len(*pending)-1] != "" {
330+ t.Fatalf("pending after complete = %#v, want trailing clear", *pending)
331+ }
332+}
333+
334+func TestFeedVimModeSequence_PendingYankNotifications(t *testing.T) {
335+ ctx := context.Background()
336+ ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
337+ "yank-section": {Keys: []string{"yah"}},
338+ "page-scroll-down": {Keys: []string{"j"}},
339+ })
340+ h := NewKeyboardHandler(ctx, ws, newTestSession())
341+ pending := capturePending(h)
342+ actions := captureSequenceActions(h)
343+ enterVimMode(t, h)
344+
345+ for _, step := range []struct {
346+ key uint
347+ want string
348+ }{
349+ {uint('y'), "y"},
350+ {uint('a'), "ya"},
351+ {uint('h'), ""},
352+ } {
353+ if !h.handleKeyPress(step.key, 0, 0) {
354+ t.Fatalf("key %c should be consumed", step.key)
355+ }
356+ if got := h.PendingSequence(); got != step.want {
357+ t.Fatalf("after %c PendingSequence() = %q, want %q", step.key, got, step.want)
358+ }
359+ }
360+ if len(*actions) != 1 || (*actions)[0].action != "yank-section" {
361+ t.Fatalf("actions = %#v, want yank-section", *actions)
362+ }
363+ if len(*pending) < 3 || (*pending)[0] != "y" || (*pending)[1] != "ya" {
364+ t.Fatalf("pending = %#v, want y then ya", *pending)
365+ }
366+}
367+
368+func TestFeedVimModeSequence_UnknownFallsThrough(t *testing.T) {
369+ ctx := context.Background()
370+ ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
371+ "heading-next": {Keys: []string{"]]"}},
372+ "page-scroll-down": {Keys: []string{"j"}},
373+ })
374+ h := NewKeyboardHandler(ctx, ws, newTestSession())
375+ var scrollCalls int
376+ h.SetOnAction(func(_ context.Context, action Action) error {
377+ if action == ActionVimScrollDown {
378+ scrollCalls++
379+ }
380+ return nil
381+ })
382+ enterVimMode(t, h)
383+
384+ // Unknown sequence key falls through; legacy j still works.
385+ if !h.handleKeyPress(uint('j'), 0, 0) {
386+ t.Fatal("legacy j should be handled")
387+ }
388+ if scrollCalls != 1 {
389+ t.Fatalf("scrollCalls = %d, want 1", scrollCalls)
390+ }
391+ if h.PendingSequence() != "" {
392+ t.Fatalf("PendingSequence = %q, want empty", h.PendingSequence())
393+ }
394+}
395+
396+func TestFeedVimModeSequence_IgnoresOutsideVimMode(t *testing.T) {
397+ ctx := context.Background()
398+ ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
399+ "heading-next": {Keys: []string{"]]"}},
400+ })
401+ h := NewKeyboardHandler(ctx, ws, newTestSession())
402+ pending := capturePending(h)
403+
404+ if h.feedVimModeSequence(uint(']'), 0) {
405+ t.Fatal("feed outside vim mode should return false")
406+ }
407+ if len(*pending) != 0 {
408+ t.Fatalf("pending = %#v, want none", *pending)
409+ }
410+}
411+
412+func TestFeedVimModeSequence_ModeExitResetsSilently(t *testing.T) {
413+ ctx := context.Background()
414+ ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
415+ "yank-section": {Keys: []string{"yah"}},
416+ "cancel": {Keys: []string{"escape"}},
417+ })
418+ h := NewKeyboardHandler(ctx, ws, newTestSession())
419+ pending := capturePending(h)
420+ enterVimMode(t, h)
421+
422+ if !h.handleKeyPress(uint('y'), 0, 0) {
423+ t.Fatal("y should be consumed")
424+ }
425+ before := len(*pending)
426+
427+ if !h.handleKeyPress(uint(gdk.KEY_Escape), 0, 0) {
428+ t.Fatal("escape should exit")
429+ }
430+ if h.Mode() != ModeNormal {
431+ t.Fatalf("mode = %v, want ModeNormal", h.Mode())
432+ }
433+ if h.PendingSequence() != "" {
434+ t.Fatalf("PendingSequence after exit = %q, want empty", h.PendingSequence())
435+ }
436+ if len(*pending) != before {
437+ t.Fatalf("silent exit must not notify pending clear: before=%d after=%d (%#v)", before, len(*pending), *pending)
438+ }
439+}
440+
441+func TestFeedVimModeSequence_CountPrefix(t *testing.T) {
442+ ctx := context.Background()
443+ ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
444+ "heading-next": {Keys: []string{"]]"}},
445+ })
446+ h := NewKeyboardHandler(ctx, ws, newTestSession())
447+ actions := captureSequenceActions(h)
448+ enterVimMode(t, h)
449+
450+ if !h.handleKeyPress(uint('2'), 0, 0) {
451+ t.Fatal("count digit should be consumed")
452+ }
453+ if h.PendingSequence() != "2" {
454+ t.Fatalf("PendingSequence = %q, want 2", h.PendingSequence())
455+ }
456+ if !h.handleKeyPress(uint(']'), 0, 0) {
457+ t.Fatal("first ] after count should be consumed")
458+ }
459+ if !h.handleKeyPress(uint(']'), 0, 0) {
460+ t.Fatal("2]] should complete")
461+ }
462+ if len(*actions) != 1 || (*actions)[0].action != "heading-next" || (*actions)[0].count != 2 {
463+ t.Fatalf("actions = %#v, want heading-next count 2", *actions)
464+ }
465+}
466+
467+func TestFeedVimModeSequence_AmbiguityTimeout(t *testing.T) {
468+ ctx := context.Background()
469+ ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
470+ "bracket-c": {Keys: []string{"]c"}},
471+ "bracket-cc": {Keys: []string{"]cc"}},
472+ })
473+ h := NewKeyboardHandler(ctx, ws, newTestSession())
474+ actions := captureSequenceActions(h)
475+
476+ var fired func()
477+ var stops int
478+ h.seq.afterFunc = func(d time.Duration, fn func()) sequenceTimer {
479+ if d != 500*time.Millisecond {
480+ t.Fatalf("ambiguity timeout = %v, want 500ms", d)
481+ }
482+ fired = fn
483+ return stubSequenceTimer{onStop: func() { stops++ }}
484+ }
485+ var queued []func()
486+ h.SetSequenceMainThreadScheduler(func(fn func()) {
487+ queued = append(queued, fn)
488+ })
489+
490+ enterVimMode(t, h)
491+ if !h.handleKeyPress(uint(']'), 0, 0) || !h.handleKeyPress(uint('c'), 0, 0) {
492+ t.Fatal("]c prefix should be consumed")
493+ }
494+ if !h.seq.matcher.Ambiguous() {
495+ t.Fatal("]c should be ambiguous against ]cc")
496+ }
497+ if fired == nil {
498+ t.Fatal("expected ambiguity timer arm")
499+ }
500+
501+ fired() // timer expiry queues resolve on scheduler
502+ if len(queued) != 1 {
503+ t.Fatalf("queued resolves = %d, want 1", len(queued))
504+ }
505+ queued[0]()
506+ if len(*actions) != 1 || (*actions)[0].action != "bracket-c" {
507+ t.Fatalf("actions = %#v, want bracket-c", *actions)
508+ }
509+ if h.PendingSequence() != "" {
510+ t.Fatalf("PendingSequence after resolve = %q, want empty", h.PendingSequence())
511+ }
512+ _ = stops
513+}
514+
515+func TestFeedVimModeSequence_TimeoutZeroResolvesImmediately(t *testing.T) {
516+ ctx := context.Background()
517+ ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
518+ "bracket-c": {Keys: []string{"]c"}},
519+ "bracket-cc": {Keys: []string{"]cc"}},
520+ })
521+ ws.VimMode.SequenceTimeoutMilliseconds = 0
522+ h := NewKeyboardHandler(ctx, ws, newTestSession())
523+ actions := captureSequenceActions(h)
524+
525+ var armed time.Duration = -1
526+ h.seq.afterFunc = func(d time.Duration, fn func()) sequenceTimer {
527+ armed = d
528+ fn() // AfterFunc(0) runs promptly; stub runs inline before schedule wrap
529+ return stubSequenceTimer{}
530+ }
531+ var queued []func()
532+ h.SetSequenceMainThreadScheduler(func(fn func()) {
533+ queued = append(queued, fn)
534+ })
535+
536+ enterVimMode(t, h)
537+ if !h.handleKeyPress(uint(']'), 0, 0) || !h.handleKeyPress(uint('c'), 0, 0) {
538+ t.Fatal("]c should be consumed")
539+ }
540+ if armed != 0 {
541+ t.Fatalf("armed timeout = %v, want 0", armed)
542+ }
543+ if len(queued) != 1 {
544+ t.Fatalf("queued = %d, want 1", len(queued))
545+ }
546+ queued[0]()
547+ if len(*actions) != 1 || (*actions)[0].action != "bracket-c" {
548+ t.Fatalf("actions = %#v, want bracket-c", *actions)
549+ }
550+}
551+
552+func TestFeedVimModeSequence_NextKeyStopsTimer(t *testing.T) {
553+ ctx := context.Background()
554+ ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
555+ "bracket-c": {Keys: []string{"]c"}},
556+ "bracket-cc": {Keys: []string{"]cc"}},
557+ })
558+ h := NewKeyboardHandler(ctx, ws, newTestSession())
559+ actions := captureSequenceActions(h)
560+
561+ var fired func()
562+ var stops int
563+ h.seq.afterFunc = func(_ time.Duration, fn func()) sequenceTimer {
564+ fired = fn
565+ return stubSequenceTimer{onStop: func() { stops++ }}
566+ }
567+ h.SetSequenceMainThreadScheduler(func(fn func()) { fn() })
568+
569+ enterVimMode(t, h)
570+ if !h.handleKeyPress(uint(']'), 0, 0) || !h.handleKeyPress(uint('c'), 0, 0) {
571+ t.Fatal("]c should be consumed")
572+ }
573+ if fired == nil {
574+ t.Fatal("expected timer")
575+ }
576+ if !h.handleKeyPress(uint('c'), 0, 0) {
577+ t.Fatal("]cc should complete")
578+ }
579+ if stops == 0 {
580+ t.Fatal("next key must Stop ambiguity timer")
581+ }
582+ if len(*actions) != 1 || (*actions)[0].action != "bracket-cc" {
583+ t.Fatalf("actions = %#v, want bracket-cc", *actions)
584+ }
585+ // Stale timer must no-op.
586+ fired()
587+ if len(*actions) != 1 {
588+ t.Fatalf("stale timer mutated actions: %#v", *actions)
589+ }
590+}
591+
592+func TestFeedVimModeSequence_StaleQueuedTimeoutNoOp(t *testing.T) {
593+ ctx := context.Background()
594+ ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
595+ "bracket-c": {Keys: []string{"]c"}},
596+ "bracket-cc": {Keys: []string{"]cc"}},
597+ "yank-section": {Keys: []string{"yah"}},
598+ })
599+ h := NewKeyboardHandler(ctx, ws, newTestSession())
600+ actions := captureSequenceActions(h)
601+ pending := capturePending(h)
602+
603+ var fired func()
604+ h.seq.afterFunc = func(_ time.Duration, fn func()) sequenceTimer {
605+ fired = fn
606+ return stubSequenceTimer{}
607+ }
608+ var queued []func()
609+ h.SetSequenceMainThreadScheduler(func(fn func()) {
610+ queued = append(queued, fn)
611+ })
612+
613+ enterVimMode(t, h)
614+ if !h.handleKeyPress(uint(']'), 0, 0) || !h.handleKeyPress(uint('c'), 0, 0) {
615+ t.Fatal("]c should be consumed")
616+ }
617+ fired() // enqueue stale resolve
618+ if len(queued) != 1 {
619+ t.Fatalf("queued = %d, want 1", len(queued))
620+ }
621+ stale := queued[0]
622+ queued = nil
623+
624+ // Interleave: feed/reset/reload before stale closure runs.
625+ h.ResetPendingSequence()
626+ if len(*pending) == 0 || (*pending)[len(*pending)-1] != "" {
627+ t.Fatalf("ResetPendingSequence should notify clear: %#v", *pending)
628+ }
629+ stale()
630+ if len(*actions) != 0 {
631+ t.Fatalf("stale resolve after reset must no-op, actions=%#v", *actions)
632+ }
633+
634+ // Reload path also invalidates queued resolve.
635+ fired = nil
636+ queued = nil
637+ if !h.handleKeyPress(uint(']'), 0, 0) || !h.handleKeyPress(uint('c'), 0, 0) {
638+ t.Fatal("]c again")
639+ }
640+ fired()
641+ stale = queued[0]
642+ h.ReloadShortcuts(ctx, ws, newTestSession())
643+ stale()
644+ if len(*actions) != 0 {
645+ t.Fatalf("stale resolve after reload must no-op, actions=%#v", *actions)
646+ }
647+}
648+
649+func TestFeedVimModeSequence_ReloadReplacesMatcher(t *testing.T) {
650+ ctx := context.Background()
651+ wsA := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
652+ "action-a": {Keys: []string{"yah"}},
653+ "page-scroll-down": {Keys: []string{"j"}},
654+ })
655+ h := NewKeyboardHandler(ctx, wsA, newTestSession())
656+ actions := captureSequenceActions(h)
657+ enterVimMode(t, h)
658+
659+ if !h.handleKeyPress(uint('y'), 0, 0) {
660+ t.Fatal("y should pending under trie A")
661+ }
662+ if h.PendingSequence() != "y" {
663+ t.Fatalf("PendingSequence = %q, want y", h.PendingSequence())
664+ }
665+
666+ wsB := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
667+ "action-b": {Keys: []string{"gO"}},
668+ "page-scroll-down": {Keys: []string{"j"}},
669+ })
670+ h.ReloadShortcuts(ctx, wsB, newTestSession())
671+ if h.PendingSequence() != "" {
672+ t.Fatalf("PendingSequence after reload = %q, want empty", h.PendingSequence())
673+ }
674+
675+ // Old A sequence must not complete; fall through / invalid.
676+ if h.feedVimModeSequence(uint('a'), 0) {
677+ t.Fatal("stale A continuation must not be consumed after reload")
678+ }
679+ if len(*actions) != 0 {
680+ t.Fatalf("no A action after reload, got %#v", *actions)
681+ }
682+
683+ // New B sequence works.
684+ if !h.handleKeyPress(uint('g'), 0, 0) {
685+ t.Fatal("g should pending under trie B")
686+ }
687+ if h.PendingSequence() != "g" {
688+ t.Fatalf("PendingSequence = %q, want g", h.PendingSequence())
689+ }
690+ if !h.handleKeyPress(uint('O'), 0, gdk.ShiftMaskValue) {
691+ t.Fatal("gO should complete under trie B")
692+ }
693+ if len(*actions) != 1 || (*actions)[0].action != "action-b" {
694+ t.Fatalf("actions = %#v, want action-b", *actions)
695+ }
696+}
@@ -3,12 +3,70 @@ package input
3 import (3 import (
4 "context"4 "context"
5 "testing"5 "testing"
6+ "time"
6 7
7 "github.com/bnema/dumber/internal/domain/entity"8 "github.com/bnema/dumber/internal/domain/entity"
8 "github.com/bnema/dumber/internal/domain/vimkeys"9 "github.com/bnema/dumber/internal/domain/vimkeys"
9 "github.com/bnema/puregotk/v4/gdk"10 "github.com/bnema/puregotk/v4/gdk"
10 )11 )
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+
12 func TestTrieOwnsBinding(t *testing.T) {70 func TestTrieOwnsBinding(t *testing.T) {
13 cases := map[string]bool{71 cases := map[string]bool{
14 "]]": true,72 "]]": true,
@@ -76,8 +134,8 @@ func TestTrieOwnsBinding_CrossContractAtoms(t *testing.T) {
76 }134 }
77 }135 }
78 136
79-func TestBuildPageModeTrie(t *testing.T) {137+func TestBuildVimModeTrie(t *testing.T) {
80- cfg := &entity.PageModeConfig{138+ cfg := &entity.VimModeConfig{
81 Actions: map[string]entity.ActionBinding{139 Actions: map[string]entity.ActionBinding{
82 "page-scroll-down": {Keys: []string{"j"}},140 "page-scroll-down": {Keys: []string{"j"}},
83 "heading-next": {Keys: []string{"]]"}},141 "heading-next": {Keys: []string{"]]"}},
@@ -89,9 +147,9 @@ func TestBuildPageModeTrie(t *testing.T) {
89 },147 },
90 }148 }
91 149
92- trie, owned := buildPageModeTrie(cfg)150+ trie, owned := buildVimModeTrie(cfg)
93 if trie == nil {151 if trie == nil {
94- t.Fatal("buildPageModeTrie returned nil trie")152+ t.Fatal("buildVimModeTrie returned nil trie")
95 }153 }
96 154
97 wantOwned := map[string]bool{155 wantOwned := map[string]bool{
@@ -121,10 +179,10 @@ func TestBuildPageModeTrie(t *testing.T) {
121 }179 }
122 }180 }
123 181
124-func TestBuildPageModeTrie_ConflictKeepsFirstSortedWinner(t *testing.T) {182+func TestBuildVimModeTrie_ConflictKeepsFirstSortedWinner(t *testing.T) {
125 // Same sequence under two raw aliases; first sorted action wins Insert.183 // Same sequence under two raw aliases; first sorted action wins Insert.
126 // Losing raw key must not be marked owned (insert failed).184 // Losing raw key must not be marked owned (insert failed).
127- cfg := &entity.PageModeConfig{185+ cfg := &entity.VimModeConfig{
128 Actions: map[string]entity.ActionBinding{186 Actions: map[string]entity.ActionBinding{
129 "zzz-later": {Keys: []string{"<c-d>"}},187 "zzz-later": {Keys: []string{"<c-d>"}},
130 "aaa-first": {Keys: []string{"<C-d>"}},188 "aaa-first": {Keys: []string{"<C-d>"}},
@@ -132,7 +190,7 @@ func TestBuildPageModeTrie_ConflictKeepsFirstSortedWinner(t *testing.T) {
132 },190 },
133 }191 }
134 192
135- trie, owned := buildPageModeTrie(cfg)193+ trie, owned := buildVimModeTrie(cfg)
136 if !owned["<C-d>"] {194 if !owned["<C-d>"] {
137 t.Fatal("winning raw key <C-d> should be owned after successful Insert")195 t.Fatal("winning raw key <C-d> should be owned after successful Insert")
138 }196 }
@@ -150,11 +208,11 @@ func TestBuildPageModeTrie_ConflictKeepsFirstSortedWinner(t *testing.T) {
150 }208 }
151 }209 }
152 210
153-func pageModeSequencesOwnershipFixture(t *testing.T) *ShortcutSet {211+func vimModeSequencesOwnershipFixture(t *testing.T) *ShortcutSet {
154 t.Helper()212 t.Helper()
155 213
156 workspace := &entity.WorkspaceConfig{214 workspace := &entity.WorkspaceConfig{
157- PageMode: entity.PageModeConfig{215+ VimMode: entity.VimModeConfig{
158 ActivationShortcut: "ctrl+y",216 ActivationShortcut: "ctrl+y",
159 Actions: map[string]entity.ActionBinding{217 Actions: map[string]entity.ActionBinding{
160 "page-scroll-down": {Keys: []string{"j"}},218 "page-scroll-down": {Keys: []string{"j"}},
@@ -170,69 +228,469 @@ func pageModeSequencesOwnershipFixture(t *testing.T) *ShortcutSet {
170 }228 }
171 229
172 set := NewShortcutSet(context.Background(), workspace, nil)230 set := NewShortcutSet(context.Background(), workspace, nil)
173- if set.PageModeSequences() == nil {231+ if set.VimModeSequences() == nil {
174- t.Fatal("PageModeSequences() is nil")232+ t.Fatal("VimModeSequences() is nil")
175 }233 }
176 return set234 return set
177 }235 }
178 236
179-func assertLegacyPageModeBindings(t *testing.T, set *ShortcutSet) {237+func assertLegacyVimModeBindings(t *testing.T, set *ShortcutSet) {
180 t.Helper()238 t.Helper()
181 239
182 jBinding, jOK := ParseKeyString("j")240 jBinding, jOK := ParseKeyString("j")
183 if !jOK {241 if !jOK {
184 t.Fatal("ParseKeyString(j) failed")242 t.Fatal("ParseKeyString(j) failed")
185 }243 }
186- if action, found := set.PageMode[jBinding]; !found || action != ActionPageScrollDown {244+ if action, found := set.VimMode[jBinding]; !found || action != ActionVimScrollDown {
187- t.Fatalf("legacy j missing from PageMode: found=%v action=%q", found, action)245+ t.Fatalf("legacy j missing from VimMode: found=%v action=%q", found, action)
188 }246 }
189 247
190 enterBinding := KeyBinding{Keyval: uint(gdk.KEY_Return), Modifiers: ModNone}248 enterBinding := KeyBinding{Keyval: uint(gdk.KEY_Return), Modifiers: ModNone}
191- if action, found := set.PageMode[enterBinding]; !found || action != ActionExitMode {249+ if action, found := set.VimMode[enterBinding]; !found || action != ActionExitMode {
192- t.Fatalf("legacy enter missing from PageMode: found=%v action=%q", found, action)250+ t.Fatalf("legacy enter missing from VimMode: found=%v action=%q", found, action)
193 }251 }
194 252
195 escapeBinding := KeyBinding{Keyval: uint(gdk.KEY_Escape), Modifiers: ModNone}253 escapeBinding := KeyBinding{Keyval: uint(gdk.KEY_Escape), Modifiers: ModNone}
196- if action, found := set.PageMode[escapeBinding]; !found || action != ActionExitMode {254+ if action, found := set.VimMode[escapeBinding]; !found || action != ActionExitMode {
197- t.Fatalf("legacy escape missing from PageMode: found=%v action=%q", found, action)255+ t.Fatalf("legacy escape missing from VimMode: found=%v action=%q", found, action)
198 }256 }
199 }257 }
200 258
201-func assertOwnedBindingsAbsentFromPageMode(t *testing.T, set *ShortcutSet) {259+func assertOwnedBindingsAbsentFromVimMode(t *testing.T, set *ShortcutSet) {
202 t.Helper()260 t.Helper()
203 261
204 for _, key := range []string{"]]", "yah", "gO", "<C-d>"} {262 for _, key := range []string{"]]", "yah", "gO", "<C-d>"} {
205 if binding, parsed := ParseKeyString(key); parsed {263 if binding, parsed := ParseKeyString(key); parsed {
206- if action, found := set.PageMode[binding]; found {264+ if action, found := set.VimMode[binding]; found {
207- t.Fatalf("owned binding %q leaked into PageMode as %q", key, action)265+ t.Fatalf("owned binding %q leaked into VimMode as %q", key, action)
208 }266 }
209 }267 }
210 }268 }
211 }269 }
212 270
213-func assertPageModeTrieYankSection(t *testing.T, set *ShortcutSet) {271+func assertVimModeTrieYankSection(t *testing.T, set *ShortcutSet) {
214 t.Helper()272 t.Helper()
215 273
216 seq, err := vimkeys.ParseBinding("yah")274 seq, err := vimkeys.ParseBinding("yah")
217 if err != nil {275 if err != nil {
218 t.Fatalf("ParseBinding(yah) error = %v", err)276 t.Fatalf("ParseBinding(yah) error = %v", err)
219 }277 }
220- node, walked := set.PageModeSequences().Walk(seq)278+ node, walked := set.VimModeSequences().Walk(seq)
221 if !walked || !node.Exact || node.Action != "yank-section" {279 if !walked || !node.Exact || node.Action != "yank-section" {
222- t.Fatalf("PageModeSequences Walk(yah) = (%#v, %v), want exact yank-section", node, walked)280+ t.Fatalf("VimModeSequences Walk(yah) = (%#v, %v), want exact yank-section", node, walked)
223 }281 }
224 }282 }
225 283
226-func TestShortcutSet_PageModeSequencesOwnership(t *testing.T) {284+func TestShortcutSet_VimModeSequencesOwnership(t *testing.T) {
227- set := pageModeSequencesOwnershipFixture(t)285+ set := vimModeSequencesOwnershipFixture(t)
228 286
229 t.Run("legacy bindings", func(t *testing.T) {287 t.Run("legacy bindings", func(t *testing.T) {
230- assertLegacyPageModeBindings(t, set)288+ assertLegacyVimModeBindings(t, set)
231 })289 })
232 t.Run("owned bindings absent from legacy table", func(t *testing.T) {290 t.Run("owned bindings absent from legacy table", func(t *testing.T) {
233- assertOwnedBindingsAbsentFromPageMode(t, set)291+ assertOwnedBindingsAbsentFromVimMode(t, set)
234 })292 })
235 t.Run("trie resolves owned sequence", func(t *testing.T) {293 t.Run("trie resolves owned sequence", func(t *testing.T) {
236- assertPageModeTrieYankSection(t, set)294+ assertVimModeTrieYankSection(t, set)
237 })295 })
238 }296 }
297+
298+func TestFeedVimModeSequence_CompleteDoubleBracket(t *testing.T) {
299+ ctx := context.Background()
300+ ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
301+ "heading-next": {Keys: []string{"]]"}},
302+ "page-scroll-down": {Keys: []string{"j"}},
303+ "cancel": {Keys: []string{"escape"}},
304+ })
305+ h := NewKeyboardHandler(ctx, ws, newTestSession())
306+ pending := capturePending(h)
307+ actions := captureSequenceActions(h)
308+ enterVimMode(t, h)
309+
310+ if !h.handleKeyPress(uint(']'), 0, 0) {
311+ t.Fatal("] prefix should be consumed")
312+ }
313+ if h.PendingSequence() != "]" {
314+ t.Fatalf("PendingSequence() = %q, want %q", h.PendingSequence(), "]")
315+ }
316+ if len(*pending) != 1 || (*pending)[0] != "]" {
317+ t.Fatalf("pending notifications = %#v, want [\"]\"]", *pending)
318+ }
319+
320+ if !h.handleKeyPress(uint(']'), 0, 0) {
321+ t.Fatal("]] should be consumed")
322+ }
323+ if h.PendingSequence() != "" {
324+ t.Fatalf("PendingSequence after complete = %q, want empty", h.PendingSequence())
325+ }
326+ if len(*actions) != 1 || (*actions)[0].action != "heading-next" || (*actions)[0].count != 0 {
327+ t.Fatalf("actions = %#v, want heading-next count 0", *actions)
328+ }
329+ if len(*pending) < 2 || (*pending)[len(*pending)-1] != "" {
330+ t.Fatalf("pending after complete = %#v, want trailing clear", *pending)
331+ }
332+}
333+
334+func TestFeedVimModeSequence_PendingYankNotifications(t *testing.T) {
335+ ctx := context.Background()
336+ ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
337+ "yank-section": {Keys: []string{"yah"}},
338+ "page-scroll-down": {Keys: []string{"j"}},
339+ })
340+ h := NewKeyboardHandler(ctx, ws, newTestSession())
341+ pending := capturePending(h)
342+ actions := captureSequenceActions(h)
343+ enterVimMode(t, h)
344+
345+ for _, step := range []struct {
346+ key uint
347+ want string
348+ }{
349+ {uint('y'), "y"},
350+ {uint('a'), "ya"},
351+ {uint('h'), ""},
352+ } {
353+ if !h.handleKeyPress(step.key, 0, 0) {
354+ t.Fatalf("key %c should be consumed", step.key)
355+ }
356+ if got := h.PendingSequence(); got != step.want {
357+ t.Fatalf("after %c PendingSequence() = %q, want %q", step.key, got, step.want)
358+ }
359+ }
360+ if len(*actions) != 1 || (*actions)[0].action != "yank-section" {
361+ t.Fatalf("actions = %#v, want yank-section", *actions)
362+ }
363+ if len(*pending) < 3 || (*pending)[0] != "y" || (*pending)[1] != "ya" {
364+ t.Fatalf("pending = %#v, want y then ya", *pending)
365+ }
366+}
367+
368+func TestFeedVimModeSequence_UnknownFallsThrough(t *testing.T) {
369+ ctx := context.Background()
370+ ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
371+ "heading-next": {Keys: []string{"]]"}},
372+ "page-scroll-down": {Keys: []string{"j"}},
373+ })
374+ h := NewKeyboardHandler(ctx, ws, newTestSession())
375+ var scrollCalls int
376+ h.SetOnAction(func(_ context.Context, action Action) error {
377+ if action == ActionVimScrollDown {
378+ scrollCalls++
379+ }
380+ return nil
381+ })
382+ enterVimMode(t, h)
383+
384+ // Unknown sequence key falls through; legacy j still works.
385+ if !h.handleKeyPress(uint('j'), 0, 0) {
386+ t.Fatal("legacy j should be handled")
387+ }
388+ if scrollCalls != 1 {
389+ t.Fatalf("scrollCalls = %d, want 1", scrollCalls)
390+ }
391+ if h.PendingSequence() != "" {
392+ t.Fatalf("PendingSequence = %q, want empty", h.PendingSequence())
393+ }
394+}
395+
396+func TestFeedVimModeSequence_IgnoresOutsideVimMode(t *testing.T) {
397+ ctx := context.Background()
398+ ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
399+ "heading-next": {Keys: []string{"]]"}},
400+ })
401+ h := NewKeyboardHandler(ctx, ws, newTestSession())
402+ pending := capturePending(h)
403+
404+ if h.feedVimModeSequence(uint(']'), 0) {
405+ t.Fatal("feed outside vim mode should return false")
406+ }
407+ if len(*pending) != 0 {
408+ t.Fatalf("pending = %#v, want none", *pending)
409+ }
410+}
411+
412+func TestFeedVimModeSequence_ModeExitResetsSilently(t *testing.T) {
413+ ctx := context.Background()
414+ ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
415+ "yank-section": {Keys: []string{"yah"}},
416+ "cancel": {Keys: []string{"escape"}},
417+ })
418+ h := NewKeyboardHandler(ctx, ws, newTestSession())
419+ pending := capturePending(h)
420+ enterVimMode(t, h)
421+
422+ if !h.handleKeyPress(uint('y'), 0, 0) {
423+ t.Fatal("y should be consumed")
424+ }
425+ before := len(*pending)
426+
427+ if !h.handleKeyPress(uint(gdk.KEY_Escape), 0, 0) {
428+ t.Fatal("escape should exit")
429+ }
430+ if h.Mode() != ModeNormal {
431+ t.Fatalf("mode = %v, want ModeNormal", h.Mode())
432+ }
433+ if h.PendingSequence() != "" {
434+ t.Fatalf("PendingSequence after exit = %q, want empty", h.PendingSequence())
435+ }
436+ if len(*pending) != before {
437+ t.Fatalf("silent exit must not notify pending clear: before=%d after=%d (%#v)", before, len(*pending), *pending)
438+ }
439+}
440+
441+func TestFeedVimModeSequence_CountPrefix(t *testing.T) {
442+ ctx := context.Background()
443+ ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
444+ "heading-next": {Keys: []string{"]]"}},
445+ })
446+ h := NewKeyboardHandler(ctx, ws, newTestSession())
447+ actions := captureSequenceActions(h)
448+ enterVimMode(t, h)
449+
450+ if !h.handleKeyPress(uint('2'), 0, 0) {
451+ t.Fatal("count digit should be consumed")
452+ }
453+ if h.PendingSequence() != "2" {
454+ t.Fatalf("PendingSequence = %q, want 2", h.PendingSequence())
455+ }
456+ if !h.handleKeyPress(uint(']'), 0, 0) {
457+ t.Fatal("first ] after count should be consumed")
458+ }
459+ if !h.handleKeyPress(uint(']'), 0, 0) {
460+ t.Fatal("2]] should complete")
461+ }
462+ if len(*actions) != 1 || (*actions)[0].action != "heading-next" || (*actions)[0].count != 2 {
463+ t.Fatalf("actions = %#v, want heading-next count 2", *actions)
464+ }
465+}
466+
467+func TestFeedVimModeSequence_AmbiguityTimeout(t *testing.T) {
468+ ctx := context.Background()
469+ ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
470+ "bracket-c": {Keys: []string{"]c"}},
471+ "bracket-cc": {Keys: []string{"]cc"}},
472+ })
473+ h := NewKeyboardHandler(ctx, ws, newTestSession())
474+ actions := captureSequenceActions(h)
475+
476+ var fired func()
477+ var stops int
478+ h.seq.afterFunc = func(d time.Duration, fn func()) sequenceTimer {
479+ if d != 500*time.Millisecond {
480+ t.Fatalf("ambiguity timeout = %v, want 500ms", d)
481+ }
482+ fired = fn
483+ return stubSequenceTimer{onStop: func() { stops++ }}
484+ }
485+ var queued []func()
486+ h.SetSequenceMainThreadScheduler(func(fn func()) {
487+ queued = append(queued, fn)
488+ })
489+
490+ enterVimMode(t, h)
491+ if !h.handleKeyPress(uint(']'), 0, 0) || !h.handleKeyPress(uint('c'), 0, 0) {
492+ t.Fatal("]c prefix should be consumed")
493+ }
494+ if !h.seq.matcher.Ambiguous() {
495+ t.Fatal("]c should be ambiguous against ]cc")
496+ }
497+ if fired == nil {
498+ t.Fatal("expected ambiguity timer arm")
499+ }
500+
501+ fired() // timer expiry queues resolve on scheduler
502+ if len(queued) != 1 {
503+ t.Fatalf("queued resolves = %d, want 1", len(queued))
504+ }
505+ queued[0]()
506+ if len(*actions) != 1 || (*actions)[0].action != "bracket-c" {
507+ t.Fatalf("actions = %#v, want bracket-c", *actions)
508+ }
509+ if h.PendingSequence() != "" {
510+ t.Fatalf("PendingSequence after resolve = %q, want empty", h.PendingSequence())
511+ }
512+ _ = stops
513+}
514+
515+func TestFeedVimModeSequence_TimeoutZeroResolvesImmediately(t *testing.T) {
516+ ctx := context.Background()
517+ ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
518+ "bracket-c": {Keys: []string{"]c"}},
519+ "bracket-cc": {Keys: []string{"]cc"}},
520+ })
521+ ws.VimMode.SequenceTimeoutMilliseconds = 0
522+ h := NewKeyboardHandler(ctx, ws, newTestSession())
523+ actions := captureSequenceActions(h)
524+
525+ var armed time.Duration = -1
526+ h.seq.afterFunc = func(d time.Duration, fn func()) sequenceTimer {
527+ armed = d
528+ fn() // AfterFunc(0) runs promptly; stub runs inline before schedule wrap
529+ return stubSequenceTimer{}
530+ }
531+ var queued []func()
532+ h.SetSequenceMainThreadScheduler(func(fn func()) {
533+ queued = append(queued, fn)
534+ })
535+
536+ enterVimMode(t, h)
537+ if !h.handleKeyPress(uint(']'), 0, 0) || !h.handleKeyPress(uint('c'), 0, 0) {
538+ t.Fatal("]c should be consumed")
539+ }
540+ if armed != 0 {
541+ t.Fatalf("armed timeout = %v, want 0", armed)
542+ }
543+ if len(queued) != 1 {
544+ t.Fatalf("queued = %d, want 1", len(queued))
545+ }
546+ queued[0]()
547+ if len(*actions) != 1 || (*actions)[0].action != "bracket-c" {
548+ t.Fatalf("actions = %#v, want bracket-c", *actions)
549+ }
550+}
551+
552+func TestFeedVimModeSequence_NextKeyStopsTimer(t *testing.T) {
553+ ctx := context.Background()
554+ ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
555+ "bracket-c": {Keys: []string{"]c"}},
556+ "bracket-cc": {Keys: []string{"]cc"}},
557+ })
558+ h := NewKeyboardHandler(ctx, ws, newTestSession())
559+ actions := captureSequenceActions(h)
560+
561+ var fired func()
562+ var stops int
563+ h.seq.afterFunc = func(_ time.Duration, fn func()) sequenceTimer {
564+ fired = fn
565+ return stubSequenceTimer{onStop: func() { stops++ }}
566+ }
567+ h.SetSequenceMainThreadScheduler(func(fn func()) { fn() })
568+
569+ enterVimMode(t, h)
570+ if !h.handleKeyPress(uint(']'), 0, 0) || !h.handleKeyPress(uint('c'), 0, 0) {
571+ t.Fatal("]c should be consumed")
572+ }
573+ if fired == nil {
574+ t.Fatal("expected timer")
575+ }
576+ if !h.handleKeyPress(uint('c'), 0, 0) {
577+ t.Fatal("]cc should complete")
578+ }
579+ if stops == 0 {
580+ t.Fatal("next key must Stop ambiguity timer")
581+ }
582+ if len(*actions) != 1 || (*actions)[0].action != "bracket-cc" {
583+ t.Fatalf("actions = %#v, want bracket-cc", *actions)
584+ }
585+ // Stale timer must no-op.
586+ fired()
587+ if len(*actions) != 1 {
588+ t.Fatalf("stale timer mutated actions: %#v", *actions)
589+ }
590+}
591+
592+func TestFeedVimModeSequence_StaleQueuedTimeoutNoOp(t *testing.T) {
593+ ctx := context.Background()
594+ ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
595+ "bracket-c": {Keys: []string{"]c"}},
596+ "bracket-cc": {Keys: []string{"]cc"}},
597+ "yank-section": {Keys: []string{"yah"}},
598+ })
599+ h := NewKeyboardHandler(ctx, ws, newTestSession())
600+ actions := captureSequenceActions(h)
601+ pending := capturePending(h)
602+
603+ var fired func()
604+ h.seq.afterFunc = func(_ time.Duration, fn func()) sequenceTimer {
605+ fired = fn
606+ return stubSequenceTimer{}
607+ }
608+ var queued []func()
609+ h.SetSequenceMainThreadScheduler(func(fn func()) {
610+ queued = append(queued, fn)
611+ })
612+
613+ enterVimMode(t, h)
614+ if !h.handleKeyPress(uint(']'), 0, 0) || !h.handleKeyPress(uint('c'), 0, 0) {
615+ t.Fatal("]c should be consumed")
616+ }
617+ fired() // enqueue stale resolve
618+ if len(queued) != 1 {
619+ t.Fatalf("queued = %d, want 1", len(queued))
620+ }
621+ stale := queued[0]
622+ queued = nil
623+
624+ // Interleave: feed/reset/reload before stale closure runs.
625+ h.ResetPendingSequence()
626+ if len(*pending) == 0 || (*pending)[len(*pending)-1] != "" {
627+ t.Fatalf("ResetPendingSequence should notify clear: %#v", *pending)
628+ }
629+ stale()
630+ if len(*actions) != 0 {
631+ t.Fatalf("stale resolve after reset must no-op, actions=%#v", *actions)
632+ }
633+
634+ // Reload path also invalidates queued resolve.
635+ fired = nil
636+ queued = nil
637+ if !h.handleKeyPress(uint(']'), 0, 0) || !h.handleKeyPress(uint('c'), 0, 0) {
638+ t.Fatal("]c again")
639+ }
640+ fired()
641+ stale = queued[0]
642+ h.ReloadShortcuts(ctx, ws, newTestSession())
643+ stale()
644+ if len(*actions) != 0 {
645+ t.Fatalf("stale resolve after reload must no-op, actions=%#v", *actions)
646+ }
647+}
648+
649+func TestFeedVimModeSequence_ReloadReplacesMatcher(t *testing.T) {
650+ ctx := context.Background()
651+ wsA := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
652+ "action-a": {Keys: []string{"yah"}},
653+ "page-scroll-down": {Keys: []string{"j"}},
654+ })
655+ h := NewKeyboardHandler(ctx, wsA, newTestSession())
656+ actions := captureSequenceActions(h)
657+ enterVimMode(t, h)
658+
659+ if !h.handleKeyPress(uint('y'), 0, 0) {
660+ t.Fatal("y should pending under trie A")
661+ }
662+ if h.PendingSequence() != "y" {
663+ t.Fatalf("PendingSequence = %q, want y", h.PendingSequence())
664+ }
665+
666+ wsB := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
667+ "action-b": {Keys: []string{"gO"}},
668+ "page-scroll-down": {Keys: []string{"j"}},
669+ })
670+ h.ReloadShortcuts(ctx, wsB, newTestSession())
671+ if h.PendingSequence() != "" {
672+ t.Fatalf("PendingSequence after reload = %q, want empty", h.PendingSequence())
673+ }
674+
675+ // Old A sequence must not complete; fall through / invalid.
676+ if h.feedVimModeSequence(uint('a'), 0) {
677+ t.Fatal("stale A continuation must not be consumed after reload")
678+ }
679+ if len(*actions) != 0 {
680+ t.Fatalf("no A action after reload, got %#v", *actions)
681+ }
682+
683+ // New B sequence works.
684+ if !h.handleKeyPress(uint('g'), 0, 0) {
685+ t.Fatal("g should pending under trie B")
686+ }
687+ if h.PendingSequence() != "g" {
688+ t.Fatalf("PendingSequence = %q, want g", h.PendingSequence())
689+ }
690+ if !h.handleKeyPress(uint('O'), 0, gdk.ShiftMaskValue) {
691+ t.Fatal("gO should complete under trie B")
692+ }
693+ if len(*actions) != 1 || (*actions)[0].action != "action-b" {
694+ t.Fatalf("actions = %#v, want action-b", *actions)
695+ }
696+}

Keyboard shortcuts

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

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

Branches 8

Tags 42

↑↓ moveenter openesc close