feat(ui): label the Vim Mode toast from the page's caret and visual state
The page reports hints, caret, visual, and visual line modes; the toast follows them while key capture and the hidden legend stay in place. Escape is now forwarded to the page, which decides whether to step back or end.
- commit
- b7a17073ac6ec8f443f51f06ed9097ba87ad75c2
- author
- brice · · 2026-10-04 15:02 UTC
- committer
- brice · · 2026-10-10 08:27 UTC
- parent
- dfd29f7
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
7 files · +131 -15
M internal/ui/app.go +45 -2
| @@ -2309,12 +2309,52 @@ func (a *App) handleVimPageInteractionEnded(ctx context.Context, paneID entity.P | |||
| 2309 | 2309 | a.releaseVimPageKeys(ctx, bw, "page-reported-end") | |
| 2310 | 2310 | } | |
| 2311 | 2311 | ||
| 2312 | + | // handleVimPageModeChanged repaints the indicator when the page moves its | |
| 2313 | + | // interaction to another sub-mode (hints, caret, visual). Key capture and the | |
| 2314 | + | // legend suspension stay as they are. | |
| 2315 | + | func (a *App) handleVimPageModeChanged(ctx context.Context, paneID entity.PaneID, mode dto.VimPageMode) { | |
| 2316 | + | bw := a.browserWindowForAnyPane(paneID) | |
| 2317 | + | if bw == nil || bw.vimPageInteractionWebView == nil || a.contentCoord == nil { | |
| 2318 | + | return | |
| 2319 | + | } | |
| 2320 | + | if a.contentCoord.GetWebView(paneID) != bw.vimPageInteractionWebView { | |
| 2321 | + | return | |
| 2322 | + | } | |
| 2323 | + | a.showVimPageMode(ctx, bw, mode) | |
| 2324 | + | } | |
| 2325 | + | ||
| 2326 | + | // showVimPageMode paints the sub-mode label for the interaction owned by bw. | |
| 2327 | + | func (a *App) showVimPageMode(ctx context.Context, bw *browserWindow, mode dto.VimPageMode) { | |
| 2328 | + | label, ok := vimPageModeLabel(mode) | |
| 2329 | + | if !ok || bw == nil || bw.vimPageInteractionWebView == nil { | |
| 2330 | + | return | |
| 2331 | + | } | |
| 2332 | + | a.showVimModeToast(ctx, bw, label) | |
| 2333 | + | } | |
| 2334 | + | ||
| 2335 | + | // vimPageModeLabel names a page-reported sub-mode for the Vim Mode indicator. | |
| 2336 | + | func vimPageModeLabel(mode dto.VimPageMode) (string, bool) { | |
| 2337 | + | switch mode { | |
| 2338 | + | case dto.VimPageModeHints: | |
| 2339 | + | return "HINTS", true | |
| 2340 | + | case dto.VimPageModeCaret: | |
| 2341 | + | return "CARET", true | |
| 2342 | + | case dto.VimPageModeVisual: | |
| 2343 | + | return "VISUAL", true | |
| 2344 | + | case dto.VimPageModeVisualLine: | |
| 2345 | + | return "VISUAL LINE", true | |
| 2346 | + | default: | |
| 2347 | + | return "", false | |
| 2348 | + | } | |
| 2349 | + | } | |
| 2350 | + | ||
| 2312 | 2351 | // vimPageInteractionLabel names the key-capturing sub-mode shown in the Vim | |
| 2313 | - | // Mode indicator so users can tell hints or visual selection are active. | |
| 2352 | + | // Mode indicator so users can tell hints or visual selection are active. The | |
| 2353 | + | // page refines the visual flow's label as it reports sub-mode changes. | |
| 2314 | 2354 | func vimPageInteractionLabel(action string) string { | |
| 2315 | 2355 | switch action { | |
| 2316 | 2356 | case "visual": | |
| 2317 | - | return "VISUAL" | |
| 2357 | + | return "HINTS" | |
| 2318 | 2358 | case "hint-follow-new": | |
| 2319 | 2359 | return "HINTS · NEW PANE" | |
| 2320 | 2360 | case "hint-yank-url": | |
| @@ -3522,6 +3562,9 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 3522 | 3562 | a.contentCoord.SetOnVimPageInteractionEnded(func(paneID entity.PaneID) { | |
| 3523 | 3563 | a.handleVimPageInteractionEnded(ctx, paneID) | |
| 3524 | 3564 | }) | |
| 3565 | + | a.contentCoord.SetOnVimPageModeChanged(func(paneID entity.PaneID, mode dto.VimPageMode) { | |
| 3566 | + | a.handleVimPageModeChanged(ctx, paneID, mode) | |
| 3567 | + | }) | |
| 3525 | 3568 | ||
| 3526 | 3569 | // JS dialogs are shown in the owning pane's overlay, wherever that pane lives. | |
| 3527 | 3570 | a.contentCoord.SetPaneViewResolver(a.paneViewForPane) | |
| @@ -2309,12 +2309,52 @@ func (a *App) handleVimPageInteractionEnded(ctx context.Context, paneID entity.P | |||||
| 2309 | a.releaseVimPageKeys(ctx, bw, "page-reported-end") | 2309 | a.releaseVimPageKeys(ctx, bw, "page-reported-end") | ||
| 2310 | } | 2310 | } | ||
| 2311 | 2311 | ||||
| 2312 | + | // handleVimPageModeChanged repaints the indicator when the page moves its | |||
| 2313 | + | // interaction to another sub-mode (hints, caret, visual). Key capture and the | |||
| 2314 | + | // legend suspension stay as they are. | |||
| 2315 | + | func (a *App) handleVimPageModeChanged(ctx context.Context, paneID entity.PaneID, mode dto.VimPageMode) { | |||
| 2316 | + | bw := a.browserWindowForAnyPane(paneID) | |||
| 2317 | + | if bw == nil || bw.vimPageInteractionWebView == nil || a.contentCoord == nil { | |||
| 2318 | + | return | |||
| 2319 | + | } | |||
| 2320 | + | if a.contentCoord.GetWebView(paneID) != bw.vimPageInteractionWebView { | |||
| 2321 | + | return | |||
| 2322 | + | } | |||
| 2323 | + | a.showVimPageMode(ctx, bw, mode) | |||
| 2324 | + | } | |||
| 2325 | + | ||||
| 2326 | + | // showVimPageMode paints the sub-mode label for the interaction owned by bw. | |||
| 2327 | + | func (a *App) showVimPageMode(ctx context.Context, bw *browserWindow, mode dto.VimPageMode) { | |||
| 2328 | + | label, ok := vimPageModeLabel(mode) | |||
| 2329 | + | if !ok || bw == nil || bw.vimPageInteractionWebView == nil { | |||
| 2330 | + | return | |||
| 2331 | + | } | |||
| 2332 | + | a.showVimModeToast(ctx, bw, label) | |||
| 2333 | + | } | |||
| 2334 | + | ||||
| 2335 | + | // vimPageModeLabel names a page-reported sub-mode for the Vim Mode indicator. | |||
| 2336 | + | func vimPageModeLabel(mode dto.VimPageMode) (string, bool) { | |||
| 2337 | + | switch mode { | |||
| 2338 | + | case dto.VimPageModeHints: | |||
| 2339 | + | return "HINTS", true | |||
| 2340 | + | case dto.VimPageModeCaret: | |||
| 2341 | + | return "CARET", true | |||
| 2342 | + | case dto.VimPageModeVisual: | |||
| 2343 | + | return "VISUAL", true | |||
| 2344 | + | case dto.VimPageModeVisualLine: | |||
| 2345 | + | return "VISUAL LINE", true | |||
| 2346 | + | default: | |||
| 2347 | + | return "", false | |||
| 2348 | + | } | |||
| 2349 | + | } | |||
| 2350 | + | ||||
| 2312 | // vimPageInteractionLabel names the key-capturing sub-mode shown in the Vim | 2351 | // vimPageInteractionLabel names the key-capturing sub-mode shown in the Vim | ||
| 2313 | - | // Mode indicator so users can tell hints or visual selection are active. | 2352 | + | // Mode indicator so users can tell hints or visual selection are active. The |
| 2353 | + | // page refines the visual flow's label as it reports sub-mode changes. | |||
| 2314 | func vimPageInteractionLabel(action string) string { | 2354 | func vimPageInteractionLabel(action string) string { | ||
| 2315 | switch action { | 2355 | switch action { | ||
| 2316 | case "visual": | 2356 | case "visual": | ||
| 2317 | - | return "VISUAL" | 2357 | + | return "HINTS" |
| 2318 | case "hint-follow-new": | 2358 | case "hint-follow-new": | ||
| 2319 | return "HINTS · NEW PANE" | 2359 | return "HINTS · NEW PANE" | ||
| 2320 | case "hint-yank-url": | 2360 | case "hint-yank-url": | ||
| @@ -3522,6 +3562,9 @@ func (a *App) initCoordinators(ctx context.Context) { | |||||
| 3522 | a.contentCoord.SetOnVimPageInteractionEnded(func(paneID entity.PaneID) { | 3562 | a.contentCoord.SetOnVimPageInteractionEnded(func(paneID entity.PaneID) { | ||
| 3523 | a.handleVimPageInteractionEnded(ctx, paneID) | 3563 | a.handleVimPageInteractionEnded(ctx, paneID) | ||
| 3524 | }) | 3564 | }) | ||
| 3565 | + | a.contentCoord.SetOnVimPageModeChanged(func(paneID entity.PaneID, mode dto.VimPageMode) { | |||
| 3566 | + | a.handleVimPageModeChanged(ctx, paneID, mode) | |||
| 3567 | + | }) | |||
| 3525 | 3568 | ||||
| 3526 | // JS dialogs are shown in the owning pane's overlay, wherever that pane lives. | 3569 | // JS dialogs are shown in the owning pane's overlay, wherever that pane lives. | ||
| 3527 | a.contentCoord.SetPaneViewResolver(a.paneViewForPane) | 3570 | a.contentCoord.SetPaneViewResolver(a.paneViewForPane) | ||
M internal/ui/app_vim_page_test.go +34 -0
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | "github.com/stretchr/testify/mock" | |
| 8 | 8 | "github.com/stretchr/testify/require" | |
| 9 | 9 | ||
| 10 | + | "github.com/bnema/dumber/internal/application/dto" | |
| 10 | 11 | portmocks "github.com/bnema/dumber/internal/application/port/mocks" | |
| 11 | 12 | "github.com/bnema/dumber/internal/domain/entity" | |
| 12 | 13 | "github.com/bnema/dumber/internal/ui/input" | |
| @@ -87,3 +88,36 @@ func TestVimPageInteractionHidesLegendWhileCaptured(t *testing.T) { | |||
| 87 | 88 | require.False(t, frame.suspended, "legend must be restored when the interaction ends") | |
| 88 | 89 | require.False(t, bw.keyboardHandler.PageKeyCaptureActive()) | |
| 89 | 90 | } | |
| 91 | + | ||
| 92 | + | // The page moves between hints, caret, visual and visual line without ending | |
| 93 | + | // the interaction: the toast follows each state while key capture and the | |
| 94 | + | // suspended legend stay in place. | |
| 95 | + | func TestVimPageModeChangesRepaintToastWithoutReleasingCapture(t *testing.T) { | |
| 96 | + | app, bw, shown := newVimPageTestWindow(t) | |
| 97 | + | frame := &modeFrame{mode: input.ModeVim, visible: true} | |
| 98 | + | bw.modeFrame = frame | |
| 99 | + | wv := portmocks.NewMockWebView(t) | |
| 100 | + | app.captureVimPageKeys(context.Background(), bw, wv, vimPageInteractionLabel("visual")) | |
| 101 | + | require.Equal(t, "VIM MODE · HINTS", (*shown)[len(*shown)-1]) | |
| 102 | + | ||
| 103 | + | for mode, want := range map[dto.VimPageMode]string{ | |
| 104 | + | dto.VimPageModeCaret: "VIM MODE · CARET", | |
| 105 | + | dto.VimPageModeVisual: "VIM MODE · VISUAL", | |
| 106 | + | dto.VimPageModeVisualLine: "VIM MODE · VISUAL LINE", | |
| 107 | + | dto.VimPageModeHints: "VIM MODE · HINTS", | |
| 108 | + | } { | |
| 109 | + | app.showVimPageMode(context.Background(), bw, mode) | |
| 110 | + | require.Equal(t, want, (*shown)[len(*shown)-1]) | |
| 111 | + | require.True(t, bw.keyboardHandler.PageKeyCaptureActive(), "mode %s must not release key capture", mode) | |
| 112 | + | require.NotNil(t, bw.vimPageInteractionWebView) | |
| 113 | + | require.True(t, frame.suspended, "legend stays hidden while the page owns the keys") | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | func TestVimPageModeChangeIgnoredWithoutInteraction(t *testing.T) { | |
| 118 | + | app, bw, shown := newVimPageTestWindow(t) | |
| 119 | + | ||
| 120 | + | app.showVimPageMode(context.Background(), bw, dto.VimPageModeCaret) | |
| 121 | + | ||
| 122 | + | require.Empty(t, *shown, "a late mode report must not repaint the indicator once the interaction ended") | |
| 123 | + | } | |
| @@ -7,6 +7,7 @@ import ( | |||||
| 7 | "github.com/stretchr/testify/mock" | 7 | "github.com/stretchr/testify/mock" | ||
| 8 | "github.com/stretchr/testify/require" | 8 | "github.com/stretchr/testify/require" | ||
| 9 | 9 | ||||
| 10 | + | "github.com/bnema/dumber/internal/application/dto" | |||
| 10 | portmocks "github.com/bnema/dumber/internal/application/port/mocks" | 11 | portmocks "github.com/bnema/dumber/internal/application/port/mocks" | ||
| 11 | "github.com/bnema/dumber/internal/domain/entity" | 12 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 12 | "github.com/bnema/dumber/internal/ui/input" | 13 | "github.com/bnema/dumber/internal/ui/input" | ||
| @@ -87,3 +88,36 @@ func TestVimPageInteractionHidesLegendWhileCaptured(t *testing.T) { | |||||
| 87 | require.False(t, frame.suspended, "legend must be restored when the interaction ends") | 88 | require.False(t, frame.suspended, "legend must be restored when the interaction ends") | ||
| 88 | require.False(t, bw.keyboardHandler.PageKeyCaptureActive()) | 89 | require.False(t, bw.keyboardHandler.PageKeyCaptureActive()) | ||
| 89 | } | 90 | } | ||
| 91 | + | ||||
| 92 | + | // The page moves between hints, caret, visual and visual line without ending | |||
| 93 | + | // the interaction: the toast follows each state while key capture and the | |||
| 94 | + | // suspended legend stay in place. | |||
| 95 | + | func TestVimPageModeChangesRepaintToastWithoutReleasingCapture(t *testing.T) { | |||
| 96 | + | app, bw, shown := newVimPageTestWindow(t) | |||
| 97 | + | frame := &modeFrame{mode: input.ModeVim, visible: true} | |||
| 98 | + | bw.modeFrame = frame | |||
| 99 | + | wv := portmocks.NewMockWebView(t) | |||
| 100 | + | app.captureVimPageKeys(context.Background(), bw, wv, vimPageInteractionLabel("visual")) | |||
| 101 | + | require.Equal(t, "VIM MODE · HINTS", (*shown)[len(*shown)-1]) | |||
| 102 | + | ||||
| 103 | + | for mode, want := range map[dto.VimPageMode]string{ | |||
| 104 | + | dto.VimPageModeCaret: "VIM MODE · CARET", | |||
| 105 | + | dto.VimPageModeVisual: "VIM MODE · VISUAL", | |||
| 106 | + | dto.VimPageModeVisualLine: "VIM MODE · VISUAL LINE", | |||
| 107 | + | dto.VimPageModeHints: "VIM MODE · HINTS", | |||
| 108 | + | } { | |||
| 109 | + | app.showVimPageMode(context.Background(), bw, mode) | |||
| 110 | + | require.Equal(t, want, (*shown)[len(*shown)-1]) | |||
| 111 | + | require.True(t, bw.keyboardHandler.PageKeyCaptureActive(), "mode %s must not release key capture", mode) | |||
| 112 | + | require.NotNil(t, bw.vimPageInteractionWebView) | |||
| 113 | + | require.True(t, frame.suspended, "legend stays hidden while the page owns the keys") | |||
| 114 | + | } | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | func TestVimPageModeChangeIgnoredWithoutInteraction(t *testing.T) { | |||
| 118 | + | app, bw, shown := newVimPageTestWindow(t) | |||
| 119 | + | ||||
| 120 | + | app.showVimPageMode(context.Background(), bw, dto.VimPageModeCaret) | |||
| 121 | + | ||||
| 122 | + | require.Empty(t, *shown, "a late mode report must not repaint the indicator once the interaction ended") | |||
| 123 | + | } | |||
M internal/ui/coordinator/content/callbacks.go +16 -5
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "net/url" | |
| 6 | 6 | "strings" | |
| 7 | 7 | ||
| 8 | + | "github.com/bnema/dumber/internal/application/dto" | |
| 8 | 9 | "github.com/bnema/dumber/internal/application/port" | |
| 9 | 10 | "github.com/bnema/dumber/internal/application/usecase" | |
| 10 | 11 | "github.com/bnema/dumber/internal/domain/entity" | |
| @@ -187,11 +188,7 @@ func (c *Coordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.P | |||
| 187 | 188 | } | |
| 188 | 189 | } | |
| 189 | 190 | ||
| 190 | - | callbacks.OnVimPageInteractionEnded = func() { | |
| 191 | - | if c.onVimPageInteractionEnded != nil { | |
| 192 | - | c.onVimPageInteractionEnded(paneID) | |
| 193 | - | } | |
| 194 | - | } | |
| 191 | + | c.setupVimPageCallbacks(paneID, callbacks) | |
| 195 | 192 | ||
| 196 | 193 | // Add popup create handler if popup handling is configured | |
| 197 | 194 | callbacks.OnCreate = c.buildPopupCreateHandler(ctx, paneID, wv) | |
| @@ -199,6 +196,20 @@ func (c *Coordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.P | |||
| 199 | 196 | wv.SetCallbacks(callbacks) | |
| 200 | 197 | } | |
| 201 | 198 | ||
| 199 | + | // setupVimPageCallbacks forwards in-page Vim interaction events for one pane. | |
| 200 | + | func (c *Coordinator) setupVimPageCallbacks(paneID entity.PaneID, callbacks *port.WebViewCallbacks) { | |
| 201 | + | callbacks.OnVimPageInteractionEnded = func() { | |
| 202 | + | if c.onVimPageInteractionEnded != nil { | |
| 203 | + | c.onVimPageInteractionEnded(paneID) | |
| 204 | + | } | |
| 205 | + | } | |
| 206 | + | callbacks.OnVimPageModeChanged = func(mode dto.VimPageMode) { | |
| 207 | + | if c.onVimPageModeChanged != nil { | |
| 208 | + | c.onVimPageModeChanged(paneID, mode) | |
| 209 | + | } | |
| 210 | + | } | |
| 211 | + | } | |
| 212 | + | ||
| 202 | 213 | // handlePermissionRequest processes media permission requests from WebKit. | |
| 203 | 214 | // It delegates to the permission use case which handles auto-allow, stored permissions, and dialogs. | |
| 204 | 215 | func (c *Coordinator) handlePermissionRequest( | |
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "net/url" | 5 | "net/url" | ||
| 6 | "strings" | 6 | "strings" | ||
| 7 | 7 | ||||
| 8 | + | "github.com/bnema/dumber/internal/application/dto" | |||
| 8 | "github.com/bnema/dumber/internal/application/port" | 9 | "github.com/bnema/dumber/internal/application/port" | ||
| 9 | "github.com/bnema/dumber/internal/application/usecase" | 10 | "github.com/bnema/dumber/internal/application/usecase" | ||
| 10 | "github.com/bnema/dumber/internal/domain/entity" | 11 | "github.com/bnema/dumber/internal/domain/entity" | ||
| @@ -187,11 +188,7 @@ func (c *Coordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.P | |||||
| 187 | } | 188 | } | ||
| 188 | } | 189 | } | ||
| 189 | 190 | ||||
| 190 | - | callbacks.OnVimPageInteractionEnded = func() { | 191 | + | c.setupVimPageCallbacks(paneID, callbacks) |
| 191 | - | if c.onVimPageInteractionEnded != nil { | |||
| 192 | - | c.onVimPageInteractionEnded(paneID) | |||
| 193 | - | } | |||
| 194 | - | } | |||
| 195 | 192 | ||||
| 196 | // Add popup create handler if popup handling is configured | 193 | // Add popup create handler if popup handling is configured | ||
| 197 | callbacks.OnCreate = c.buildPopupCreateHandler(ctx, paneID, wv) | 194 | callbacks.OnCreate = c.buildPopupCreateHandler(ctx, paneID, wv) | ||
| @@ -199,6 +196,20 @@ func (c *Coordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.P | |||||
| 199 | wv.SetCallbacks(callbacks) | 196 | wv.SetCallbacks(callbacks) | ||
| 200 | } | 197 | } | ||
| 201 | 198 | ||||
| 199 | + | // setupVimPageCallbacks forwards in-page Vim interaction events for one pane. | |||
| 200 | + | func (c *Coordinator) setupVimPageCallbacks(paneID entity.PaneID, callbacks *port.WebViewCallbacks) { | |||
| 201 | + | callbacks.OnVimPageInteractionEnded = func() { | |||
| 202 | + | if c.onVimPageInteractionEnded != nil { | |||
| 203 | + | c.onVimPageInteractionEnded(paneID) | |||
| 204 | + | } | |||
| 205 | + | } | |||
| 206 | + | callbacks.OnVimPageModeChanged = func(mode dto.VimPageMode) { | |||
| 207 | + | if c.onVimPageModeChanged != nil { | |||
| 208 | + | c.onVimPageModeChanged(paneID, mode) | |||
| 209 | + | } | |||
| 210 | + | } | |||
| 211 | + | } | |||
| 212 | + | ||||
| 202 | // handlePermissionRequest processes media permission requests from WebKit. | 213 | // handlePermissionRequest processes media permission requests from WebKit. | ||
| 203 | // It delegates to the permission use case which handles auto-allow, stored permissions, and dialogs. | 214 | // It delegates to the permission use case which handles auto-allow, stored permissions, and dialogs. | ||
| 204 | func (c *Coordinator) handlePermissionRequest( | 215 | func (c *Coordinator) handlePermissionRequest( | ||
M internal/ui/coordinator/content/coordinator.go +9 -0
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "sync" | |
| 6 | 6 | ||
| 7 | + | "github.com/bnema/dumber/internal/application/dto" | |
| 7 | 8 | "github.com/bnema/dumber/internal/application/port" | |
| 8 | 9 | "github.com/bnema/dumber/internal/application/usecase" | |
| 9 | 10 | "github.com/bnema/dumber/internal/domain/entity" | |
| @@ -123,6 +124,8 @@ type Coordinator struct { | |||
| 123 | 124 | onEditableFocusChanged func(paneID entity.PaneID, editable bool) | |
| 124 | 125 | // onVimPageInteractionEnded fires when a page finishes link hints or visual selection. | |
| 125 | 126 | onVimPageInteractionEnded func(paneID entity.PaneID) | |
| 127 | + | // onVimPageModeChanged fires when a page interaction moves to another sub-mode. | |
| 128 | + | onVimPageModeChanged func(paneID entity.PaneID, mode dto.VimPageMode) | |
| 126 | 129 | ||
| 127 | 130 | // Callback for first load_started event (triggers deferred initialization) | |
| 128 | 131 | onFirstLoadStarted func() | |
| @@ -285,6 +288,12 @@ func (c *Coordinator) SetOnVimPageInteractionEnded(fn func(paneID entity.PaneID) | |||
| 285 | 288 | c.onVimPageInteractionEnded = fn | |
| 286 | 289 | } | |
| 287 | 290 | ||
| 291 | + | // SetOnVimPageModeChanged sets the callback for sub-mode changes of an | |
| 292 | + | // in-page Vim interaction (hints, caret, visual). | |
| 293 | + | func (c *Coordinator) SetOnVimPageModeChanged(fn func(paneID entity.PaneID, mode dto.VimPageMode)) { | |
| 294 | + | c.onVimPageModeChanged = fn | |
| 295 | + | } | |
| 296 | + | ||
| 288 | 297 | // SetOnFirstLoadStarted sets the callback for when the first navigation starts. | |
| 289 | 298 | // This is used to trigger deferred initialization after the initial load_uri() | |
| 290 | 299 | // has been processed by the GTK main loop. | |
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "sync" | 5 | "sync" | ||
| 6 | 6 | ||||
| 7 | + | "github.com/bnema/dumber/internal/application/dto" | |||
| 7 | "github.com/bnema/dumber/internal/application/port" | 8 | "github.com/bnema/dumber/internal/application/port" | ||
| 8 | "github.com/bnema/dumber/internal/application/usecase" | 9 | "github.com/bnema/dumber/internal/application/usecase" | ||
| 9 | "github.com/bnema/dumber/internal/domain/entity" | 10 | "github.com/bnema/dumber/internal/domain/entity" | ||
| @@ -123,6 +124,8 @@ type Coordinator struct { | |||||
| 123 | onEditableFocusChanged func(paneID entity.PaneID, editable bool) | 124 | onEditableFocusChanged func(paneID entity.PaneID, editable bool) | ||
| 124 | // onVimPageInteractionEnded fires when a page finishes link hints or visual selection. | 125 | // onVimPageInteractionEnded fires when a page finishes link hints or visual selection. | ||
| 125 | onVimPageInteractionEnded func(paneID entity.PaneID) | 126 | onVimPageInteractionEnded func(paneID entity.PaneID) | ||
| 127 | + | // onVimPageModeChanged fires when a page interaction moves to another sub-mode. | |||
| 128 | + | onVimPageModeChanged func(paneID entity.PaneID, mode dto.VimPageMode) | |||
| 126 | 129 | ||||
| 127 | // Callback for first load_started event (triggers deferred initialization) | 130 | // Callback for first load_started event (triggers deferred initialization) | ||
| 128 | onFirstLoadStarted func() | 131 | onFirstLoadStarted func() | ||
| @@ -285,6 +288,12 @@ func (c *Coordinator) SetOnVimPageInteractionEnded(fn func(paneID entity.PaneID) | |||||
| 285 | c.onVimPageInteractionEnded = fn | 288 | c.onVimPageInteractionEnded = fn | ||
| 286 | } | 289 | } | ||
| 287 | 290 | ||||
| 291 | + | // SetOnVimPageModeChanged sets the callback for sub-mode changes of an | |||
| 292 | + | // in-page Vim interaction (hints, caret, visual). | |||
| 293 | + | func (c *Coordinator) SetOnVimPageModeChanged(fn func(paneID entity.PaneID, mode dto.VimPageMode)) { | |||
| 294 | + | c.onVimPageModeChanged = fn | |||
| 295 | + | } | |||
| 296 | + | ||||
| 288 | // SetOnFirstLoadStarted sets the callback for when the first navigation starts. | 297 | // SetOnFirstLoadStarted sets the callback for when the first navigation starts. | ||
| 289 | // This is used to trigger deferred initialization after the initial load_uri() | 298 | // This is used to trigger deferred initialization after the initial load_uri() | ||
| 290 | // has been processed by the GTK main loop. | 299 | // has been processed by the GTK main loop. | ||
M internal/ui/input/vim_page_capture.go +5 -4
| @@ -36,8 +36,10 @@ func (h *KeyboardHandler) PageKeyCaptureActive() bool { | |||
| 36 | 36 | } | |
| 37 | 37 | ||
| 38 | 38 | // forwardPageKey sends a Vim Mode key to the active page capture. Escape is | |
| 39 | - | // forwarded and also releases the capture locally so a page that never | |
| 40 | - | // reports completion cannot trap the keyboard. | |
| 39 | + | // forwarded like any other key: the page decides whether it steps back (visual | |
| 40 | + | // to caret) or ends the interaction, and reports the end so the capture is | |
| 41 | + | // released. A page that cannot be reached ends the capture through the | |
| 42 | + | // forwarder's error path, navigation, or leaving Vim Mode. | |
| 41 | 43 | func (h *KeyboardHandler) forwardPageKey(mode Mode, keyval uint, state gdk.ModifierType) bool { | |
| 42 | 44 | if mode != ModeVim { | |
| 43 | 45 | return false | |
| @@ -56,9 +58,8 @@ func (h *KeyboardHandler) forwardPageKey(mode Mode, keyval uint, state gdk.Modif | |||
| 56 | 58 | return true | |
| 57 | 59 | } | |
| 58 | 60 | if key.Sym == "Esc" { | |
| 59 | - | // Any Escape ends the interaction, so the page must see a plain one. | |
| 61 | + | // The page must see a plain Escape whatever modifiers are held. | |
| 60 | 62 | key.Mods = 0 | |
| 61 | - | h.ClearPageKeyCapture() | |
| 62 | 63 | } | |
| 63 | 64 | forward(vimkeys.Sequence{key}.String()) | |
| 64 | 65 | // Captured keys bypass dispatchAction, so keep Vim Mode's timeout alive. | |
| @@ -36,8 +36,10 @@ func (h *KeyboardHandler) PageKeyCaptureActive() bool { | |||||
| 36 | } | 36 | } | ||
| 37 | 37 | ||||
| 38 | // forwardPageKey sends a Vim Mode key to the active page capture. Escape is | 38 | // forwardPageKey sends a Vim Mode key to the active page capture. Escape is | ||
| 39 | - | // forwarded and also releases the capture locally so a page that never | 39 | + | // forwarded like any other key: the page decides whether it steps back (visual |
| 40 | - | // reports completion cannot trap the keyboard. | 40 | + | // to caret) or ends the interaction, and reports the end so the capture is |
| 41 | + | // released. A page that cannot be reached ends the capture through the | |||
| 42 | + | // forwarder's error path, navigation, or leaving Vim Mode. | |||
| 41 | func (h *KeyboardHandler) forwardPageKey(mode Mode, keyval uint, state gdk.ModifierType) bool { | 43 | func (h *KeyboardHandler) forwardPageKey(mode Mode, keyval uint, state gdk.ModifierType) bool { | ||
| 42 | if mode != ModeVim { | 44 | if mode != ModeVim { | ||
| 43 | return false | 45 | return false | ||
| @@ -56,9 +58,8 @@ func (h *KeyboardHandler) forwardPageKey(mode Mode, keyval uint, state gdk.Modif | |||||
| 56 | return true | 58 | return true | ||
| 57 | } | 59 | } | ||
| 58 | if key.Sym == "Esc" { | 60 | if key.Sym == "Esc" { | ||
| 59 | - | // Any Escape ends the interaction, so the page must see a plain one. | 61 | + | // The page must see a plain Escape whatever modifiers are held. |
| 60 | key.Mods = 0 | 62 | key.Mods = 0 | ||
| 61 | - | h.ClearPageKeyCapture() | |||
| 62 | } | 63 | } | ||
| 63 | forward(vimkeys.Sequence{key}.String()) | 64 | forward(vimkeys.Sequence{key}.String()) | ||
| 64 | // Captured keys bypass dispatchAction, so keep Vim Mode's timeout alive. | 65 | // Captured keys bypass dispatchAction, so keep Vim Mode's timeout alive. | ||
M internal/ui/input/vim_page_capture_test.go +5 -3
| @@ -34,7 +34,7 @@ func TestPageKeyCaptureForwardsVimModeKeys(t *testing.T) { | |||
| 34 | 34 | assert.Empty(t, h.PendingSequence()) | |
| 35 | 35 | } | |
| 36 | 36 | ||
| 37 | - | func TestPageKeyCaptureEscapeReleasesCaptureAndStaysInVimMode(t *testing.T) { | |
| 37 | + | func TestPageKeyCaptureEscapeIsForwardedAndKeepsCaptureForPageToEnd(t *testing.T) { | |
| 38 | 38 | h := NewKeyboardHandler(context.Background(), vimModeSequenceWorkspace(nil), newTestSession()) | |
| 39 | 39 | enterVimMode(t, h) | |
| 40 | 40 | var forwarded []string | |
| @@ -43,8 +43,11 @@ func TestPageKeyCaptureEscapeReleasesCaptureAndStaysInVimMode(t *testing.T) { | |||
| 43 | 43 | require.True(t, h.handleKeyPress(uint(gdk.KEY_Escape), 0, 0)) | |
| 44 | 44 | ||
| 45 | 45 | assert.Equal(t, []string{"<Escape>"}, forwarded) | |
| 46 | - | assert.False(t, h.PageKeyCaptureActive()) | |
| 46 | + | assert.True(t, h.PageKeyCaptureActive(), "the page steps back or reports the end itself") | |
| 47 | 47 | assert.Equal(t, ModeVim, h.Mode()) | |
| 48 | + | ||
| 49 | + | h.ClearPageKeyCapture() | |
| 50 | + | assert.False(t, h.PageKeyCaptureActive()) | |
| 48 | 51 | } | |
| 49 | 52 | ||
| 50 | 53 | func TestPageKeyCaptureClearedWhenLeavingVimMode(t *testing.T) { | |
| @@ -66,7 +69,6 @@ func TestPageKeyCaptureModifiedEscapeForwardsPlainEscape(t *testing.T) { | |||
| 66 | 69 | require.True(t, h.handleKeyPress(uint(gdk.KEY_Escape), 0, gdk.ShiftMaskValue)) | |
| 67 | 70 | ||
| 68 | 71 | assert.Equal(t, []string{"<Escape>"}, forwarded) | |
| 69 | - | assert.False(t, h.PageKeyCaptureActive()) | |
| 70 | 72 | } | |
| 71 | 73 | ||
| 72 | 74 | // Capturing keys for the page must not report a cleared pending sequence: | |
| @@ -34,7 +34,7 @@ func TestPageKeyCaptureForwardsVimModeKeys(t *testing.T) { | |||||
| 34 | assert.Empty(t, h.PendingSequence()) | 34 | assert.Empty(t, h.PendingSequence()) | ||
| 35 | } | 35 | } | ||
| 36 | 36 | ||||
| 37 | - | func TestPageKeyCaptureEscapeReleasesCaptureAndStaysInVimMode(t *testing.T) { | 37 | + | func TestPageKeyCaptureEscapeIsForwardedAndKeepsCaptureForPageToEnd(t *testing.T) { |
| 38 | h := NewKeyboardHandler(context.Background(), vimModeSequenceWorkspace(nil), newTestSession()) | 38 | h := NewKeyboardHandler(context.Background(), vimModeSequenceWorkspace(nil), newTestSession()) | ||
| 39 | enterVimMode(t, h) | 39 | enterVimMode(t, h) | ||
| 40 | var forwarded []string | 40 | var forwarded []string | ||
| @@ -43,8 +43,11 @@ func TestPageKeyCaptureEscapeReleasesCaptureAndStaysInVimMode(t *testing.T) { | |||||
| 43 | require.True(t, h.handleKeyPress(uint(gdk.KEY_Escape), 0, 0)) | 43 | require.True(t, h.handleKeyPress(uint(gdk.KEY_Escape), 0, 0)) | ||
| 44 | 44 | ||||
| 45 | assert.Equal(t, []string{"<Escape>"}, forwarded) | 45 | assert.Equal(t, []string{"<Escape>"}, forwarded) | ||
| 46 | - | assert.False(t, h.PageKeyCaptureActive()) | 46 | + | assert.True(t, h.PageKeyCaptureActive(), "the page steps back or reports the end itself") |
| 47 | assert.Equal(t, ModeVim, h.Mode()) | 47 | assert.Equal(t, ModeVim, h.Mode()) | ||
| 48 | + | ||||
| 49 | + | h.ClearPageKeyCapture() | |||
| 50 | + | assert.False(t, h.PageKeyCaptureActive()) | |||
| 48 | } | 51 | } | ||
| 49 | 52 | ||||
| 50 | func TestPageKeyCaptureClearedWhenLeavingVimMode(t *testing.T) { | 53 | func TestPageKeyCaptureClearedWhenLeavingVimMode(t *testing.T) { | ||
| @@ -66,7 +69,6 @@ func TestPageKeyCaptureModifiedEscapeForwardsPlainEscape(t *testing.T) { | |||||
| 66 | require.True(t, h.handleKeyPress(uint(gdk.KEY_Escape), 0, gdk.ShiftMaskValue)) | 69 | require.True(t, h.handleKeyPress(uint(gdk.KEY_Escape), 0, gdk.ShiftMaskValue)) | ||
| 67 | 70 | ||||
| 68 | assert.Equal(t, []string{"<Escape>"}, forwarded) | 71 | assert.Equal(t, []string{"<Escape>"}, forwarded) | ||
| 69 | - | assert.False(t, h.PageKeyCaptureActive()) | |||
| 70 | } | 72 | } | ||
| 71 | 73 | ||||
| 72 | // Capturing keys for the page must not report a cleared pending sequence: | 74 | // Capturing keys for the page must not report a cleared pending sequence: | ||
M internal/ui/mode_frame_test.go +17 -1
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package ui | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "github.com/bnema/dumber/internal/application/dto" | |
| 4 | 5 | "testing" | |
| 5 | 6 | ||
| 6 | 7 | "github.com/bnema/dumber/internal/domain/entity" | |
| @@ -157,8 +158,23 @@ func TestShouldStartLinger(t *testing.T) { | |||
| 157 | 158 | require.False(t, shouldStartLinger(false, false, true, true, 15, 25, rect)) | |
| 158 | 159 | } | |
| 159 | 160 | ||
| 161 | + | func TestVimPageModeLabel(t *testing.T) { | |
| 162 | + | for mode, want := range map[dto.VimPageMode]string{ | |
| 163 | + | dto.VimPageModeHints: "HINTS", | |
| 164 | + | dto.VimPageModeCaret: "CARET", | |
| 165 | + | dto.VimPageModeVisual: "VISUAL", | |
| 166 | + | dto.VimPageModeVisualLine: "VISUAL LINE", | |
| 167 | + | } { | |
| 168 | + | got, ok := vimPageModeLabel(mode) | |
| 169 | + | require.True(t, ok, mode) | |
| 170 | + | require.Equal(t, want, got) | |
| 171 | + | } | |
| 172 | + | _, ok := vimPageModeLabel("bogus") | |
| 173 | + | require.False(t, ok) | |
| 174 | + | } | |
| 175 | + | ||
| 160 | 176 | func TestVimPageInteractionLabel(t *testing.T) { | |
| 161 | - | require.Equal(t, "VISUAL", vimPageInteractionLabel("visual")) | |
| 177 | + | require.Equal(t, "HINTS", vimPageInteractionLabel("visual"), "visual starts on text-anchor hints; the page reports later states") | |
| 162 | 178 | require.Equal(t, "HINTS", vimPageInteractionLabel("hint-follow")) | |
| 163 | 179 | require.Equal(t, "HINTS · NEW PANE", vimPageInteractionLabel("hint-follow-new")) | |
| 164 | 180 | require.Equal(t, "HINTS · YANK URL", vimPageInteractionLabel("hint-yank-url")) | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package ui | 1 | package ui | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "github.com/bnema/dumber/internal/application/dto" | |||
| 4 | "testing" | 5 | "testing" | ||
| 5 | 6 | ||||
| 6 | "github.com/bnema/dumber/internal/domain/entity" | 7 | "github.com/bnema/dumber/internal/domain/entity" | ||
| @@ -157,8 +158,23 @@ func TestShouldStartLinger(t *testing.T) { | |||||
| 157 | require.False(t, shouldStartLinger(false, false, true, true, 15, 25, rect)) | 158 | require.False(t, shouldStartLinger(false, false, true, true, 15, 25, rect)) | ||
| 158 | } | 159 | } | ||
| 159 | 160 | ||||
| 161 | + | func TestVimPageModeLabel(t *testing.T) { | |||
| 162 | + | for mode, want := range map[dto.VimPageMode]string{ | |||
| 163 | + | dto.VimPageModeHints: "HINTS", | |||
| 164 | + | dto.VimPageModeCaret: "CARET", | |||
| 165 | + | dto.VimPageModeVisual: "VISUAL", | |||
| 166 | + | dto.VimPageModeVisualLine: "VISUAL LINE", | |||
| 167 | + | } { | |||
| 168 | + | got, ok := vimPageModeLabel(mode) | |||
| 169 | + | require.True(t, ok, mode) | |||
| 170 | + | require.Equal(t, want, got) | |||
| 171 | + | } | |||
| 172 | + | _, ok := vimPageModeLabel("bogus") | |||
| 173 | + | require.False(t, ok) | |||
| 174 | + | } | |||
| 175 | + | ||||
| 160 | func TestVimPageInteractionLabel(t *testing.T) { | 176 | func TestVimPageInteractionLabel(t *testing.T) { | ||
| 161 | - | require.Equal(t, "VISUAL", vimPageInteractionLabel("visual")) | 177 | + | require.Equal(t, "HINTS", vimPageInteractionLabel("visual"), "visual starts on text-anchor hints; the page reports later states") |
| 162 | require.Equal(t, "HINTS", vimPageInteractionLabel("hint-follow")) | 178 | require.Equal(t, "HINTS", vimPageInteractionLabel("hint-follow")) | ||
| 163 | require.Equal(t, "HINTS · NEW PANE", vimPageInteractionLabel("hint-follow-new")) | 179 | require.Equal(t, "HINTS · NEW PANE", vimPageInteractionLabel("hint-follow-new")) | ||
| 164 | require.Equal(t, "HINTS · YANK URL", vimPageInteractionLabel("hint-yank-url")) | 180 | require.Equal(t, "HINTS · YANK URL", vimPageInteractionLabel("hint-yank-url")) | ||