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