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
67 // Callback for mode changes (called synchronously under lock).
68 onModeChange func(from, to Mode)
69
70 mu sync.RWMutex
71}
72
73// NewModalState creates a new modal state manager.
74func NewModalState(ctx context.Context) *ModalState {
75 log := logging.FromContext(ctx)
76 log.Debug().Msg("creating modal state")
77 return &ModalState{
78 mode: ModeNormal,
79 }
80}
81
82// Mode returns the current mode (thread-safe).
83func (m *ModalState) Mode() Mode {
84 m.mu.RLock()
85 defer m.mu.RUnlock()
86 return m.mode
87}
88
89// EnterTabMode switches to tab mode with an optional timeout.
90// If timeout is 0, the mode stays until explicitly exited.
91func (m *ModalState) EnterTabMode(ctx context.Context, timeout time.Duration) {
92 log := logging.FromContext(ctx)
93 m.mu.Lock()
94 defer m.mu.Unlock()
95
96 if m.mode == ModeTab {
97 // Already in tab mode, just reset timeout
98 m.resetTimeoutLocked(ctx, timeout)
99 return
100 }
101
102 m.cancelTimerLocked()
103 oldMode := m.mode
104 m.mode = ModeTab
105 m.timeout = timeout
106
107 if timeout > 0 {
108 m.timer = time.AfterFunc(timeout, func() {
109 m.ExitMode(ctx)
110 })
111 }
112
113 log.Debug().
114 Str("from", oldMode.String()).
115 Str("to", m.mode.String()).
116 Dur("timeout", timeout).
117 Msg("entered tab mode")
118
119 if m.onModeChange != nil {
120 m.onModeChange(oldMode, m.mode)
121 }
122}
123
124// EnterPaneMode switches to pane mode with an optional timeout.
125// If timeout is 0, the mode stays until explicitly exited.
126func (m *ModalState) EnterPaneMode(ctx context.Context, timeout time.Duration) {
127 log := logging.FromContext(ctx)
128 m.mu.Lock()
129 defer m.mu.Unlock()
130
131 if m.mode == ModePane {
132 // Already in pane mode, just reset timeout
133 m.resetTimeoutLocked(ctx, timeout)
134 return
135 }
136
137 m.cancelTimerLocked()
138 oldMode := m.mode
139 m.mode = ModePane
140 m.timeout = timeout
141
142 if timeout > 0 {
143 m.timer = time.AfterFunc(timeout, func() {
144 m.ExitMode(ctx)
145 })
146 }
147
148 log.Debug().
149 Str("from", oldMode.String()).
150 Str("to", m.mode.String()).
151 Dur("timeout", timeout).
152 Msg("entered pane mode")
153
154 if m.onModeChange != nil {
155 m.onModeChange(oldMode, m.mode)
156 }
157}
158
159// EnterSessionMode switches to session mode with an optional timeout.
160// If timeout is 0, the mode stays until explicitly exited.
161func (m *ModalState) EnterSessionMode(ctx context.Context, timeout time.Duration) {
162 log := logging.FromContext(ctx)
163 m.mu.Lock()
164 defer m.mu.Unlock()
165
166 if m.mode == ModeSession {
167 // Already in session mode, just reset timeout
168 m.resetTimeoutLocked(ctx, timeout)
169 return
170 }
171
172 m.cancelTimerLocked()
173 oldMode := m.mode
174 m.mode = ModeSession
175 m.timeout = timeout
176
177 if timeout > 0 {
178 m.timer = time.AfterFunc(timeout, func() {
179 m.ExitMode(ctx)
180 })
181 }
182
183 log.Debug().
184 Str("from", oldMode.String()).
185 Str("to", m.mode.String()).
186 Dur("timeout", timeout).
187 Msg("entered session mode")
188
189 if m.onModeChange != nil {
190 m.onModeChange(oldMode, m.mode)
191 }
192}
193
194// EnterResizeMode switches to resize mode with an optional timeout.
195// If timeout is 0, the mode stays until explicitly exited.
196func (m *ModalState) EnterResizeMode(ctx context.Context, timeout time.Duration) {
197 log := logging.FromContext(ctx)
198 m.mu.Lock()
199 defer m.mu.Unlock()
200
201 if m.mode == ModeResize {
202 // Already in resize mode, just reset timeout
203 m.resetTimeoutLocked(ctx, timeout)
204 return
205 }
206
207 m.cancelTimerLocked()
208 oldMode := m.mode
209 m.mode = ModeResize
210 m.timeout = timeout
211
212 if timeout > 0 {
213 m.timer = time.AfterFunc(timeout, func() {
214 m.ExitMode(ctx)
215 })
216 }
217
218 log.Debug().
219 Str("from", oldMode.String()).
220 Str("to", m.mode.String()).
221 Dur("timeout", timeout).
222 Msg("entered resize mode")
223
224 if m.onModeChange != nil {
225 m.onModeChange(oldMode, m.mode)
226 }
227}
228
229// ExitMode returns to normal mode.
230func (m *ModalState) ExitMode(ctx context.Context) {
231 log := logging.FromContext(ctx)
232 m.mu.Lock()
233 defer m.mu.Unlock()
234
235 if m.mode == ModeNormal {
236 return
237 }
238
239 m.cancelTimerLocked()
240 oldMode := m.mode
241 m.mode = ModeNormal
242 m.timeout = 0
243
244 log.Debug().
245 Str("from", oldMode.String()).
246 Str("to", "normal").
247 Msg("exited modal mode")
248
249 if m.onModeChange != nil {
250 m.onModeChange(oldMode, ModeNormal)
251 }
252}
253
254// ResetTimeout restarts the mode timeout (e.g., after a valid keystroke).
255// Does nothing if not in a modal mode or if no timeout was set.
256func (m *ModalState) ResetTimeout(ctx context.Context) {
257 m.mu.Lock()
258 defer m.mu.Unlock()
259
260 if m.mode == ModeNormal || m.timeout == 0 {
261 return
262 }
263
264 m.resetTimeoutLocked(ctx, m.timeout)
265}
266
267// SetOnModeChange sets the callback for mode changes.
268// The callback is invoked synchronously under the lock.
269func (m *ModalState) SetOnModeChange(fn func(from, to Mode)) {
270 m.mu.Lock()
271 defer m.mu.Unlock()
272 m.onModeChange = fn
273}
274
275// cancelTimerLocked stops any active timeout timer.
276// Must be called with m.mu held.
277func (m *ModalState) cancelTimerLocked() {
278 if m.timer != nil {
279 m.timer.Stop()
280 m.timer = nil
281 }
282}
283
284// resetTimeoutLocked resets the timeout timer.
285// Must be called with m.mu held.
286func (m *ModalState) resetTimeoutLocked(ctx context.Context, timeout time.Duration) {
287 m.cancelTimerLocked()
288 m.timeout = timeout
289 if timeout > 0 {
290 m.timer = time.AfterFunc(timeout, func() {
291 m.ExitMode(ctx)
292 })
293 }
294}