1package input
2
3import (
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
14func isCountStartKey(key vimkeys.Key) bool {
15 return key.Mods == 0 && len(key.Sym) == 1 && key.Sym[0] >= '1' && key.Sym[0] <= '9'
16}
17
18// trieOwnsBinding reports whether a raw config binding belongs to the Vim
19// sequence trie rather than the legacy single-chord Vim Mode table.
20// Multi-key sequences and Vim-only chords that ParseKeyString cannot read are owned.
21func trieOwnsBinding(raw string) (vimkeys.Sequence, bool) {
22 seq, err := vimkeys.ParseBinding(raw)
23 if err != nil {
24 return nil, false
25 }
26 if len(seq) >= 2 {
27 if isCountStartKey(seq[0]) {
28 return nil, false
29 }
30 return seq, true
31 }
32 if _, ok := ParseKeyString(raw); !ok {
33 return seq, true
34 }
35 return nil, false
36}
37
38// trieOnlyActionBinding owns a single-chord binding when its action has no
39// legacy shortcut mapping and must therefore complete through the trie.
40func trieOnlyActionBinding(action, raw string) (vimkeys.Sequence, bool) {
41 if mapModeConfigAction(action, "vim") != "" {
42 return nil, false
43 }
44 seq, err := vimkeys.ParseBinding(raw)
45 if err != nil || len(seq) != 1 || isCountStartKey(seq[0]) {
46 return nil, false
47 }
48 return seq, true
49}
50
51// buildVimModeTrie inserts trie-owned Vim mode bindings. Action names are
52// sorted so the first sorted name wins on Insert conflicts (validation already
53// rejects duplicates). Ownership is marked only after a successful Insert.
54func buildVimModeTrie(cfg *entity.VimModeConfig) (*vimkeys.Trie, map[string]bool) {
55 trie := vimkeys.NewTrie()
56 owned := make(map[string]bool)
57 if cfg == nil {
58 return trie, owned
59 }
60
61 actions := make([]string, 0, len(cfg.Actions))
62 for action := range cfg.Actions {
63 actions = append(actions, action)
64 }
65 sort.Strings(actions)
66
67 for _, action := range actions {
68 binding := cfg.Actions[action]
69 keys := append([]string(nil), binding.Keys...)
70 sort.Strings(keys)
71 for _, key := range keys {
72 seq, ok := trieOwnsBinding(key)
73 if !ok {
74 // Single chords for actions without a legacy Action (for example
75 // "f" or "v") can only be dispatched through the sequence trie.
76 seq, ok = trieOnlyActionBinding(action, key)
77 }
78 if !ok {
79 continue
80 }
81 if err := trie.Insert(seq, action); err != nil {
82 log.Debug().
83 Err(err).
84 Str("key", key).
85 Str("action", action).
86 Msg("vim mode trie insert skipped")
87 continue
88 }
89 owned[key] = true
90 }
91 }
92 return trie, owned
93}
94
95// VimModeSequences returns the Vim sequence trie, if any.
96func (s *ShortcutSet) VimModeSequences() *vimkeys.Trie {
97 if s == nil {
98 return nil
99 }
100 return s.vimModeSequences
101}
102
103// filterLegacyVimModeBindings copies GetKeyBindings entries that the trie does not own.
104func filterLegacyVimModeBindings(bindings map[string]string, owned map[string]bool) map[string]string {
105 legacy := make(map[string]string, len(bindings))
106 for key, action := range bindings {
107 if owned[key] {
108 continue
109 }
110 legacy[key] = action
111 }
112 return legacy
113}
114
115// sequenceTimer is the injectable ambiguity-timeout seam.
116type sequenceTimer interface {
117 Stop() bool
118}
119
120// vimModeSequenceState holds per-handler matcher state and ambiguity timing.
121type vimModeSequenceState struct {
122 mu sync.Mutex
123 matcher *vimkeys.Matcher
124 onPending func(string)
125 onAction func(string, int)
126 timeout time.Duration
127 timer sequenceTimer
128 afterFunc func(time.Duration, func()) sequenceTimer
129 scheduleOnMainThread func(func())
130 generation uint64
131}
132
133func newVimModeSequenceState(trie *vimkeys.Trie, timeout time.Duration) vimModeSequenceState {
134 return vimModeSequenceState{
135 matcher: vimkeys.NewMatcher(trie),
136 timeout: timeout,
137 afterFunc: func(d time.Duration, fn func()) sequenceTimer {
138 return time.AfterFunc(d, fn)
139 },
140 scheduleOnMainThread: func(fn func()) { fn() },
141 }
142}
143
144// SetOnPendingSequenceChange sets the callback for pending sequence text updates.
145func (h *KeyboardHandler) SetOnPendingSequenceChange(fn func(string)) {
146 h.seq.mu.Lock()
147 defer h.seq.mu.Unlock()
148 h.seq.onPending = fn
149}
150
151// SetOnSequenceAction sets the callback for completed sequence actions.
152func (h *KeyboardHandler) SetOnSequenceAction(fn func(string, int)) {
153 h.seq.mu.Lock()
154 defer h.seq.mu.Unlock()
155 h.seq.onAction = fn
156}
157
158// SetSequenceTimeout sets the ambiguity resolution timeout.
159func (h *KeyboardHandler) SetSequenceTimeout(d time.Duration) {
160 h.seq.mu.Lock()
161 defer h.seq.mu.Unlock()
162 h.seq.timeout = d
163}
164
165// SetSequenceMainThreadScheduler sets the main-thread dispatcher for ambiguity
166// resolution. Timer goroutines must not touch matcher state directly.
167func (h *KeyboardHandler) SetSequenceMainThreadScheduler(fn func(func())) {
168 h.seq.mu.Lock()
169 defer h.seq.mu.Unlock()
170 if fn == nil {
171 return
172 }
173 h.seq.scheduleOnMainThread = fn
174}
175
176// PendingSequence returns the current pending count+keys string.
177func (h *KeyboardHandler) PendingSequence() string {
178 h.seq.mu.Lock()
179 defer h.seq.mu.Unlock()
180 if h.seq.matcher == nil {
181 return ""
182 }
183 return h.seq.matcher.Pending()
184}
185
186// ResetPendingSequence clears matcher state and notifies pending listeners.
187func (h *KeyboardHandler) ResetPendingSequence() {
188 h.resetPendingSequence(true)
189}
190
191// resetPendingSequence clears matcher/timer state. When notify is false (mode
192// exit under ModalState lock), pending callbacks are skipped.
193func (h *KeyboardHandler) resetPendingSequence(notify bool) {
194 h.seq.mu.Lock()
195 h.stopSequenceTimerLocked()
196 h.seq.generation++
197 if h.seq.matcher != nil {
198 h.seq.matcher.Reset()
199 }
200 onPending := h.seq.onPending
201 h.seq.mu.Unlock()
202
203 if notify && onPending != nil {
204 onPending("")
205 }
206}
207
208// teardownSequenceState linearizably cancels sequence timing and clears
209// UI-retaining callbacks without notifying. Used by Detach/DetachForDestroy.
210// Lock order: call without holding h.mu (same as resetPendingSequence).
211func (h *KeyboardHandler) teardownSequenceState() {
212 h.seq.mu.Lock()
213 defer h.seq.mu.Unlock()
214 h.stopSequenceTimerLocked()
215 h.seq.generation++
216 if h.seq.matcher != nil {
217 h.seq.matcher.Reset()
218 }
219 h.seq.onPending = nil
220 h.seq.onAction = nil
221 h.seq.scheduleOnMainThread = nil
222 log.Debug().Msg("vim mode sequence state torn down without notify")
223}
224
225// InvalidateSequenceMatcher stops timing, bumps generation, and replaces the
226// matcher from the current VimModeSequences trie (hot reload).
227func (h *KeyboardHandler) InvalidateSequenceMatcher() {
228 h.mu.RLock()
229 var trie *vimkeys.Trie
230 if h.shortcuts != nil {
231 trie = h.shortcuts.VimModeSequences()
232 }
233 h.mu.RUnlock()
234
235 h.seq.mu.Lock()
236 defer h.seq.mu.Unlock()
237 h.stopSequenceTimerLocked()
238 h.seq.generation++
239 h.seq.matcher = vimkeys.NewMatcher(trie)
240 log.Debug().Msg("vim mode sequence matcher replaced")
241}
242
243// feedVimModeSequence feeds a key into the vim-mode sequence matcher.
244// Pending/Complete consume the event (true). Invalid clears and returns false
245// so the legacy shortcut path can handle the key.
246func (h *KeyboardHandler) feedVimModeSequence(keyval uint, state gdk.ModifierType) bool {
247 if h.modal.Mode() != ModeVim {
248 return false
249 }
250 key, ok := KeyvalToVimKey(keyval, state)
251 if !ok {
252 return false
253 }
254
255 h.seq.mu.Lock()
256 if h.seq.matcher == nil {
257 h.seq.mu.Unlock()
258 return false
259 }
260
261 h.stopSequenceTimerLocked()
262 h.seq.generation++
263 hadPending := h.seq.matcher.Pending() != ""
264
265 result := h.seq.matcher.Feed(key)
266 onPending := h.seq.onPending
267 onAction := h.seq.onAction
268
269 switch result.Kind {
270 case vimkeys.ResultPending:
271 pending := result.Pending
272 if pending == "" {
273 pending = h.seq.matcher.Pending()
274 }
275 ambiguous := h.seq.matcher.Ambiguous()
276 if ambiguous {
277 h.armAmbiguityTimeoutLocked()
278 }
279 h.seq.mu.Unlock()
280 if onPending != nil {
281 onPending(pending)
282 }
283 log.Debug().Str("pending", pending).Bool("ambiguous", ambiguous).Msg("vim mode sequence pending")
284 return true
285
286 case vimkeys.ResultComplete:
287 action := result.Action
288 count := result.Count
289 h.seq.mu.Unlock()
290 if onPending != nil {
291 onPending("")
292 }
293 if action == "confirm" {
294 h.confirmVimMode()
295 } else if onAction != nil {
296 onAction(action, count)
297 }
298 log.Debug().Str("action", action).Int("count", count).Msg("vim mode sequence complete")
299 return true
300
301 default: // ResultInvalid
302 h.seq.mu.Unlock()
303 if hadPending && onPending != nil {
304 onPending("")
305 }
306 log.Debug().Msg("vim mode sequence invalid; falling through to legacy")
307 return false
308 }
309}
310
311func (h *KeyboardHandler) stopSequenceTimerLocked() {
312 if h.seq.timer != nil {
313 h.seq.timer.Stop()
314 h.seq.timer = nil
315 }
316}
317
318func (h *KeyboardHandler) armAmbiguityTimeoutLocked() {
319 gen := h.seq.generation
320 timeout := h.seq.timeout
321 schedule := h.seq.scheduleOnMainThread
322 afterFunc := h.seq.afterFunc
323 if afterFunc == nil {
324 afterFunc = func(d time.Duration, fn func()) sequenceTimer {
325 return time.AfterFunc(d, fn)
326 }
327 }
328 if schedule == nil {
329 schedule = func(fn func()) { fn() }
330 }
331 h.seq.timer = afterFunc(timeout, func() {
332 schedule(func() {
333 h.resolveAmbiguity(gen)
334 })
335 })
336 log.Debug().Dur("timeout", timeout).Uint64("generation", gen).Msg("vim mode ambiguity timer armed")
337}
338
339func (h *KeyboardHandler) resolveAmbiguity(gen uint64) {
340 h.seq.mu.Lock()
341 if h.seq.generation != gen {
342 h.seq.mu.Unlock()
343 log.Debug().Uint64("generation", gen).Msg("stale vim mode ambiguity resolve ignored")
344 return
345 }
346 if h.seq.matcher == nil {
347 h.seq.mu.Unlock()
348 return
349 }
350 result, ok := h.seq.matcher.ResolveAmbiguity()
351 if !ok {
352 h.seq.mu.Unlock()
353 return
354 }
355 h.seq.timer = nil
356 h.seq.generation++
357 onPending := h.seq.onPending
358 onAction := h.seq.onAction
359 action := result.Action
360 count := result.Count
361 h.seq.mu.Unlock()
362
363 if onPending != nil {
364 onPending("")
365 }
366 if action == "confirm" {
367 h.confirmVimMode()
368 } else if onAction != nil {
369 onAction(action, count)
370 }
371 log.Debug().Str("action", action).Int("count", count).Msg("vim mode ambiguity resolved")
372}