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