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