1package ui
2
3import (
4 "context"
5 "testing"
6 "time"
7
8 "github.com/bnema/puregotk/v4/glib"
9 "github.com/stretchr/testify/assert"
10 "github.com/stretchr/testify/mock"
11 "github.com/stretchr/testify/require"
12
13 "github.com/bnema/dumber/internal/application/dto"
14 "github.com/bnema/dumber/internal/application/port"
15 portmocks "github.com/bnema/dumber/internal/application/port/mocks"
16 "github.com/bnema/dumber/internal/application/usecase"
17 "github.com/bnema/dumber/internal/domain/entity"
18 "github.com/bnema/dumber/internal/ui/component"
19 contentcoord "github.com/bnema/dumber/internal/ui/coordinator/content"
20 "github.com/bnema/dumber/internal/ui/focus"
21 "github.com/bnema/dumber/internal/ui/input"
22 "github.com/bnema/dumber/internal/ui/layout"
23 "github.com/bnema/dumber/internal/ui/layout/mocks"
24 "github.com/bnema/dumber/internal/ui/theme"
25)
26
27// ---------------------------------------------------------------------------
28// Mock-based PaneView and WorkspaceView creation helpers
29//
30// These are used by vimModeTestFixture to build a minimal test environment
31// without any GTK infrastructure.
32// ---------------------------------------------------------------------------
33
34// newLoadingSkeletonMocks sets up the mock expectations for the loading
35// skeleton that NewPaneView creates internally.
36func newLoadingSkeletonMocks(
37 t *testing.T,
38 factory *mocks.MockWidgetFactory,
39 overlay *mocks.MockOverlayWidget,
40) {
41 t.Helper()
42
43 loadingContainer := mocks.NewMockBoxWidget(t)
44 loadingContent := mocks.NewMockBoxWidget(t)
45 spinner := mocks.NewMockSpinnerWidget(t)
46 logo := mocks.NewMockImageWidget(t)
47 version := mocks.NewMockLabelWidget(t)
48
49 factory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(loadingContainer).Once()
50 loadingContainer.EXPECT().SetHexpand(true).Maybe()
51 loadingContainer.EXPECT().SetVexpand(true).Maybe()
52 loadingContainer.EXPECT().SetHalign(mock.Anything).Maybe()
53 loadingContainer.EXPECT().SetValign(mock.Anything).Maybe()
54 loadingContainer.EXPECT().SetCanFocus(false).Maybe()
55 loadingContainer.EXPECT().SetCanTarget(false).Maybe()
56 loadingContainer.EXPECT().AddCssClass(mock.Anything).Maybe()
57 loadingContainer.EXPECT().SetVisible(mock.Anything).Maybe()
58
59 factory.EXPECT().NewBox(layout.OrientationVertical, 6).Return(loadingContent).Once()
60 loadingContent.EXPECT().SetHalign(mock.Anything).Maybe()
61 loadingContent.EXPECT().SetValign(mock.Anything).Maybe()
62 loadingContent.EXPECT().SetCanFocus(false).Maybe()
63 loadingContent.EXPECT().SetCanTarget(false).Maybe()
64 loadingContent.EXPECT().AddCssClass(mock.Anything).Maybe()
65
66 factory.EXPECT().NewImage().Return(logo).Once()
67 logo.EXPECT().SetHalign(mock.Anything).Maybe()
68 logo.EXPECT().SetValign(mock.Anything).Maybe()
69 logo.EXPECT().SetCanFocus(false).Maybe()
70 logo.EXPECT().SetCanTarget(false).Maybe()
71 logo.EXPECT().SetSizeRequest(mock.Anything, mock.Anything).Maybe()
72 logo.EXPECT().SetPixelSize(mock.Anything).Maybe()
73 logo.EXPECT().AddCssClass(mock.Anything).Maybe()
74 logo.EXPECT().SetFromPaintable(mock.Anything).Maybe()
75
76 factory.EXPECT().NewSpinner().Return(spinner).Once()
77 spinner.EXPECT().SetHalign(mock.Anything).Maybe()
78 spinner.EXPECT().SetValign(mock.Anything).Maybe()
79 spinner.EXPECT().SetCanFocus(false).Maybe()
80 spinner.EXPECT().SetCanTarget(false).Maybe()
81 spinner.EXPECT().SetSizeRequest(mock.Anything, mock.Anything).Maybe()
82 spinner.EXPECT().AddCssClass(mock.Anything).Maybe()
83 spinner.EXPECT().Start().Maybe()
84 spinner.EXPECT().Stop().Maybe()
85
86 factory.EXPECT().NewLabel(mock.Anything).Return(version).Once()
87 version.EXPECT().SetHalign(mock.Anything).Maybe()
88 version.EXPECT().SetValign(mock.Anything).Maybe()
89 version.EXPECT().SetCanFocus(false).Maybe()
90 version.EXPECT().SetCanTarget(false).Maybe()
91 version.EXPECT().SetMaxWidthChars(mock.Anything).Maybe()
92 version.EXPECT().SetEllipsize(mock.Anything).Maybe()
93 version.EXPECT().AddCssClass(mock.Anything).Maybe()
94
95 loadingContent.EXPECT().Append(logo).Maybe()
96 loadingContent.EXPECT().Append(spinner).Maybe()
97 loadingContent.EXPECT().Append(version).Maybe()
98 loadingContainer.EXPECT().Append(loadingContent).Maybe()
99
100 overlay.EXPECT().AddOverlay(loadingContainer).Once()
101 overlay.EXPECT().SetClipOverlay(loadingContainer, false).Once()
102 overlay.EXPECT().SetMeasureOverlay(loadingContainer, false).Once()
103}
104
105// createTestPaneView builds a mock-backed PaneView. Returns the PaneView and
106// its overlay mock so subsequent vim-mode indicator / pulse expectations can
107// be set up by the caller.
108func createTestPaneView(
109 t *testing.T,
110 factory *mocks.MockWidgetFactory,
111 paneID entity.PaneID,
112) (*component.PaneView, *mocks.MockOverlayWidget) {
113 t.Helper()
114
115 overlay := mocks.NewMockOverlayWidget(t)
116 borderBox := mocks.NewMockBoxWidget(t)
117
118 // Overlay
119 factory.EXPECT().NewOverlay().Return(overlay).Once()
120 overlay.EXPECT().SetHexpand(true).Once()
121 overlay.EXPECT().SetVexpand(true).Once()
122 overlay.EXPECT().SetVisible(true).Once()
123 overlay.EXPECT().AddCssClass("pane-overlay").Once()
124
125 // Loading skeleton
126 newLoadingSkeletonMocks(t, factory, overlay)
127
128 // Border box
129 factory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(borderBox).Once()
130 borderBox.EXPECT().SetCanFocus(false).Once()
131 borderBox.EXPECT().SetCanTarget(false).Once()
132 borderBox.EXPECT().AddCssClass("pane-border").Once()
133 borderBox.EXPECT().SetHexpand(true).Once()
134 borderBox.EXPECT().SetVexpand(true).Once()
135 overlay.EXPECT().AddOverlay(borderBox).Once()
136 overlay.EXPECT().SetClipOverlay(borderBox, false).Once()
137 overlay.EXPECT().SetMeasureOverlay(borderBox, false).Once()
138
139 pv := component.NewPaneView(context.Background(), factory, paneID, nil)
140 require.NotNil(t, pv)
141
142 return pv, overlay
143}
144
145// enterVimMode marks the owning pane with the pane-local visual accent.
146func enterVimMode(
147 t *testing.T,
148 app *App,
149 overlay *mocks.MockOverlayWidget,
150 bw *browserWindow,
151) {
152 t.Helper()
153 overlay.EXPECT().AddCssClass("vim-mode-active").Once()
154 app.handleVimModeOwnership(context.Background(), bw, input.ModeVim, input.ModeNormal)
155}
156
157func expectVimModeAccentHidden(overlay *mocks.MockOverlayWidget) {
158 overlay.EXPECT().RemoveCssClass("vim-mode-active").Once()
159}
160
161type accessibilityEnablingWebView struct {
162 *portmocks.MockWebView
163 enableAccessibilityCalls int
164}
165
166func newAccessibilityEnablingWebView(t *testing.T, id port.WebViewID) *accessibilityEnablingWebView {
167 t.Helper()
168 wv := portmocks.NewMockWebView(t)
169 wv.EXPECT().ID().Return(id).Once()
170 return &accessibilityEnablingWebView{MockWebView: wv}
171}
172
173func (wv *accessibilityEnablingWebView) EnableAccessibility() {
174 wv.enableAccessibilityCalls++
175}
176
177type semanticNavigatingWebView struct {
178 *portmocks.MockWebView
179 requests []dto.SemanticNavigationRequest
180 activateNavigationTargetCalls int
181 clearNavigationHighlightCalls int
182}
183
184func newSemanticNavigatingWebView(t *testing.T, id port.WebViewID) *semanticNavigatingWebView {
185 t.Helper()
186 wv := portmocks.NewMockWebView(t)
187 wv.EXPECT().ID().Return(id)
188 return &semanticNavigatingWebView{MockWebView: wv}
189}
190
191func (wv *semanticNavigatingWebView) NavigateSemantic(_ context.Context, request dto.SemanticNavigationRequest) error {
192 wv.requests = append(wv.requests, request)
193 return nil
194}
195
196func (wv *semanticNavigatingWebView) ActivateSemanticNavigationTarget(context.Context) error {
197 wv.activateNavigationTargetCalls++
198 return nil
199}
200
201func (wv *semanticNavigatingWebView) ClearSemanticNavigationHighlight(context.Context) error {
202 wv.clearNavigationHighlightCalls++
203 return nil
204}
205
206type inputFocusingWebView struct {
207 *portmocks.MockWebView
208 focusNextInputCalls int
209 pageFocusNavigationCalls []bool
210}
211
212func newInputFocusingWebView(t *testing.T, id port.WebViewID) *inputFocusingWebView {
213 t.Helper()
214 wv := portmocks.NewMockWebView(t)
215 wv.EXPECT().ID().Return(id).Once()
216 return &inputFocusingWebView{MockWebView: wv}
217}
218
219func (wv *inputFocusingWebView) FocusNextInput() {
220 wv.focusNextInputCalls++
221}
222
223func (wv *inputFocusingWebView) NavigatePageFocus(backward bool) {
224 wv.pageFocusNavigationCalls = append(wv.pageFocusNavigationCalls, backward)
225}
226
227func newVimModeAccessibilityFixture(t *testing.T) (*App, *browserWindow, entity.PaneID) {
228 t.Helper()
229 paneID := entity.PaneID("pane-a")
230 ws := &entity.Workspace{
231 ID: "ws-1",
232 ActivePaneID: paneID,
233 Root: &entity.PaneNode{
234 ID: "ws-1-root",
235 Pane: entity.NewPane(paneID),
236 },
237 }
238 tab := &entity.Tab{ID: "tab-1", Workspace: ws}
239 tabs := entity.NewTabList()
240 tabs.Add(tab)
241 tabs.SetActive(tab.ID)
242 bw := &browserWindow{id: "win-1", tabs: tabs}
243 return &App{
244 contentCoord: contentcoord.NewCoordinator(context.Background(), nil, nil, nil, nil, nil, nil, nil),
245 }, bw, paneID
246}
247
248// setupWorkspaceViewMocks creates a mock-backed WorkspaceView.
249func setupWorkspaceViewMocks(
250 t *testing.T,
251 factory *mocks.MockWidgetFactory,
252) *component.WorkspaceView {
253 t.Helper()
254
255 box := mocks.NewMockBoxWidget(t)
256 overlay := mocks.NewMockOverlayWidget(t)
257
258 factory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(box).Once()
259 box.EXPECT().SetHexpand(true).Once()
260 box.EXPECT().SetVexpand(true).Once()
261 box.EXPECT().SetVisible(true).Once()
262
263 factory.EXPECT().NewOverlay().Return(overlay).Once()
264 overlay.EXPECT().SetHexpand(true).Once()
265 overlay.EXPECT().SetVexpand(true).Once()
266 overlay.EXPECT().SetChild(box).Once()
267 overlay.EXPECT().SetVisible(true).Once()
268
269 wv := component.NewWorkspaceView(context.Background(), factory)
270 require.NotNil(t, wv)
271
272 return wv
273}
274
275// newKeyboardHandlerInVimMode creates a KeyboardHandler whose Mode() returns
276// ModeVim — no GTK widgets required.
277func newKeyboardHandlerInVimMode(t *testing.T) *input.KeyboardHandler {
278 t.Helper()
279 kh := input.NewKeyboardHandler(
280 context.Background(),
281 &entity.WorkspaceConfig{},
282 &entity.SessionConfig{},
283 )
284 kh.EnterVimMode()
285 return kh
286}
287
288func bindVimModeKeyboardHandler(t *testing.T, app *App, bw *browserWindow) *input.KeyboardHandler {
289 t.Helper()
290 kh := newKeyboardHandlerInVimMode(t)
291 kh.SetOnModeChange(func(from, to input.Mode) {
292 app.handleModeChange(context.Background(), bw, from, to)
293 })
294 bw.keyboardHandler = kh
295 return kh
296}
297
298// ---------------------------------------------------------------------------
299// vimModeTestFixture — complete, minimal App + workspace environment
300//
301// Provides two browser windows, each with a two-pane workspace. The active
302// pane on both workspaces starts at "pane-a".
303// ---------------------------------------------------------------------------
304
305type vimModeTestFixture struct {
306 app *App
307 bw1 *browserWindow
308 bw2 *browserWindow
309 pv1A *component.PaneView // bw1 / pane-a (active)
310 pv1B *component.PaneView // bw1 / pane-b
311 pv2A *component.PaneView // bw2 / pane-a (active)
312 pv2B *component.PaneView // bw2 / pane-b
313
314 factory *mocks.MockWidgetFactory
315 overlay1A *mocks.MockOverlayWidget
316 overlay1B *mocks.MockOverlayWidget
317 overlay2A *mocks.MockOverlayWidget
318 overlay2B *mocks.MockOverlayWidget
319}
320
321func newVimModeTestFixture(t *testing.T) *vimModeTestFixture {
322 t.Helper()
323 factory := mocks.NewMockWidgetFactory(t)
324
325 // ---- Browser window 1 ----
326 pv1A, ol1A := createTestPaneView(t, factory, "pane-a")
327 pv1B, ol1B := createTestPaneView(t, factory, "pane-b")
328
329 ws1 := &entity.Workspace{
330 ID: "ws-1",
331 ActivePaneID: "pane-a",
332 Root: &entity.PaneNode{
333 ID: "ws-1-root",
334 Children: []*entity.PaneNode{
335 {ID: "ws-1-pane-a", Pane: entity.NewPane("pane-a")},
336 {ID: "ws-1-pane-b", Pane: entity.NewPane("pane-b")},
337 },
338 SplitDir: entity.SplitHorizontal,
339 },
340 }
341 tab1 := &entity.Tab{ID: "tab-1", Workspace: ws1}
342 tabs1 := entity.NewTabList()
343 tabs1.Add(tab1)
344 tabs1.SetActive(tab1.ID)
345
346 wv1 := setupWorkspaceViewMocks(t, factory)
347 wv1.SetPaneViewsForTest(map[entity.PaneID]*component.PaneView{
348 "pane-a": pv1A, "pane-b": pv1B,
349 })
350
351 bw1 := &browserWindow{id: "win-1", tabs: tabs1}
352
353 // ---- Browser window 2 ----
354 pv2A, ol2A := createTestPaneView(t, factory, "pane-a")
355 pv2B, ol2B := createTestPaneView(t, factory, "pane-b")
356
357 ws2 := &entity.Workspace{
358 ID: "ws-2",
359 ActivePaneID: "pane-a",
360 Root: &entity.PaneNode{
361 ID: "ws-2-root",
362 Children: []*entity.PaneNode{
363 {ID: "ws-2-pane-a", Pane: entity.NewPane("pane-a")},
364 {ID: "ws-2-pane-b", Pane: entity.NewPane("pane-b")},
365 },
366 SplitDir: entity.SplitHorizontal,
367 },
368 }
369 tab2 := &entity.Tab{ID: "tab-2", Workspace: ws2}
370 tabs2 := entity.NewTabList()
371 tabs2.Add(tab2)
372 tabs2.SetActive(tab2.ID)
373
374 wv2 := setupWorkspaceViewMocks(t, factory)
375 wv2.SetPaneViewsForTest(map[entity.PaneID]*component.PaneView{
376 "pane-a": pv2A, "pane-b": pv2B,
377 })
378
379 bw2 := &browserWindow{id: "win-2", tabs: tabs2}
380
381 app := &App{
382 workspaceViews: map[entity.TabID]*component.WorkspaceView{
383 "tab-1": wv1, "tab-2": wv2,
384 },
385 browserWindows: map[string]*browserWindow{
386 "win-1": bw1, "win-2": bw2,
387 },
388 lastFocusedWindowID: "win-1",
389 }
390
391 return &vimModeTestFixture{
392 app: app, bw1: bw1, bw2: bw2,
393 pv1A: pv1A, pv1B: pv1B, pv2A: pv2A, pv2B: pv2B,
394 factory: factory,
395 overlay1A: ol1A, overlay1B: ol1B,
396 overlay2A: ol2A, overlay2B: ol2B,
397 }
398}
399
400func newSingleWindowVimModeFixture(t *testing.T) *vimModeTestFixture {
401 t.Helper()
402 f := newVimModeTestFixture(t)
403 delete(f.app.browserWindows, "win-2")
404 delete(f.app.workspaceViews, entity.TabID("tab-2"))
405 f.bw2 = nil
406 f.pv2A = nil
407 f.pv2B = nil
408 f.overlay2A = nil
409 f.overlay2B = nil
410 return f
411}
412
413// ============================================================================
414// 1. Entering Vim mode marks only the active pane
415// ============================================================================
416
417func TestVimMode_Enter_SetsOwnershipOnActivePane(t *testing.T) {
418 f := newVimModeTestFixture(t)
419
420 enterVimMode(t, f.app, f.overlay1A, f.bw1)
421
422 assert.Equal(t, entity.PaneID("pane-a"), f.bw1.vimModePaneID,
423 "entering vim mode sets ownership on the active pane")
424 assert.True(t, f.pv1A.IsVimMode(),
425 "the active pane view enters vim mode")
426 assert.False(t, f.pv1B.IsVimMode(),
427 "the inactive pane is NOT in vim mode")
428 assert.Empty(t, f.bw2.vimModePaneID,
429 "second window is untouched")
430 assert.False(t, f.pv2A.IsVimMode(),
431 "second window active pane is NOT in vim mode")
432}
433
434func TestVimMode_Enter_MarksCorrectWindow(t *testing.T) {
435 f := newVimModeTestFixture(t)
436
437 enterVimMode(t, f.app, f.overlay2A, f.bw2)
438
439 assert.Equal(t, entity.PaneID("pane-a"), f.bw2.vimModePaneID)
440 assert.True(t, f.pv2A.IsVimMode())
441 assert.Empty(t, f.bw1.vimModePaneID)
442 assert.False(t, f.pv1A.IsVimMode())
443}
444
445func TestVimMode_Enter_EnablesAccessibilityForSupportedActiveWebView(t *testing.T) {
446 app, bw, paneID := newVimModeAccessibilityFixture(t)
447 wv := newAccessibilityEnablingWebView(t, 1)
448 app.contentCoord.RegisterPopupWebView(paneID, wv)
449
450 app.handleModeChange(context.Background(), bw, input.ModeNormal, input.ModeVim)
451
452 assert.Equal(t, 1, wv.enableAccessibilityCalls)
453}
454
455func TestVimMode_Enter_AccessibilityNoopsForUnsupportedActiveWebView(t *testing.T) {
456 app, bw, paneID := newVimModeAccessibilityFixture(t)
457 wv := portmocks.NewMockWebView(t)
458 wv.EXPECT().ID().Return(port.WebViewID(2)).Once()
459 app.contentCoord.RegisterPopupWebView(paneID, wv)
460
461 app.handleModeChange(context.Background(), bw, input.ModeNormal, input.ModeVim)
462}
463
464func TestVimMode_Enter_AccessibilityNoopsWhenActiveWebViewMissing(t *testing.T) {
465 app, bw, _ := newVimModeAccessibilityFixture(t)
466
467 app.handleModeChange(context.Background(), bw, input.ModeNormal, input.ModeVim)
468}
469
470func TestVimMode_PreloadAccessibilityEnablesShownWebViewWhenConfigured(t *testing.T) {
471 app, _, paneID := newVimModeAccessibilityFixture(t)
472 wv := newAccessibilityEnablingWebView(t, 4)
473 app.contentCoord.RegisterPopupWebView(paneID, wv)
474 app.runtimeConfig = runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{
475 UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{
476 VimMode: entity.VimModeConfig{PreloadAccessibility: true},
477 }},
478 })
479
480 app.preloadAccessibilityForShownPane(paneID)
481
482 assert.Equal(t, 1, wv.enableAccessibilityCalls)
483}
484
485func TestVimMode_PreloadAccessibilityNoopsWhenDisabled(t *testing.T) {
486 app, _, paneID := newVimModeAccessibilityFixture(t)
487 wv := newAccessibilityEnablingWebView(t, 5)
488 app.contentCoord.RegisterPopupWebView(paneID, wv)
489
490 app.preloadAccessibilityForShownPane(paneID)
491
492 assert.Equal(t, 0, wv.enableAccessibilityCalls)
493}
494
495func TestVimNavigationHighlightColorUsesCurrentThemeAccent(t *testing.T) {
496 resolved := entity.ResolvedTheme{
497 PrefersDark: true,
498 DarkPalette: entity.ColorPalette{
499 Accent: "#22c55e",
500 },
501 }
502 app := &App{deps: &Dependencies{Theme: theme.NewManager(context.Background(), resolved)}}
503
504 assert.Equal(t, "#22c55e", app.vimNavigationHighlightColor())
505}
506
507func TestVimMode_SequenceActionNavigatesActiveWebViewHeading(t *testing.T) {
508 app, bw, paneID := newVimModeAccessibilityFixture(t)
509 wv := newSemanticNavigatingWebView(t, 4)
510 app.contentCoord.RegisterPopupWebView(paneID, wv)
511
512 app.navigateVimSequenceAction(context.Background(), bw, "heading-next", 3)
513 app.navigateVimSequenceAction(context.Background(), bw, "heading-prev", 0)
514 app.navigateVimSequenceAction(context.Background(), bw, "code-next", 1)
515
516 assert.Equal(t, []dto.SemanticNavigationRequest{
517 {Target: dto.SemanticNavigationTargetHeading, Direction: dto.SemanticNavigationForward, Count: 3},
518 {Target: dto.SemanticNavigationTargetHeading, Direction: dto.SemanticNavigationBackward, Count: 0},
519 }, wv.requests)
520}
521
522func TestVimMode_ConfirmActivatesSelectedHeadingLink(t *testing.T) {
523 app, bw, paneID := newVimModeAccessibilityFixture(t)
524 wv := newSemanticNavigatingWebView(t, 12)
525 app.contentCoord.RegisterPopupWebView(paneID, wv)
526
527 app.navigateVimSequenceAction(context.Background(), bw, "confirm", 0)
528
529 assert.Equal(t, 1, wv.activateNavigationTargetCalls)
530}
531
532func TestVimMode_SequenceActionFocusesNextInput(t *testing.T) {
533 app, bw, paneID := newVimModeAccessibilityFixture(t)
534 wv := newInputFocusingWebView(t, 6)
535 app.contentCoord.RegisterPopupWebView(paneID, wv)
536
537 app.navigateVimSequenceAction(context.Background(), bw, "focus-input", 1)
538
539 assert.Equal(t, 1, wv.focusNextInputCalls)
540}
541
542func TestVimMode_Leave_ClearsNavigationHighlight(t *testing.T) {
543 app, bw, paneID := newVimModeAccessibilityFixture(t)
544 wv := newSemanticNavigatingWebView(t, 8)
545 app.contentCoord.RegisterPopupWebView(paneID, wv)
546 app.navigateVimSequenceAction(context.Background(), bw, "heading-next", 1)
547 app.navigateVimSequenceAction(context.Background(), bw, "heading-next", 1)
548
549 app.handleModeChange(context.Background(), bw, input.ModeVim, input.ModeNormal)
550
551 assert.Equal(t, 1, wv.clearNavigationHighlightCalls)
552 assert.Empty(t, bw.vimNavigationHighlightedWebViews)
553 assert.Empty(t, bw.vimNavigationHighlightedWebViewIDs)
554}
555
556func TestVimMode_Leave_ClearsNavigationHighlightAfterPaneSwitch(t *testing.T) {
557 f := newSingleWindowVimModeFixture(t)
558 f.app.contentCoord = contentcoord.NewCoordinator(context.Background(), nil, nil, nil, nil, nil, nil, nil)
559 oldWebView := newSemanticNavigatingWebView(t, 9)
560 newWebView := newSemanticNavigatingWebView(t, 10)
561 f.app.contentCoord.RegisterPopupWebView("pane-a", oldWebView)
562 f.app.contentCoord.RegisterPopupWebView("pane-b", newWebView)
563
564 f.app.navigateVimSequenceAction(context.Background(), f.bw1, "heading-next", 1)
565 f.bw1.tabs.ActiveTab().Workspace.ActivePaneID = "pane-b"
566 f.app.handleModeChange(context.Background(), f.bw1, input.ModeVim, input.ModeNormal)
567
568 assert.Equal(t, 1, oldWebView.clearNavigationHighlightCalls)
569 assert.Equal(t, 0, newWebView.clearNavigationHighlightCalls)
570}
571
572func TestVimMode_Leave_ClearsNavigationHighlightAfterTabSwitch(t *testing.T) {
573 app, bw, paneID := newVimModeAccessibilityFixture(t)
574 oldWebView := newSemanticNavigatingWebView(t, 11)
575 app.contentCoord.RegisterPopupWebView(paneID, oldWebView)
576 otherPaneID := entity.PaneID("pane-b")
577 otherTab := entity.NewTab("tab-2", "ws-2", entity.NewPane(otherPaneID))
578 bw.tabs.Add(otherTab)
579 bw.tabs.SetActive("tab-1")
580
581 app.navigateVimSequenceAction(context.Background(), bw, "heading-next", 1)
582 bw.tabs.SetActive(otherTab.ID)
583 app.handleModeChange(context.Background(), bw, input.ModeVim, input.ModeNormal)
584
585 assert.Equal(t, 1, oldWebView.clearNavigationHighlightCalls)
586}
587
588func TestVimMode_NativePageFocusNavigationUsesActiveEditableWebView(t *testing.T) {
589 app, bw, paneID := newVimModeAccessibilityFixture(t)
590 wv := newInputFocusingWebView(t, 7)
591 app.contentCoord.RegisterPopupWebView(paneID, wv)
592 app.pageEditableFocusByPane = map[entity.PaneID]bool{paneID: true}
593
594 assert.True(t, app.navigatePageFocus(context.Background(), bw, true))
595 assert.Equal(t, []bool{true}, wv.pageFocusNavigationCalls)
596}
597
598func TestVimMode_SequenceActionNoopsForUnsupportedActiveWebView(t *testing.T) {
599 app, bw, paneID := newVimModeAccessibilityFixture(t)
600 wv := portmocks.NewMockWebView(t)
601 wv.EXPECT().ID().Return(port.WebViewID(5)).Once()
602 app.contentCoord.RegisterPopupWebView(paneID, wv)
603
604 app.navigateVimSequenceAction(context.Background(), bw, "heading-next", 1)
605 app.navigateVimSequenceAction(context.Background(), bw, "focus-input", 1)
606}
607
608func TestVimMode_AccessibilityOnlyEnabledOnEnteringVimMode(t *testing.T) {
609 app, bw, paneID := newVimModeAccessibilityFixture(t)
610 wv := newAccessibilityEnablingWebView(t, 3)
611 app.contentCoord.RegisterPopupWebView(paneID, wv)
612
613 app.handleModeChange(context.Background(), bw, input.ModeNormal, input.ModeTab)
614 app.handleModeChange(context.Background(), bw, input.ModeVim, input.ModeNormal)
615 app.handleModeChange(context.Background(), bw, input.ModeVim, input.ModeVim)
616 assert.Equal(t, 0, wv.enableAccessibilityCalls)
617
618 app.handleModeChange(context.Background(), bw, input.ModeNormal, input.ModeVim)
619 assert.Equal(t, 1, wv.enableAccessibilityCalls)
620
621 app.handleModeChange(context.Background(), bw, input.ModePane, input.ModeVim)
622 assert.Equal(t, 2, wv.enableAccessibilityCalls)
623}
624
625// ============================================================================
626// 2. Leaving Vim mode clears ownership
627// ============================================================================
628
629func TestVimMode_Leave_ClearsOwnershipFromOwningPane(t *testing.T) {
630 f := newVimModeTestFixture(t)
631
632 // Enter first
633 enterVimMode(t, f.app, f.overlay1A, f.bw1)
634 require.Equal(t, entity.PaneID("pane-a"), f.bw1.vimModePaneID)
635
636 // Leave — SetVimMode(false) hides indicator and removes the CSS class
637 expectVimModeAccentHidden(f.overlay1A)
638 f.app.handleVimModeOwnership(context.Background(), f.bw1, input.ModeNormal, input.ModeVim)
639
640 assert.Empty(t, f.bw1.vimModePaneID,
641 "leaving vim mode clears ownership field")
642 assert.False(t, f.pv1A.IsVimMode(),
643 "the previously owning pane exits vim mode")
644}
645
646// ============================================================================
647// 3. Transfer within the same window
648// ============================================================================
649
650func TestVimMode_Transfer_MovesOwnershipToNewPane(t *testing.T) {
651 f := newVimModeTestFixture(t)
652
653 // Enter vim mode on bw1 pane-a — save label for hide expectation
654 enterVimMode(t, f.app, f.overlay1A, f.bw1)
655 require.Equal(t, entity.PaneID("pane-a"), f.bw1.vimModePaneID)
656
657 // Give bw1 a keyboard handler in vim mode so transfer performs the switch
658 f.bw1.keyboardHandler = newKeyboardHandlerInVimMode(t)
659
660 // transferVimModeOwnershipToPane calls SetVimMode(false) on the old
661 // pane before SetVimMode(true) on the new pane. Set up both.
662 expectVimModeAccentHidden(f.overlay1A)
663
664 // Set up the pane-local accent for pane-b.
665 f.overlay1B.EXPECT().AddCssClass("vim-mode-active").Once()
666
667 // Transfer ownership to pane-b
668 f.app.transferVimModeOwnershipToPane(context.Background(), f.bw1, entity.PaneID("pane-b"))
669
670 assert.Equal(t, entity.PaneID("pane-b"), f.bw1.vimModePaneID,
671 "ownership transferred to new pane")
672 assert.False(t, f.pv1A.IsVimMode(),
673 "previous owning pane deactivated")
674 assert.True(t, f.pv1B.IsVimMode(),
675 "new pane is now in vim mode")
676}
677
678func TestVimMode_Transfer_NoVimModeClearsStale(t *testing.T) {
679 f := newVimModeTestFixture(t)
680
681 f.bw1.vimModePaneID = entity.PaneID("pane-a")
682 f.bw1.keyboardHandler = input.NewKeyboardHandler(
683 context.Background(),
684 &entity.WorkspaceConfig{},
685 &entity.SessionConfig{},
686 )
687 // No EnterVimMode — handler stays in ModeNormal.
688
689 f.app.transferVimModeOwnershipToPane(context.Background(), f.bw1, entity.PaneID("pane-b"))
690
691 assert.Empty(t, f.bw1.vimModePaneID,
692 "stale ownership cleared when window is not in vim mode")
693 assert.False(t, f.pv1A.IsVimMode())
694 assert.False(t, f.pv1B.IsVimMode())
695}
696
697func TestVimMode_Transfer_NilBwDoesNotCrash(t *testing.T) {
698 app := &App{}
699 app.transferVimModeOwnershipToPane(context.Background(), nil, entity.PaneID("pane-1"))
700}
701
702func TestVimMode_Transfer_SamePaneIsNoop(t *testing.T) {
703 app := &App{}
704 paneID := entity.PaneID("pane-a")
705 bw := &browserWindow{vimModePaneID: paneID}
706 app.transferVimModeOwnershipToPane(context.Background(), bw, paneID)
707 assert.Equal(t, paneID, bw.vimModePaneID)
708}
709
710func TestVimMode_Transfer_EmptyOwnershipIsNoop(t *testing.T) {
711 app := &App{}
712 bw := &browserWindow{}
713 app.transferVimModeOwnershipToPane(context.Background(), bw, entity.PaneID("pane-a"))
714 assert.Empty(t, bw.vimModePaneID)
715}
716
717// ============================================================================
718// 4. Pulse targeting — normal vs fast
719// ============================================================================
720
721func setUpNormalPulse(overlay *mocks.MockOverlayWidget) {
722 overlay.EXPECT().RemoveCssClass("vim-mode-pulse").Once()
723 overlay.EXPECT().RemoveCssClass("vim-mode-pulse-fast").Once()
724 overlay.EXPECT().RemoveCssClass("vim-mode-pulse-cycle-a").Once()
725 overlay.EXPECT().RemoveCssClass("vim-mode-pulse-cycle-b").Once()
726 overlay.EXPECT().AddCssClass("vim-mode-pulse").Once()
727 overlay.EXPECT().AddCssClass("vim-mode-pulse-cycle-a").Once()
728}
729
730func setUpFastPulse(overlay *mocks.MockOverlayWidget) {
731 overlay.EXPECT().RemoveCssClass("vim-mode-pulse").Once()
732 overlay.EXPECT().RemoveCssClass("vim-mode-pulse-fast").Once()
733 overlay.EXPECT().RemoveCssClass("vim-mode-pulse-cycle-a").Once()
734 overlay.EXPECT().RemoveCssClass("vim-mode-pulse-cycle-b").Once()
735 overlay.EXPECT().AddCssClass("vim-mode-pulse-fast").Once()
736 overlay.EXPECT().AddCssClass("vim-mode-pulse-cycle-a").Once()
737}
738
739func TestVimMode_Pulse_NormalTriggersOnOwningPane(t *testing.T) {
740 f := newVimModeTestFixture(t)
741
742 enterVimMode(t, f.app, f.overlay1A, f.bw1)
743 require.Equal(t, entity.PaneID("pane-a"), f.bw1.vimModePaneID)
744
745 // Pulse needs the indicator to exist — it was created lazily during
746 // enterVimMode, but we also need to set up pulse expectations.
747 // (enterVimMode created it, so we just set up pulse mocks.)
748 setUpNormalPulse(f.overlay1A)
749
750 f.app.triggerVimModePulse(context.Background(), false)
751}
752
753func TestVimMode_Pulse_FastTriggersOnOwningPane(t *testing.T) {
754 f := newVimModeTestFixture(t)
755
756 enterVimMode(t, f.app, f.overlay1A, f.bw1)
757 require.Equal(t, entity.PaneID("pane-a"), f.bw1.vimModePaneID)
758
759 setUpFastPulse(f.overlay1A)
760
761 f.app.triggerVimModePulse(context.Background(), true)
762}
763
764func TestVimMode_Pulse_NoOwnerIsNoop(t *testing.T) {
765 f := newVimModeTestFixture(t)
766 f.app.triggerVimModePulse(context.Background(), false)
767 f.app.triggerVimModePulse(context.Background(), true)
768 // No pane has ownership — no pulse expectations needed.
769}
770
771// ============================================================================
772// 5. Multi-window isolation
773// ============================================================================
774
775func TestVimMode_MultiWindow_TwoWindowsEnterLeave(t *testing.T) {
776 f := newVimModeTestFixture(t)
777
778 enterVimMode(t, f.app, f.overlay1A, f.bw1)
779 enterVimMode(t, f.app, f.overlay2A, f.bw2)
780
781 assert.Equal(t, entity.PaneID("pane-a"), f.bw1.vimModePaneID)
782 assert.Equal(t, entity.PaneID("pane-a"), f.bw2.vimModePaneID)
783
784 // Leave bw1 only
785 expectVimModeAccentHidden(f.overlay1A)
786 f.app.handleVimModeOwnership(context.Background(), f.bw1, input.ModeNormal, input.ModeVim)
787
788 assert.Empty(t, f.bw1.vimModePaneID)
789 assert.False(t, f.pv1A.IsVimMode())
790
791 // bw2 unchanged
792 assert.Equal(t, entity.PaneID("pane-a"), f.bw2.vimModePaneID)
793 assert.True(t, f.pv2A.IsVimMode())
794}
795
796func TestVimMode_MultiWindow_TransferIsolated(t *testing.T) {
797 f := newVimModeTestFixture(t)
798
799 enterVimMode(t, f.app, f.overlay1A, f.bw1)
800 enterVimMode(t, f.app, f.overlay2A, f.bw2)
801
802 f.bw1.keyboardHandler = newKeyboardHandlerInVimMode(t)
803
804 // transferVimModeOwnershipToPane calls SetVimMode(false) on old pane first
805 expectVimModeAccentHidden(f.overlay1A)
806
807 // Transfer bw1 to pane-b.
808 f.overlay1B.EXPECT().AddCssClass("vim-mode-active").Once()
809 f.app.transferVimModeOwnershipToPane(context.Background(), f.bw1, entity.PaneID("pane-b"))
810
811 assert.Equal(t, entity.PaneID("pane-b"), f.bw1.vimModePaneID)
812 assert.True(t, f.pv1B.IsVimMode())
813 assert.False(t, f.pv1A.IsVimMode())
814
815 // bw2 unchanged
816 assert.Equal(t, entity.PaneID("pane-a"), f.bw2.vimModePaneID)
817 assert.True(t, f.pv2A.IsVimMode())
818}
819
820func TestVimMode_MultiWindow_PulseIsolated(t *testing.T) {
821 f := newVimModeTestFixture(t)
822
823 enterVimMode(t, f.app, f.overlay1A, f.bw1)
824 enterVimMode(t, f.app, f.overlay2A, f.bw2)
825
826 // Pulse on bw1 (lastFocusedWindowID = "win-1")
827 setUpNormalPulse(f.overlay1A)
828 f.app.triggerVimModePulse(context.Background(), false)
829
830 // bw2 pane still in vim mode
831 assert.True(t, f.pv2A.IsVimMode())
832}
833
834// ============================================================================
835// Edge cases — nil / no-workspace / startup guards
836// ============================================================================
837
838func TestVimMode_Enter_NoWorkspaceIsNoop(t *testing.T) {
839 app := &App{}
840 bw := &browserWindow{id: "win-1"}
841 app.handleVimModeOwnership(context.Background(), bw, input.ModeVim, input.ModeNormal)
842 assert.Empty(t, bw.vimModePaneID)
843}
844
845func TestVimMode_Clear_NilBwDoesNotCrash(t *testing.T) {
846 (&App{}).clearVimModeOwnership(context.Background(), nil)
847}
848
849func TestVimMode_Clear_NoPaneID(t *testing.T) {
850 app := &App{}
851 bw := &browserWindow{id: "test-win"}
852 app.clearVimModeOwnership(context.Background(), bw)
853 assert.Empty(t, bw.vimModePaneID)
854}
855
856func TestVimMode_Clear_StalePaneWithoutWorkspace(t *testing.T) {
857 app := &App{}
858 bw := &browserWindow{id: "test-win", vimModePaneID: entity.PaneID("stale-pane")}
859 app.clearVimModeOwnership(context.Background(), bw)
860 assert.Empty(t, bw.vimModePaneID)
861}
862
863func TestVimMode_HandleModeChange_NilBwDoesNotCrash(t *testing.T) {
864 (&App{}).handleModeChange(context.Background(), nil, input.ModeNormal, input.ModeVim)
865}
866
867func TestVimMode_EditableFocusOnActivePaneExitsVimMode(t *testing.T) {
868 f := newSingleWindowVimModeFixture(t)
869 enterVimMode(t, f.app, f.overlay1A, f.bw1)
870 kh := bindVimModeKeyboardHandler(t, f.app, f.bw1)
871
872 expectVimModeAccentHidden(f.overlay1A)
873 f.app.handlePageEditableFocusChanged(context.Background(), entity.PaneID("pane-a"), true)
874
875 assert.Equal(t, input.ModeNormal, kh.Mode())
876 assert.Empty(t, f.bw1.vimModePaneID)
877 assert.False(t, f.pv1A.IsVimMode())
878}
879
880func TestVimMode_EditableFocusOnInactivePaneDoesNotExitVimMode(t *testing.T) {
881 f := newSingleWindowVimModeFixture(t)
882 enterVimMode(t, f.app, f.overlay1A, f.bw1)
883 kh := bindVimModeKeyboardHandler(t, f.app, f.bw1)
884
885 f.app.handlePageEditableFocusChanged(context.Background(), entity.PaneID("pane-b"), true)
886
887 assert.Equal(t, input.ModeVim, kh.Mode())
888 assert.Equal(t, entity.PaneID("pane-a"), f.bw1.vimModePaneID)
889 assert.True(t, f.pv1A.IsVimMode())
890}
891
892func TestVimMode_PaneSwitchToEditablePaneExitsVimMode(t *testing.T) {
893 f := newSingleWindowVimModeFixture(t)
894 enterVimMode(t, f.app, f.overlay1A, f.bw1)
895 kh := bindVimModeKeyboardHandler(t, f.app, f.bw1)
896 f.app.pageEditableFocusByPane = map[entity.PaneID]bool{
897 "pane-b": true,
898 }
899
900 expectVimModeAccentHidden(f.overlay1A)
901 f.app.transferVimModeOwnershipToPane(context.Background(), f.bw1, entity.PaneID("pane-b"))
902
903 assert.Equal(t, input.ModeNormal, kh.Mode())
904 assert.Empty(t, f.bw1.vimModePaneID)
905 assert.False(t, f.pv1A.IsVimMode())
906 assert.False(t, f.pv1B.IsVimMode())
907}
908
909func TestVimMode_BackgroundWindowEditableFocusDoesNotExitFocusedWindowVimMode(t *testing.T) {
910 factory := mocks.NewMockWidgetFactory(t)
911 pv1, overlay1 := createTestPaneView(t, factory, "pane-a")
912 pv2, _ := createTestPaneView(t, factory, "pane-x")
913
914 ws1 := &entity.Workspace{ID: "ws-1", ActivePaneID: "pane-a", Root: &entity.PaneNode{ID: "ws-1-pane-a", Pane: entity.NewPane("pane-a")}}
915 ws2 := &entity.Workspace{ID: "ws-2", ActivePaneID: "pane-x", Root: &entity.PaneNode{ID: "ws-2-pane-x", Pane: entity.NewPane("pane-x")}}
916 bw1 := &browserWindow{id: "win-1", tabs: entity.NewTabList()}
917 bw2 := &browserWindow{id: "win-2", tabs: entity.NewTabList()}
918 tab1 := &entity.Tab{ID: "tab-1", Workspace: ws1}
919 tab2 := &entity.Tab{ID: "tab-2", Workspace: ws2}
920 bw1.tabs.Add(tab1)
921 bw1.tabs.SetActive(tab1.ID)
922 bw2.tabs.Add(tab2)
923 bw2.tabs.SetActive(tab2.ID)
924 view1 := setupWorkspaceViewMocks(t, factory)
925 view1.SetPaneViewsForTest(map[entity.PaneID]*component.PaneView{"pane-a": pv1})
926 view2 := setupWorkspaceViewMocks(t, factory)
927 view2.SetPaneViewsForTest(map[entity.PaneID]*component.PaneView{"pane-x": pv2})
928
929 app := &App{
930 workspaceViews: map[entity.TabID]*component.WorkspaceView{"tab-1": view1, "tab-2": view2},
931 browserWindows: map[string]*browserWindow{"win-1": bw1, "win-2": bw2},
932 lastFocusedWindowID: "win-1",
933 }
934
935 enterVimMode(t, app, overlay1, bw1)
936 kh := bindVimModeKeyboardHandler(t, app, bw1)
937
938 app.handlePageEditableFocusChanged(context.Background(), entity.PaneID("pane-x"), true)
939
940 assert.Equal(t, input.ModeVim, kh.Mode())
941 assert.Equal(t, entity.PaneID("pane-a"), bw1.vimModePaneID)
942 assert.True(t, pv1.IsVimMode())
943}
944
945func TestVimMode_OmniboxFocusExitsVimMode(t *testing.T) {
946 f := newSingleWindowVimModeFixture(t)
947 enterVimMode(t, f.app, f.overlay1A, f.bw1)
948 kh := bindVimModeKeyboardHandler(t, f.app, f.bw1)
949
950 expectVimModeAccentHidden(f.overlay1A)
951 f.app.handleVimModeFocusTrigger(context.Background(), f.bw1, usecase.VimModePolicyTriggerOmniboxFocus)
952
953 assert.Equal(t, input.ModeNormal, kh.Mode())
954 assert.Empty(t, f.bw1.vimModePaneID)
955}
956
957func TestVimMode_FindBarFocusExitsVimMode(t *testing.T) {
958 f := newSingleWindowVimModeFixture(t)
959 enterVimMode(t, f.app, f.overlay1A, f.bw1)
960 kh := bindVimModeKeyboardHandler(t, f.app, f.bw1)
961
962 expectVimModeAccentHidden(f.overlay1A)
963 f.app.handleVimModeFocusTrigger(context.Background(), f.bw1, usecase.VimModePolicyTriggerFindBarFocus)
964
965 assert.Equal(t, input.ModeNormal, kh.Mode())
966 assert.Empty(t, f.bw1.vimModePaneID)
967}
968
969func TestVimMode_TabSwitchExitsAndClearsOldAccent(t *testing.T) {
970 f := newSingleWindowVimModeFixture(t)
971 enterVimMode(t, f.app, f.overlay1A, f.bw1)
972 kh := bindVimModeKeyboardHandler(t, f.app, f.bw1)
973
974 tab2 := &entity.Tab{ID: "tab-2b", Workspace: &entity.Workspace{
975 ID: "ws-2b",
976 ActivePaneID: "pane-c",
977 Root: &entity.PaneNode{ID: "ws-2b-pane-c", Pane: entity.NewPane("pane-c")},
978 }}
979 f.bw1.tabs.Add(tab2)
980 f.bw1.tabs.SetActive(tab2.ID)
981 f.app.workspaceViews[tab2.ID] = setupWorkspaceViewMocks(t, f.factory)
982
983 expectVimModeAccentHidden(f.overlay1A)
984 f.app.handleVimModeTabSwitch(context.Background(), f.bw1)
985
986 assert.Equal(t, input.ModeNormal, kh.Mode())
987 assert.Empty(t, f.bw1.vimModePaneID)
988 assert.False(t, f.pv1A.IsVimMode())
989}
990
991func TestVimMode_ActivationRemainsAvailableWhenPageIsEditable(t *testing.T) {
992 f := newSingleWindowVimModeFixture(t)
993 f.app.pageEditableFocusByPane = map[entity.PaneID]bool{
994 "pane-a": true,
995 }
996
997 assert.False(t, f.app.shouldBypassVimModeActivation(f.bw1))
998}
999
1000func TestVimMode_ActivationAvailabilityDoesNotDependOnEditableFocus(t *testing.T) {
1001 f := newSingleWindowVimModeFixture(t)
1002
1003 f.app.handlePageEditableFocusChanged(context.Background(), entity.PaneID("pane-a"), true)
1004 assert.False(t, f.app.shouldBypassVimModeActivation(f.bw1))
1005
1006 f.app.handlePageEditableFocusChanged(context.Background(), entity.PaneID("pane-a"), false)
1007 assert.False(t, f.app.shouldBypassVimModeActivation(f.bw1))
1008}
1009
1010func TestVimMode_ClearEditableFocusStateRemovesStoredBypass(t *testing.T) {
1011 f := newSingleWindowVimModeFixture(t)
1012 f.app.pageEditableFocusByPane = map[entity.PaneID]bool{"pane-a": true}
1013
1014 f.app.clearPageEditableFocusState(entity.PaneID("pane-a"))
1015
1016 assert.False(t, f.app.shouldBypassVimModeActivation(f.bw1))
1017}
1018
1019// ============================================================================
1020// CSS class constants
1021// ============================================================================
1022
1023func TestVimMode_CSSClassConstants(t *testing.T) {
1024 assert.Equal(t, "vim-mode-active", component.VimModeActiveClass)
1025 assert.Equal(t, "vim-mode-pulse", component.VimModePulseClass)
1026 assert.Equal(t, "vim-mode-pulse-fast", component.VimModeFastPulseClass)
1027}
1028
1029// ============================================================================
1030// Mode toaster
1031// ============================================================================
1032
1033func newModeToasterForVimModeTest(t *testing.T, factory *mocks.MockWidgetFactory) (*component.Toaster, *mocks.MockBoxWidget, *mocks.MockLabelWidget) {
1034 t.Helper()
1035
1036 container := mocks.NewMockBoxWidget(t)
1037 label := mocks.NewMockLabelWidget(t)
1038 factory.EXPECT().NewBox(layout.OrientationHorizontal, 0).Return(container).Once()
1039 container.EXPECT().AddCssClass("toast").Once()
1040 container.EXPECT().AddCssClass("toast-info").Once()
1041 container.EXPECT().SetHalign(mock.Anything).Once()
1042 container.EXPECT().SetValign(mock.Anything).Once()
1043 container.EXPECT().SetHexpand(false).Once()
1044 container.EXPECT().SetVexpand(false).Once()
1045 container.EXPECT().SetCanTarget(false).Once()
1046 container.EXPECT().SetCanFocus(false).Once()
1047 container.EXPECT().SetVisible(false).Once()
1048 factory.EXPECT().NewLabel("").Return(label).Once()
1049 label.EXPECT().SetCanTarget(false).Once()
1050 label.EXPECT().SetCanFocus(false).Once()
1051 container.EXPECT().Append(label).Once()
1052
1053 return component.NewToaster(factory), container, label
1054}
1055
1056func expectVimModeToastShown(container *mocks.MockBoxWidget, label *mocks.MockLabelWidget) {
1057 container.EXPECT().RemoveCssClass("toast-info").Once()
1058 container.EXPECT().AddCssClass("toast-vim-mode").Once()
1059 container.EXPECT().SetHalign(mock.Anything).Once()
1060 container.EXPECT().SetValign(mock.Anything).Once()
1061 label.EXPECT().SetText("VIM MODE").Once()
1062 container.EXPECT().SetVisible(true).Once()
1063}
1064
1065func TestVimMode_ToasterRemainsVisibleUntilModeExit(t *testing.T) {
1066 factory := mocks.NewMockWidgetFactory(t)
1067 toaster, container, label := newModeToasterForVimModeTest(t, factory)
1068 expectVimModeToastShown(container, label)
1069 // These optional calls are made only by the old brief auto-dismiss path.
1070 container.EXPECT().RemoveCssClass("toast-vim-mode").Maybe()
1071 container.EXPECT().SetVisible(false).Maybe()
1072
1073 app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{
1074 UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{Styling: entity.WorkspaceStylingConfig{
1075 ModeIndicatorToasterEnabled: true,
1076 }}},
1077 })}
1078 bw := &browserWindow{id: "win-1", modeToaster: toaster}
1079 app.browserWindows = map[string]*browserWindow{bw.id: bw}
1080 app.lastFocusedWindowID = bw.id
1081
1082 app.updateModeIndicatorToaster(context.Background(), bw, input.ModeVim)
1083 require.True(t, toaster.IsVisible())
1084
1085 deadline := time.Now().Add(time.Duration(component.ToastBriefDurationMs+100) * time.Millisecond)
1086 mainContext := glib.MainContextDefault()
1087 for time.Now().Before(deadline) {
1088 time.Sleep(10 * time.Millisecond)
1089 for mainContext.Pending() {
1090 mainContext.Iteration(false)
1091 }
1092 }
1093
1094 assert.True(t, toaster.IsVisible(), "Vim Mode toast must remain visible until mode exit")
1095}
1096
1097func TestVimMode_ModeExitHidesToaster(t *testing.T) {
1098 factory := mocks.NewMockWidgetFactory(t)
1099 toaster, container, label := newModeToasterForVimModeTest(t, factory)
1100 expectVimModeToastShown(container, label)
1101 container.EXPECT().RemoveCssClass("toast-custom").Once()
1102 container.EXPECT().RemoveCssClass("toast-vim-mode").Once()
1103 container.EXPECT().SetVisible(false).Once()
1104
1105 app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{
1106 UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{Styling: entity.WorkspaceStylingConfig{
1107 ModeIndicatorToasterEnabled: true,
1108 }}},
1109 })}
1110 bw := &browserWindow{id: "win-1", modeToaster: toaster}
1111
1112 app.updateModeIndicatorToaster(context.Background(), bw, input.ModeVim)
1113 require.True(t, toaster.IsVisible())
1114 app.updateModeIndicatorToaster(context.Background(), bw, input.ModeNormal)
1115
1116 assert.False(t, toaster.IsVisible(), "leaving Vim Mode must hide its persistent toast")
1117}
1118
1119func TestVimMode_DisabledModeIndicatorPreferenceSuppressesToaster(t *testing.T) {
1120 factory := mocks.NewMockWidgetFactory(t)
1121 toaster, _, _ := newModeToasterForVimModeTest(t, factory)
1122 app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{})}
1123 bw := &browserWindow{id: "win-1", modeToaster: toaster}
1124
1125 app.updateModeIndicatorToaster(context.Background(), bw, input.ModeVim)
1126
1127 assert.False(t, toaster.IsVisible())
1128}
1129
1130func TestVimMode_MultiWindowToastersExitIndependently(t *testing.T) {
1131 factory := mocks.NewMockWidgetFactory(t)
1132 toaster1, container1, label1 := newModeToasterForVimModeTest(t, factory)
1133 toaster2, container2, label2 := newModeToasterForVimModeTest(t, factory)
1134 expectVimModeToastShown(container1, label1)
1135 expectVimModeToastShown(container2, label2)
1136 container1.EXPECT().RemoveCssClass("toast-custom").Once()
1137 container1.EXPECT().RemoveCssClass("toast-vim-mode").Once()
1138 container1.EXPECT().SetVisible(false).Once()
1139
1140 app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{
1141 UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{Styling: entity.WorkspaceStylingConfig{
1142 ModeIndicatorToasterEnabled: true,
1143 }}},
1144 })}
1145 bw1 := &browserWindow{id: "win-1", modeToaster: toaster1}
1146 bw2 := &browserWindow{id: "win-2", modeToaster: toaster2}
1147
1148 app.updateModeIndicatorToaster(context.Background(), bw1, input.ModeVim)
1149 app.updateModeIndicatorToaster(context.Background(), bw2, input.ModeVim)
1150 app.updateModeIndicatorToaster(context.Background(), bw1, input.ModeNormal)
1151
1152 assert.False(t, toaster1.IsVisible())
1153 assert.True(t, toaster2.IsVisible(), "exiting one window must not hide another window's Vim Mode toast")
1154}
1155
1156func TestVimMode_RuntimePreferenceDisableHidesPersistentToaster(t *testing.T) {
1157 factory := mocks.NewMockWidgetFactory(t)
1158 toaster, container, label := newModeToasterForVimModeTest(t, factory)
1159 expectVimModeToastShown(container, label)
1160 container.EXPECT().RemoveCssClass("toast-custom").Once()
1161 container.EXPECT().RemoveCssClass("toast-vim-mode").Once()
1162 container.EXPECT().SetVisible(false).Once()
1163
1164 enabled := entity.RuntimeConfigSnapshot{
1165 UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{Styling: entity.WorkspaceStylingConfig{
1166 ModeIndicatorToasterEnabled: true,
1167 }}},
1168 }
1169 keyboardHandler := newKeyboardHandlerInVimMode(t)
1170 bw := &browserWindow{id: "win-1", modeToaster: toaster, keyboardHandler: keyboardHandler}
1171 app := &App{
1172 runtimeConfig: runtimeConfigStateFromSnapshotForTest(enabled),
1173 browserWindows: map[string]*browserWindow{bw.id: bw},
1174 }
1175
1176 app.updateModeIndicatorToaster(context.Background(), bw, input.ModeVim)
1177 require.True(t, toaster.IsVisible())
1178 app.applyRuntimeConfigChange(context.Background(), entity.RuntimeConfigSnapshot{})
1179
1180 assert.False(t, toaster.IsVisible(), "runtime preference disable must reconcile an existing persistent toast")
1181}
1182
1183func TestVimMode_RuntimePreferenceEnableShowsPersistentToaster(t *testing.T) {
1184 factory := mocks.NewMockWidgetFactory(t)
1185 toaster, container, label := newModeToasterForVimModeTest(t, factory)
1186 expectVimModeToastShown(container, label)
1187
1188 keyboardHandler := newKeyboardHandlerInVimMode(t)
1189 bw := &browserWindow{id: "win-1", modeToaster: toaster, keyboardHandler: keyboardHandler}
1190 app := &App{
1191 runtimeConfig: runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{}),
1192 browserWindows: map[string]*browserWindow{bw.id: bw},
1193 }
1194 enabled := entity.RuntimeConfigSnapshot{
1195 UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{Styling: entity.WorkspaceStylingConfig{
1196 ModeIndicatorToasterEnabled: true,
1197 }}},
1198 }
1199
1200 app.applyRuntimeConfigChange(context.Background(), enabled)
1201
1202 assert.True(t, toaster.IsVisible(), "runtime preference enable must reconcile active Vim Mode")
1203}
1204
1205// ============================================================================
1206// BorderManager — Vim mode must NOT use the global border overlay
1207// ============================================================================
1208
1209func TestVimMode_GlobalBorderOverlayStaysOff(t *testing.T) {
1210 mockFactory := mocks.NewMockWidgetFactory(t)
1211 mockBox := mocks.NewMockBoxWidget(t)
1212
1213 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBox).Once()
1214 mockBox.EXPECT().SetCanFocus(false).Once()
1215 mockBox.EXPECT().SetCanTarget(false).Once()
1216 mockBox.EXPECT().SetHexpand(true).Once()
1217 mockBox.EXPECT().SetVexpand(true).Once()
1218 mockBox.EXPECT().SetVisible(false).Once()
1219
1220 bm := focus.NewBorderManager(mockFactory)
1221
1222 mockBox.EXPECT().SetVisible(false).Once()
1223 bm.OnModeChange(context.Background(), input.ModeNormal, input.ModeVim)
1224
1225 mockBox.EXPECT().SetVisible(false).Once()
1226 bm.OnModeChange(context.Background(), input.ModeVim, input.ModeNormal)
1227}
1228
1229// ============================================================================
1230// Pulse Debounce
1231// ============================================================================
1232
1233func TestVimMode_Pulse_DebounceSkipsRapidConsecutiveCalls(t *testing.T) {
1234 f := newSingleWindowVimModeFixture(t)
1235 enterVimMode(t, f.app, f.overlay1A, f.bw1)
1236
1237 // Set up expectations for exactly ONE normal pulse.
1238 setUpNormalPulse(f.overlay1A)
1239
1240 // First call fires the pulse (debounce timer is zero).
1241 f.app.triggerVimModePulse(context.Background(), false)
1242
1243 // Second call within the same frame should be debounced — no
1244 // mock expectations set up for it, so the test fails if it fires.
1245 f.app.triggerVimModePulse(context.Background(), false)
1246}
1247
1248func TestVimMode_Pulse_DebounceAllowsSpacedCalls(t *testing.T) {
1249 // Verify that the debounce timer starts at zero on a fresh App,
1250 // so the first pulse after entering vim mode always fires.
1251 // The exit-and-re-enter path is covered by the
1252 // TestVimMode_Pulse_DebounceResetOnClearOwnership test below.
1253
1254 f := newSingleWindowVimModeFixture(t)
1255
1256 // Fresh app: debounce timer is zero.
1257 assert.True(t, f.app.vimModePulseLastTime.IsZero(),
1258 "fresh app should have zero pulse timer")
1259
1260 enterVimMode(t, f.app, f.overlay1A, f.bw1)
1261
1262 // Pulse #1 fires because timer is zero (time.Since(zero) is huge).
1263 setUpNormalPulse(f.overlay1A)
1264 f.app.triggerVimModePulse(context.Background(), false)
1265
1266 // Timer is now set, proving the first pulse registered.
1267 assert.False(t, f.app.vimModePulseLastTime.IsZero(),
1268 "pulse timer should be non-zero after first pulse")
1269}
1270
1271func TestVimMode_Pulse_DebounceResetOnClearOwnership(t *testing.T) {
1272 // Verify that clearVimModeOwnership resets the debounce timer
1273 // so a subsequent pulse in a new vim mode session is not skipped.
1274 // We check the timer state directly rather than triggering a second
1275 // pulse (which would need pulse-cycle alternation handling).
1276
1277 f := newSingleWindowVimModeFixture(t)
1278 enterVimMode(t, f.app, f.overlay1A, f.bw1)
1279
1280 // Pulse #1 fires and sets the timer.
1281 setUpNormalPulse(f.overlay1A)
1282 f.app.triggerVimModePulse(context.Background(), false)
1283 assert.False(t, f.app.vimModePulseLastTime.IsZero(),
1284 "pulse timer should be non-zero after first pulse")
1285
1286 // Clear ownership — this resets the debounce timer.
1287 expectVimModeAccentHidden(f.overlay1A)
1288 f.app.clearVimModeOwnership(context.Background(), f.bw1)
1289 assert.Empty(t, f.bw1.vimModePaneID)
1290
1291 // Debounce timer should now be back to zero.
1292 assert.True(t, f.app.vimModePulseLastTime.IsZero(),
1293 "pulse timer should be reset after clearVimModeOwnership")
1294}
1295
1296func TestVimMode_Pulse_DebounceFirstPulseAlwaysFires(t *testing.T) {
1297 // Even if triggerVimModePulse is called rapidly on a fresh
1298 // App (e.g., first keystroke after vim mode entry), the first
1299 // call must always fire. This is guaranteed by the zero value
1300 // of time.Time producing a time.Since result much larger than
1301 // the debounce interval.
1302 f := newSingleWindowVimModeFixture(t)
1303 enterVimMode(t, f.app, f.overlay1A, f.bw1)
1304
1305 // Rapid double pulse — only first should fire regardless of order.
1306 // Set up just one set of expectations.
1307 setUpNormalPulse(f.overlay1A)
1308 f.app.triggerVimModePulse(context.Background(), false)
1309 f.app.triggerVimModePulse(context.Background(), false)
1310}
1311
1312// ============================================================================
1313// UI Scale: Vim mode CSS uses relative (em) units
1314// ============================================================================
1315
1316func TestVimMode_CSSUsesEms(t *testing.T) {
1317 css := theme.GenerateCSS(theme.DefaultDarkPalette())
1318
1319 assert.Contains(t, css, "box-shadow: inset 0 0 0 0.125em",
1320 "vim mode overlay box-shadow should use em for scaling")
1321 assert.Contains(t, css, "0.2em",
1322 "vim mode overlay pulse glow radius uses em")
1323 assert.Contains(t, css, "0.26em",
1324 "vim mode overlay fast-pulse glow radius uses em")
1325 assert.NotContains(t, css, "vim-mode-indicator",
1326 "removed PAGE label styling must not remain")
1327}
1328
1329func TestVimMode_CSSPulseTimingUsesDefaultTransitionDuration(t *testing.T) {
1330 // The pulse animation durations should be derived from the
1331 // default transition duration (normal = 3x, fast = 6x).
1332 // Default = 120ms → normal = 360ms, fast = 720ms.
1333 css := theme.GenerateCSS(theme.DefaultDarkPalette())
1334
1335 assert.Contains(t, css, "360ms",
1336 "normal pulse duration should be 3x transition duration (360ms for 120ms base)")
1337 assert.Contains(t, css, "720ms",
1338 "fast pulse duration should be 6x transition duration (720ms for 120ms base)")
1339}
1340
1341func TestVimMode_CSSWithCustomTransitionDuration(t *testing.T) {
1342 // Custom transition duration (e.g., 200ms) should produce
1343 // adjusted pulse timings: normal = 600ms, fast = 1200ms.
1344 css := theme.GenerateCSSFullWithTiming(
1345 theme.DefaultDarkPalette(),
1346 1.0,
1347 theme.DefaultFontConfig(),
1348 theme.DefaultModeColors(),
1349 200,
1350 )
1351
1352 assert.Contains(t, css, "600ms",
1353 "normal pulse should be 3x custom transition duration (200ms -> 600ms)")
1354 assert.Contains(t, css, "1200ms",
1355 "fast pulse should be 6x custom transition duration (200ms -> 1200ms)")
1356}