refactor(ui): move the vim page capture lifecycle and derive labels from the outcome
Move capture, release, ended/mode handling and labels out of app.go into vim_page.go, share one owner lookup between the ended and mode handlers, and have the usecase outcome carry the interaction kind and initial sub-mode so a single mapping produces every indicator label. Wrap usecase errors with context.
- commit
- 75d2da2fbd8d889199883724364ac7fd2062151c
- author
- brice · · 2026-10-04 16:12 UTC
- committer
- brice · · 2026-10-10 08:27 UTC
- parent
- dfde6a8
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
8 files · +270 -169
M internal/application/dto/vim_page_interaction.go +5 -0
| @@ -78,4 +78,9 @@ type VimNavigationOutcome struct { | |||
| 78 | 78 | // CapturePageKeys asks the UI to forward keys to the page interaction until | |
| 79 | 79 | // the page reports that the interaction ended. | |
| 80 | 80 | CapturePageKeys bool | |
| 81 | + | // PageKind and PageMode name the key-capturing interaction and the | |
| 82 | + | // sub-mode it starts in, so the UI labels it without knowing actions. | |
| 83 | + | // Both are zero when CapturePageKeys is false. | |
| 84 | + | PageKind VimPageInteractionKind | |
| 85 | + | PageMode VimPageMode | |
| 81 | 86 | } | |
| @@ -78,4 +78,9 @@ type VimNavigationOutcome struct { | |||||
| 78 | // CapturePageKeys asks the UI to forward keys to the page interaction until | 78 | // CapturePageKeys asks the UI to forward keys to the page interaction until | ||
| 79 | // the page reports that the interaction ended. | 79 | // the page reports that the interaction ended. | ||
| 80 | CapturePageKeys bool | 80 | CapturePageKeys bool | ||
| 81 | + | // PageKind and PageMode name the key-capturing interaction and the | |||
| 82 | + | // sub-mode it starts in, so the UI labels it without knowing actions. | |||
| 83 | + | // Both are zero when CapturePageKeys is false. | |||
| 84 | + | PageKind VimPageInteractionKind | |||
| 85 | + | PageMode VimPageMode | |||
| 81 | } | 86 | } | ||
M internal/application/usecase/vim_navigation.go +16 -4
| @@ -3,6 +3,7 @@ package usecase | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | + | "fmt" | |
| 6 | 7 | ||
| 7 | 8 | "github.com/bnema/dumber/internal/application/dto" | |
| 8 | 9 | "github.com/bnema/dumber/internal/application/port" | |
| @@ -119,9 +120,14 @@ func (*VimNavigationUseCase) startPageInteraction( | |||
| 119 | 120 | return dto.VimNavigationOutcome{}, errUnsupportedVimNavigationEngine | |
| 120 | 121 | } | |
| 121 | 122 | if err := interactor.StartVimPageInteraction(ctx, request); err != nil { | |
| 122 | - | return dto.VimNavigationOutcome{}, err | |
| 123 | + | return dto.VimNavigationOutcome{}, fmt.Errorf("vim navigation: start page interaction: %w", err) | |
| 123 | 124 | } | |
| 124 | - | return dto.VimNavigationOutcome{CapturePageKeys: request.Kind.CapturesKeys()}, nil | |
| 125 | + | if !request.Kind.CapturesKeys() { | |
| 126 | + | return dto.VimNavigationOutcome{}, nil | |
| 127 | + | } | |
| 128 | + | // Every key-capturing interaction opens on text or link hints; the page | |
| 129 | + | // reports later sub-modes itself. | |
| 130 | + | return dto.VimNavigationOutcome{CapturePageKeys: true, PageKind: request.Kind, PageMode: dto.VimPageModeHints}, nil | |
| 125 | 131 | } | |
| 126 | 132 | ||
| 127 | 133 | // SendPageKey forwards one canonical key to the active in-page interaction. | |
| @@ -130,7 +136,10 @@ func (*VimNavigationUseCase) SendPageKey(ctx context.Context, wv port.WebView, k | |||
| 130 | 136 | if !ok { | |
| 131 | 137 | return errUnsupportedVimNavigationEngine | |
| 132 | 138 | } | |
| 133 | - | return interactor.SendVimPageKey(ctx, key) | |
| 139 | + | if err := interactor.SendVimPageKey(ctx, key); err != nil { | |
| 140 | + | return fmt.Errorf("vim navigation: send page key: %w", err) | |
| 141 | + | } | |
| 142 | + | return nil | |
| 134 | 143 | } | |
| 135 | 144 | ||
| 136 | 145 | // CancelPageInteraction ends any in-page interaction, removing its overlay or | |
| @@ -140,7 +149,10 @@ func (*VimNavigationUseCase) CancelPageInteraction(ctx context.Context, wv port. | |||
| 140 | 149 | if !ok { | |
| 141 | 150 | return nil | |
| 142 | 151 | } | |
| 143 | - | return interactor.CancelVimPageInteraction(ctx) | |
| 152 | + | if err := interactor.CancelVimPageInteraction(ctx); err != nil { | |
| 153 | + | return fmt.Errorf("vim navigation: cancel page interaction: %w", err) | |
| 154 | + | } | |
| 155 | + | return nil | |
| 144 | 156 | } | |
| 145 | 157 | ||
| 146 | 158 | // ClearSemanticNavigationHighlight removes the visual target left by semantic | |
| @@ -3,6 +3,7 @@ package usecase | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "errors" | 5 | "errors" | ||
| 6 | + | "fmt" | |||
| 6 | 7 | ||||
| 7 | "github.com/bnema/dumber/internal/application/dto" | 8 | "github.com/bnema/dumber/internal/application/dto" | ||
| 8 | "github.com/bnema/dumber/internal/application/port" | 9 | "github.com/bnema/dumber/internal/application/port" | ||
| @@ -119,9 +120,14 @@ func (*VimNavigationUseCase) startPageInteraction( | |||||
| 119 | return dto.VimNavigationOutcome{}, errUnsupportedVimNavigationEngine | 120 | return dto.VimNavigationOutcome{}, errUnsupportedVimNavigationEngine | ||
| 120 | } | 121 | } | ||
| 121 | if err := interactor.StartVimPageInteraction(ctx, request); err != nil { | 122 | if err := interactor.StartVimPageInteraction(ctx, request); err != nil { | ||
| 122 | - | return dto.VimNavigationOutcome{}, err | 123 | + | return dto.VimNavigationOutcome{}, fmt.Errorf("vim navigation: start page interaction: %w", err) |
| 123 | } | 124 | } | ||
| 124 | - | return dto.VimNavigationOutcome{CapturePageKeys: request.Kind.CapturesKeys()}, nil | 125 | + | if !request.Kind.CapturesKeys() { |
| 126 | + | return dto.VimNavigationOutcome{}, nil | |||
| 127 | + | } | |||
| 128 | + | // Every key-capturing interaction opens on text or link hints; the page | |||
| 129 | + | // reports later sub-modes itself. | |||
| 130 | + | return dto.VimNavigationOutcome{CapturePageKeys: true, PageKind: request.Kind, PageMode: dto.VimPageModeHints}, nil | |||
| 125 | } | 131 | } | ||
| 126 | 132 | ||||
| 127 | // SendPageKey forwards one canonical key to the active in-page interaction. | 133 | // SendPageKey forwards one canonical key to the active in-page interaction. | ||
| @@ -130,7 +136,10 @@ func (*VimNavigationUseCase) SendPageKey(ctx context.Context, wv port.WebView, k | |||||
| 130 | if !ok { | 136 | if !ok { | ||
| 131 | return errUnsupportedVimNavigationEngine | 137 | return errUnsupportedVimNavigationEngine | ||
| 132 | } | 138 | } | ||
| 133 | - | return interactor.SendVimPageKey(ctx, key) | 139 | + | if err := interactor.SendVimPageKey(ctx, key); err != nil { |
| 140 | + | return fmt.Errorf("vim navigation: send page key: %w", err) | |||
| 141 | + | } | |||
| 142 | + | return nil | |||
| 134 | } | 143 | } | ||
| 135 | 144 | ||||
| 136 | // CancelPageInteraction ends any in-page interaction, removing its overlay or | 145 | // CancelPageInteraction ends any in-page interaction, removing its overlay or | ||
| @@ -140,7 +149,10 @@ func (*VimNavigationUseCase) CancelPageInteraction(ctx context.Context, wv port. | |||||
| 140 | if !ok { | 149 | if !ok { | ||
| 141 | return nil | 150 | return nil | ||
| 142 | } | 151 | } | ||
| 143 | - | return interactor.CancelVimPageInteraction(ctx) | 152 | + | if err := interactor.CancelVimPageInteraction(ctx); err != nil { |
| 153 | + | return fmt.Errorf("vim navigation: cancel page interaction: %w", err) | |||
| 154 | + | } | |||
| 155 | + | return nil | |||
| 144 | } | 156 | } | ||
| 145 | 157 | ||||
| 146 | // ClearSemanticNavigationHighlight removes the visual target left by semantic | 158 | // ClearSemanticNavigationHighlight removes the visual target left by semantic | ||
M internal/application/usecase/vim_page_interaction_test.go +24 -0
| @@ -61,6 +61,13 @@ func TestVimNavigationUseCaseStartsPageInteractions(t *testing.T) { | |||
| 61 | 61 | ||
| 62 | 62 | require.NoError(t, err) | |
| 63 | 63 | assert.Equal(t, tt.wantCapture, outcome.CapturePageKeys) | |
| 64 | + | if tt.wantCapture { | |
| 65 | + | assert.Equal(t, dto.VimNavigationOutcome{ | |
| 66 | + | CapturePageKeys: true, PageKind: tt.want.Kind, PageMode: dto.VimPageModeHints, | |
| 67 | + | }, outcome, "capturing interactions open on hints and name their kind for the label") | |
| 68 | + | } else { | |
| 69 | + | assert.Equal(t, dto.VimNavigationOutcome{}, outcome) | |
| 70 | + | } | |
| 64 | 71 | }) | |
| 65 | 72 | } | |
| 66 | 73 | } | |
| @@ -74,6 +81,7 @@ func TestVimNavigationUseCasePageInteractionErrorDoesNotCapture(t *testing.T) { | |||
| 74 | 81 | outcome, err := NewVimNavigationUseCase().Execute(ctx, wv, "visual", 1, "") | |
| 75 | 82 | ||
| 76 | 83 | require.ErrorIs(t, err, boom) | |
| 84 | + | require.ErrorContains(t, err, "vim navigation: start page interaction") | |
| 77 | 85 | assert.False(t, outcome.CapturePageKeys) | |
| 78 | 86 | } | |
| 79 | 87 | ||
| @@ -99,3 +107,19 @@ func TestVimNavigationUseCaseForwardsPageKeysAndCancel(t *testing.T) { | |||
| 99 | 107 | require.NoError(t, uc.SendPageKey(ctx, wv, "J")) | |
| 100 | 108 | require.NoError(t, uc.CancelPageInteraction(ctx, wv)) | |
| 101 | 109 | } | |
| 110 | + | ||
| 111 | + | func TestVimNavigationUseCaseWrapsPageKeyAndCancelErrors(t *testing.T) { | |
| 112 | + | ctx := context.Background() | |
| 113 | + | wv, interactor := newInteractiveWebView(t) | |
| 114 | + | boom := errors.New("boom") | |
| 115 | + | interactor.EXPECT().SendVimPageKey(ctx, "j").Return(boom).Once() | |
| 116 | + | interactor.EXPECT().CancelVimPageInteraction(ctx).Return(boom).Once() | |
| 117 | + | uc := NewVimNavigationUseCase() | |
| 118 | + | ||
| 119 | + | err := uc.SendPageKey(ctx, wv, "j") | |
| 120 | + | require.ErrorIs(t, err, boom) | |
| 121 | + | require.ErrorContains(t, err, "vim navigation: send page key") | |
| 122 | + | err = uc.CancelPageInteraction(ctx, wv) | |
| 123 | + | require.ErrorIs(t, err, boom) | |
| 124 | + | require.ErrorContains(t, err, "vim navigation: cancel page interaction") | |
| 125 | + | } | |
| @@ -61,6 +61,13 @@ func TestVimNavigationUseCaseStartsPageInteractions(t *testing.T) { | |||||
| 61 | 61 | ||||
| 62 | require.NoError(t, err) | 62 | require.NoError(t, err) | ||
| 63 | assert.Equal(t, tt.wantCapture, outcome.CapturePageKeys) | 63 | assert.Equal(t, tt.wantCapture, outcome.CapturePageKeys) | ||
| 64 | + | if tt.wantCapture { | |||
| 65 | + | assert.Equal(t, dto.VimNavigationOutcome{ | |||
| 66 | + | CapturePageKeys: true, PageKind: tt.want.Kind, PageMode: dto.VimPageModeHints, | |||
| 67 | + | }, outcome, "capturing interactions open on hints and name their kind for the label") | |||
| 68 | + | } else { | |||
| 69 | + | assert.Equal(t, dto.VimNavigationOutcome{}, outcome) | |||
| 70 | + | } | |||
| 64 | }) | 71 | }) | ||
| 65 | } | 72 | } | ||
| 66 | } | 73 | } | ||
| @@ -74,6 +81,7 @@ func TestVimNavigationUseCasePageInteractionErrorDoesNotCapture(t *testing.T) { | |||||
| 74 | outcome, err := NewVimNavigationUseCase().Execute(ctx, wv, "visual", 1, "") | 81 | outcome, err := NewVimNavigationUseCase().Execute(ctx, wv, "visual", 1, "") | ||
| 75 | 82 | ||||
| 76 | require.ErrorIs(t, err, boom) | 83 | require.ErrorIs(t, err, boom) | ||
| 84 | + | require.ErrorContains(t, err, "vim navigation: start page interaction") | |||
| 77 | assert.False(t, outcome.CapturePageKeys) | 85 | assert.False(t, outcome.CapturePageKeys) | ||
| 78 | } | 86 | } | ||
| 79 | 87 | ||||
| @@ -99,3 +107,19 @@ func TestVimNavigationUseCaseForwardsPageKeysAndCancel(t *testing.T) { | |||||
| 99 | require.NoError(t, uc.SendPageKey(ctx, wv, "J")) | 107 | require.NoError(t, uc.SendPageKey(ctx, wv, "J")) | ||
| 100 | require.NoError(t, uc.CancelPageInteraction(ctx, wv)) | 108 | require.NoError(t, uc.CancelPageInteraction(ctx, wv)) | ||
| 101 | } | 109 | } | ||
| 110 | + | ||||
| 111 | + | func TestVimNavigationUseCaseWrapsPageKeyAndCancelErrors(t *testing.T) { | |||
| 112 | + | ctx := context.Background() | |||
| 113 | + | wv, interactor := newInteractiveWebView(t) | |||
| 114 | + | boom := errors.New("boom") | |||
| 115 | + | interactor.EXPECT().SendVimPageKey(ctx, "j").Return(boom).Once() | |||
| 116 | + | interactor.EXPECT().CancelVimPageInteraction(ctx).Return(boom).Once() | |||
| 117 | + | uc := NewVimNavigationUseCase() | |||
| 118 | + | ||||
| 119 | + | err := uc.SendPageKey(ctx, wv, "j") | |||
| 120 | + | require.ErrorIs(t, err, boom) | |||
| 121 | + | require.ErrorContains(t, err, "vim navigation: send page key") | |||
| 122 | + | err = uc.CancelPageInteraction(ctx, wv) | |||
| 123 | + | require.ErrorIs(t, err, boom) | |||
| 124 | + | require.ErrorContains(t, err, "vim navigation: cancel page interaction") | |||
| 125 | + | } | |||
M internal/ui/app.go +1 -136
| @@ -2230,7 +2230,7 @@ func (a *App) navigateVimSequenceAction(ctx context.Context, bw *browserWindow, | |||
| 2230 | 2230 | return | |
| 2231 | 2231 | } | |
| 2232 | 2232 | if outcome.CapturePageKeys { | |
| 2233 | - | a.captureVimPageKeys(ctx, bw, wv, vimPageInteractionLabel(action)) | |
| 2233 | + | a.captureVimPageKeys(ctx, bw, wv, outcome) | |
| 2234 | 2234 | } | |
| 2235 | 2235 | if bw != nil && (action == "heading-next" || action == "heading-prev") { | |
| 2236 | 2236 | if _, ok := wv.(port.SemanticNavigable); ok { | |
| @@ -2246,141 +2246,6 @@ func (a *App) navigateVimSequenceAction(ctx context.Context, bw *browserWindow, | |||
| 2246 | 2246 | } | |
| 2247 | 2247 | } | |
| 2248 | 2248 | ||
| 2249 | - | // captureVimPageKeys routes this window's Vim Mode keys to the in-page | |
| 2250 | - | // interaction running in wv until the page reports its end. | |
| 2251 | - | func (a *App) captureVimPageKeys(ctx context.Context, bw *browserWindow, wv port.WebView, label string) { | |
| 2252 | - | if bw == nil || bw.keyboardHandler == nil || wv == nil { | |
| 2253 | - | return | |
| 2254 | - | } | |
| 2255 | - | bw.vimPageInteractionWebView = wv | |
| 2256 | - | // Capture first: it resets the pending sequence, and the toast and legend | |
| 2257 | - | // below must reflect the sub-mode, not a cleared sequence. | |
| 2258 | - | bw.keyboardHandler.SetPageKeyCapture(func(key string) { | |
| 2259 | - | if err := a.vimNavigationUseCase().SendPageKey(ctx, wv, key); err != nil { | |
| 2260 | - | logging.FromContext(ctx).Debug().Err(err).Msg("vim page key forwarding failed") | |
| 2261 | - | a.endVimPageInteraction(ctx, bw, a.vimModeActiveForBrowserWindow(bw)) | |
| 2262 | - | } | |
| 2263 | - | }) | |
| 2264 | - | a.showVimModeToast(ctx, bw, label) | |
| 2265 | - | if bw.modeFrame != nil { | |
| 2266 | - | bw.modeFrame.setSuspended(true) | |
| 2267 | - | } | |
| 2268 | - | logging.FromContext(ctx).Debug(). | |
| 2269 | - | Str("window_id", bw.id). | |
| 2270 | - | Str("sub_mode", label). | |
| 2271 | - | Msg("vim page interaction capturing keys") | |
| 2272 | - | } | |
| 2273 | - | ||
| 2274 | - | // releaseVimPageKeys returns this window's keys to Vim Mode bindings and | |
| 2275 | - | // restores the plain indicator and legend when stillVim reports the window | |
| 2276 | - | // remains in Vim Mode. The caller supplies it because the mode-change callback | |
| 2277 | - | // runs under the modal lock, where reading the mode back would deadlock. | |
| 2278 | - | // It reports the WebView that owned the interaction, if any. | |
| 2279 | - | func (a *App) releaseVimPageKeys(ctx context.Context, bw *browserWindow, reason string, stillVim bool) port.WebView { | |
| 2280 | - | wv := bw.vimPageInteractionWebView | |
| 2281 | - | bw.vimPageInteractionWebView = nil | |
| 2282 | - | if bw.keyboardHandler != nil { | |
| 2283 | - | bw.keyboardHandler.ClearPageKeyCapture() | |
| 2284 | - | } | |
| 2285 | - | if wv == nil { | |
| 2286 | - | return nil | |
| 2287 | - | } | |
| 2288 | - | if stillVim { | |
| 2289 | - | a.showVimModeToast(ctx, bw, "") | |
| 2290 | - | if bw.modeFrame != nil { | |
| 2291 | - | bw.modeFrame.setSuspended(false) | |
| 2292 | - | } | |
| 2293 | - | } | |
| 2294 | - | logging.FromContext(ctx).Debug(). | |
| 2295 | - | Str("window_id", bw.id). | |
| 2296 | - | Str("reason", reason). | |
| 2297 | - | Msg("vim page interaction released keys") | |
| 2298 | - | return wv | |
| 2299 | - | } | |
| 2300 | - | ||
| 2301 | - | // handleVimPageInteractionEnded releases key capture when the page reports | |
| 2302 | - | // that its hint or visual interaction finished. | |
| 2303 | - | func (a *App) handleVimPageInteractionEnded(ctx context.Context, paneID entity.PaneID) { | |
| 2304 | - | bw := a.browserWindowForAnyPane(paneID) | |
| 2305 | - | if bw == nil || bw.vimPageInteractionWebView == nil || a.contentCoord == nil { | |
| 2306 | - | return | |
| 2307 | - | } | |
| 2308 | - | if a.contentCoord.GetWebView(paneID) != bw.vimPageInteractionWebView { | |
| 2309 | - | return | |
| 2310 | - | } | |
| 2311 | - | a.releaseVimPageKeys(ctx, bw, "page-reported-end", a.vimModeActiveForBrowserWindow(bw)) | |
| 2312 | - | } | |
| 2313 | - | ||
| 2314 | - | // handleVimPageModeChanged repaints the indicator when the page moves its | |
| 2315 | - | // interaction to another sub-mode (hints, caret, visual). Key capture and the | |
| 2316 | - | // legend suspension stay as they are. | |
| 2317 | - | func (a *App) handleVimPageModeChanged(ctx context.Context, paneID entity.PaneID, mode dto.VimPageMode) { | |
| 2318 | - | bw := a.browserWindowForAnyPane(paneID) | |
| 2319 | - | if bw == nil || bw.vimPageInteractionWebView == nil || a.contentCoord == nil { | |
| 2320 | - | return | |
| 2321 | - | } | |
| 2322 | - | if a.contentCoord.GetWebView(paneID) != bw.vimPageInteractionWebView { | |
| 2323 | - | return | |
| 2324 | - | } | |
| 2325 | - | a.showVimPageMode(ctx, bw, mode) | |
| 2326 | - | } | |
| 2327 | - | ||
| 2328 | - | // showVimPageMode paints the sub-mode label for the interaction owned by bw. | |
| 2329 | - | func (a *App) showVimPageMode(ctx context.Context, bw *browserWindow, mode dto.VimPageMode) { | |
| 2330 | - | label, ok := vimPageModeLabel(mode) | |
| 2331 | - | if !ok || bw == nil || bw.vimPageInteractionWebView == nil { | |
| 2332 | - | return | |
| 2333 | - | } | |
| 2334 | - | a.showVimModeToast(ctx, bw, label) | |
| 2335 | - | } | |
| 2336 | - | ||
| 2337 | - | // vimPageModeLabel names a page-reported sub-mode for the Vim Mode indicator. | |
| 2338 | - | func vimPageModeLabel(mode dto.VimPageMode) (string, bool) { | |
| 2339 | - | switch mode { | |
| 2340 | - | case dto.VimPageModeHints: | |
| 2341 | - | return "HINTS", true | |
| 2342 | - | case dto.VimPageModeCaret: | |
| 2343 | - | return "CARET", true | |
| 2344 | - | case dto.VimPageModeVisual: | |
| 2345 | - | return "VISUAL", true | |
| 2346 | - | case dto.VimPageModeVisualLine: | |
| 2347 | - | return "VISUAL LINE", true | |
| 2348 | - | default: | |
| 2349 | - | return "", false | |
| 2350 | - | } | |
| 2351 | - | } | |
| 2352 | - | ||
| 2353 | - | // vimPageInteractionLabel names the key-capturing sub-mode shown in the Vim | |
| 2354 | - | // Mode indicator so users can tell hints or visual selection are active. The | |
| 2355 | - | // page refines the visual flow's label as it reports sub-mode changes. | |
| 2356 | - | func vimPageInteractionLabel(action string) string { | |
| 2357 | - | switch action { | |
| 2358 | - | case "visual": | |
| 2359 | - | return "HINTS" | |
| 2360 | - | case "hint-follow-new": | |
| 2361 | - | return "HINTS · NEW PANE" | |
| 2362 | - | case "hint-yank-url": | |
| 2363 | - | return "HINTS · YANK URL" | |
| 2364 | - | default: | |
| 2365 | - | return "HINTS" | |
| 2366 | - | } | |
| 2367 | - | } | |
| 2368 | - | ||
| 2369 | - | // endVimPageInteraction cancels any in-page interaction owned by this window. | |
| 2370 | - | // stillVim reports whether the window stays in Vim Mode (see releaseVimPageKeys). | |
| 2371 | - | func (a *App) endVimPageInteraction(ctx context.Context, bw *browserWindow, stillVim bool) { | |
| 2372 | - | if bw == nil { | |
| 2373 | - | return | |
| 2374 | - | } | |
| 2375 | - | wv := a.releaseVimPageKeys(ctx, bw, "canceled", stillVim) | |
| 2376 | - | if wv == nil { | |
| 2377 | - | return | |
| 2378 | - | } | |
| 2379 | - | if err := a.vimNavigationUseCase().CancelPageInteraction(ctx, wv); err != nil { | |
| 2380 | - | logging.FromContext(ctx).Debug().Err(err).Msg("failed to cancel vim page interaction") | |
| 2381 | - | } | |
| 2382 | - | } | |
| 2383 | - | ||
| 2384 | 2249 | func (a *App) clearVimNavigationHighlight(ctx context.Context, bw *browserWindow) { | |
| 2385 | 2250 | if bw == nil { | |
| 2386 | 2251 | return | |
| @@ -2230,7 +2230,7 @@ func (a *App) navigateVimSequenceAction(ctx context.Context, bw *browserWindow, | |||||
| 2230 | return | 2230 | return | ||
| 2231 | } | 2231 | } | ||
| 2232 | if outcome.CapturePageKeys { | 2232 | if outcome.CapturePageKeys { | ||
| 2233 | - | a.captureVimPageKeys(ctx, bw, wv, vimPageInteractionLabel(action)) | 2233 | + | a.captureVimPageKeys(ctx, bw, wv, outcome) |
| 2234 | } | 2234 | } | ||
| 2235 | if bw != nil && (action == "heading-next" || action == "heading-prev") { | 2235 | if bw != nil && (action == "heading-next" || action == "heading-prev") { | ||
| 2236 | if _, ok := wv.(port.SemanticNavigable); ok { | 2236 | if _, ok := wv.(port.SemanticNavigable); ok { | ||
| @@ -2246,141 +2246,6 @@ func (a *App) navigateVimSequenceAction(ctx context.Context, bw *browserWindow, | |||||
| 2246 | } | 2246 | } | ||
| 2247 | } | 2247 | } | ||
| 2248 | 2248 | ||||
| 2249 | - | // captureVimPageKeys routes this window's Vim Mode keys to the in-page | |||
| 2250 | - | // interaction running in wv until the page reports its end. | |||
| 2251 | - | func (a *App) captureVimPageKeys(ctx context.Context, bw *browserWindow, wv port.WebView, label string) { | |||
| 2252 | - | if bw == nil || bw.keyboardHandler == nil || wv == nil { | |||
| 2253 | - | return | |||
| 2254 | - | } | |||
| 2255 | - | bw.vimPageInteractionWebView = wv | |||
| 2256 | - | // Capture first: it resets the pending sequence, and the toast and legend | |||
| 2257 | - | // below must reflect the sub-mode, not a cleared sequence. | |||
| 2258 | - | bw.keyboardHandler.SetPageKeyCapture(func(key string) { | |||
| 2259 | - | if err := a.vimNavigationUseCase().SendPageKey(ctx, wv, key); err != nil { | |||
| 2260 | - | logging.FromContext(ctx).Debug().Err(err).Msg("vim page key forwarding failed") | |||
| 2261 | - | a.endVimPageInteraction(ctx, bw, a.vimModeActiveForBrowserWindow(bw)) | |||
| 2262 | - | } | |||
| 2263 | - | }) | |||
| 2264 | - | a.showVimModeToast(ctx, bw, label) | |||
| 2265 | - | if bw.modeFrame != nil { | |||
| 2266 | - | bw.modeFrame.setSuspended(true) | |||
| 2267 | - | } | |||
| 2268 | - | logging.FromContext(ctx).Debug(). | |||
| 2269 | - | Str("window_id", bw.id). | |||
| 2270 | - | Str("sub_mode", label). | |||
| 2271 | - | Msg("vim page interaction capturing keys") | |||
| 2272 | - | } | |||
| 2273 | - | ||||
| 2274 | - | // releaseVimPageKeys returns this window's keys to Vim Mode bindings and | |||
| 2275 | - | // restores the plain indicator and legend when stillVim reports the window | |||
| 2276 | - | // remains in Vim Mode. The caller supplies it because the mode-change callback | |||
| 2277 | - | // runs under the modal lock, where reading the mode back would deadlock. | |||
| 2278 | - | // It reports the WebView that owned the interaction, if any. | |||
| 2279 | - | func (a *App) releaseVimPageKeys(ctx context.Context, bw *browserWindow, reason string, stillVim bool) port.WebView { | |||
| 2280 | - | wv := bw.vimPageInteractionWebView | |||
| 2281 | - | bw.vimPageInteractionWebView = nil | |||
| 2282 | - | if bw.keyboardHandler != nil { | |||
| 2283 | - | bw.keyboardHandler.ClearPageKeyCapture() | |||
| 2284 | - | } | |||
| 2285 | - | if wv == nil { | |||
| 2286 | - | return nil | |||
| 2287 | - | } | |||
| 2288 | - | if stillVim { | |||
| 2289 | - | a.showVimModeToast(ctx, bw, "") | |||
| 2290 | - | if bw.modeFrame != nil { | |||
| 2291 | - | bw.modeFrame.setSuspended(false) | |||
| 2292 | - | } | |||
| 2293 | - | } | |||
| 2294 | - | logging.FromContext(ctx).Debug(). | |||
| 2295 | - | Str("window_id", bw.id). | |||
| 2296 | - | Str("reason", reason). | |||
| 2297 | - | Msg("vim page interaction released keys") | |||
| 2298 | - | return wv | |||
| 2299 | - | } | |||
| 2300 | - | ||||
| 2301 | - | // handleVimPageInteractionEnded releases key capture when the page reports | |||
| 2302 | - | // that its hint or visual interaction finished. | |||
| 2303 | - | func (a *App) handleVimPageInteractionEnded(ctx context.Context, paneID entity.PaneID) { | |||
| 2304 | - | bw := a.browserWindowForAnyPane(paneID) | |||
| 2305 | - | if bw == nil || bw.vimPageInteractionWebView == nil || a.contentCoord == nil { | |||
| 2306 | - | return | |||
| 2307 | - | } | |||
| 2308 | - | if a.contentCoord.GetWebView(paneID) != bw.vimPageInteractionWebView { | |||
| 2309 | - | return | |||
| 2310 | - | } | |||
| 2311 | - | a.releaseVimPageKeys(ctx, bw, "page-reported-end", a.vimModeActiveForBrowserWindow(bw)) | |||
| 2312 | - | } | |||
| 2313 | - | ||||
| 2314 | - | // handleVimPageModeChanged repaints the indicator when the page moves its | |||
| 2315 | - | // interaction to another sub-mode (hints, caret, visual). Key capture and the | |||
| 2316 | - | // legend suspension stay as they are. | |||
| 2317 | - | func (a *App) handleVimPageModeChanged(ctx context.Context, paneID entity.PaneID, mode dto.VimPageMode) { | |||
| 2318 | - | bw := a.browserWindowForAnyPane(paneID) | |||
| 2319 | - | if bw == nil || bw.vimPageInteractionWebView == nil || a.contentCoord == nil { | |||
| 2320 | - | return | |||
| 2321 | - | } | |||
| 2322 | - | if a.contentCoord.GetWebView(paneID) != bw.vimPageInteractionWebView { | |||
| 2323 | - | return | |||
| 2324 | - | } | |||
| 2325 | - | a.showVimPageMode(ctx, bw, mode) | |||
| 2326 | - | } | |||
| 2327 | - | ||||
| 2328 | - | // showVimPageMode paints the sub-mode label for the interaction owned by bw. | |||
| 2329 | - | func (a *App) showVimPageMode(ctx context.Context, bw *browserWindow, mode dto.VimPageMode) { | |||
| 2330 | - | label, ok := vimPageModeLabel(mode) | |||
| 2331 | - | if !ok || bw == nil || bw.vimPageInteractionWebView == nil { | |||
| 2332 | - | return | |||
| 2333 | - | } | |||
| 2334 | - | a.showVimModeToast(ctx, bw, label) | |||
| 2335 | - | } | |||
| 2336 | - | ||||
| 2337 | - | // vimPageModeLabel names a page-reported sub-mode for the Vim Mode indicator. | |||
| 2338 | - | func vimPageModeLabel(mode dto.VimPageMode) (string, bool) { | |||
| 2339 | - | switch mode { | |||
| 2340 | - | case dto.VimPageModeHints: | |||
| 2341 | - | return "HINTS", true | |||
| 2342 | - | case dto.VimPageModeCaret: | |||
| 2343 | - | return "CARET", true | |||
| 2344 | - | case dto.VimPageModeVisual: | |||
| 2345 | - | return "VISUAL", true | |||
| 2346 | - | case dto.VimPageModeVisualLine: | |||
| 2347 | - | return "VISUAL LINE", true | |||
| 2348 | - | default: | |||
| 2349 | - | return "", false | |||
| 2350 | - | } | |||
| 2351 | - | } | |||
| 2352 | - | ||||
| 2353 | - | // vimPageInteractionLabel names the key-capturing sub-mode shown in the Vim | |||
| 2354 | - | // Mode indicator so users can tell hints or visual selection are active. The | |||
| 2355 | - | // page refines the visual flow's label as it reports sub-mode changes. | |||
| 2356 | - | func vimPageInteractionLabel(action string) string { | |||
| 2357 | - | switch action { | |||
| 2358 | - | case "visual": | |||
| 2359 | - | return "HINTS" | |||
| 2360 | - | case "hint-follow-new": | |||
| 2361 | - | return "HINTS · NEW PANE" | |||
| 2362 | - | case "hint-yank-url": | |||
| 2363 | - | return "HINTS · YANK URL" | |||
| 2364 | - | default: | |||
| 2365 | - | return "HINTS" | |||
| 2366 | - | } | |||
| 2367 | - | } | |||
| 2368 | - | ||||
| 2369 | - | // endVimPageInteraction cancels any in-page interaction owned by this window. | |||
| 2370 | - | // stillVim reports whether the window stays in Vim Mode (see releaseVimPageKeys). | |||
| 2371 | - | func (a *App) endVimPageInteraction(ctx context.Context, bw *browserWindow, stillVim bool) { | |||
| 2372 | - | if bw == nil { | |||
| 2373 | - | return | |||
| 2374 | - | } | |||
| 2375 | - | wv := a.releaseVimPageKeys(ctx, bw, "canceled", stillVim) | |||
| 2376 | - | if wv == nil { | |||
| 2377 | - | return | |||
| 2378 | - | } | |||
| 2379 | - | if err := a.vimNavigationUseCase().CancelPageInteraction(ctx, wv); err != nil { | |||
| 2380 | - | logging.FromContext(ctx).Debug().Err(err).Msg("failed to cancel vim page interaction") | |||
| 2381 | - | } | |||
| 2382 | - | } | |||
| 2383 | - | ||||
| 2384 | func (a *App) clearVimNavigationHighlight(ctx context.Context, bw *browserWindow) { | 2249 | func (a *App) clearVimNavigationHighlight(ctx context.Context, bw *browserWindow) { | ||
| 2385 | if bw == nil { | 2250 | if bw == nil { | ||
| 2386 | return | 2251 | return | ||
M internal/ui/app_vim_page_test.go +64 -6
| @@ -42,6 +42,12 @@ func newVimPageTestWindow(t *testing.T) (*App, *browserWindow, *[]string) { | |||
| 42 | 42 | return app, bw, shown | |
| 43 | 43 | } | |
| 44 | 44 | ||
| 45 | + | var visualOutcome = hintsOutcome(dto.VimPageVisual) | |
| 46 | + | ||
| 47 | + | func hintsOutcome(kind dto.VimPageInteractionKind) dto.VimNavigationOutcome { | |
| 48 | + | return dto.VimNavigationOutcome{CapturePageKeys: true, PageKind: kind, PageMode: dto.VimPageModeHints} | |
| 49 | + | } | |
| 50 | + | ||
| 45 | 51 | // Starting a key-capturing interaction must leave the sub-mode label on the | |
| 46 | 52 | // toast. The capture path used to reset the pending sequence with | |
| 47 | 53 | // notification, which repainted the toast back to plain "VIM MODE" in the | |
| @@ -50,10 +56,10 @@ func TestCaptureVimPageKeysKeepsSubModeLabelOnToast(t *testing.T) { | |||
| 50 | 56 | app, bw, shown := newVimPageTestWindow(t) | |
| 51 | 57 | wv := portmocks.NewMockWebView(t) | |
| 52 | 58 | ||
| 53 | - | app.captureVimPageKeys(context.Background(), bw, wv, "VISUAL") | |
| 59 | + | app.captureVimPageKeys(context.Background(), bw, wv, visualOutcome) | |
| 54 | 60 | ||
| 55 | 61 | require.NotEmpty(t, *shown) | |
| 56 | - | require.Equal(t, "VIM MODE · VISUAL", (*shown)[len(*shown)-1]) | |
| 62 | + | require.Equal(t, "VIM MODE · HINTS", (*shown)[len(*shown)-1], "every interaction opens on hints") | |
| 57 | 63 | require.True(t, bw.keyboardHandler.PageKeyCaptureActive()) | |
| 58 | 64 | } | |
| 59 | 65 | ||
| @@ -65,7 +71,7 @@ func TestVimPageSubModeLabelSurvivesSequenceCompletion(t *testing.T) { | |||
| 65 | 71 | wv := portmocks.NewMockWebView(t) | |
| 66 | 72 | ||
| 67 | 73 | app.showPendingSequence(context.Background(), bw, "") | |
| 68 | - | app.captureVimPageKeys(context.Background(), bw, wv, "HINTS") | |
| 74 | + | app.captureVimPageKeys(context.Background(), bw, wv, hintsOutcome(dto.VimPageHintFollow)) | |
| 69 | 75 | // Any pending notification emitted while the page owns the keys is noise. | |
| 70 | 76 | app.showPendingSequence(context.Background(), bw, "") | |
| 71 | 77 | ||
| @@ -81,7 +87,7 @@ func TestVimPageInteractionHidesLegendWhileCaptured(t *testing.T) { | |||
| 81 | 87 | bw.modeFrame = frame | |
| 82 | 88 | wv := portmocks.NewMockWebView(t) | |
| 83 | 89 | ||
| 84 | - | app.captureVimPageKeys(context.Background(), bw, wv, "VISUAL") | |
| 90 | + | app.captureVimPageKeys(context.Background(), bw, wv, visualOutcome) | |
| 85 | 91 | require.True(t, frame.suspended, "legend must be suspended while the page captures keys") | |
| 86 | 92 | ||
| 87 | 93 | wv.EXPECT().ID().Return(1).Maybe() | |
| @@ -98,7 +104,7 @@ func TestVimPageModeChangesRepaintToastWithoutReleasingCapture(t *testing.T) { | |||
| 98 | 104 | frame := &modeFrame{mode: input.ModeVim, visible: true} | |
| 99 | 105 | bw.modeFrame = frame | |
| 100 | 106 | wv := portmocks.NewMockWebView(t) | |
| 101 | - | app.captureVimPageKeys(context.Background(), bw, wv, vimPageInteractionLabel("visual")) | |
| 107 | + | app.captureVimPageKeys(context.Background(), bw, wv, visualOutcome) | |
| 102 | 108 | require.Equal(t, "VIM MODE · HINTS", (*shown)[len(*shown)-1]) | |
| 103 | 109 | ||
| 104 | 110 | for mode, want := range map[dto.VimPageMode]string{ | |
| @@ -148,7 +154,7 @@ func TestExitModeWithPageCaptureDoesNotDeadlock(t *testing.T) { | |||
| 148 | 154 | }) | |
| 149 | 155 | wv := portmocks.NewMockWebView(t) | |
| 150 | 156 | wv.EXPECT().ID().Return(1).Maybe() | |
| 151 | - | app.captureVimPageKeys(context.Background(), bw, wv, "VISUAL") | |
| 157 | + | app.captureVimPageKeys(context.Background(), bw, wv, visualOutcome) | |
| 152 | 158 | ||
| 153 | 159 | exitModeWithin(t, bw.keyboardHandler.ExitMode) | |
| 154 | 160 | ||
| @@ -156,3 +162,55 @@ func TestExitModeWithPageCaptureDoesNotDeadlock(t *testing.T) { | |||
| 156 | 162 | require.Nil(t, bw.vimPageInteractionWebView) | |
| 157 | 163 | require.Equal(t, input.ModeNormal, bw.keyboardHandler.Mode()) | |
| 158 | 164 | } | |
| 165 | + | ||
| 166 | + | func TestVimPageModeLabel(t *testing.T) { | |
| 167 | + | for mode, want := range map[dto.VimPageMode]string{ | |
| 168 | + | dto.VimPageModeHints: "HINTS", | |
| 169 | + | dto.VimPageModeCaret: "CARET", | |
| 170 | + | dto.VimPageModeVisual: "VISUAL", | |
| 171 | + | dto.VimPageModeVisualLine: "VISUAL LINE", | |
| 172 | + | } { | |
| 173 | + | got, ok := vimPageModeLabel(mode) | |
| 174 | + | require.True(t, ok, mode) | |
| 175 | + | require.Equal(t, want, got) | |
| 176 | + | } | |
| 177 | + | _, ok := vimPageModeLabel("bogus") | |
| 178 | + | require.False(t, ok) | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | // One mapping drives every label: the sub-mode, plus the target of hints that | |
| 182 | + | // do more than follow. Later page-reported modes drop the hint target. | |
| 183 | + | func TestVimPageLabel(t *testing.T) { | |
| 184 | + | tests := []struct { | |
| 185 | + | kind dto.VimPageInteractionKind | |
| 186 | + | mode dto.VimPageMode | |
| 187 | + | want string | |
| 188 | + | }{ | |
| 189 | + | {dto.VimPageVisual, dto.VimPageModeHints, "HINTS"}, | |
| 190 | + | {dto.VimPageHintFollow, dto.VimPageModeHints, "HINTS"}, | |
| 191 | + | {dto.VimPageHintFollowNew, dto.VimPageModeHints, "HINTS · NEW PANE"}, | |
| 192 | + | {dto.VimPageHintYankURL, dto.VimPageModeHints, "HINTS · YANK URL"}, | |
| 193 | + | {dto.VimPageVisual, dto.VimPageModeCaret, "CARET"}, | |
| 194 | + | {dto.VimPageVisual, dto.VimPageModeVisual, "VISUAL"}, | |
| 195 | + | {dto.VimPageVisual, dto.VimPageModeVisualLine, "VISUAL LINE"}, | |
| 196 | + | } | |
| 197 | + | for _, tt := range tests { | |
| 198 | + | got, ok := vimPageLabel(tt.kind, tt.mode) | |
| 199 | + | require.True(t, ok) | |
| 200 | + | require.Equal(t, tt.want, got, "kind %d mode %s", tt.kind, tt.mode) | |
| 201 | + | } | |
| 202 | + | _, ok := vimPageLabel(dto.VimPageVisual, "bogus") | |
| 203 | + | require.False(t, ok) | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | func TestVimPageInteractionLabelsFollowTheOutcome(t *testing.T) { | |
| 207 | + | app, bw, shown := newVimPageTestWindow(t) | |
| 208 | + | wv := portmocks.NewMockWebView(t) | |
| 209 | + | ||
| 210 | + | app.captureVimPageKeys(context.Background(), bw, wv, hintsOutcome(dto.VimPageHintFollowNew)) | |
| 211 | + | require.Equal(t, "VIM MODE · HINTS · NEW PANE", (*shown)[len(*shown)-1]) | |
| 212 | + | app.releaseVimPageKeys(context.Background(), bw, "test", true) | |
| 213 | + | ||
| 214 | + | app.captureVimPageKeys(context.Background(), bw, wv, hintsOutcome(dto.VimPageHintYankURL)) | |
| 215 | + | require.Equal(t, "VIM MODE · HINTS · YANK URL", (*shown)[len(*shown)-1]) | |
| 216 | + | } | |
| @@ -42,6 +42,12 @@ func newVimPageTestWindow(t *testing.T) (*App, *browserWindow, *[]string) { | |||||
| 42 | return app, bw, shown | 42 | return app, bw, shown | ||
| 43 | } | 43 | } | ||
| 44 | 44 | ||||
| 45 | + | var visualOutcome = hintsOutcome(dto.VimPageVisual) | |||
| 46 | + | ||||
| 47 | + | func hintsOutcome(kind dto.VimPageInteractionKind) dto.VimNavigationOutcome { | |||
| 48 | + | return dto.VimNavigationOutcome{CapturePageKeys: true, PageKind: kind, PageMode: dto.VimPageModeHints} | |||
| 49 | + | } | |||
| 50 | + | ||||
| 45 | // Starting a key-capturing interaction must leave the sub-mode label on the | 51 | // Starting a key-capturing interaction must leave the sub-mode label on the | ||
| 46 | // toast. The capture path used to reset the pending sequence with | 52 | // toast. The capture path used to reset the pending sequence with | ||
| 47 | // notification, which repainted the toast back to plain "VIM MODE" in the | 53 | // notification, which repainted the toast back to plain "VIM MODE" in the | ||
| @@ -50,10 +56,10 @@ func TestCaptureVimPageKeysKeepsSubModeLabelOnToast(t *testing.T) { | |||||
| 50 | app, bw, shown := newVimPageTestWindow(t) | 56 | app, bw, shown := newVimPageTestWindow(t) | ||
| 51 | wv := portmocks.NewMockWebView(t) | 57 | wv := portmocks.NewMockWebView(t) | ||
| 52 | 58 | ||||
| 53 | - | app.captureVimPageKeys(context.Background(), bw, wv, "VISUAL") | 59 | + | app.captureVimPageKeys(context.Background(), bw, wv, visualOutcome) |
| 54 | 60 | ||||
| 55 | require.NotEmpty(t, *shown) | 61 | require.NotEmpty(t, *shown) | ||
| 56 | - | require.Equal(t, "VIM MODE · VISUAL", (*shown)[len(*shown)-1]) | 62 | + | require.Equal(t, "VIM MODE · HINTS", (*shown)[len(*shown)-1], "every interaction opens on hints") |
| 57 | require.True(t, bw.keyboardHandler.PageKeyCaptureActive()) | 63 | require.True(t, bw.keyboardHandler.PageKeyCaptureActive()) | ||
| 58 | } | 64 | } | ||
| 59 | 65 | ||||
| @@ -65,7 +71,7 @@ func TestVimPageSubModeLabelSurvivesSequenceCompletion(t *testing.T) { | |||||
| 65 | wv := portmocks.NewMockWebView(t) | 71 | wv := portmocks.NewMockWebView(t) | ||
| 66 | 72 | ||||
| 67 | app.showPendingSequence(context.Background(), bw, "") | 73 | app.showPendingSequence(context.Background(), bw, "") | ||
| 68 | - | app.captureVimPageKeys(context.Background(), bw, wv, "HINTS") | 74 | + | app.captureVimPageKeys(context.Background(), bw, wv, hintsOutcome(dto.VimPageHintFollow)) |
| 69 | // Any pending notification emitted while the page owns the keys is noise. | 75 | // Any pending notification emitted while the page owns the keys is noise. | ||
| 70 | app.showPendingSequence(context.Background(), bw, "") | 76 | app.showPendingSequence(context.Background(), bw, "") | ||
| 71 | 77 | ||||
| @@ -81,7 +87,7 @@ func TestVimPageInteractionHidesLegendWhileCaptured(t *testing.T) { | |||||
| 81 | bw.modeFrame = frame | 87 | bw.modeFrame = frame | ||
| 82 | wv := portmocks.NewMockWebView(t) | 88 | wv := portmocks.NewMockWebView(t) | ||
| 83 | 89 | ||||
| 84 | - | app.captureVimPageKeys(context.Background(), bw, wv, "VISUAL") | 90 | + | app.captureVimPageKeys(context.Background(), bw, wv, visualOutcome) |
| 85 | require.True(t, frame.suspended, "legend must be suspended while the page captures keys") | 91 | require.True(t, frame.suspended, "legend must be suspended while the page captures keys") | ||
| 86 | 92 | ||||
| 87 | wv.EXPECT().ID().Return(1).Maybe() | 93 | wv.EXPECT().ID().Return(1).Maybe() | ||
| @@ -98,7 +104,7 @@ func TestVimPageModeChangesRepaintToastWithoutReleasingCapture(t *testing.T) { | |||||
| 98 | frame := &modeFrame{mode: input.ModeVim, visible: true} | 104 | frame := &modeFrame{mode: input.ModeVim, visible: true} | ||
| 99 | bw.modeFrame = frame | 105 | bw.modeFrame = frame | ||
| 100 | wv := portmocks.NewMockWebView(t) | 106 | wv := portmocks.NewMockWebView(t) | ||
| 101 | - | app.captureVimPageKeys(context.Background(), bw, wv, vimPageInteractionLabel("visual")) | 107 | + | app.captureVimPageKeys(context.Background(), bw, wv, visualOutcome) |
| 102 | require.Equal(t, "VIM MODE · HINTS", (*shown)[len(*shown)-1]) | 108 | require.Equal(t, "VIM MODE · HINTS", (*shown)[len(*shown)-1]) | ||
| 103 | 109 | ||||
| 104 | for mode, want := range map[dto.VimPageMode]string{ | 110 | for mode, want := range map[dto.VimPageMode]string{ | ||
| @@ -148,7 +154,7 @@ func TestExitModeWithPageCaptureDoesNotDeadlock(t *testing.T) { | |||||
| 148 | }) | 154 | }) | ||
| 149 | wv := portmocks.NewMockWebView(t) | 155 | wv := portmocks.NewMockWebView(t) | ||
| 150 | wv.EXPECT().ID().Return(1).Maybe() | 156 | wv.EXPECT().ID().Return(1).Maybe() | ||
| 151 | - | app.captureVimPageKeys(context.Background(), bw, wv, "VISUAL") | 157 | + | app.captureVimPageKeys(context.Background(), bw, wv, visualOutcome) |
| 152 | 158 | ||||
| 153 | exitModeWithin(t, bw.keyboardHandler.ExitMode) | 159 | exitModeWithin(t, bw.keyboardHandler.ExitMode) | ||
| 154 | 160 | ||||
| @@ -156,3 +162,55 @@ func TestExitModeWithPageCaptureDoesNotDeadlock(t *testing.T) { | |||||
| 156 | require.Nil(t, bw.vimPageInteractionWebView) | 162 | require.Nil(t, bw.vimPageInteractionWebView) | ||
| 157 | require.Equal(t, input.ModeNormal, bw.keyboardHandler.Mode()) | 163 | require.Equal(t, input.ModeNormal, bw.keyboardHandler.Mode()) | ||
| 158 | } | 164 | } | ||
| 165 | + | ||||
| 166 | + | func TestVimPageModeLabel(t *testing.T) { | |||
| 167 | + | for mode, want := range map[dto.VimPageMode]string{ | |||
| 168 | + | dto.VimPageModeHints: "HINTS", | |||
| 169 | + | dto.VimPageModeCaret: "CARET", | |||
| 170 | + | dto.VimPageModeVisual: "VISUAL", | |||
| 171 | + | dto.VimPageModeVisualLine: "VISUAL LINE", | |||
| 172 | + | } { | |||
| 173 | + | got, ok := vimPageModeLabel(mode) | |||
| 174 | + | require.True(t, ok, mode) | |||
| 175 | + | require.Equal(t, want, got) | |||
| 176 | + | } | |||
| 177 | + | _, ok := vimPageModeLabel("bogus") | |||
| 178 | + | require.False(t, ok) | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | // One mapping drives every label: the sub-mode, plus the target of hints that | |||
| 182 | + | // do more than follow. Later page-reported modes drop the hint target. | |||
| 183 | + | func TestVimPageLabel(t *testing.T) { | |||
| 184 | + | tests := []struct { | |||
| 185 | + | kind dto.VimPageInteractionKind | |||
| 186 | + | mode dto.VimPageMode | |||
| 187 | + | want string | |||
| 188 | + | }{ | |||
| 189 | + | {dto.VimPageVisual, dto.VimPageModeHints, "HINTS"}, | |||
| 190 | + | {dto.VimPageHintFollow, dto.VimPageModeHints, "HINTS"}, | |||
| 191 | + | {dto.VimPageHintFollowNew, dto.VimPageModeHints, "HINTS · NEW PANE"}, | |||
| 192 | + | {dto.VimPageHintYankURL, dto.VimPageModeHints, "HINTS · YANK URL"}, | |||
| 193 | + | {dto.VimPageVisual, dto.VimPageModeCaret, "CARET"}, | |||
| 194 | + | {dto.VimPageVisual, dto.VimPageModeVisual, "VISUAL"}, | |||
| 195 | + | {dto.VimPageVisual, dto.VimPageModeVisualLine, "VISUAL LINE"}, | |||
| 196 | + | } | |||
| 197 | + | for _, tt := range tests { | |||
| 198 | + | got, ok := vimPageLabel(tt.kind, tt.mode) | |||
| 199 | + | require.True(t, ok) | |||
| 200 | + | require.Equal(t, tt.want, got, "kind %d mode %s", tt.kind, tt.mode) | |||
| 201 | + | } | |||
| 202 | + | _, ok := vimPageLabel(dto.VimPageVisual, "bogus") | |||
| 203 | + | require.False(t, ok) | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | func TestVimPageInteractionLabelsFollowTheOutcome(t *testing.T) { | |||
| 207 | + | app, bw, shown := newVimPageTestWindow(t) | |||
| 208 | + | wv := portmocks.NewMockWebView(t) | |||
| 209 | + | ||||
| 210 | + | app.captureVimPageKeys(context.Background(), bw, wv, hintsOutcome(dto.VimPageHintFollowNew)) | |||
| 211 | + | require.Equal(t, "VIM MODE · HINTS · NEW PANE", (*shown)[len(*shown)-1]) | |||
| 212 | + | app.releaseVimPageKeys(context.Background(), bw, "test", true) | |||
| 213 | + | ||||
| 214 | + | app.captureVimPageKeys(context.Background(), bw, wv, hintsOutcome(dto.VimPageHintYankURL)) | |||
| 215 | + | require.Equal(t, "VIM MODE · HINTS · YANK URL", (*shown)[len(*shown)-1]) | |||
| 216 | + | } | |||
M internal/ui/browser_window.go +4 -0
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "slices" | |
| 7 | 7 | "sort" | |
| 8 | 8 | ||
| 9 | + | "github.com/bnema/dumber/internal/application/dto" | |
| 9 | 10 | "github.com/bnema/dumber/internal/application/port" | |
| 10 | 11 | "github.com/bnema/dumber/internal/application/usecase" | |
| 11 | 12 | "github.com/bnema/dumber/internal/domain/entity" | |
| @@ -62,6 +63,8 @@ type browserWindow struct { | |||
| 62 | 63 | // vimPageInteractionWebView is the WebView running link hints or visual | |
| 63 | 64 | // selection while this window's keys are captured for the page. | |
| 64 | 65 | vimPageInteractionWebView port.WebView | |
| 66 | + | // vimPageInteractionKind names that interaction for its indicator label. | |
| 67 | + | vimPageInteractionKind dto.VimPageInteractionKind | |
| 65 | 68 | } | |
| 66 | 69 | ||
| 67 | 70 | func (bw *browserWindow) detachInputForDestroy() { | |
| @@ -125,6 +128,7 @@ func (bw *browserWindow) clearShellState() { | |||
| 125 | 128 | bw.vimNavigationHighlightedWebViews = nil | |
| 126 | 129 | bw.vimNavigationHighlightedWebViewIDs = nil | |
| 127 | 130 | bw.vimPageInteractionWebView = nil | |
| 131 | + | bw.vimPageInteractionKind = 0 | |
| 128 | 132 | } | |
| 129 | 133 | ||
| 130 | 134 | func (bw *browserWindow) initChrome(ctx context.Context, a *App) { | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "slices" | 6 | "slices" | ||
| 7 | "sort" | 7 | "sort" | ||
| 8 | 8 | ||||
| 9 | + | "github.com/bnema/dumber/internal/application/dto" | |||
| 9 | "github.com/bnema/dumber/internal/application/port" | 10 | "github.com/bnema/dumber/internal/application/port" | ||
| 10 | "github.com/bnema/dumber/internal/application/usecase" | 11 | "github.com/bnema/dumber/internal/application/usecase" | ||
| 11 | "github.com/bnema/dumber/internal/domain/entity" | 12 | "github.com/bnema/dumber/internal/domain/entity" | ||
| @@ -62,6 +63,8 @@ type browserWindow struct { | |||||
| 62 | // vimPageInteractionWebView is the WebView running link hints or visual | 63 | // vimPageInteractionWebView is the WebView running link hints or visual | ||
| 63 | // selection while this window's keys are captured for the page. | 64 | // selection while this window's keys are captured for the page. | ||
| 64 | vimPageInteractionWebView port.WebView | 65 | vimPageInteractionWebView port.WebView | ||
| 66 | + | // vimPageInteractionKind names that interaction for its indicator label. | |||
| 67 | + | vimPageInteractionKind dto.VimPageInteractionKind | |||
| 65 | } | 68 | } | ||
| 66 | 69 | ||||
| 67 | func (bw *browserWindow) detachInputForDestroy() { | 70 | func (bw *browserWindow) detachInputForDestroy() { | ||
| @@ -125,6 +128,7 @@ func (bw *browserWindow) clearShellState() { | |||||
| 125 | bw.vimNavigationHighlightedWebViews = nil | 128 | bw.vimNavigationHighlightedWebViews = nil | ||
| 126 | bw.vimNavigationHighlightedWebViewIDs = nil | 129 | bw.vimNavigationHighlightedWebViewIDs = nil | ||
| 127 | bw.vimPageInteractionWebView = nil | 130 | bw.vimPageInteractionWebView = nil | ||
| 131 | + | bw.vimPageInteractionKind = 0 | |||
| 128 | } | 132 | } | ||
| 129 | 133 | ||||
| 130 | func (bw *browserWindow) initChrome(ctx context.Context, a *App) { | 134 | func (bw *browserWindow) initChrome(ctx context.Context, a *App) { | ||
M internal/ui/mode_frame_test.go +0 -23
| @@ -1,7 +1,6 @@ | |||
| 1 | 1 | package ui | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "github.com/bnema/dumber/internal/application/dto" | |
| 5 | 4 | "testing" | |
| 6 | 5 | ||
| 7 | 6 | "github.com/bnema/dumber/internal/domain/entity" | |
| @@ -157,25 +156,3 @@ func TestShouldStartLinger(t *testing.T) { | |||
| 157 | 156 | require.False(t, shouldStartLinger(true, true, true, true, 15, 25, rect)) | |
| 158 | 157 | require.False(t, shouldStartLinger(false, false, true, true, 15, 25, rect)) | |
| 159 | 158 | } | |
| 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 | - | ||
| 176 | - | func TestVimPageInteractionLabel(t *testing.T) { | |
| 177 | - | require.Equal(t, "HINTS", vimPageInteractionLabel("visual"), "visual starts on text-anchor hints; the page reports later states") | |
| 178 | - | require.Equal(t, "HINTS", vimPageInteractionLabel("hint-follow")) | |
| 179 | - | require.Equal(t, "HINTS · NEW PANE", vimPageInteractionLabel("hint-follow-new")) | |
| 180 | - | require.Equal(t, "HINTS · YANK URL", vimPageInteractionLabel("hint-yank-url")) | |
| 181 | - | } | |
| @@ -1,7 +1,6 @@ | |||||
| 1 | package ui | 1 | package ui | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "github.com/bnema/dumber/internal/application/dto" | |||
| 5 | "testing" | 4 | "testing" | ||
| 6 | 5 | ||||
| 7 | "github.com/bnema/dumber/internal/domain/entity" | 6 | "github.com/bnema/dumber/internal/domain/entity" | ||
| @@ -157,25 +156,3 @@ func TestShouldStartLinger(t *testing.T) { | |||||
| 157 | require.False(t, shouldStartLinger(true, true, true, true, 15, 25, rect)) | 156 | require.False(t, shouldStartLinger(true, true, true, true, 15, 25, rect)) | ||
| 158 | require.False(t, shouldStartLinger(false, false, true, true, 15, 25, rect)) | 157 | require.False(t, shouldStartLinger(false, false, true, true, 15, 25, rect)) | ||
| 159 | } | 158 | } | ||
| 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 | - | ||||
| 176 | - | func TestVimPageInteractionLabel(t *testing.T) { | |||
| 177 | - | require.Equal(t, "HINTS", vimPageInteractionLabel("visual"), "visual starts on text-anchor hints; the page reports later states") | |||
| 178 | - | require.Equal(t, "HINTS", vimPageInteractionLabel("hint-follow")) | |||
| 179 | - | require.Equal(t, "HINTS · NEW PANE", vimPageInteractionLabel("hint-follow-new")) | |||
| 180 | - | require.Equal(t, "HINTS · YANK URL", vimPageInteractionLabel("hint-yank-url")) | |||
| 181 | - | } | |||
A internal/ui/vim_page.go +156 -0
| @@ -0,0 +1,156 @@ | |||
| 1 | + | package ui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/dumber/internal/application/dto" | |
| 7 | + | "github.com/bnema/dumber/internal/application/port" | |
| 8 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 9 | + | "github.com/bnema/dumber/internal/logging" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // captureVimPageKeys routes this window's Vim Mode keys to the in-page | |
| 13 | + | // interaction running in wv until the page reports its end. The outcome names | |
| 14 | + | // the interaction and the sub-mode it starts in. | |
| 15 | + | func (a *App) captureVimPageKeys(ctx context.Context, bw *browserWindow, wv port.WebView, outcome dto.VimNavigationOutcome) { | |
| 16 | + | if bw == nil || bw.keyboardHandler == nil || wv == nil { | |
| 17 | + | return | |
| 18 | + | } | |
| 19 | + | bw.vimPageInteractionWebView = wv | |
| 20 | + | bw.vimPageInteractionKind = outcome.PageKind | |
| 21 | + | label, _ := vimPageLabel(outcome.PageKind, outcome.PageMode) | |
| 22 | + | // Capture first: it resets the pending sequence, and the toast and legend | |
| 23 | + | // below must reflect the sub-mode, not a cleared sequence. | |
| 24 | + | bw.keyboardHandler.SetPageKeyCapture(func(key string) { | |
| 25 | + | if err := a.vimNavigationUseCase().SendPageKey(ctx, wv, key); err != nil { | |
| 26 | + | logging.FromContext(ctx).Debug().Err(err).Msg("vim page key forwarding failed") | |
| 27 | + | a.endVimPageInteraction(ctx, bw, a.vimModeActiveForBrowserWindow(bw)) | |
| 28 | + | } | |
| 29 | + | }) | |
| 30 | + | a.showVimModeToast(ctx, bw, label) | |
| 31 | + | if bw.modeFrame != nil { | |
| 32 | + | bw.modeFrame.setSuspended(true) | |
| 33 | + | } | |
| 34 | + | logging.FromContext(ctx).Debug(). | |
| 35 | + | Str("window_id", bw.id). | |
| 36 | + | Str("sub_mode", label). | |
| 37 | + | Msg("vim page interaction capturing keys") | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // releaseVimPageKeys returns this window's keys to Vim Mode bindings and | |
| 41 | + | // restores the plain indicator and legend when stillVim reports the window | |
| 42 | + | // remains in Vim Mode. The caller supplies it because the mode-change callback | |
| 43 | + | // runs under the modal lock, where reading the mode back would deadlock. | |
| 44 | + | // It reports the WebView that owned the interaction, if any. | |
| 45 | + | func (a *App) releaseVimPageKeys(ctx context.Context, bw *browserWindow, reason string, stillVim bool) port.WebView { | |
| 46 | + | wv := bw.vimPageInteractionWebView | |
| 47 | + | bw.vimPageInteractionWebView = nil | |
| 48 | + | bw.vimPageInteractionKind = 0 | |
| 49 | + | if bw.keyboardHandler != nil { | |
| 50 | + | bw.keyboardHandler.ClearPageKeyCapture() | |
| 51 | + | } | |
| 52 | + | if wv == nil { | |
| 53 | + | return nil | |
| 54 | + | } | |
| 55 | + | if stillVim { | |
| 56 | + | a.showVimModeToast(ctx, bw, "") | |
| 57 | + | if bw.modeFrame != nil { | |
| 58 | + | bw.modeFrame.setSuspended(false) | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | logging.FromContext(ctx).Debug(). | |
| 62 | + | Str("window_id", bw.id). | |
| 63 | + | Str("reason", reason). | |
| 64 | + | Msg("vim page interaction released keys") | |
| 65 | + | return wv | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // vimPageInteractionOwner returns the window whose key capture belongs to the | |
| 69 | + | // interaction running in paneID's WebView, or nil when that pane owns none. | |
| 70 | + | func (a *App) vimPageInteractionOwner(paneID entity.PaneID) *browserWindow { | |
| 71 | + | bw := a.browserWindowForAnyPane(paneID) | |
| 72 | + | if bw == nil || bw.vimPageInteractionWebView == nil || a.contentCoord == nil { | |
| 73 | + | return nil | |
| 74 | + | } | |
| 75 | + | if a.contentCoord.GetWebView(paneID) != bw.vimPageInteractionWebView { | |
| 76 | + | return nil | |
| 77 | + | } | |
| 78 | + | return bw | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // handleVimPageInteractionEnded releases key capture when the page reports | |
| 82 | + | // that its hint or visual interaction finished. | |
| 83 | + | func (a *App) handleVimPageInteractionEnded(ctx context.Context, paneID entity.PaneID) { | |
| 84 | + | bw := a.vimPageInteractionOwner(paneID) | |
| 85 | + | if bw == nil { | |
| 86 | + | return | |
| 87 | + | } | |
| 88 | + | a.releaseVimPageKeys(ctx, bw, "page-reported-end", a.vimModeActiveForBrowserWindow(bw)) | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | // handleVimPageModeChanged repaints the indicator when the page moves its | |
| 92 | + | // interaction to another sub-mode (hints, caret, visual). Key capture and the | |
| 93 | + | // legend suspension stay as they are. | |
| 94 | + | func (a *App) handleVimPageModeChanged(ctx context.Context, paneID entity.PaneID, mode dto.VimPageMode) { | |
| 95 | + | if bw := a.vimPageInteractionOwner(paneID); bw != nil { | |
| 96 | + | a.showVimPageMode(ctx, bw, mode) | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | // showVimPageMode paints the sub-mode label for the interaction owned by bw. | |
| 101 | + | func (a *App) showVimPageMode(ctx context.Context, bw *browserWindow, mode dto.VimPageMode) { | |
| 102 | + | if bw.vimPageInteractionWebView == nil { | |
| 103 | + | return | |
| 104 | + | } | |
| 105 | + | if label, ok := vimPageLabel(bw.vimPageInteractionKind, mode); ok { | |
| 106 | + | a.showVimModeToast(ctx, bw, label) | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | // vimPageModeLabel names a page-reported sub-mode for the Vim Mode indicator. | |
| 111 | + | func vimPageModeLabel(mode dto.VimPageMode) (string, bool) { | |
| 112 | + | switch mode { | |
| 113 | + | case dto.VimPageModeHints: | |
| 114 | + | return "HINTS", true | |
| 115 | + | case dto.VimPageModeCaret: | |
| 116 | + | return "CARET", true | |
| 117 | + | case dto.VimPageModeVisual: | |
| 118 | + | return "VISUAL", true | |
| 119 | + | case dto.VimPageModeVisualLine: | |
| 120 | + | return "VISUAL LINE", true | |
| 121 | + | default: | |
| 122 | + | return "", false | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | // vimPageLabel is the single source of the indicator label: the sub-mode name, | |
| 127 | + | // plus the target of link hints that do more than follow (new pane, yank URL). | |
| 128 | + | func vimPageLabel(kind dto.VimPageInteractionKind, mode dto.VimPageMode) (string, bool) { | |
| 129 | + | label, ok := vimPageModeLabel(mode) | |
| 130 | + | if !ok || mode != dto.VimPageModeHints { | |
| 131 | + | return label, ok | |
| 132 | + | } | |
| 133 | + | switch kind { | |
| 134 | + | case dto.VimPageHintFollowNew: | |
| 135 | + | return label + " · NEW PANE", true | |
| 136 | + | case dto.VimPageHintYankURL: | |
| 137 | + | return label + " · YANK URL", true | |
| 138 | + | default: | |
| 139 | + | return label, true | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | // endVimPageInteraction cancels any in-page interaction owned by this window. | |
| 144 | + | // stillVim reports whether the window stays in Vim Mode (see releaseVimPageKeys). | |
| 145 | + | func (a *App) endVimPageInteraction(ctx context.Context, bw *browserWindow, stillVim bool) { | |
| 146 | + | if bw == nil { | |
| 147 | + | return | |
| 148 | + | } | |
| 149 | + | wv := a.releaseVimPageKeys(ctx, bw, "canceled", stillVim) | |
| 150 | + | if wv == nil { | |
| 151 | + | return | |
| 152 | + | } | |
| 153 | + | if err := a.vimNavigationUseCase().CancelPageInteraction(ctx, wv); err != nil { | |
| 154 | + | logging.FromContext(ctx).Debug().Err(err).Msg("failed to cancel vim page interaction") | |
| 155 | + | } | |
| 156 | + | } | |
| @@ -0,0 +1,156 @@ | |||||
| 1 | + | package ui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/dumber/internal/application/dto" | |||
| 7 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 8 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 9 | + | "github.com/bnema/dumber/internal/logging" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // captureVimPageKeys routes this window's Vim Mode keys to the in-page | |||
| 13 | + | // interaction running in wv until the page reports its end. The outcome names | |||
| 14 | + | // the interaction and the sub-mode it starts in. | |||
| 15 | + | func (a *App) captureVimPageKeys(ctx context.Context, bw *browserWindow, wv port.WebView, outcome dto.VimNavigationOutcome) { | |||
| 16 | + | if bw == nil || bw.keyboardHandler == nil || wv == nil { | |||
| 17 | + | return | |||
| 18 | + | } | |||
| 19 | + | bw.vimPageInteractionWebView = wv | |||
| 20 | + | bw.vimPageInteractionKind = outcome.PageKind | |||
| 21 | + | label, _ := vimPageLabel(outcome.PageKind, outcome.PageMode) | |||
| 22 | + | // Capture first: it resets the pending sequence, and the toast and legend | |||
| 23 | + | // below must reflect the sub-mode, not a cleared sequence. | |||
| 24 | + | bw.keyboardHandler.SetPageKeyCapture(func(key string) { | |||
| 25 | + | if err := a.vimNavigationUseCase().SendPageKey(ctx, wv, key); err != nil { | |||
| 26 | + | logging.FromContext(ctx).Debug().Err(err).Msg("vim page key forwarding failed") | |||
| 27 | + | a.endVimPageInteraction(ctx, bw, a.vimModeActiveForBrowserWindow(bw)) | |||
| 28 | + | } | |||
| 29 | + | }) | |||
| 30 | + | a.showVimModeToast(ctx, bw, label) | |||
| 31 | + | if bw.modeFrame != nil { | |||
| 32 | + | bw.modeFrame.setSuspended(true) | |||
| 33 | + | } | |||
| 34 | + | logging.FromContext(ctx).Debug(). | |||
| 35 | + | Str("window_id", bw.id). | |||
| 36 | + | Str("sub_mode", label). | |||
| 37 | + | Msg("vim page interaction capturing keys") | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | // releaseVimPageKeys returns this window's keys to Vim Mode bindings and | |||
| 41 | + | // restores the plain indicator and legend when stillVim reports the window | |||
| 42 | + | // remains in Vim Mode. The caller supplies it because the mode-change callback | |||
| 43 | + | // runs under the modal lock, where reading the mode back would deadlock. | |||
| 44 | + | // It reports the WebView that owned the interaction, if any. | |||
| 45 | + | func (a *App) releaseVimPageKeys(ctx context.Context, bw *browserWindow, reason string, stillVim bool) port.WebView { | |||
| 46 | + | wv := bw.vimPageInteractionWebView | |||
| 47 | + | bw.vimPageInteractionWebView = nil | |||
| 48 | + | bw.vimPageInteractionKind = 0 | |||
| 49 | + | if bw.keyboardHandler != nil { | |||
| 50 | + | bw.keyboardHandler.ClearPageKeyCapture() | |||
| 51 | + | } | |||
| 52 | + | if wv == nil { | |||
| 53 | + | return nil | |||
| 54 | + | } | |||
| 55 | + | if stillVim { | |||
| 56 | + | a.showVimModeToast(ctx, bw, "") | |||
| 57 | + | if bw.modeFrame != nil { | |||
| 58 | + | bw.modeFrame.setSuspended(false) | |||
| 59 | + | } | |||
| 60 | + | } | |||
| 61 | + | logging.FromContext(ctx).Debug(). | |||
| 62 | + | Str("window_id", bw.id). | |||
| 63 | + | Str("reason", reason). | |||
| 64 | + | Msg("vim page interaction released keys") | |||
| 65 | + | return wv | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | // vimPageInteractionOwner returns the window whose key capture belongs to the | |||
| 69 | + | // interaction running in paneID's WebView, or nil when that pane owns none. | |||
| 70 | + | func (a *App) vimPageInteractionOwner(paneID entity.PaneID) *browserWindow { | |||
| 71 | + | bw := a.browserWindowForAnyPane(paneID) | |||
| 72 | + | if bw == nil || bw.vimPageInteractionWebView == nil || a.contentCoord == nil { | |||
| 73 | + | return nil | |||
| 74 | + | } | |||
| 75 | + | if a.contentCoord.GetWebView(paneID) != bw.vimPageInteractionWebView { | |||
| 76 | + | return nil | |||
| 77 | + | } | |||
| 78 | + | return bw | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | // handleVimPageInteractionEnded releases key capture when the page reports | |||
| 82 | + | // that its hint or visual interaction finished. | |||
| 83 | + | func (a *App) handleVimPageInteractionEnded(ctx context.Context, paneID entity.PaneID) { | |||
| 84 | + | bw := a.vimPageInteractionOwner(paneID) | |||
| 85 | + | if bw == nil { | |||
| 86 | + | return | |||
| 87 | + | } | |||
| 88 | + | a.releaseVimPageKeys(ctx, bw, "page-reported-end", a.vimModeActiveForBrowserWindow(bw)) | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | // handleVimPageModeChanged repaints the indicator when the page moves its | |||
| 92 | + | // interaction to another sub-mode (hints, caret, visual). Key capture and the | |||
| 93 | + | // legend suspension stay as they are. | |||
| 94 | + | func (a *App) handleVimPageModeChanged(ctx context.Context, paneID entity.PaneID, mode dto.VimPageMode) { | |||
| 95 | + | if bw := a.vimPageInteractionOwner(paneID); bw != nil { | |||
| 96 | + | a.showVimPageMode(ctx, bw, mode) | |||
| 97 | + | } | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | // showVimPageMode paints the sub-mode label for the interaction owned by bw. | |||
| 101 | + | func (a *App) showVimPageMode(ctx context.Context, bw *browserWindow, mode dto.VimPageMode) { | |||
| 102 | + | if bw.vimPageInteractionWebView == nil { | |||
| 103 | + | return | |||
| 104 | + | } | |||
| 105 | + | if label, ok := vimPageLabel(bw.vimPageInteractionKind, mode); ok { | |||
| 106 | + | a.showVimModeToast(ctx, bw, label) | |||
| 107 | + | } | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | // vimPageModeLabel names a page-reported sub-mode for the Vim Mode indicator. | |||
| 111 | + | func vimPageModeLabel(mode dto.VimPageMode) (string, bool) { | |||
| 112 | + | switch mode { | |||
| 113 | + | case dto.VimPageModeHints: | |||
| 114 | + | return "HINTS", true | |||
| 115 | + | case dto.VimPageModeCaret: | |||
| 116 | + | return "CARET", true | |||
| 117 | + | case dto.VimPageModeVisual: | |||
| 118 | + | return "VISUAL", true | |||
| 119 | + | case dto.VimPageModeVisualLine: | |||
| 120 | + | return "VISUAL LINE", true | |||
| 121 | + | default: | |||
| 122 | + | return "", false | |||
| 123 | + | } | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | // vimPageLabel is the single source of the indicator label: the sub-mode name, | |||
| 127 | + | // plus the target of link hints that do more than follow (new pane, yank URL). | |||
| 128 | + | func vimPageLabel(kind dto.VimPageInteractionKind, mode dto.VimPageMode) (string, bool) { | |||
| 129 | + | label, ok := vimPageModeLabel(mode) | |||
| 130 | + | if !ok || mode != dto.VimPageModeHints { | |||
| 131 | + | return label, ok | |||
| 132 | + | } | |||
| 133 | + | switch kind { | |||
| 134 | + | case dto.VimPageHintFollowNew: | |||
| 135 | + | return label + " · NEW PANE", true | |||
| 136 | + | case dto.VimPageHintYankURL: | |||
| 137 | + | return label + " · YANK URL", true | |||
| 138 | + | default: | |||
| 139 | + | return label, true | |||
| 140 | + | } | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | // endVimPageInteraction cancels any in-page interaction owned by this window. | |||
| 144 | + | // stillVim reports whether the window stays in Vim Mode (see releaseVimPageKeys). | |||
| 145 | + | func (a *App) endVimPageInteraction(ctx context.Context, bw *browserWindow, stillVim bool) { | |||
| 146 | + | if bw == nil { | |||
| 147 | + | return | |||
| 148 | + | } | |||
| 149 | + | wv := a.releaseVimPageKeys(ctx, bw, "canceled", stillVim) | |||
| 150 | + | if wv == nil { | |||
| 151 | + | return | |||
| 152 | + | } | |||
| 153 | + | if err := a.vimNavigationUseCase().CancelPageInteraction(ctx, wv); err != nil { | |||
| 154 | + | logging.FromContext(ctx).Debug().Err(err).Msg("failed to cancel vim page interaction") | |||
| 155 | + | } | |||
| 156 | + | } | |||