1package input
2
3import (
4 "context"
5 "sync"
6 "time"
7
8 "github.com/bnema/dumber/internal/logging"
9)
10
11type modalTimer interface {
12 Stop() bool
13}
14
15// Mode represents the current input mode.
16type Mode int
17
18const (
19 // ModeNormal is the default mode where keys pass through to WebView.
20 ModeNormal Mode = iota
21 // ModeTab is the modal tab management mode.
22 ModeTab
23 // ModePane is the modal pane management mode.
24 ModePane
25 // ModeSession is the modal session management mode.
26 ModeSession
27 // ModeResize is the modal pane resizing mode.
28 ModeResize
29 // ModeVim is the modal Vim-style scrolling mode.
30 ModeVim
31 // ModePrefix is the tmux-style one-shot prefix mode.
32 ModePrefix
33)
34
35// String returns a human-readable mode name.
36func (m Mode) String() string {
37 switch m {
38 case ModeNormal:
39 return "normal"
40 case ModeTab:
41 return "tab"
42 case ModePane:
43 return "pane"
44 case ModeSession:
45 return "session"
46 case ModeResize:
47 return "resize"
48 case ModeVim:
49 return "vim"
50 case ModePrefix:
51 return "prefix"
52 default:
53 return "unknown"
54 }
55}
56
57// DisplayName returns the mode name for display in UI (e.g., toaster).
58func (m Mode) DisplayName() string {
59 switch m {
60 case ModeTab:
61 return "TAB MODE"
62 case ModePane:
63 return "PANE MODE"
64 case ModeSession:
65 return "SESSION MODE"
66 case ModeResize:
67 return "RESIZE MODE"
68 case ModeVim:
69 return "VIM MODE"
70 case ModePrefix:
71 return "PREFIX"
72 default:
73 return ""
74 }
75}
76
77// ModalState manages the current input mode with optional timeout.
78type ModalState struct {
79 mode Mode
80 timeout time.Duration
81 timer modalTimer
82 timerGen int64 // incremented each time a timer is started; used to ignore stale callbacks
83
84 afterFunc func(time.Duration, func()) modalTimer
85
86 // Callback for mode changes (called synchronously under lock).
87 onModeChange func(from, to Mode)
88
89 // scheduleOnMainThread dispatches a function to the GTK main thread.
90 // Timer callbacks use this so that onModeChange (which may make GTK
91 // calls like switching controller phase) always runs on the GTK thread.
92 // Defaults to direct execution (suitable for tests without a GTK loop).
93 // The app sets this to a glib.IdleAdd wrapper via SetMainThreadScheduler.
94 scheduleOnMainThread func(fn func())
95
96 mu sync.RWMutex
97}
98
99// NewModalState creates a new modal state manager.
100func NewModalState(ctx context.Context) *ModalState {
101 log := logging.FromContext(ctx)
102 log.Debug().Msg("creating modal state")
103 return &ModalState{
104 mode: ModeNormal,
105 afterFunc: func(d time.Duration, fn func()) modalTimer { return time.AfterFunc(d, fn) },
106 scheduleOnMainThread: func(fn func()) { fn() },
107 }
108}
109
110// EnterVimMode switches to Vim-style scrolling mode with an optional timeout.
111func (m *ModalState) EnterVimMode(ctx context.Context, timeout time.Duration) {
112 m.enterMode(ctx, ModeVim, timeout)
113}
114
115// SetMainThreadScheduler sets the function used to dispatch timer callbacks
116// to the GTK main thread. In the real app this should be a glib.IdleAdd
117// wrapper. In tests, the default (direct execution) is used.
118func (m *ModalState) SetMainThreadScheduler(fn func(func())) {
119 m.mu.Lock()
120 defer m.mu.Unlock()
121 if fn == nil {
122 return
123 }
124 m.scheduleOnMainThread = fn
125}
126
127// Mode returns the current mode (thread-safe).
128func (m *ModalState) Mode() Mode {
129 m.mu.RLock()
130 defer m.mu.RUnlock()
131 return m.mode
132}
133
134// EnterTabMode switches to tab mode with an optional timeout.
135func (m *ModalState) EnterTabMode(ctx context.Context, timeout time.Duration) {
136 m.enterMode(ctx, ModeTab, timeout)
137}
138
139// EnterPaneMode switches to pane mode with an optional timeout.
140func (m *ModalState) EnterPaneMode(ctx context.Context, timeout time.Duration) {
141 m.enterMode(ctx, ModePane, timeout)
142}
143
144// EnterSessionMode switches to session mode with an optional timeout.
145func (m *ModalState) EnterSessionMode(ctx context.Context, timeout time.Duration) {
146 m.enterMode(ctx, ModeSession, timeout)
147}
148
149// EnterPrefixMode switches to the one-shot prefix mode with an optional timeout.
150func (m *ModalState) EnterPrefixMode(ctx context.Context, timeout time.Duration) {
151 m.enterMode(ctx, ModePrefix, timeout)
152}
153
154// EnterResizeMode switches to resize mode with an optional timeout.
155func (m *ModalState) EnterResizeMode(ctx context.Context, timeout time.Duration) {
156 m.enterMode(ctx, ModeResize, timeout)
157}
158
159// enterMode is the shared implementation for all mode-enter methods.
160// If already in the target mode, it resets the timeout instead.
161func (m *ModalState) enterMode(ctx context.Context, target Mode, timeout time.Duration) {
162 log := logging.FromContext(ctx)
163 m.mu.Lock()
164 defer m.mu.Unlock()
165
166 if m.mode == target {
167 m.resetTimeoutLocked(ctx, timeout)
168 return
169 }
170
171 m.cancelTimerLocked()
172 oldMode := m.mode
173 m.mode = target
174 m.timeout = timeout
175
176 if timeout > 0 {
177 m.startTimeoutLocked(ctx, timeout)
178 }
179
180 log.Debug().
181 Str("from", oldMode.String()).
182 Str("to", target.String()).
183 Dur("timeout", timeout).
184 Msg("entered " + target.String() + " mode")
185
186 if m.onModeChange != nil {
187 m.onModeChange(oldMode, target)
188 }
189}
190
191// ExitMode returns to normal mode.
192func (m *ModalState) ExitMode(ctx context.Context) {
193 log := logging.FromContext(ctx)
194 m.mu.Lock()
195 defer m.mu.Unlock()
196
197 if m.mode == ModeNormal {
198 return
199 }
200
201 m.cancelTimerLocked()
202 oldMode := m.mode
203 m.mode = ModeNormal
204 m.timeout = 0
205
206 log.Debug().
207 Str("from", oldMode.String()).
208 Str("to", "normal").
209 Msg("exited modal mode")
210
211 if m.onModeChange != nil {
212 m.onModeChange(oldMode, ModeNormal)
213 }
214}
215
216// ResetTimeout restarts the mode timeout (e.g., after a valid keystroke).
217// Does nothing if not in a modal mode or if no timeout was set.
218func (m *ModalState) ResetTimeout(ctx context.Context) {
219 m.mu.Lock()
220 defer m.mu.Unlock()
221
222 if m.mode == ModeNormal || m.timeout == 0 {
223 return
224 }
225
226 m.resetTimeoutLocked(ctx, m.timeout)
227}
228
229// SetOnModeChange sets the callback for mode changes.
230// The callback is invoked synchronously under the lock.
231func (m *ModalState) SetOnModeChange(fn func(from, to Mode)) {
232 m.mu.Lock()
233 defer m.mu.Unlock()
234 m.onModeChange = fn
235}
236
237// cancelTimerLocked stops any active timeout timer.
238// Must be called with m.mu held.
239func (m *ModalState) cancelTimerLocked() {
240 if m.timer != nil {
241 m.timer.Stop()
242 m.timer = nil
243 m.timerGen++
244 }
245}
246
247// startTimeoutLocked starts a new timeout timer that exits the mode via the
248// main thread scheduler. Timer goroutines must not call ExitMode directly
249// because onModeChange may make GTK calls (e.g., switching controller phase).
250// Must be called with m.mu held.
251func (m *ModalState) startTimeoutLocked(ctx context.Context, timeout time.Duration) {
252 m.timerGen++
253 gen := m.timerGen
254 schedule := m.scheduleOnMainThread
255 m.timer = m.afterFunc(timeout, func() {
256 schedule(func() {
257 // Ignore stale callback: time.Timer.Stop can race with the
258 // timer firing, so a canceled timer's callback may still run.
259 m.mu.RLock()
260 stale := m.timerGen != gen
261 m.mu.RUnlock()
262 if stale {
263 return
264 }
265 m.ExitMode(ctx)
266 })
267 })
268}
269
270// resetTimeoutLocked resets the timeout timer.
271// Must be called with m.mu held.
272func (m *ModalState) resetTimeoutLocked(ctx context.Context, timeout time.Duration) {
273 m.cancelTimerLocked()
274 m.timeout = timeout
275 if timeout > 0 {
276 m.startTimeoutLocked(ctx, timeout)
277 }
278}