1package ui
2
3import (
4 "context"
5 "time"
6
7 "github.com/bnema/dumber/internal/application/usecase"
8 "github.com/bnema/dumber/internal/domain/entity"
9 "github.com/bnema/dumber/internal/logging"
10 "github.com/bnema/dumber/internal/ui/input"
11)
12
13func (a *App) vimModePolicy() *usecase.VimModePolicyUseCase {
14 if a == nil {
15 return usecase.NewVimModePolicyUseCase()
16 }
17 if a.vimModePolicyUC == nil {
18 a.vimModePolicyUC = usecase.NewVimModePolicyUseCase()
19 }
20 return a.vimModePolicyUC
21}
22
23func (a *App) pageEditableFocused(paneID entity.PaneID) bool {
24 return a != nil && a.vimMode.editableFocused(paneID)
25}
26
27func (a *App) setPageEditableFocused(paneID entity.PaneID, editable bool) {
28 if a != nil {
29 a.vimMode.setEditableFocused(paneID, editable)
30 }
31}
32
33func (a *App) clearPageEditableFocusState(paneID entity.PaneID) {
34 a.setPageEditableFocused(paneID, false)
35}
36
37func (a *App) vimModeActiveForBrowserWindow(bw *browserWindow) bool {
38 return bw != nil && bw.keyboardHandler != nil && bw.keyboardHandler.Mode() == input.ModeVim
39}
40
41func (a *App) applyVimModePolicyTransition(_ context.Context, bw *browserWindow, transition usecase.VimModePolicyTransition) {
42 if transition != usecase.VimModePolicyTransitionExit || bw == nil || bw.keyboardHandler == nil {
43 return
44 }
45 if bw.keyboardHandler.Mode() != input.ModeVim {
46 return
47 }
48 bw.keyboardHandler.ExitMode()
49}
50
51func (a *App) handleVimModeFocusTrigger(ctx context.Context, bw *browserWindow, trigger usecase.VimModePolicyTrigger) {
52 if bw == nil {
53 return
54 }
55 transition := a.vimModePolicy().Evaluate(usecase.VimModePolicyInput{
56 Trigger: trigger,
57 VimModeActive: a.vimModeActiveForBrowserWindow(bw),
58 })
59 a.applyVimModePolicyTransition(ctx, bw, transition)
60}
61
62func (a *App) handleVimModeTabSwitch(_ context.Context, bw *browserWindow) {
63 if bw == nil || bw.tabs == nil || !a.vimModeActiveForBrowserWindow(bw) {
64 return
65 }
66 transition := a.vimModePolicy().Evaluate(usecase.VimModePolicyInput{
67 Trigger: usecase.VimModePolicyTriggerContextChanged,
68 VimModeActive: true,
69 PreserveOnContextChange: false,
70 })
71 if transition != usecase.VimModePolicyTransitionExit {
72 return
73 }
74 if prevTabID := bw.tabs.PreviousActiveTabID; prevTabID != "" {
75 if prevView := a.workspaceViews[prevTabID]; prevView != nil {
76 if pv := prevView.GetPaneView(bw.vimModePaneID); pv != nil {
77 pv.SetVimMode(false)
78 }
79 }
80 }
81 bw.vimModePaneID = ""
82 bw.keyboardHandler.ExitMode()
83}
84
85func (a *App) handlePageEditableFocusChanged(ctx context.Context, paneID entity.PaneID, editable bool) {
86 if paneID == "" {
87 return
88 }
89 a.setPageEditableFocused(paneID, editable)
90
91 bw := a.browserWindowForAnyPane(paneID)
92 if bw == nil {
93 return
94 }
95 ws := a.activeWorkspaceForBrowserWindow(bw)
96 activeContext := ws != nil && ws.ActivePaneID == paneID && a.lastFocusedBrowserWindow() == bw
97 transition := a.vimModePolicy().Evaluate(usecase.VimModePolicyInput{
98 Trigger: usecase.VimModePolicyTriggerPageEditableFocusChanged,
99 VimModeActive: a.vimModeActiveForBrowserWindow(bw),
100 PageEditableFocused: editable,
101 EventInActiveContext: activeContext,
102 })
103 a.applyVimModePolicyTransition(ctx, bw, transition)
104}
105
106func (*App) shouldBypassVimModeActivation(_ *browserWindow) bool {
107 return false
108}
109
110// handleModeChange is called when the input mode changes for a specific browser window.
111func (a *App) handleModeChange(ctx context.Context, bw *browserWindow, from, to input.Mode) {
112 log := logging.FromContext(ctx)
113 log.Debug().Str("from", from.String()).Str("to", to.String()).Msg("input mode changed")
114
115 if to == input.ModeVim && from != input.ModeVim {
116 a.enableAccessibilityForVimMode(ctx, bw)
117 }
118 if from == input.ModeVim && to != input.ModeVim {
119 // Runs under the modal lock: the window is leaving Vim Mode, so do not
120 // read the mode back.
121 a.endVimPageInteraction(ctx, bw, false)
122 a.clearVimNavigationHighlight(ctx, bw)
123 }
124
125 // Handle pane-local Vim Mode visual ownership.
126 // Entering Vim Mode accents the active pane; leaving removes the accent.
127 a.handleVimModeOwnership(ctx, bw, to, from)
128
129 // Retarget the border and reconcile the mode toaster (indicator + legend).
130 a.updateModeFrame(bw, to)
131}
132
133// transferVimModeOwnershipToPane transfers the pane-local Vim Mode accent
134// from the current owning pane to another pane in the same browser window.
135// The transfer only happens while that window is in Vim Mode.
136func (a *App) transferVimModeOwnershipToPane(ctx context.Context, bw *browserWindow, newPaneID entity.PaneID) {
137 if bw == nil {
138 return
139 }
140
141 // Only transfer if vim mode is currently active and the owner is changing.
142 if bw.vimModePaneID == "" || bw.vimModePaneID == newPaneID {
143 return
144 }
145 // A page interaction belongs to the previous owner's WebView.
146 a.endVimPageInteraction(ctx, bw, a.vimModeActiveForBrowserWindow(bw))
147
148 // Check if this window is actually in vim mode.
149 if bw.keyboardHandler == nil || bw.keyboardHandler.Mode() != input.ModeVim {
150 // Vim mode not active on this window; just clear stale ownership.
151 bw.vimModePaneID = ""
152 return
153 }
154
155 wsView := a.activeWorkspaceViewForBrowserWindow(bw)
156 if wsView == nil {
157 return
158 }
159
160 transition := a.vimModePolicy().Evaluate(usecase.VimModePolicyInput{
161 Trigger: usecase.VimModePolicyTriggerContextChanged,
162 VimModeActive: true,
163 PreserveOnContextChange: !a.pageEditableFocused(newPaneID),
164 })
165 if transition == usecase.VimModePolicyTransitionExit {
166 a.applyVimModePolicyTransition(ctx, bw, transition)
167 return
168 }
169
170 oldPaneID := bw.vimModePaneID
171
172 // Deactivate the old owning pane.
173 if oldPV := wsView.GetPaneView(oldPaneID); oldPV != nil {
174 oldPV.SetVimMode(false)
175 }
176
177 // Activate the new pane.
178 if newPV := wsView.GetPaneView(newPaneID); newPV != nil {
179 newPV.SetVimMode(true)
180 }
181 bw.vimModePaneID = newPaneID
182
183 logging.FromContext(ctx).Debug().
184 Str("window_id", bw.id).
185 Str("old_pane_id", string(oldPaneID)).
186 Str("new_pane_id", string(newPaneID)).
187 Msg("vim mode ownership transferred")
188}
189
190// handleVimModeOwnership manages the pane-local Vim Mode accent and pulse
191// owner for a specific browser window when input modes change.
192func (a *App) handleVimModeOwnership(ctx context.Context, bw *browserWindow, to, from input.Mode) {
193 if bw == nil {
194 return
195 }
196
197 ws := a.activeWorkspaceForBrowserWindow(bw)
198 wsView := a.activeWorkspaceViewForBrowserWindow(bw)
199 if ws == nil || wsView == nil {
200 // If there's no workspace yet (startup), just track the intent.
201 if to != input.ModeVim && from == input.ModeVim {
202 bw.vimModePaneID = ""
203 }
204 return
205 }
206
207 if to == input.ModeVim && from != input.ModeVim {
208 // Entering vim mode: activate on the current active pane of this window.
209 paneID := ws.ActivePaneID
210 if pv := wsView.GetPaneView(paneID); pv != nil {
211 pv.SetVimMode(true)
212 bw.vimModePaneID = paneID
213 logging.FromContext(ctx).Debug().
214 Str("window_id", bw.id).
215 Str("pane_id", string(paneID)).
216 Msg("vim mode activated on pane")
217 }
218 } else if from == input.ModeVim && to != input.ModeVim {
219 // Leaving Vim Mode: deactivate the pane that owns the visual accent.
220 a.clearVimModeOwnership(ctx, bw)
221 }
222}
223
224// triggerVimModePulse triggers a pane-local vim mode pulse on the
225// last-focused browser window's owning pane. This is called by the
226// keyboard dispatcher after a page scroll action. The bw used is the
227// window that was active when the action was dispatched (via
228// dispatchBrowserWindowAction which calls activateBrowserWindow first).
229// If no pane is currently in vim mode, the pulse is a no-op.
230//
231// Pulses are debounced at vimModePulseInterval to prevent excessive GTK CSS
232// class churn during held-key repeats. Smooth scroll cadence is owned by the
233// Vim Mode repeater and backend scroll path; pulse feedback is deliberately
234// slower so indicator/overlay animation work does not become part of the
235// scrolling critical path.
236func (a *App) triggerVimModePulse(_ context.Context, fast bool) {
237 // Debounce: skip if called again too soon during a continuous hold.
238 // We still serialize the timestamp update so repeated dispatcher calls do
239 // not restart CSS animations at scroll cadence.
240 if !a.vimMode.allowPulse(time.Now()) {
241 return
242 }
243
244 bw := a.lastFocusedBrowserWindow()
245 if bw == nil || bw.vimModePaneID == "" {
246 return
247 }
248 if bw.modeFrame != nil {
249 bw.modeFrame.pulse(fast)
250 }
251}
252
253// clearVimModeOwnership deactivates vim mode on the owning pane for a
254// specific browser window and resets its tracked pane ID.
255// Also resets the pulse debounce timer so the first pulse on re-entry
256// is never skipped.
257func (a *App) clearVimModeOwnership(ctx context.Context, bw *browserWindow) {
258 if bw == nil || bw.vimModePaneID == "" {
259 return
260 }
261
262 wsView := a.activeWorkspaceViewForBrowserWindow(bw)
263 if wsView != nil {
264 if pv := wsView.GetPaneView(bw.vimModePaneID); pv != nil {
265 pv.SetVimMode(false)
266 logging.FromContext(ctx).Debug().
267 Str("window_id", bw.id).
268 Str("pane_id", string(bw.vimModePaneID)).
269 Msg("vim mode deactivated on pane")
270 }
271 }
272 bw.vimModePaneID = ""
273
274 a.vimMode.resetPulse()
275}
276
277// bindVimModeSequenceToaster wires pending Vim-sequence text onto this
278// window's modeToaster only (never appToaster / pane toasters).
279func (a *App) bindVimModeSequenceToaster(ctx context.Context, bw *browserWindow) {
280 if a == nil || bw == nil || bw.keyboardHandler == nil {
281 return
282 }
283 bw.keyboardHandler.SetOnPendingSequenceChange(func(pending string) {
284 a.showPendingSequence(ctx, bw, pending)
285 })
286 logging.FromContext(ctx).Debug().
287 Str("window_id", bw.id).
288 Msg("vim mode pending sequence toaster bound")
289}
290
291// showPendingSequence shows "VIM MODE · pending" on the per-window modeToaster
292// while a sequence is incomplete, and restores stable "VIM MODE" when pending
293// clears. Mode exit is handled by handleModeChange/updateModeFrame;
294// silent reset under ModalState avoids stale pending callbacks.
295func (a *App) showPendingSequence(ctx context.Context, bw *browserWindow, pending string) {
296 if a == nil || bw == nil {
297 return
298 }
299 if bw.vimPageInteractionWebView != nil {
300 // The page owns the keys: the indicator shows the interaction's
301 // sub-mode, and the legend is suspended. Nothing to repaint.
302 return
303 }
304 if bw.modeToaster == nil {
305 return
306 }
307
308 bw.modeToaster.setPending(pending)
309 logging.FromContext(ctx).Debug().
310 Str("window_id", bw.id).
311 Str("pending", pending).
312 Msg("vim mode pending toaster updated")
313}
314
315// showVimModeToast shows "VIM MODE" with an optional " · detail" suffix on the
316// per-window mode toaster.
317func (a *App) showVimModeToast(_ context.Context, bw *browserWindow, detail string) {
318 if bw == nil || bw.modeToaster == nil {
319 return
320 }
321 bw.modeToaster.setDetail(detail)
322}