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