detached b7a1707

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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
23092309 a.releaseVimPageKeys(ctx, bw, "page-reported-end")
23102310 }
23112311
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+
23122351 // 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.
23142354 func vimPageInteractionLabel(action string) string {
23152355 switch action {
23162356 case "visual":
2317- return "VISUAL"
2357+ return "HINTS"
23182358 case "hint-follow-new":
23192359 return "HINTS · NEW PANE"
23202360 case "hint-yank-url":
@@ -3522,6 +3562,9 @@ func (a *App) initCoordinators(ctx context.Context) {
35223562 a.contentCoord.SetOnVimPageInteractionEnded(func(paneID entity.PaneID) {
35233563 a.handleVimPageInteractionEnded(ctx, paneID)
35243564 })
3565+ a.contentCoord.SetOnVimPageModeChanged(func(paneID entity.PaneID, mode dto.VimPageMode) {
3566+ a.handleVimPageModeChanged(ctx, paneID, mode)
3567+ })
35253568
35263569 // JS dialogs are shown in the owning pane's overlay, wherever that pane lives.
35273570 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 Vim2351 // 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 (
77 "github.com/stretchr/testify/mock"
88 "github.com/stretchr/testify/require"
99
10+ "github.com/bnema/dumber/internal/application/dto"
1011 portmocks "github.com/bnema/dumber/internal/application/port/mocks"
1112 "github.com/bnema/dumber/internal/domain/entity"
1213 "github.com/bnema/dumber/internal/ui/input"
@@ -87,3 +88,36 @@ func TestVimPageInteractionHidesLegendWhileCaptured(t *testing.T) {
8788 require.False(t, frame.suspended, "legend must be restored when the interaction ends")
8889 require.False(t, bw.keyboardHandler.PageKeyCaptureActive())
8990 }
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 (
55 "net/url"
66 "strings"
77
8+ "github.com/bnema/dumber/internal/application/dto"
89 "github.com/bnema/dumber/internal/application/port"
910 "github.com/bnema/dumber/internal/application/usecase"
1011 "github.com/bnema/dumber/internal/domain/entity"
@@ -187,11 +188,7 @@ func (c *Coordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.P
187188 }
188189 }
189190
190- callbacks.OnVimPageInteractionEnded = func() {
191- if c.onVimPageInteractionEnded != nil {
192- c.onVimPageInteractionEnded(paneID)
193- }
194- }
191+ c.setupVimPageCallbacks(paneID, callbacks)
195192
196193 // Add popup create handler if popup handling is configured
197194 callbacks.OnCreate = c.buildPopupCreateHandler(ctx, paneID, wv)
@@ -199,6 +196,20 @@ func (c *Coordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.P
199196 wv.SetCallbacks(callbacks)
200197 }
201198
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+
202213 // handlePermissionRequest processes media permission requests from WebKit.
203214 // It delegates to the permission use case which handles auto-allow, stored permissions, and dialogs.
204215 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 configured193 // 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 (
44 "context"
55 "sync"
66
7+ "github.com/bnema/dumber/internal/application/dto"
78 "github.com/bnema/dumber/internal/application/port"
89 "github.com/bnema/dumber/internal/application/usecase"
910 "github.com/bnema/dumber/internal/domain/entity"
@@ -123,6 +124,8 @@ type Coordinator struct {
123124 onEditableFocusChanged func(paneID entity.PaneID, editable bool)
124125 // onVimPageInteractionEnded fires when a page finishes link hints or visual selection.
125126 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)
126129
127130 // Callback for first load_started event (triggers deferred initialization)
128131 onFirstLoadStarted func()
@@ -285,6 +288,12 @@ func (c *Coordinator) SetOnVimPageInteractionEnded(fn func(paneID entity.PaneID)
285288 c.onVimPageInteractionEnded = fn
286289 }
287290
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+
288297 // SetOnFirstLoadStarted sets the callback for when the first navigation starts.
289298 // This is used to trigger deferred initialization after the initial load_uri()
290299 // 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 = fn288 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 {
3636 }
3737
3838 // 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.
4143 func (h *KeyboardHandler) forwardPageKey(mode Mode, keyval uint, state gdk.ModifierType) bool {
4244 if mode != ModeVim {
4345 return false
@@ -56,9 +58,8 @@ func (h *KeyboardHandler) forwardPageKey(mode Mode, keyval uint, state gdk.Modif
5658 return true
5759 }
5860 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.
6062 key.Mods = 0
61- h.ClearPageKeyCapture()
6263 }
6364 forward(vimkeys.Sequence{key}.String())
6465 // 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 is38 // 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 never39+// 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 false45 return false
@@ -56,9 +58,8 @@ func (h *KeyboardHandler) forwardPageKey(mode Mode, keyval uint, state gdk.Modif
56 return true58 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 = 062 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) {
3434 assert.Empty(t, h.PendingSequence())
3535 }
3636
37-func TestPageKeyCaptureEscapeReleasesCaptureAndStaysInVimMode(t *testing.T) {
37+func TestPageKeyCaptureEscapeIsForwardedAndKeepsCaptureForPageToEnd(t *testing.T) {
3838 h := NewKeyboardHandler(context.Background(), vimModeSequenceWorkspace(nil), newTestSession())
3939 enterVimMode(t, h)
4040 var forwarded []string
@@ -43,8 +43,11 @@ func TestPageKeyCaptureEscapeReleasesCaptureAndStaysInVimMode(t *testing.T) {
4343 require.True(t, h.handleKeyPress(uint(gdk.KEY_Escape), 0, 0))
4444
4545 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")
4747 assert.Equal(t, ModeVim, h.Mode())
48+
49+ h.ClearPageKeyCapture()
50+ assert.False(t, h.PageKeyCaptureActive())
4851 }
4952
5053 func TestPageKeyCaptureClearedWhenLeavingVimMode(t *testing.T) {
@@ -66,7 +69,6 @@ func TestPageKeyCaptureModifiedEscapeForwardsPlainEscape(t *testing.T) {
6669 require.True(t, h.handleKeyPress(uint(gdk.KEY_Escape), 0, gdk.ShiftMaskValue))
6770
6871 assert.Equal(t, []string{"<Escape>"}, forwarded)
69- assert.False(t, h.PageKeyCaptureActive())
7072 }
7173
7274 // 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 []string40 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 @@
11 package ui
22
33 import (
4+ "github.com/bnema/dumber/internal/application/dto"
45 "testing"
56
67 "github.com/bnema/dumber/internal/domain/entity"
@@ -157,8 +158,23 @@ func TestShouldStartLinger(t *testing.T) {
157158 require.False(t, shouldStartLinger(false, false, true, true, 15, 25, rect))
158159 }
159160
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+
160176 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")
162178 require.Equal(t, "HINTS", vimPageInteractionLabel("hint-follow"))
163179 require.Equal(t, "HINTS · NEW PANE", vimPageInteractionLabel("hint-follow-new"))
164180 require.Equal(t, "HINTS · YANK URL", vimPageInteractionLabel("hint-yank-url"))
@@ -1,6 +1,7 @@
1 package ui1 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"))

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close