detached 4bafd5c

refactor(vim): finalize navigation boundaries

commit
4bafd5c0e3f506845ce1070d65556f3faccfa3c5
author
brice · · 2026-08-14 14:41 UTC
parent
0afb76e
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
18 files · +207 -1514
M docs/config/index.md +3 -0
@@ -404,6 +404,8 @@ cancel = ["escape"]
404404 |-----|------|---------|-------------|
405405 | `workspace.vim_mode.activation_shortcut` | string | `"ctrl+y"` | Vim mode activation key |
406406 | `workspace.vim_mode.timeout_ms` | int | `0` | Vim mode timeout in milliseconds (`0` disables auto-timeout) |
407+| `workspace.vim_mode.sequence_timeout_ms` | int | `500` | Timeout for ambiguous multi-key Vim sequences |
408+| `workspace.vim_mode.preload_accessibility` | bool | `false` | Pre-enable browser accessibility when a WebView is shown |
407409 | `workspace.vim_mode.actions` | map | See below | Action→binding mappings |
408410
409411 **Default vim mode actions:**
@@ -461,6 +463,7 @@ Notes:
461463 - The default `timeout_ms = 0` means Vim Mode stays active until you exit it or focus moves into an editable/browser UI context. `Ctrl+Y` can still activate it when a page input is already focused.
462464 - Arrow keys still use the browser engine's native page-navigation path while Vim Mode is active.
463465 - `Tab` and `Shift+Tab` keep focus traversal inside the page while a page input is focused.
466+- Live input, heading, and page-focus navigation currently use the CEF engine path; WebKit fallback supports the Vim scroll commands while equivalent semantic navigation is pending.
464467 - Other app-level shortcuts stay suspended until Vim Mode exits, except for the Vim Mode toggle itself.
465468 - `workspace.styling.pane_mode_color`, `workspace.styling.transition_duration`, and `workspace.styling.mode_indicator_toaster_enabled` control the pane-local Vim Mode visuals.
466469 - Scroll execution: CEF and WebKit both use the shared `BuildPageScrollByJS` resolver when the page is ready. Each step starts under the viewport center, walks up through ancestors that can move in the requested direction, and hands scrolling to the document when a nested container reaches its boundary. The application repeater owns held-key cadence; each engine executes one immediate scroll step per tick. Cross-origin frame contents remain best-effort. CEF may use native precision-wheel input only as a pre-frame fallback before the browser/main frame is ready.
@@ -404,6 +404,8 @@ cancel = ["escape"]
404 |-----|------|---------|-------------|404 |-----|------|---------|-------------|
405 | `workspace.vim_mode.activation_shortcut` | string | `"ctrl+y"` | Vim mode activation key |405 | `workspace.vim_mode.activation_shortcut` | string | `"ctrl+y"` | Vim mode activation key |
406 | `workspace.vim_mode.timeout_ms` | int | `0` | Vim mode timeout in milliseconds (`0` disables auto-timeout) |406 | `workspace.vim_mode.timeout_ms` | int | `0` | Vim mode timeout in milliseconds (`0` disables auto-timeout) |
407+| `workspace.vim_mode.sequence_timeout_ms` | int | `500` | Timeout for ambiguous multi-key Vim sequences |
408+| `workspace.vim_mode.preload_accessibility` | bool | `false` | Pre-enable browser accessibility when a WebView is shown |
407 | `workspace.vim_mode.actions` | map | See below | Action→binding mappings |409 | `workspace.vim_mode.actions` | map | See below | Action→binding mappings |
408 410
409 **Default vim mode actions:**411 **Default vim mode actions:**
@@ -461,6 +463,7 @@ Notes:
461 - The default `timeout_ms = 0` means Vim Mode stays active until you exit it or focus moves into an editable/browser UI context. `Ctrl+Y` can still activate it when a page input is already focused.463 - The default `timeout_ms = 0` means Vim Mode stays active until you exit it or focus moves into an editable/browser UI context. `Ctrl+Y` can still activate it when a page input is already focused.
462 - Arrow keys still use the browser engine's native page-navigation path while Vim Mode is active.464 - Arrow keys still use the browser engine's native page-navigation path while Vim Mode is active.
463 - `Tab` and `Shift+Tab` keep focus traversal inside the page while a page input is focused.465 - `Tab` and `Shift+Tab` keep focus traversal inside the page while a page input is focused.
466+- Live input, heading, and page-focus navigation currently use the CEF engine path; WebKit fallback supports the Vim scroll commands while equivalent semantic navigation is pending.
464 - Other app-level shortcuts stay suspended until Vim Mode exits, except for the Vim Mode toggle itself.467 - Other app-level shortcuts stay suspended until Vim Mode exits, except for the Vim Mode toggle itself.
465 - `workspace.styling.pane_mode_color`, `workspace.styling.transition_duration`, and `workspace.styling.mode_indicator_toaster_enabled` control the pane-local Vim Mode visuals.468 - `workspace.styling.pane_mode_color`, `workspace.styling.transition_duration`, and `workspace.styling.mode_indicator_toaster_enabled` control the pane-local Vim Mode visuals.
466 - Scroll execution: CEF and WebKit both use the shared `BuildPageScrollByJS` resolver when the page is ready. Each step starts under the viewport center, walks up through ancestors that can move in the requested direction, and hands scrolling to the document when a nested container reaches its boundary. The application repeater owns held-key cadence; each engine executes one immediate scroll step per tick. Cross-origin frame contents remain best-effort. CEF may use native precision-wheel input only as a pre-frame fallback before the browser/main frame is ready.469 - Scroll execution: CEF and WebKit both use the shared `BuildPageScrollByJS` resolver when the page is ready. Each step starts under the viewport center, walks up through ancestors that can move in the requested direction, and hands scrolling to the document when a nested container reaches its boundary. The application repeater owns held-key cadence; each engine executes one immediate scroll step per tick. Cross-origin frame contents remain best-effort. CEF may use native precision-wheel input only as a pre-frame fallback before the browser/main frame is ready.
M docs/reference/configuration.md +2 -0
@@ -102,6 +102,8 @@
102102 | `workspace.tab_mode.actions.<action>` | []string | see defaults | tab mode key mappings |
103103 | `workspace.vim_mode.activation_shortcut` | string | `ctrl+y` | |
104104 | `workspace.vim_mode.timeout_ms` | int | `0` | >= 0 |
105+| `workspace.vim_mode.sequence_timeout_ms` | int | `500` | >= 0; timeout for ambiguous multi-key Vim sequences |
106+| `workspace.vim_mode.preload_accessibility` | bool | `false` | Pre-enable browser accessibility when a WebView is shown |
105107 | `workspace.vim_mode.actions.<action>` | object | see defaults | vim mode action object with `keys` ([]string) and `desc` (string) |
106108 | `workspace.resize_mode.activation_shortcut` | string | `ctrl+n` | |
107109 | `workspace.resize_mode.timeout_ms` | int | `3000` | |
@@ -102,6 +102,8 @@
102 | `workspace.tab_mode.actions.<action>` | []string | see defaults | tab mode key mappings |102 | `workspace.tab_mode.actions.<action>` | []string | see defaults | tab mode key mappings |
103 | `workspace.vim_mode.activation_shortcut` | string | `ctrl+y` | |103 | `workspace.vim_mode.activation_shortcut` | string | `ctrl+y` | |
104 | `workspace.vim_mode.timeout_ms` | int | `0` | >= 0 |104 | `workspace.vim_mode.timeout_ms` | int | `0` | >= 0 |
105+| `workspace.vim_mode.sequence_timeout_ms` | int | `500` | >= 0; timeout for ambiguous multi-key Vim sequences |
106+| `workspace.vim_mode.preload_accessibility` | bool | `false` | Pre-enable browser accessibility when a WebView is shown |
105 | `workspace.vim_mode.actions.<action>` | object | see defaults | vim mode action object with `keys` ([]string) and `desc` (string) |107 | `workspace.vim_mode.actions.<action>` | object | see defaults | vim mode action object with `keys` ([]string) and `desc` (string) |
106 | `workspace.resize_mode.activation_shortcut` | string | `ctrl+n` | |108 | `workspace.resize_mode.activation_shortcut` | string | `ctrl+n` | |
107 | `workspace.resize_mode.timeout_ms` | int | `3000` | |109 | `workspace.resize_mode.timeout_ms` | int | `3000` | |
M docs/reference/keybindings.md +1 -1
@@ -59,7 +59,7 @@ Vim Mode is an explicit Vim-style navigation mode for the active pane only. It a
5959
6060 CEF and WebKit execute Vim Mode scroll commands (`h/j/k/l`, `Shift+J/K`) with the shared `BuildPageScrollByJS` resolver. Each step starts under the viewport center, walks up through ancestors that can move in the requested direction, and hands scrolling to the document when a nested container reaches its boundary. Cross-origin frame contents remain best-effort.
6161
62-`gi` focuses the next visible, editable page input. With no eligible input focused it starts at the first one; repeated `gi` commands cycle through the inputs. `]]` and `[[` move through visible `h1`–`h6` headings and outline the selected heading with the current Dumber theme accent. When a page input is focused, `Tab` and `Shift+Tab` keep focus traversal inside the page; traversal stops safely at the page boundaries instead of moving into the host window.
62+`gi` focuses the next visible, editable page input. With no eligible input focused it starts at the first one; repeated `gi` commands cycle through the inputs. `]]` and `[[` move through visible `h1`–`h6` headings and outline the selected heading with the current Dumber theme accent. When a page input is focused, `Tab` and `Shift+Tab` keep focus traversal inside the page; traversal stops safely at the page boundaries instead of moving into the host window. These live input, heading, and page-focus motions currently use the CEF engine path; the WebKit fallback supports Vim scrolling but not these semantic motions yet.
6363
6464 | Action | Keys |
6565 |--------|------|
@@ -59,7 +59,7 @@ Vim Mode is an explicit Vim-style navigation mode for the active pane only. It a
59 59
60 CEF and WebKit execute Vim Mode scroll commands (`h/j/k/l`, `Shift+J/K`) with the shared `BuildPageScrollByJS` resolver. Each step starts under the viewport center, walks up through ancestors that can move in the requested direction, and hands scrolling to the document when a nested container reaches its boundary. Cross-origin frame contents remain best-effort.60 CEF and WebKit execute Vim Mode scroll commands (`h/j/k/l`, `Shift+J/K`) with the shared `BuildPageScrollByJS` resolver. Each step starts under the viewport center, walks up through ancestors that can move in the requested direction, and hands scrolling to the document when a nested container reaches its boundary. Cross-origin frame contents remain best-effort.
61 61
62-`gi` focuses the next visible, editable page input. With no eligible input focused it starts at the first one; repeated `gi` commands cycle through the inputs. `]]` and `[[` move through visible `h1`–`h6` headings and outline the selected heading with the current Dumber theme accent. When a page input is focused, `Tab` and `Shift+Tab` keep focus traversal inside the page; traversal stops safely at the page boundaries instead of moving into the host window.62+`gi` focuses the next visible, editable page input. With no eligible input focused it starts at the first one; repeated `gi` commands cycle through the inputs. `]]` and `[[` move through visible `h1`–`h6` headings and outline the selected heading with the current Dumber theme accent. When a page input is focused, `Tab` and `Shift+Tab` keep focus traversal inside the page; traversal stops safely at the page boundaries instead of moving into the host window. These live input, heading, and page-focus motions currently use the CEF engine path; the WebKit fallback supports Vim scrolling but not these semantic motions yet.
63 63
64 | Action | Keys |64 | Action | Keys |
65 |--------|------|65 |--------|------|
A internal/application/usecase/vim_navigation.go +76 -0
@@ -0,0 +1,76 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "errors"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+)
9+
10+var errUnsupportedVimNavigationAction = errors.New("vim navigation: unsupported action")
11+
12+// VimNavigationUseCase maps configured Vim Mode navigation actions to optional
13+// WebView capabilities. The UI layer supplies the active WebView and remains
14+// responsible only for event and theme context.
15+type VimNavigationUseCase struct{}
16+
17+// NewVimNavigationUseCase creates a stateless Vim navigation use case.
18+func NewVimNavigationUseCase() *VimNavigationUseCase {
19+ return &VimNavigationUseCase{}
20+}
21+
22+// Execute dispatches a configured semantic Vim action to the active WebView.
23+func (*VimNavigationUseCase) Execute(ctx context.Context, wv port.WebView, action string, count int, highlightColor string) error {
24+ if wv == nil {
25+ return errors.New("vim navigation: nil webview")
26+ }
27+
28+ if action == "focus-input" {
29+ focuser, ok := wv.(port.PageInputFocuser)
30+ if !ok {
31+ return nil
32+ }
33+ focuser.FocusNextInput()
34+ return nil
35+ }
36+
37+ var request port.SemanticNavigationRequest
38+ switch action {
39+ case "heading-next":
40+ request = port.SemanticNavigationRequest{
41+ Target: port.SemanticNavigationTargetHeading,
42+ Direction: port.SemanticNavigationForward,
43+ Count: count,
44+ HighlightColor: highlightColor,
45+ }
46+ case "heading-prev":
47+ request = port.SemanticNavigationRequest{
48+ Target: port.SemanticNavigationTargetHeading,
49+ Direction: port.SemanticNavigationBackward,
50+ Count: count,
51+ HighlightColor: highlightColor,
52+ }
53+ default:
54+ return errUnsupportedVimNavigationAction
55+ }
56+
57+ navigator, ok := wv.(port.SemanticNavigable)
58+ if !ok {
59+ return nil
60+ }
61+ return navigator.NavigateSemantic(ctx, request)
62+}
63+
64+// NavigatePageFocus keeps focus traversal inside a page when the active page
65+// control would otherwise hand the host window a Tab event.
66+func (*VimNavigationUseCase) NavigatePageFocus(wv port.WebView, backward bool) bool {
67+ if wv == nil {
68+ return false
69+ }
70+ navigator, ok := wv.(port.PageFocusNavigator)
71+ if !ok {
72+ return false
73+ }
74+ navigator.NavigatePageFocus(backward)
75+ return true
76+}
@@ -0,0 +1,76 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "errors"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+)
9+
10+var errUnsupportedVimNavigationAction = errors.New("vim navigation: unsupported action")
11+
12+// VimNavigationUseCase maps configured Vim Mode navigation actions to optional
13+// WebView capabilities. The UI layer supplies the active WebView and remains
14+// responsible only for event and theme context.
15+type VimNavigationUseCase struct{}
16+
17+// NewVimNavigationUseCase creates a stateless Vim navigation use case.
18+func NewVimNavigationUseCase() *VimNavigationUseCase {
19+ return &VimNavigationUseCase{}
20+}
21+
22+// Execute dispatches a configured semantic Vim action to the active WebView.
23+func (*VimNavigationUseCase) Execute(ctx context.Context, wv port.WebView, action string, count int, highlightColor string) error {
24+ if wv == nil {
25+ return errors.New("vim navigation: nil webview")
26+ }
27+
28+ if action == "focus-input" {
29+ focuser, ok := wv.(port.PageInputFocuser)
30+ if !ok {
31+ return nil
32+ }
33+ focuser.FocusNextInput()
34+ return nil
35+ }
36+
37+ var request port.SemanticNavigationRequest
38+ switch action {
39+ case "heading-next":
40+ request = port.SemanticNavigationRequest{
41+ Target: port.SemanticNavigationTargetHeading,
42+ Direction: port.SemanticNavigationForward,
43+ Count: count,
44+ HighlightColor: highlightColor,
45+ }
46+ case "heading-prev":
47+ request = port.SemanticNavigationRequest{
48+ Target: port.SemanticNavigationTargetHeading,
49+ Direction: port.SemanticNavigationBackward,
50+ Count: count,
51+ HighlightColor: highlightColor,
52+ }
53+ default:
54+ return errUnsupportedVimNavigationAction
55+ }
56+
57+ navigator, ok := wv.(port.SemanticNavigable)
58+ if !ok {
59+ return nil
60+ }
61+ return navigator.NavigateSemantic(ctx, request)
62+}
63+
64+// NavigatePageFocus keeps focus traversal inside a page when the active page
65+// control would otherwise hand the host window a Tab event.
66+func (*VimNavigationUseCase) NavigatePageFocus(wv port.WebView, backward bool) bool {
67+ if wv == nil {
68+ return false
69+ }
70+ navigator, ok := wv.(port.PageFocusNavigator)
71+ if !ok {
72+ return false
73+ }
74+ navigator.NavigatePageFocus(backward)
75+ return true
76+}
A internal/application/usecase/vim_navigation_test.go +64 -0
@@ -0,0 +1,64 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ portmocks "github.com/bnema/dumber/internal/application/port/mocks"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+type vimNavigationWebView struct {
14+ *portmocks.MockWebView
15+ focusNextInputCalls int
16+ pageFocusBackward []bool
17+ semanticRequests []port.SemanticNavigationRequest
18+}
19+
20+func (wv *vimNavigationWebView) FocusNextInput() {
21+ wv.focusNextInputCalls++
22+}
23+
24+func (wv *vimNavigationWebView) NavigatePageFocus(backward bool) {
25+ wv.pageFocusBackward = append(wv.pageFocusBackward, backward)
26+}
27+
28+func (wv *vimNavigationWebView) NavigateSemantic(_ context.Context, request port.SemanticNavigationRequest) error {
29+ wv.semanticRequests = append(wv.semanticRequests, request)
30+ return nil
31+}
32+
33+func TestVimNavigationUseCaseExecuteDispatchesConfiguredActions(t *testing.T) {
34+ wv := &vimNavigationWebView{MockWebView: portmocks.NewMockWebView(t)}
35+ uc := NewVimNavigationUseCase()
36+
37+ require.NoError(t, uc.Execute(context.Background(), wv, "focus-input", 1, ""))
38+ require.NoError(t, uc.Execute(context.Background(), wv, "heading-prev", 3, "#aabbcc"))
39+
40+ assert.Equal(t, 1, wv.focusNextInputCalls)
41+ require.Len(t, wv.semanticRequests, 1)
42+ assert.Equal(t, port.SemanticNavigationRequest{
43+ Target: port.SemanticNavigationTargetHeading,
44+ Direction: port.SemanticNavigationBackward,
45+ Count: 3,
46+ HighlightColor: "#aabbcc",
47+ }, wv.semanticRequests[0])
48+}
49+
50+func TestVimNavigationUseCaseNavigatePageFocus(t *testing.T) {
51+ wv := &vimNavigationWebView{MockWebView: portmocks.NewMockWebView(t)}
52+
53+ handled := NewVimNavigationUseCase().NavigatePageFocus(wv, true)
54+
55+ assert.True(t, handled)
56+ assert.Equal(t, []bool{true}, wv.pageFocusBackward)
57+}
58+
59+func TestVimNavigationUseCaseIgnoresUnsupportedCapability(t *testing.T) {
60+ wv := portmocks.NewMockWebView(t)
61+
62+ require.NoError(t, NewVimNavigationUseCase().Execute(context.Background(), wv, "focus-input", 1, ""))
63+ assert.False(t, NewVimNavigationUseCase().NavigatePageFocus(wv, true))
64+}
@@ -0,0 +1,64 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ portmocks "github.com/bnema/dumber/internal/application/port/mocks"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+type vimNavigationWebView struct {
14+ *portmocks.MockWebView
15+ focusNextInputCalls int
16+ pageFocusBackward []bool
17+ semanticRequests []port.SemanticNavigationRequest
18+}
19+
20+func (wv *vimNavigationWebView) FocusNextInput() {
21+ wv.focusNextInputCalls++
22+}
23+
24+func (wv *vimNavigationWebView) NavigatePageFocus(backward bool) {
25+ wv.pageFocusBackward = append(wv.pageFocusBackward, backward)
26+}
27+
28+func (wv *vimNavigationWebView) NavigateSemantic(_ context.Context, request port.SemanticNavigationRequest) error {
29+ wv.semanticRequests = append(wv.semanticRequests, request)
30+ return nil
31+}
32+
33+func TestVimNavigationUseCaseExecuteDispatchesConfiguredActions(t *testing.T) {
34+ wv := &vimNavigationWebView{MockWebView: portmocks.NewMockWebView(t)}
35+ uc := NewVimNavigationUseCase()
36+
37+ require.NoError(t, uc.Execute(context.Background(), wv, "focus-input", 1, ""))
38+ require.NoError(t, uc.Execute(context.Background(), wv, "heading-prev", 3, "#aabbcc"))
39+
40+ assert.Equal(t, 1, wv.focusNextInputCalls)
41+ require.Len(t, wv.semanticRequests, 1)
42+ assert.Equal(t, port.SemanticNavigationRequest{
43+ Target: port.SemanticNavigationTargetHeading,
44+ Direction: port.SemanticNavigationBackward,
45+ Count: 3,
46+ HighlightColor: "#aabbcc",
47+ }, wv.semanticRequests[0])
48+}
49+
50+func TestVimNavigationUseCaseNavigatePageFocus(t *testing.T) {
51+ wv := &vimNavigationWebView{MockWebView: portmocks.NewMockWebView(t)}
52+
53+ handled := NewVimNavigationUseCase().NavigatePageFocus(wv, true)
54+
55+ assert.True(t, handled)
56+ assert.Equal(t, []bool{true}, wv.pageFocusBackward)
57+}
58+
59+func TestVimNavigationUseCaseIgnoresUnsupportedCapability(t *testing.T) {
60+ wv := portmocks.NewMockWebView(t)
61+
62+ require.NoError(t, NewVimNavigationUseCase().Execute(context.Background(), wv, "focus-input", 1, ""))
63+ assert.False(t, NewVimNavigationUseCase().NavigatePageFocus(wv, true))
64+}
D internal/domain/docmodel/block_motions_test.go +0 -256
@@ -1,256 +0,0 @@
1-package docmodel
2-
3-import (
4- "reflect"
5- "testing"
6-)
7-
8-func TestStrictNextBlockOfKind(t *testing.T) {
9- doc := blockKindMotionDocument(t)
10-
11- tests := []struct {
12- name string
13- from Cursor
14- kind BlockKind
15- count int
16- want Cursor
17- ok bool
18- }{
19- {
20- name: "next code block",
21- from: Cursor{BlockID: "p0"},
22- kind: BlockKindCode,
23- want: Cursor{BlockID: "code1"},
24- ok: true,
25- },
26- {
27- name: "next table block",
28- from: Cursor{BlockID: "p0"},
29- kind: BlockKindTable,
30- want: Cursor{BlockID: "table1"},
31- ok: true,
32- },
33- {
34- name: "next image block",
35- from: Cursor{BlockID: "p0"},
36- kind: BlockKindImage,
37- want: Cursor{BlockID: "image1"},
38- ok: true,
39- },
40- {
41- name: "count below one means one",
42- from: Cursor{BlockID: "p1"},
43- kind: BlockKindTable,
44- count: -2,
45- want: Cursor{BlockID: "table1"},
46- ok: true,
47- },
48- {
49- name: "strict next does not match current block",
50- from: Cursor{BlockID: "code1"},
51- kind: BlockKindCode,
52- count: 1,
53- want: Cursor{BlockID: "code2"},
54- ok: true,
55- },
56- {
57- name: "count selects nth next block of kind",
58- from: Cursor{BlockID: "p0"},
59- kind: BlockKindCode,
60- count: 2,
61- want: Cursor{BlockID: "code2"},
62- ok: true,
63- },
64- {
65- name: "no later matching kind returns false",
66- from: Cursor{BlockID: "code2"},
67- kind: BlockKindCode,
68- },
69- {
70- name: "count past available matches returns false",
71- from: Cursor{BlockID: "p0"},
72- kind: BlockKindImage,
73- count: 3,
74- },
75- {
76- name: "zero cursor returns false",
77- from: Cursor{},
78- kind: BlockKindCode,
79- },
80- {
81- name: "missing cursor returns false",
82- from: Cursor{BlockID: "missing"},
83- kind: BlockKindCode,
84- },
85- {
86- name: "empty kind returns false",
87- from: Cursor{BlockID: "p0"},
88- kind: "",
89- },
90- }
91-
92- for _, tt := range tests {
93- t.Run(tt.name, func(t *testing.T) {
94- before := doc.Blocks()
95- got, ok := doc.NextBlockOfKind(tt.from, tt.kind, tt.count)
96- if ok != tt.ok || got != tt.want {
97- t.Fatalf("NextBlockOfKind(%#v, %q, %d) = (%#v, %v), want (%#v, %v)", tt.from, tt.kind, tt.count, got, ok, tt.want, tt.ok)
98- }
99- if after := doc.Blocks(); !reflect.DeepEqual(after, before) {
100- t.Fatalf("NextBlockOfKind mutated document: before %#v after %#v", before, after)
101- }
102- })
103- }
104-}
105-
106-func TestStrictPreviousBlockOfKind(t *testing.T) {
107- doc := blockKindMotionDocument(t)
108-
109- tests := []struct {
110- name string
111- from Cursor
112- kind BlockKind
113- count int
114- want Cursor
115- ok bool
116- }{
117- {
118- name: "previous code block",
119- from: Cursor{BlockID: "p2"},
120- kind: BlockKindCode,
121- want: Cursor{BlockID: "code2"},
122- ok: true,
123- },
124- {
125- name: "previous table block",
126- from: Cursor{BlockID: "p2"},
127- kind: BlockKindTable,
128- want: Cursor{BlockID: "table2"},
129- ok: true,
130- },
131- {
132- name: "previous image block",
133- from: Cursor{BlockID: "p2"},
134- kind: BlockKindImage,
135- want: Cursor{BlockID: "image2"},
136- ok: true,
137- },
138- {
139- name: "count below one means one",
140- from: Cursor{BlockID: "p2"},
141- kind: BlockKindTable,
142- count: 0,
143- want: Cursor{BlockID: "table2"},
144- ok: true,
145- },
146- {
147- name: "strict previous does not match current block",
148- from: Cursor{BlockID: "image2"},
149- kind: BlockKindImage,
150- count: 1,
151- want: Cursor{BlockID: "image1"},
152- ok: true,
153- },
154- {
155- name: "count selects nth previous block of kind",
156- from: Cursor{BlockID: "p2"},
157- kind: BlockKindTable,
158- count: 2,
159- want: Cursor{BlockID: "table1"},
160- ok: true,
161- },
162- {
163- name: "no earlier matching kind returns false",
164- from: Cursor{BlockID: "code1"},
165- kind: BlockKindCode,
166- },
167- {
168- name: "count before available matches returns false",
169- from: Cursor{BlockID: "p2"},
170- kind: BlockKindImage,
171- count: 3,
172- },
173- {
174- name: "zero cursor returns false",
175- from: Cursor{},
176- kind: BlockKindCode,
177- },
178- {
179- name: "missing cursor returns false",
180- from: Cursor{BlockID: "missing"},
181- kind: BlockKindCode,
182- },
183- {
184- name: "empty kind returns false",
185- from: Cursor{BlockID: "p2"},
186- kind: "",
187- },
188- }
189-
190- for _, tt := range tests {
191- t.Run(tt.name, func(t *testing.T) {
192- before := doc.Blocks()
193- got, ok := doc.PreviousBlockOfKind(tt.from, tt.kind, tt.count)
194- if ok != tt.ok || got != tt.want {
195- t.Fatalf("PreviousBlockOfKind(%#v, %q, %d) = (%#v, %v), want (%#v, %v)", tt.from, tt.kind, tt.count, got, ok, tt.want, tt.ok)
196- }
197- if after := doc.Blocks(); !reflect.DeepEqual(after, before) {
198- t.Fatalf("PreviousBlockOfKind mutated document: before %#v after %#v", before, after)
199- }
200- })
201- }
202-}
203-
204-func TestBlockOfKindMotionsOnEmptyAndUnmatchedDocuments(t *testing.T) {
205- tests := []struct {
206- name string
207- blocks []Block
208- from Cursor
209- kind BlockKind
210- }{
211- {
212- name: "empty document",
213- from: Cursor{BlockID: "missing"},
214- kind: BlockKindCode,
215- },
216- {
217- name: "document without requested structural kind",
218- blocks: []Block{
219- {ID: "p0", Kind: BlockKind("paragraph")},
220- {ID: "h1", Kind: BlockKindHeading, HeadingLevel: 1},
221- {ID: "p1", Kind: BlockKind("list_item")},
222- },
223- from: Cursor{BlockID: "p0"},
224- kind: BlockKindImage,
225- },
226- }
227-
228- for _, tt := range tests {
229- t.Run(tt.name, func(t *testing.T) {
230- doc := mustDocument(t, tt.blocks)
231-
232- if got, ok := doc.NextBlockOfKind(tt.from, tt.kind, 1); ok || got != (Cursor{}) {
233- t.Fatalf("NextBlockOfKind() = (%#v, %v), want zero cursor and false", got, ok)
234- }
235- if got, ok := doc.PreviousBlockOfKind(tt.from, tt.kind, 1); ok || got != (Cursor{}) {
236- t.Fatalf("PreviousBlockOfKind() = (%#v, %v), want zero cursor and false", got, ok)
237- }
238- })
239- }
240-}
241-
242-func blockKindMotionDocument(t *testing.T) Document {
243- t.Helper()
244-
245- return mustDocument(t, []Block{
246- {ID: "p0", Kind: BlockKind("paragraph")},
247- {ID: "code1", Kind: BlockKindCode},
248- {ID: "p1", Kind: BlockKind("paragraph")},
249- {ID: "table1", Kind: BlockKindTable},
250- {ID: "image1", Kind: BlockKindImage},
251- {ID: "code2", Kind: BlockKindCode},
252- {ID: "table2", Kind: BlockKindTable},
253- {ID: "image2", Kind: BlockKindImage},
254- {ID: "p2", Kind: BlockKind("paragraph")},
255- })
256-}
@@ -1,256 +0,0 @@
1-package docmodel
2-
3-import (
4- "reflect"
5- "testing"
6-)
7-
8-func TestStrictNextBlockOfKind(t *testing.T) {
9- doc := blockKindMotionDocument(t)
10-
11- tests := []struct {
12- name string
13- from Cursor
14- kind BlockKind
15- count int
16- want Cursor
17- ok bool
18- }{
19- {
20- name: "next code block",
21- from: Cursor{BlockID: "p0"},
22- kind: BlockKindCode,
23- want: Cursor{BlockID: "code1"},
24- ok: true,
25- },
26- {
27- name: "next table block",
28- from: Cursor{BlockID: "p0"},
29- kind: BlockKindTable,
30- want: Cursor{BlockID: "table1"},
31- ok: true,
32- },
33- {
34- name: "next image block",
35- from: Cursor{BlockID: "p0"},
36- kind: BlockKindImage,
37- want: Cursor{BlockID: "image1"},
38- ok: true,
39- },
40- {
41- name: "count below one means one",
42- from: Cursor{BlockID: "p1"},
43- kind: BlockKindTable,
44- count: -2,
45- want: Cursor{BlockID: "table1"},
46- ok: true,
47- },
48- {
49- name: "strict next does not match current block",
50- from: Cursor{BlockID: "code1"},
51- kind: BlockKindCode,
52- count: 1,
53- want: Cursor{BlockID: "code2"},
54- ok: true,
55- },
56- {
57- name: "count selects nth next block of kind",
58- from: Cursor{BlockID: "p0"},
59- kind: BlockKindCode,
60- count: 2,
61- want: Cursor{BlockID: "code2"},
62- ok: true,
63- },
64- {
65- name: "no later matching kind returns false",
66- from: Cursor{BlockID: "code2"},
67- kind: BlockKindCode,
68- },
69- {
70- name: "count past available matches returns false",
71- from: Cursor{BlockID: "p0"},
72- kind: BlockKindImage,
73- count: 3,
74- },
75- {
76- name: "zero cursor returns false",
77- from: Cursor{},
78- kind: BlockKindCode,
79- },
80- {
81- name: "missing cursor returns false",
82- from: Cursor{BlockID: "missing"},
83- kind: BlockKindCode,
84- },
85- {
86- name: "empty kind returns false",
87- from: Cursor{BlockID: "p0"},
88- kind: "",
89- },
90- }
91-
92- for _, tt := range tests {
93- t.Run(tt.name, func(t *testing.T) {
94- before := doc.Blocks()
95- got, ok := doc.NextBlockOfKind(tt.from, tt.kind, tt.count)
96- if ok != tt.ok || got != tt.want {
97- t.Fatalf("NextBlockOfKind(%#v, %q, %d) = (%#v, %v), want (%#v, %v)", tt.from, tt.kind, tt.count, got, ok, tt.want, tt.ok)
98- }
99- if after := doc.Blocks(); !reflect.DeepEqual(after, before) {
100- t.Fatalf("NextBlockOfKind mutated document: before %#v after %#v", before, after)
101- }
102- })
103- }
104-}
105-
106-func TestStrictPreviousBlockOfKind(t *testing.T) {
107- doc := blockKindMotionDocument(t)
108-
109- tests := []struct {
110- name string
111- from Cursor
112- kind BlockKind
113- count int
114- want Cursor
115- ok bool
116- }{
117- {
118- name: "previous code block",
119- from: Cursor{BlockID: "p2"},
120- kind: BlockKindCode,
121- want: Cursor{BlockID: "code2"},
122- ok: true,
123- },
124- {
125- name: "previous table block",
126- from: Cursor{BlockID: "p2"},
127- kind: BlockKindTable,
128- want: Cursor{BlockID: "table2"},
129- ok: true,
130- },
131- {
132- name: "previous image block",
133- from: Cursor{BlockID: "p2"},
134- kind: BlockKindImage,
135- want: Cursor{BlockID: "image2"},
136- ok: true,
137- },
138- {
139- name: "count below one means one",
140- from: Cursor{BlockID: "p2"},
141- kind: BlockKindTable,
142- count: 0,
143- want: Cursor{BlockID: "table2"},
144- ok: true,
145- },
146- {
147- name: "strict previous does not match current block",
148- from: Cursor{BlockID: "image2"},
149- kind: BlockKindImage,
150- count: 1,
151- want: Cursor{BlockID: "image1"},
152- ok: true,
153- },
154- {
155- name: "count selects nth previous block of kind",
156- from: Cursor{BlockID: "p2"},
157- kind: BlockKindTable,
158- count: 2,
159- want: Cursor{BlockID: "table1"},
160- ok: true,
161- },
162- {
163- name: "no earlier matching kind returns false",
164- from: Cursor{BlockID: "code1"},
165- kind: BlockKindCode,
166- },
167- {
168- name: "count before available matches returns false",
169- from: Cursor{BlockID: "p2"},
170- kind: BlockKindImage,
171- count: 3,
172- },
173- {
174- name: "zero cursor returns false",
175- from: Cursor{},
176- kind: BlockKindCode,
177- },
178- {
179- name: "missing cursor returns false",
180- from: Cursor{BlockID: "missing"},
181- kind: BlockKindCode,
182- },
183- {
184- name: "empty kind returns false",
185- from: Cursor{BlockID: "p2"},
186- kind: "",
187- },
188- }
189-
190- for _, tt := range tests {
191- t.Run(tt.name, func(t *testing.T) {
192- before := doc.Blocks()
193- got, ok := doc.PreviousBlockOfKind(tt.from, tt.kind, tt.count)
194- if ok != tt.ok || got != tt.want {
195- t.Fatalf("PreviousBlockOfKind(%#v, %q, %d) = (%#v, %v), want (%#v, %v)", tt.from, tt.kind, tt.count, got, ok, tt.want, tt.ok)
196- }
197- if after := doc.Blocks(); !reflect.DeepEqual(after, before) {
198- t.Fatalf("PreviousBlockOfKind mutated document: before %#v after %#v", before, after)
199- }
200- })
201- }
202-}
203-
204-func TestBlockOfKindMotionsOnEmptyAndUnmatchedDocuments(t *testing.T) {
205- tests := []struct {
206- name string
207- blocks []Block
208- from Cursor
209- kind BlockKind
210- }{
211- {
212- name: "empty document",
213- from: Cursor{BlockID: "missing"},
214- kind: BlockKindCode,
215- },
216- {
217- name: "document without requested structural kind",
218- blocks: []Block{
219- {ID: "p0", Kind: BlockKind("paragraph")},
220- {ID: "h1", Kind: BlockKindHeading, HeadingLevel: 1},
221- {ID: "p1", Kind: BlockKind("list_item")},
222- },
223- from: Cursor{BlockID: "p0"},
224- kind: BlockKindImage,
225- },
226- }
227-
228- for _, tt := range tests {
229- t.Run(tt.name, func(t *testing.T) {
230- doc := mustDocument(t, tt.blocks)
231-
232- if got, ok := doc.NextBlockOfKind(tt.from, tt.kind, 1); ok || got != (Cursor{}) {
233- t.Fatalf("NextBlockOfKind() = (%#v, %v), want zero cursor and false", got, ok)
234- }
235- if got, ok := doc.PreviousBlockOfKind(tt.from, tt.kind, 1); ok || got != (Cursor{}) {
236- t.Fatalf("PreviousBlockOfKind() = (%#v, %v), want zero cursor and false", got, ok)
237- }
238- })
239- }
240-}
241-
242-func blockKindMotionDocument(t *testing.T) Document {
243- t.Helper()
244-
245- return mustDocument(t, []Block{
246- {ID: "p0", Kind: BlockKind("paragraph")},
247- {ID: "code1", Kind: BlockKindCode},
248- {ID: "p1", Kind: BlockKind("paragraph")},
249- {ID: "table1", Kind: BlockKindTable},
250- {ID: "image1", Kind: BlockKindImage},
251- {ID: "code2", Kind: BlockKindCode},
252- {ID: "table2", Kind: BlockKindTable},
253- {ID: "image2", Kind: BlockKindImage},
254- {ID: "p2", Kind: BlockKind("paragraph")},
255- })
256-}
D internal/domain/docmodel/docmodel.go +0 -152
@@ -1,152 +0,0 @@
1-package docmodel
2-
3-import (
4- "errors"
5- "fmt"
6-)
7-
8-// BlockID identifies a block within a document.
9-type BlockID string
10-
11-// BlockKind describes the semantic role of a block. Non-heading kinds are
12-// intentionally left schema-agnostic and are preserved as provided.
13-type BlockKind string
14-
15-const (
16- // BlockKindHeading marks blocks that participate in heading motions.
17- BlockKindHeading BlockKind = "heading"
18- // BlockKindParagraph marks a paragraph block for Vim Mode paragraph motions.
19- BlockKindParagraph BlockKind = "paragraph"
20- // BlockKindCode marks a structural code block for Vim Mode block-kind motions.
21- BlockKindCode BlockKind = "code"
22- // BlockKindTable marks a structural table block for Vim Mode block-kind motions.
23- BlockKindTable BlockKind = "table"
24- // BlockKindImage marks a structural image block for Vim Mode block-kind motions.
25- BlockKindImage BlockKind = "image"
26- // BlockKindList marks a structural list block for Vim Mode list motions.
27- BlockKindList BlockKind = "list"
28-
29- // MinHeadingLevel is the lowest valid heading level.
30- MinHeadingLevel = 1
31- // MaxHeadingLevel is the highest valid heading level.
32- MaxHeadingLevel = 6
33-)
34-
35-var (
36- // ErrEmptyBlockID reports a block with no ID.
37- ErrEmptyBlockID = errors.New("docmodel: empty block ID")
38- // ErrDuplicateBlockID reports a repeated block ID.
39- ErrDuplicateBlockID = errors.New("docmodel: duplicate block ID")
40- // ErrInvalidHeadingLevel reports a heading outside levels 1 through 6.
41- ErrInvalidHeadingLevel = errors.New("docmodel: invalid heading level")
42- // ErrUnexpectedHeadingLevel reports a non-heading block with a heading level.
43- ErrUnexpectedHeadingLevel = errors.New("docmodel: non-heading block has heading level")
44-)
45-
46-// Block is an ordered document block. HeadingLevel is meaningful only when
47-// Kind is BlockKindHeading; non-heading blocks must leave it as zero.
48-type Block struct {
49- ID BlockID
50- Kind BlockKind
51- HeadingLevel int
52-}
53-
54-// IsHeading reports whether the block is a heading.
55-func (b Block) IsHeading() bool {
56- return b.Kind == BlockKindHeading
57-}
58-
59-// Cursor identifies the current block for document navigation.
60-type Cursor struct {
61- BlockID BlockID
62-}
63-
64-// BlockRange identifies a half-open span of blocks in document order. A zero
65-// EndExclusive cursor represents the end of the document.
66-type BlockRange struct {
67- StartInclusive Cursor
68- EndExclusive Cursor
69-}
70-
71-// Document is an immutable, order-preserving collection of blocks.
72-type Document struct {
73- blocks []Block
74- indexByID map[BlockID]int
75-}
76-
77-// NewDocument validates blocks and returns an order-preserving document. The
78-// input slice is copied so later caller mutations cannot affect navigation.
79-func NewDocument(blocks []Block) (Document, error) {
80- copied := append([]Block(nil), blocks...)
81- indexByID := make(map[BlockID]int, len(copied))
82-
83- for i, block := range copied {
84- if block.ID == "" {
85- return Document{}, fmt.Errorf("%w at index %d", ErrEmptyBlockID, i)
86- }
87- if _, exists := indexByID[block.ID]; exists {
88- return Document{}, fmt.Errorf("%w %q at index %d", ErrDuplicateBlockID, block.ID, i)
89- }
90- if err := validateBlockHeading(block, i); err != nil {
91- return Document{}, err
92- }
93-
94- indexByID[block.ID] = i
95- }
96-
97- return Document{
98- blocks: copied,
99- indexByID: indexByID,
100- }, nil
101-}
102-
103-// Len returns the number of blocks in document order.
104-func (d Document) Len() int {
105- return len(d.blocks)
106-}
107-
108-// Blocks returns a copy of the blocks in document order.
109-func (d Document) Blocks() []Block {
110- blocks := make([]Block, len(d.blocks))
111- copy(blocks, d.blocks)
112- return blocks
113-}
114-
115-// BlockByID returns the block with id when present.
116-func (d Document) BlockByID(id BlockID) (Block, bool) {
117- idx, ok := d.indexByID[id]
118- if !ok {
119- return Block{}, false
120- }
121- return d.blocks[idx], true
122-}
123-
124-// CursorByID returns a cursor positioned on id when present.
125-func (d Document) CursorByID(id BlockID) (Cursor, bool) {
126- if _, ok := d.indexByID[id]; !ok {
127- return Cursor{}, false
128- }
129- return Cursor{BlockID: id}, true
130-}
131-
132-// FirstCursor returns a cursor positioned on the first block in document order.
133-func (d Document) FirstCursor() (Cursor, bool) {
134- if len(d.blocks) == 0 {
135- return Cursor{}, false
136- }
137- return Cursor{BlockID: d.blocks[0].ID}, true
138-}
139-
140-func validateBlockHeading(block Block, index int) error {
141- if block.IsHeading() {
142- if block.HeadingLevel < MinHeadingLevel || block.HeadingLevel > MaxHeadingLevel {
143- return fmt.Errorf("%w %d at index %d", ErrInvalidHeadingLevel, block.HeadingLevel, index)
144- }
145- return nil
146- }
147-
148- if block.HeadingLevel != 0 {
149- return fmt.Errorf("%w %d at index %d", ErrUnexpectedHeadingLevel, block.HeadingLevel, index)
150- }
151- return nil
152-}
@@ -1,152 +0,0 @@
1-package docmodel
2-
3-import (
4- "errors"
5- "fmt"
6-)
7-
8-// BlockID identifies a block within a document.
9-type BlockID string
10-
11-// BlockKind describes the semantic role of a block. Non-heading kinds are
12-// intentionally left schema-agnostic and are preserved as provided.
13-type BlockKind string
14-
15-const (
16- // BlockKindHeading marks blocks that participate in heading motions.
17- BlockKindHeading BlockKind = "heading"
18- // BlockKindParagraph marks a paragraph block for Vim Mode paragraph motions.
19- BlockKindParagraph BlockKind = "paragraph"
20- // BlockKindCode marks a structural code block for Vim Mode block-kind motions.
21- BlockKindCode BlockKind = "code"
22- // BlockKindTable marks a structural table block for Vim Mode block-kind motions.
23- BlockKindTable BlockKind = "table"
24- // BlockKindImage marks a structural image block for Vim Mode block-kind motions.
25- BlockKindImage BlockKind = "image"
26- // BlockKindList marks a structural list block for Vim Mode list motions.
27- BlockKindList BlockKind = "list"
28-
29- // MinHeadingLevel is the lowest valid heading level.
30- MinHeadingLevel = 1
31- // MaxHeadingLevel is the highest valid heading level.
32- MaxHeadingLevel = 6
33-)
34-
35-var (
36- // ErrEmptyBlockID reports a block with no ID.
37- ErrEmptyBlockID = errors.New("docmodel: empty block ID")
38- // ErrDuplicateBlockID reports a repeated block ID.
39- ErrDuplicateBlockID = errors.New("docmodel: duplicate block ID")
40- // ErrInvalidHeadingLevel reports a heading outside levels 1 through 6.
41- ErrInvalidHeadingLevel = errors.New("docmodel: invalid heading level")
42- // ErrUnexpectedHeadingLevel reports a non-heading block with a heading level.
43- ErrUnexpectedHeadingLevel = errors.New("docmodel: non-heading block has heading level")
44-)
45-
46-// Block is an ordered document block. HeadingLevel is meaningful only when
47-// Kind is BlockKindHeading; non-heading blocks must leave it as zero.
48-type Block struct {
49- ID BlockID
50- Kind BlockKind
51- HeadingLevel int
52-}
53-
54-// IsHeading reports whether the block is a heading.
55-func (b Block) IsHeading() bool {
56- return b.Kind == BlockKindHeading
57-}
58-
59-// Cursor identifies the current block for document navigation.
60-type Cursor struct {
61- BlockID BlockID
62-}
63-
64-// BlockRange identifies a half-open span of blocks in document order. A zero
65-// EndExclusive cursor represents the end of the document.
66-type BlockRange struct {
67- StartInclusive Cursor
68- EndExclusive Cursor
69-}
70-
71-// Document is an immutable, order-preserving collection of blocks.
72-type Document struct {
73- blocks []Block
74- indexByID map[BlockID]int
75-}
76-
77-// NewDocument validates blocks and returns an order-preserving document. The
78-// input slice is copied so later caller mutations cannot affect navigation.
79-func NewDocument(blocks []Block) (Document, error) {
80- copied := append([]Block(nil), blocks...)
81- indexByID := make(map[BlockID]int, len(copied))
82-
83- for i, block := range copied {
84- if block.ID == "" {
85- return Document{}, fmt.Errorf("%w at index %d", ErrEmptyBlockID, i)
86- }
87- if _, exists := indexByID[block.ID]; exists {
88- return Document{}, fmt.Errorf("%w %q at index %d", ErrDuplicateBlockID, block.ID, i)
89- }
90- if err := validateBlockHeading(block, i); err != nil {
91- return Document{}, err
92- }
93-
94- indexByID[block.ID] = i
95- }
96-
97- return Document{
98- blocks: copied,
99- indexByID: indexByID,
100- }, nil
101-}
102-
103-// Len returns the number of blocks in document order.
104-func (d Document) Len() int {
105- return len(d.blocks)
106-}
107-
108-// Blocks returns a copy of the blocks in document order.
109-func (d Document) Blocks() []Block {
110- blocks := make([]Block, len(d.blocks))
111- copy(blocks, d.blocks)
112- return blocks
113-}
114-
115-// BlockByID returns the block with id when present.
116-func (d Document) BlockByID(id BlockID) (Block, bool) {
117- idx, ok := d.indexByID[id]
118- if !ok {
119- return Block{}, false
120- }
121- return d.blocks[idx], true
122-}
123-
124-// CursorByID returns a cursor positioned on id when present.
125-func (d Document) CursorByID(id BlockID) (Cursor, bool) {
126- if _, ok := d.indexByID[id]; !ok {
127- return Cursor{}, false
128- }
129- return Cursor{BlockID: id}, true
130-}
131-
132-// FirstCursor returns a cursor positioned on the first block in document order.
133-func (d Document) FirstCursor() (Cursor, bool) {
134- if len(d.blocks) == 0 {
135- return Cursor{}, false
136- }
137- return Cursor{BlockID: d.blocks[0].ID}, true
138-}
139-
140-func validateBlockHeading(block Block, index int) error {
141- if block.IsHeading() {
142- if block.HeadingLevel < MinHeadingLevel || block.HeadingLevel > MaxHeadingLevel {
143- return fmt.Errorf("%w %d at index %d", ErrInvalidHeadingLevel, block.HeadingLevel, index)
144- }
145- return nil
146- }
147-
148- if block.HeadingLevel != 0 {
149- return fmt.Errorf("%w %d at index %d", ErrUnexpectedHeadingLevel, block.HeadingLevel, index)
150- }
151- return nil
152-}
D internal/domain/docmodel/docmodel_test.go +0 -384
@@ -1,384 +0,0 @@
1-package docmodel
2-
3-import (
4- "errors"
5- "reflect"
6- "testing"
7-)
8-
9-func TestNewDocumentValidation(t *testing.T) {
10- tests := []struct {
11- name string
12- blocks []Block
13- wantErr error
14- }{
15- {
16- name: "empty document is valid",
17- },
18- {
19- name: "valid headings and non headings",
20- blocks: []Block{
21- {ID: "h1", Kind: BlockKindHeading, HeadingLevel: 1},
22- {ID: "body", Kind: BlockKind("paragraph")},
23- {ID: "h6", Kind: BlockKindHeading, HeadingLevel: 6},
24- },
25- },
26- {
27- name: "empty block ID is invalid",
28- blocks: []Block{
29- {ID: "ok", Kind: BlockKind("paragraph")},
30- {ID: "", Kind: BlockKindHeading, HeadingLevel: 1},
31- },
32- wantErr: ErrEmptyBlockID,
33- },
34- {
35- name: "duplicate block ID is invalid",
36- blocks: []Block{
37- {ID: "same", Kind: BlockKindHeading, HeadingLevel: 1},
38- {ID: "same", Kind: BlockKind("paragraph")},
39- },
40- wantErr: ErrDuplicateBlockID,
41- },
42- {
43- name: "heading level below one is invalid",
44- blocks: []Block{
45- {ID: "h0", Kind: BlockKindHeading, HeadingLevel: 0},
46- },
47- wantErr: ErrInvalidHeadingLevel,
48- },
49- {
50- name: "heading level above six is invalid",
51- blocks: []Block{
52- {ID: "h7", Kind: BlockKindHeading, HeadingLevel: 7},
53- },
54- wantErr: ErrInvalidHeadingLevel,
55- },
56- {
57- name: "non heading cannot carry heading level",
58- blocks: []Block{
59- {ID: "body", Kind: BlockKind("paragraph"), HeadingLevel: 2},
60- },
61- wantErr: ErrUnexpectedHeadingLevel,
62- },
63- }
64-
65- for _, tt := range tests {
66- t.Run(tt.name, func(t *testing.T) {
67- _, err := NewDocument(tt.blocks)
68- if tt.wantErr == nil {
69- if err != nil {
70- t.Fatalf("NewDocument() error = %v, want nil", err)
71- }
72- return
73- }
74-
75- if !errors.Is(err, tt.wantErr) {
76- t.Fatalf("NewDocument() error = %v, want errors.Is(_, %v)", err, tt.wantErr)
77- }
78- })
79- }
80-}
81-
82-func TestDocumentOrderAndCopies(t *testing.T) {
83- blocks := []Block{
84- {ID: "intro", Kind: BlockKindHeading, HeadingLevel: 1},
85- {ID: "body", Kind: BlockKind("paragraph")},
86- {ID: "details", Kind: BlockKindHeading, HeadingLevel: 2},
87- }
88-
89- doc, err := NewDocument(blocks)
90- if err != nil {
91- t.Fatalf("NewDocument() error = %v", err)
92- }
93-
94- blocks[0] = Block{ID: "mutated", Kind: BlockKindHeading, HeadingLevel: 6}
95-
96- got := doc.Blocks()
97- want := []Block{
98- {ID: "intro", Kind: BlockKindHeading, HeadingLevel: 1},
99- {ID: "body", Kind: BlockKind("paragraph")},
100- {ID: "details", Kind: BlockKindHeading, HeadingLevel: 2},
101- }
102- if !reflect.DeepEqual(got, want) {
103- t.Fatalf("Blocks() = %#v, want %#v", got, want)
104- }
105-
106- got[0] = Block{ID: "also-mutated", Kind: BlockKind("paragraph")}
107- gotAgain := doc.Blocks()
108- if !reflect.DeepEqual(gotAgain, want) {
109- t.Fatalf("Blocks() after mutating returned slice = %#v, want %#v", gotAgain, want)
110- }
111-}
112-
113-func TestDocumentCursorNavigation(t *testing.T) {
114- t.Run("empty document has no first cursor", func(t *testing.T) {
115- doc, err := NewDocument(nil)
116- if err != nil {
117- t.Fatalf("NewDocument() error = %v", err)
118- }
119-
120- if got, ok := doc.FirstCursor(); ok || got != (Cursor{}) {
121- t.Fatalf("FirstCursor() = (%#v, %v), want zero cursor and false", got, ok)
122- }
123- })
124-
125- t.Run("cursor by ID and first cursor use document order", func(t *testing.T) {
126- doc := mustDocument(t, []Block{
127- {ID: "first", Kind: BlockKind("paragraph")},
128- {ID: "second", Kind: BlockKindHeading, HeadingLevel: 2},
129- })
130-
131- if got, ok := doc.FirstCursor(); !ok || got != (Cursor{BlockID: "first"}) {
132- t.Fatalf("FirstCursor() = (%#v, %v), want first cursor and true", got, ok)
133- }
134-
135- if got, ok := doc.CursorByID("second"); !ok || got != (Cursor{BlockID: "second"}) {
136- t.Fatalf("CursorByID(second) = (%#v, %v), want second cursor and true", got, ok)
137- }
138-
139- if got, ok := doc.CursorByID("missing"); ok || got != (Cursor{}) {
140- t.Fatalf("CursorByID(missing) = (%#v, %v), want zero cursor and false", got, ok)
141- }
142- })
143-}
144-
145-func TestStrictNextHeading(t *testing.T) {
146- doc := headingMotionDocument(t)
147-
148- tests := []struct {
149- name string
150- from BlockID
151- count int
152- levels []int
153- want Cursor
154- ok bool
155- }{
156- {
157- name: "from non heading uses next heading",
158- from: "p0",
159- want: Cursor{BlockID: "h1"},
160- ok: true,
161- },
162- {
163- name: "count below one means one",
164- from: "p1",
165- count: -3,
166- want: Cursor{BlockID: "h2"},
167- ok: true,
168- },
169- {
170- name: "strict next does not match current heading",
171- from: "h1",
172- count: 1,
173- want: Cursor{BlockID: "h2"},
174- ok: true,
175- },
176- {
177- name: "count selects nth next heading",
178- from: "h1",
179- count: 2,
180- want: Cursor{BlockID: "h3"},
181- ok: true,
182- },
183- {
184- name: "level filter skips other headings",
185- from: "h1",
186- count: 1,
187- levels: []int{1},
188- want: Cursor{BlockID: "h1b"},
189- ok: true,
190- },
191- {
192- name: "count applies after filtering",
193- from: "h1",
194- count: 2,
195- levels: []int{2},
196- want: Cursor{BlockID: "h2b"},
197- ok: true,
198- },
199- {
200- name: "multi level filter matches any listed level",
201- from: "h2",
202- count: 1,
203- levels: []int{1, 3},
204- want: Cursor{BlockID: "h3"},
205- ok: true,
206- },
207- {
208- name: "no matching level returns false",
209- from: "p0",
210- levels: []int{6},
211- },
212- {
213- name: "past last heading returns false",
214- from: "h1b",
215- },
216- {
217- name: "missing cursor returns false",
218- from: "missing",
219- },
220- }
221-
222- for _, tt := range tests {
223- t.Run(tt.name, func(t *testing.T) {
224- before := doc.Blocks()
225- got, ok := doc.NextHeading(Cursor{BlockID: tt.from}, tt.count, tt.levels...)
226- if ok != tt.ok || got != tt.want {
227- t.Fatalf("NextHeading(%q, %d, %v) = (%#v, %v), want (%#v, %v)", tt.from, tt.count, tt.levels, got, ok, tt.want, tt.ok)
228- }
229- if after := doc.Blocks(); !reflect.DeepEqual(after, before) {
230- t.Fatalf("NextHeading mutated document: before %#v after %#v", before, after)
231- }
232- })
233- }
234-}
235-
236-func TestStrictPreviousHeading(t *testing.T) {
237- doc := headingMotionDocument(t)
238-
239- tests := []struct {
240- name string
241- from BlockID
242- count int
243- levels []int
244- want Cursor
245- ok bool
246- }{
247- {
248- name: "from non heading uses previous heading",
249- from: "p2",
250- want: Cursor{BlockID: "h2b"},
251- ok: true,
252- },
253- {
254- name: "count below one means one",
255- from: "p2",
256- count: 0,
257- want: Cursor{BlockID: "h2b"},
258- ok: true,
259- },
260- {
261- name: "strict previous does not match current heading",
262- from: "h1b",
263- count: 1,
264- want: Cursor{BlockID: "h2b"},
265- ok: true,
266- },
267- {
268- name: "count selects nth previous heading",
269- from: "h1b",
270- count: 2,
271- want: Cursor{BlockID: "h3"},
272- ok: true,
273- },
274- {
275- name: "level filter skips other headings",
276- from: "h1b",
277- count: 1,
278- levels: []int{1},
279- want: Cursor{BlockID: "h1"},
280- ok: true,
281- },
282- {
283- name: "count applies after filtering",
284- from: "h1b",
285- count: 2,
286- levels: []int{2},
287- want: Cursor{BlockID: "h2"},
288- ok: true,
289- },
290- {
291- name: "multi level filter matches any listed level",
292- from: "h2b",
293- count: 1,
294- levels: []int{1, 3},
295- want: Cursor{BlockID: "h3"},
296- ok: true,
297- },
298- {
299- name: "no matching level returns false",
300- from: "h1b",
301- levels: []int{6},
302- },
303- {
304- name: "before first heading returns false",
305- from: "h1",
306- },
307- {
308- name: "missing cursor returns false",
309- from: "missing",
310- },
311- }
312-
313- for _, tt := range tests {
314- t.Run(tt.name, func(t *testing.T) {
315- before := doc.Blocks()
316- got, ok := doc.PreviousHeading(Cursor{BlockID: tt.from}, tt.count, tt.levels...)
317- if ok != tt.ok || got != tt.want {
318- t.Fatalf("PreviousHeading(%q, %d, %v) = (%#v, %v), want (%#v, %v)", tt.from, tt.count, tt.levels, got, ok, tt.want, tt.ok)
319- }
320- if after := doc.Blocks(); !reflect.DeepEqual(after, before) {
321- t.Fatalf("PreviousHeading mutated document: before %#v after %#v", before, after)
322- }
323- })
324- }
325-}
326-
327-func TestHeadingMotionsOnEmptyAndHeadinglessDocuments(t *testing.T) {
328- tests := []struct {
329- name string
330- blocks []Block
331- from Cursor
332- }{
333- {
334- name: "empty document",
335- from: Cursor{BlockID: "missing"},
336- },
337- {
338- name: "document without headings",
339- blocks: []Block{
340- {ID: "p0", Kind: BlockKind("paragraph")},
341- {ID: "p1", Kind: BlockKind("list_item")},
342- },
343- from: Cursor{BlockID: "p0"},
344- },
345- }
346-
347- for _, tt := range tests {
348- t.Run(tt.name, func(t *testing.T) {
349- doc := mustDocument(t, tt.blocks)
350-
351- if got, ok := doc.NextHeading(tt.from, 1); ok || got != (Cursor{}) {
352- t.Fatalf("NextHeading() = (%#v, %v), want zero cursor and false", got, ok)
353- }
354- if got, ok := doc.PreviousHeading(tt.from, 1); ok || got != (Cursor{}) {
355- t.Fatalf("PreviousHeading() = (%#v, %v), want zero cursor and false", got, ok)
356- }
357- })
358- }
359-}
360-
361-func headingMotionDocument(t *testing.T) Document {
362- t.Helper()
363-
364- return mustDocument(t, []Block{
365- {ID: "p0", Kind: BlockKindParagraph},
366- {ID: "h1", Kind: BlockKindHeading, HeadingLevel: 1},
367- {ID: "p1", Kind: BlockKindParagraph},
368- {ID: "h2", Kind: BlockKindHeading, HeadingLevel: 2},
369- {ID: "h3", Kind: BlockKindHeading, HeadingLevel: 3},
370- {ID: "h2b", Kind: BlockKindHeading, HeadingLevel: 2},
371- {ID: "p2", Kind: BlockKindParagraph},
372- {ID: "h1b", Kind: BlockKindHeading, HeadingLevel: 1},
373- })
374-}
375-
376-func mustDocument(t *testing.T, blocks []Block) Document {
377- t.Helper()
378-
379- doc, err := NewDocument(blocks)
380- if err != nil {
381- t.Fatalf("NewDocument() error = %v", err)
382- }
383- return doc
384-}
@@ -1,384 +0,0 @@
1-package docmodel
2-
3-import (
4- "errors"
5- "reflect"
6- "testing"
7-)
8-
9-func TestNewDocumentValidation(t *testing.T) {
10- tests := []struct {
11- name string
12- blocks []Block
13- wantErr error
14- }{
15- {
16- name: "empty document is valid",
17- },
18- {
19- name: "valid headings and non headings",
20- blocks: []Block{
21- {ID: "h1", Kind: BlockKindHeading, HeadingLevel: 1},
22- {ID: "body", Kind: BlockKind("paragraph")},
23- {ID: "h6", Kind: BlockKindHeading, HeadingLevel: 6},
24- },
25- },
26- {
27- name: "empty block ID is invalid",
28- blocks: []Block{
29- {ID: "ok", Kind: BlockKind("paragraph")},
30- {ID: "", Kind: BlockKindHeading, HeadingLevel: 1},
31- },
32- wantErr: ErrEmptyBlockID,
33- },
34- {
35- name: "duplicate block ID is invalid",
36- blocks: []Block{
37- {ID: "same", Kind: BlockKindHeading, HeadingLevel: 1},
38- {ID: "same", Kind: BlockKind("paragraph")},
39- },
40- wantErr: ErrDuplicateBlockID,
41- },
42- {
43- name: "heading level below one is invalid",
44- blocks: []Block{
45- {ID: "h0", Kind: BlockKindHeading, HeadingLevel: 0},
46- },
47- wantErr: ErrInvalidHeadingLevel,
48- },
49- {
50- name: "heading level above six is invalid",
51- blocks: []Block{
52- {ID: "h7", Kind: BlockKindHeading, HeadingLevel: 7},
53- },
54- wantErr: ErrInvalidHeadingLevel,
55- },
56- {
57- name: "non heading cannot carry heading level",
58- blocks: []Block{
59- {ID: "body", Kind: BlockKind("paragraph"), HeadingLevel: 2},
60- },
61- wantErr: ErrUnexpectedHeadingLevel,
62- },
63- }
64-
65- for _, tt := range tests {
66- t.Run(tt.name, func(t *testing.T) {
67- _, err := NewDocument(tt.blocks)
68- if tt.wantErr == nil {
69- if err != nil {
70- t.Fatalf("NewDocument() error = %v, want nil", err)
71- }
72- return
73- }
74-
75- if !errors.Is(err, tt.wantErr) {
76- t.Fatalf("NewDocument() error = %v, want errors.Is(_, %v)", err, tt.wantErr)
77- }
78- })
79- }
80-}
81-
82-func TestDocumentOrderAndCopies(t *testing.T) {
83- blocks := []Block{
84- {ID: "intro", Kind: BlockKindHeading, HeadingLevel: 1},
85- {ID: "body", Kind: BlockKind("paragraph")},
86- {ID: "details", Kind: BlockKindHeading, HeadingLevel: 2},
87- }
88-
89- doc, err := NewDocument(blocks)
90- if err != nil {
91- t.Fatalf("NewDocument() error = %v", err)
92- }
93-
94- blocks[0] = Block{ID: "mutated", Kind: BlockKindHeading, HeadingLevel: 6}
95-
96- got := doc.Blocks()
97- want := []Block{
98- {ID: "intro", Kind: BlockKindHeading, HeadingLevel: 1},
99- {ID: "body", Kind: BlockKind("paragraph")},
100- {ID: "details", Kind: BlockKindHeading, HeadingLevel: 2},
101- }
102- if !reflect.DeepEqual(got, want) {
103- t.Fatalf("Blocks() = %#v, want %#v", got, want)
104- }
105-
106- got[0] = Block{ID: "also-mutated", Kind: BlockKind("paragraph")}
107- gotAgain := doc.Blocks()
108- if !reflect.DeepEqual(gotAgain, want) {
109- t.Fatalf("Blocks() after mutating returned slice = %#v, want %#v", gotAgain, want)
110- }
111-}
112-
113-func TestDocumentCursorNavigation(t *testing.T) {
114- t.Run("empty document has no first cursor", func(t *testing.T) {
115- doc, err := NewDocument(nil)
116- if err != nil {
117- t.Fatalf("NewDocument() error = %v", err)
118- }
119-
120- if got, ok := doc.FirstCursor(); ok || got != (Cursor{}) {
121- t.Fatalf("FirstCursor() = (%#v, %v), want zero cursor and false", got, ok)
122- }
123- })
124-
125- t.Run("cursor by ID and first cursor use document order", func(t *testing.T) {
126- doc := mustDocument(t, []Block{
127- {ID: "first", Kind: BlockKind("paragraph")},
128- {ID: "second", Kind: BlockKindHeading, HeadingLevel: 2},
129- })
130-
131- if got, ok := doc.FirstCursor(); !ok || got != (Cursor{BlockID: "first"}) {
132- t.Fatalf("FirstCursor() = (%#v, %v), want first cursor and true", got, ok)
133- }
134-
135- if got, ok := doc.CursorByID("second"); !ok || got != (Cursor{BlockID: "second"}) {
136- t.Fatalf("CursorByID(second) = (%#v, %v), want second cursor and true", got, ok)
137- }
138-
139- if got, ok := doc.CursorByID("missing"); ok || got != (Cursor{}) {
140- t.Fatalf("CursorByID(missing) = (%#v, %v), want zero cursor and false", got, ok)
141- }
142- })
143-}
144-
145-func TestStrictNextHeading(t *testing.T) {
146- doc := headingMotionDocument(t)
147-
148- tests := []struct {
149- name string
150- from BlockID
151- count int
152- levels []int
153- want Cursor
154- ok bool
155- }{
156- {
157- name: "from non heading uses next heading",
158- from: "p0",
159- want: Cursor{BlockID: "h1"},
160- ok: true,
161- },
162- {
163- name: "count below one means one",
164- from: "p1",
165- count: -3,
166- want: Cursor{BlockID: "h2"},
167- ok: true,
168- },
169- {
170- name: "strict next does not match current heading",
171- from: "h1",
172- count: 1,
173- want: Cursor{BlockID: "h2"},
174- ok: true,
175- },
176- {
177- name: "count selects nth next heading",
178- from: "h1",
179- count: 2,
180- want: Cursor{BlockID: "h3"},
181- ok: true,
182- },
183- {
184- name: "level filter skips other headings",
185- from: "h1",
186- count: 1,
187- levels: []int{1},
188- want: Cursor{BlockID: "h1b"},
189- ok: true,
190- },
191- {
192- name: "count applies after filtering",
193- from: "h1",
194- count: 2,
195- levels: []int{2},
196- want: Cursor{BlockID: "h2b"},
197- ok: true,
198- },
199- {
200- name: "multi level filter matches any listed level",
201- from: "h2",
202- count: 1,
203- levels: []int{1, 3},
204- want: Cursor{BlockID: "h3"},
205- ok: true,
206- },
207- {
208- name: "no matching level returns false",
209- from: "p0",
210- levels: []int{6},
211- },
212- {
213- name: "past last heading returns false",
214- from: "h1b",
215- },
216- {
217- name: "missing cursor returns false",
218- from: "missing",
219- },
220- }
221-
222- for _, tt := range tests {
223- t.Run(tt.name, func(t *testing.T) {
224- before := doc.Blocks()
225- got, ok := doc.NextHeading(Cursor{BlockID: tt.from}, tt.count, tt.levels...)
226- if ok != tt.ok || got != tt.want {
227- t.Fatalf("NextHeading(%q, %d, %v) = (%#v, %v), want (%#v, %v)", tt.from, tt.count, tt.levels, got, ok, tt.want, tt.ok)
228- }
229- if after := doc.Blocks(); !reflect.DeepEqual(after, before) {
230- t.Fatalf("NextHeading mutated document: before %#v after %#v", before, after)
231- }
232- })
233- }
234-}
235-
236-func TestStrictPreviousHeading(t *testing.T) {
237- doc := headingMotionDocument(t)
238-
239- tests := []struct {
240- name string
241- from BlockID
242- count int
243- levels []int
244- want Cursor
245- ok bool
246- }{
247- {
248- name: "from non heading uses previous heading",
249- from: "p2",
250- want: Cursor{BlockID: "h2b"},
251- ok: true,
252- },
253- {
254- name: "count below one means one",
255- from: "p2",
256- count: 0,
257- want: Cursor{BlockID: "h2b"},
258- ok: true,
259- },
260- {
261- name: "strict previous does not match current heading",
262- from: "h1b",
263- count: 1,
264- want: Cursor{BlockID: "h2b"},
265- ok: true,
266- },
267- {
268- name: "count selects nth previous heading",
269- from: "h1b",
270- count: 2,
271- want: Cursor{BlockID: "h3"},
272- ok: true,
273- },
274- {
275- name: "level filter skips other headings",
276- from: "h1b",
277- count: 1,
278- levels: []int{1},
279- want: Cursor{BlockID: "h1"},
280- ok: true,
281- },
282- {
283- name: "count applies after filtering",
284- from: "h1b",
285- count: 2,
286- levels: []int{2},
287- want: Cursor{BlockID: "h2"},
288- ok: true,
289- },
290- {
291- name: "multi level filter matches any listed level",
292- from: "h2b",
293- count: 1,
294- levels: []int{1, 3},
295- want: Cursor{BlockID: "h3"},
296- ok: true,
297- },
298- {
299- name: "no matching level returns false",
300- from: "h1b",
301- levels: []int{6},
302- },
303- {
304- name: "before first heading returns false",
305- from: "h1",
306- },
307- {
308- name: "missing cursor returns false",
309- from: "missing",
310- },
311- }
312-
313- for _, tt := range tests {
314- t.Run(tt.name, func(t *testing.T) {
315- before := doc.Blocks()
316- got, ok := doc.PreviousHeading(Cursor{BlockID: tt.from}, tt.count, tt.levels...)
317- if ok != tt.ok || got != tt.want {
318- t.Fatalf("PreviousHeading(%q, %d, %v) = (%#v, %v), want (%#v, %v)", tt.from, tt.count, tt.levels, got, ok, tt.want, tt.ok)
319- }
320- if after := doc.Blocks(); !reflect.DeepEqual(after, before) {
321- t.Fatalf("PreviousHeading mutated document: before %#v after %#v", before, after)
322- }
323- })
324- }
325-}
326-
327-func TestHeadingMotionsOnEmptyAndHeadinglessDocuments(t *testing.T) {
328- tests := []struct {
329- name string
330- blocks []Block
331- from Cursor
332- }{
333- {
334- name: "empty document",
335- from: Cursor{BlockID: "missing"},
336- },
337- {
338- name: "document without headings",
339- blocks: []Block{
340- {ID: "p0", Kind: BlockKind("paragraph")},
341- {ID: "p1", Kind: BlockKind("list_item")},
342- },
343- from: Cursor{BlockID: "p0"},
344- },
345- }
346-
347- for _, tt := range tests {
348- t.Run(tt.name, func(t *testing.T) {
349- doc := mustDocument(t, tt.blocks)
350-
351- if got, ok := doc.NextHeading(tt.from, 1); ok || got != (Cursor{}) {
352- t.Fatalf("NextHeading() = (%#v, %v), want zero cursor and false", got, ok)
353- }
354- if got, ok := doc.PreviousHeading(tt.from, 1); ok || got != (Cursor{}) {
355- t.Fatalf("PreviousHeading() = (%#v, %v), want zero cursor and false", got, ok)
356- }
357- })
358- }
359-}
360-
361-func headingMotionDocument(t *testing.T) Document {
362- t.Helper()
363-
364- return mustDocument(t, []Block{
365- {ID: "p0", Kind: BlockKindParagraph},
366- {ID: "h1", Kind: BlockKindHeading, HeadingLevel: 1},
367- {ID: "p1", Kind: BlockKindParagraph},
368- {ID: "h2", Kind: BlockKindHeading, HeadingLevel: 2},
369- {ID: "h3", Kind: BlockKindHeading, HeadingLevel: 3},
370- {ID: "h2b", Kind: BlockKindHeading, HeadingLevel: 2},
371- {ID: "p2", Kind: BlockKindParagraph},
372- {ID: "h1b", Kind: BlockKindHeading, HeadingLevel: 1},
373- })
374-}
375-
376-func mustDocument(t *testing.T, blocks []Block) Document {
377- t.Helper()
378-
379- doc, err := NewDocument(blocks)
380- if err != nil {
381- t.Fatalf("NewDocument() error = %v", err)
382- }
383- return doc
384-}
D internal/domain/docmodel/list_motions_test.go +0 -176
@@ -1,176 +0,0 @@
1-package docmodel
2-
3-import (
4- "reflect"
5- "testing"
6-)
7-
8-func TestListNextBlockOfKindSupportsStrictForwardNavigation(t *testing.T) {
9- doc := listMotionDocument(t)
10-
11- tests := []struct {
12- name string
13- from Cursor
14- count int
15- want Cursor
16- ok bool
17- }{
18- {
19- name: "forward list motion skips non lists",
20- from: Cursor{BlockID: "h1"},
21- want: Cursor{BlockID: "list1"},
22- ok: true,
23- },
24- {
25- name: "count below one means one",
26- from: Cursor{BlockID: "h1"},
27- count: -1,
28- want: Cursor{BlockID: "list1"},
29- ok: true,
30- },
31- {
32- name: "count zero means one",
33- from: Cursor{BlockID: "code1"},
34- count: 0,
35- want: Cursor{BlockID: "list2"},
36- ok: true,
37- },
38- {
39- name: "strict forward list motion does not match current list",
40- from: Cursor{BlockID: "list1"},
41- count: 1,
42- want: Cursor{BlockID: "list2"},
43- ok: true,
44- },
45- {
46- name: "count selects nth next list",
47- from: Cursor{BlockID: "p0"},
48- count: 2,
49- want: Cursor{BlockID: "list2"},
50- ok: true,
51- },
52- {
53- name: "no later list returns false",
54- from: Cursor{BlockID: "list3"},
55- },
56- {
57- name: "count past later lists returns false",
58- from: Cursor{BlockID: "list1"},
59- count: 3,
60- },
61- {
62- name: "zero cursor returns false",
63- from: Cursor{},
64- },
65- {
66- name: "missing cursor returns false",
67- from: Cursor{BlockID: "missing"},
68- },
69- }
70-
71- for _, tt := range tests {
72- t.Run(tt.name, func(t *testing.T) {
73- before := doc.Blocks()
74- got, ok := doc.NextBlockOfKind(tt.from, BlockKindList, tt.count)
75- if ok != tt.ok || got != tt.want {
76- t.Fatalf("NextBlockOfKind(%#v, BlockKindList, %d) = (%#v, %v), want (%#v, %v)", tt.from, tt.count, got, ok, tt.want, tt.ok)
77- }
78- if after := doc.Blocks(); !reflect.DeepEqual(after, before) {
79- t.Fatalf("NextBlockOfKind mutated document: before %#v after %#v", before, after)
80- }
81- })
82- }
83-}
84-
85-func TestListPreviousBlockOfKindSupportsStrictBackwardNavigation(t *testing.T) {
86- doc := listMotionDocument(t)
87-
88- tests := []struct {
89- name string
90- from Cursor
91- count int
92- want Cursor
93- ok bool
94- }{
95- {
96- name: "backward list motion skips non lists",
97- from: Cursor{BlockID: "table1"},
98- want: Cursor{BlockID: "list3"},
99- ok: true,
100- },
101- {
102- name: "count below one means one",
103- from: Cursor{BlockID: "table1"},
104- count: -1,
105- want: Cursor{BlockID: "list3"},
106- ok: true,
107- },
108- {
109- name: "count zero means one",
110- from: Cursor{BlockID: "code1"},
111- count: 0,
112- want: Cursor{BlockID: "list1"},
113- ok: true,
114- },
115- {
116- name: "strict backward list motion does not match current list",
117- from: Cursor{BlockID: "list3"},
118- count: 1,
119- want: Cursor{BlockID: "list2"},
120- ok: true,
121- },
122- {
123- name: "count selects nth previous list",
124- from: Cursor{BlockID: "list3"},
125- count: 2,
126- want: Cursor{BlockID: "list1"},
127- ok: true,
128- },
129- {
130- name: "no earlier list returns false",
131- from: Cursor{BlockID: "list0"},
132- },
133- {
134- name: "count past earlier lists returns false",
135- from: Cursor{BlockID: "list2"},
136- count: 3,
137- },
138- {
139- name: "zero cursor returns false",
140- from: Cursor{},
141- },
142- {
143- name: "missing cursor returns false",
144- from: Cursor{BlockID: "missing"},
145- },
146- }
147-
148- for _, tt := range tests {
149- t.Run(tt.name, func(t *testing.T) {
150- before := doc.Blocks()
151- got, ok := doc.PreviousBlockOfKind(tt.from, BlockKindList, tt.count)
152- if ok != tt.ok || got != tt.want {
153- t.Fatalf("PreviousBlockOfKind(%#v, BlockKindList, %d) = (%#v, %v), want (%#v, %v)", tt.from, tt.count, got, ok, tt.want, tt.ok)
154- }
155- if after := doc.Blocks(); !reflect.DeepEqual(after, before) {
156- t.Fatalf("PreviousBlockOfKind mutated document: before %#v after %#v", before, after)
157- }
158- })
159- }
160-}
161-
162-func listMotionDocument(t *testing.T) Document {
163- t.Helper()
164-
165- return mustDocument(t, []Block{
166- {ID: "list0", Kind: BlockKindList},
167- {ID: "p0", Kind: BlockKindParagraph},
168- {ID: "h1", Kind: BlockKindHeading, HeadingLevel: 1},
169- {ID: "list1", Kind: BlockKindList},
170- {ID: "code1", Kind: BlockKindCode},
171- {ID: "list2", Kind: BlockKindList},
172- {ID: "image1", Kind: BlockKindImage},
173- {ID: "list3", Kind: BlockKindList},
174- {ID: "table1", Kind: BlockKindTable},
175- })
176-}
@@ -1,176 +0,0 @@
1-package docmodel
2-
3-import (
4- "reflect"
5- "testing"
6-)
7-
8-func TestListNextBlockOfKindSupportsStrictForwardNavigation(t *testing.T) {
9- doc := listMotionDocument(t)
10-
11- tests := []struct {
12- name string
13- from Cursor
14- count int
15- want Cursor
16- ok bool
17- }{
18- {
19- name: "forward list motion skips non lists",
20- from: Cursor{BlockID: "h1"},
21- want: Cursor{BlockID: "list1"},
22- ok: true,
23- },
24- {
25- name: "count below one means one",
26- from: Cursor{BlockID: "h1"},
27- count: -1,
28- want: Cursor{BlockID: "list1"},
29- ok: true,
30- },
31- {
32- name: "count zero means one",
33- from: Cursor{BlockID: "code1"},
34- count: 0,
35- want: Cursor{BlockID: "list2"},
36- ok: true,
37- },
38- {
39- name: "strict forward list motion does not match current list",
40- from: Cursor{BlockID: "list1"},
41- count: 1,
42- want: Cursor{BlockID: "list2"},
43- ok: true,
44- },
45- {
46- name: "count selects nth next list",
47- from: Cursor{BlockID: "p0"},
48- count: 2,
49- want: Cursor{BlockID: "list2"},
50- ok: true,
51- },
52- {
53- name: "no later list returns false",
54- from: Cursor{BlockID: "list3"},
55- },
56- {
57- name: "count past later lists returns false",
58- from: Cursor{BlockID: "list1"},
59- count: 3,
60- },
61- {
62- name: "zero cursor returns false",
63- from: Cursor{},
64- },
65- {
66- name: "missing cursor returns false",
67- from: Cursor{BlockID: "missing"},
68- },
69- }
70-
71- for _, tt := range tests {
72- t.Run(tt.name, func(t *testing.T) {
73- before := doc.Blocks()
74- got, ok := doc.NextBlockOfKind(tt.from, BlockKindList, tt.count)
75- if ok != tt.ok || got != tt.want {
76- t.Fatalf("NextBlockOfKind(%#v, BlockKindList, %d) = (%#v, %v), want (%#v, %v)", tt.from, tt.count, got, ok, tt.want, tt.ok)
77- }
78- if after := doc.Blocks(); !reflect.DeepEqual(after, before) {
79- t.Fatalf("NextBlockOfKind mutated document: before %#v after %#v", before, after)
80- }
81- })
82- }
83-}
84-
85-func TestListPreviousBlockOfKindSupportsStrictBackwardNavigation(t *testing.T) {
86- doc := listMotionDocument(t)
87-
88- tests := []struct {
89- name string
90- from Cursor
91- count int
92- want Cursor
93- ok bool
94- }{
95- {
96- name: "backward list motion skips non lists",
97- from: Cursor{BlockID: "table1"},
98- want: Cursor{BlockID: "list3"},
99- ok: true,
100- },
101- {
102- name: "count below one means one",
103- from: Cursor{BlockID: "table1"},
104- count: -1,
105- want: Cursor{BlockID: "list3"},
106- ok: true,
107- },
108- {
109- name: "count zero means one",
110- from: Cursor{BlockID: "code1"},
111- count: 0,
112- want: Cursor{BlockID: "list1"},
113- ok: true,
114- },
115- {
116- name: "strict backward list motion does not match current list",
117- from: Cursor{BlockID: "list3"},
118- count: 1,
119- want: Cursor{BlockID: "list2"},
120- ok: true,
121- },
122- {
123- name: "count selects nth previous list",
124- from: Cursor{BlockID: "list3"},
125- count: 2,
126- want: Cursor{BlockID: "list1"},
127- ok: true,
128- },
129- {
130- name: "no earlier list returns false",
131- from: Cursor{BlockID: "list0"},
132- },
133- {
134- name: "count past earlier lists returns false",
135- from: Cursor{BlockID: "list2"},
136- count: 3,
137- },
138- {
139- name: "zero cursor returns false",
140- from: Cursor{},
141- },
142- {
143- name: "missing cursor returns false",
144- from: Cursor{BlockID: "missing"},
145- },
146- }
147-
148- for _, tt := range tests {
149- t.Run(tt.name, func(t *testing.T) {
150- before := doc.Blocks()
151- got, ok := doc.PreviousBlockOfKind(tt.from, BlockKindList, tt.count)
152- if ok != tt.ok || got != tt.want {
153- t.Fatalf("PreviousBlockOfKind(%#v, BlockKindList, %d) = (%#v, %v), want (%#v, %v)", tt.from, tt.count, got, ok, tt.want, tt.ok)
154- }
155- if after := doc.Blocks(); !reflect.DeepEqual(after, before) {
156- t.Fatalf("PreviousBlockOfKind mutated document: before %#v after %#v", before, after)
157- }
158- })
159- }
160-}
161-
162-func listMotionDocument(t *testing.T) Document {
163- t.Helper()
164-
165- return mustDocument(t, []Block{
166- {ID: "list0", Kind: BlockKindList},
167- {ID: "p0", Kind: BlockKindParagraph},
168- {ID: "h1", Kind: BlockKindHeading, HeadingLevel: 1},
169- {ID: "list1", Kind: BlockKindList},
170- {ID: "code1", Kind: BlockKindCode},
171- {ID: "list2", Kind: BlockKindList},
172- {ID: "image1", Kind: BlockKindImage},
173- {ID: "list3", Kind: BlockKindList},
174- {ID: "table1", Kind: BlockKindTable},
175- })
176-}
D internal/domain/docmodel/motions.go +0 -86
@@ -1,86 +0,0 @@
1-package docmodel
2-
3-// NextHeading returns the cursor for the count-th heading after from. Motions
4-// are strict: a heading at from is not considered a match. A count less than or
5-// equal to zero is treated as one. When levels are provided, only headings with
6-// one of those levels match.
7-func (d Document) NextHeading(from Cursor, count int, levels ...int) (Cursor, bool) {
8- return d.headingMotion(from, count, 1, levels)
9-}
10-
11-// PreviousHeading returns the cursor for the count-th heading before from.
12-// Motions are strict: a heading at from is not considered a match. A count less
13-// than or equal to zero is treated as one. When levels are provided, only
14-// headings with one of those levels match.
15-func (d Document) PreviousHeading(from Cursor, count int, levels ...int) (Cursor, bool) {
16- return d.headingMotion(from, count, -1, levels)
17-}
18-
19-// NextBlockOfKind returns the cursor for the count-th block of kind after from.
20-// Motions are strict: a block at from is not considered a match. A count less
21-// than or equal to zero is treated as one. Empty kinds never match.
22-func (d Document) NextBlockOfKind(from Cursor, kind BlockKind, count int) (Cursor, bool) {
23- return d.blockKindMotion(from, kind, count, 1)
24-}
25-
26-// PreviousBlockOfKind returns the cursor for the count-th block of kind before
27-// from. Motions are strict: a block at from is not considered a match. A count
28-// less than or equal to zero is treated as one. Empty kinds never match.
29-func (d Document) PreviousBlockOfKind(from Cursor, kind BlockKind, count int) (Cursor, bool) {
30- return d.blockKindMotion(from, kind, count, -1)
31-}
32-
33-func (d Document) headingMotion(from Cursor, count, direction int, levels []int) (Cursor, bool) {
34- return d.matchingBlockMotion(from, count, direction, func(block Block) bool {
35- return block.IsHeading() && headingLevelMatches(block.HeadingLevel, levels)
36- })
37-}
38-
39-func (d Document) blockKindMotion(from Cursor, kind BlockKind, count, direction int) (Cursor, bool) {
40- if kind == "" {
41- return Cursor{}, false
42- }
43-
44- return d.matchingBlockMotion(from, count, direction, func(block Block) bool {
45- return block.Kind == kind
46- })
47-}
48-
49-func (d Document) matchingBlockMotion(from Cursor, count, direction int, matches func(Block) bool) (Cursor, bool) {
50- start, ok := d.indexByID[from.BlockID]
51- if !ok {
52- return Cursor{}, false
53- }
54-
55- if count <= 0 {
56- count = 1
57- }
58-
59- remaining := count
60- for i := start + direction; i >= 0 && i < len(d.blocks); i += direction {
61- block := d.blocks[i]
62- if !matches(block) {
63- continue
64- }
65-
66- remaining--
67- if remaining == 0 {
68- return Cursor{BlockID: block.ID}, true
69- }
70- }
71-
72- return Cursor{}, false
73-}
74-
75-func headingLevelMatches(level int, levels []int) bool {
76- if len(levels) == 0 {
77- return true
78- }
79-
80- for _, allowed := range levels {
81- if level == allowed {
82- return true
83- }
84- }
85- return false
86-}
@@ -1,86 +0,0 @@
1-package docmodel
2-
3-// NextHeading returns the cursor for the count-th heading after from. Motions
4-// are strict: a heading at from is not considered a match. A count less than or
5-// equal to zero is treated as one. When levels are provided, only headings with
6-// one of those levels match.
7-func (d Document) NextHeading(from Cursor, count int, levels ...int) (Cursor, bool) {
8- return d.headingMotion(from, count, 1, levels)
9-}
10-
11-// PreviousHeading returns the cursor for the count-th heading before from.
12-// Motions are strict: a heading at from is not considered a match. A count less
13-// than or equal to zero is treated as one. When levels are provided, only
14-// headings with one of those levels match.
15-func (d Document) PreviousHeading(from Cursor, count int, levels ...int) (Cursor, bool) {
16- return d.headingMotion(from, count, -1, levels)
17-}
18-
19-// NextBlockOfKind returns the cursor for the count-th block of kind after from.
20-// Motions are strict: a block at from is not considered a match. A count less
21-// than or equal to zero is treated as one. Empty kinds never match.
22-func (d Document) NextBlockOfKind(from Cursor, kind BlockKind, count int) (Cursor, bool) {
23- return d.blockKindMotion(from, kind, count, 1)
24-}
25-
26-// PreviousBlockOfKind returns the cursor for the count-th block of kind before
27-// from. Motions are strict: a block at from is not considered a match. A count
28-// less than or equal to zero is treated as one. Empty kinds never match.
29-func (d Document) PreviousBlockOfKind(from Cursor, kind BlockKind, count int) (Cursor, bool) {
30- return d.blockKindMotion(from, kind, count, -1)
31-}
32-
33-func (d Document) headingMotion(from Cursor, count, direction int, levels []int) (Cursor, bool) {
34- return d.matchingBlockMotion(from, count, direction, func(block Block) bool {
35- return block.IsHeading() && headingLevelMatches(block.HeadingLevel, levels)
36- })
37-}
38-
39-func (d Document) blockKindMotion(from Cursor, kind BlockKind, count, direction int) (Cursor, bool) {
40- if kind == "" {
41- return Cursor{}, false
42- }
43-
44- return d.matchingBlockMotion(from, count, direction, func(block Block) bool {
45- return block.Kind == kind
46- })
47-}
48-
49-func (d Document) matchingBlockMotion(from Cursor, count, direction int, matches func(Block) bool) (Cursor, bool) {
50- start, ok := d.indexByID[from.BlockID]
51- if !ok {
52- return Cursor{}, false
53- }
54-
55- if count <= 0 {
56- count = 1
57- }
58-
59- remaining := count
60- for i := start + direction; i >= 0 && i < len(d.blocks); i += direction {
61- block := d.blocks[i]
62- if !matches(block) {
63- continue
64- }
65-
66- remaining--
67- if remaining == 0 {
68- return Cursor{BlockID: block.ID}, true
69- }
70- }
71-
72- return Cursor{}, false
73-}
74-
75-func headingLevelMatches(level int, levels []int) bool {
76- if len(levels) == 0 {
77- return true
78- }
79-
80- for _, allowed := range levels {
81- if level == allowed {
82- return true
83- }
84- }
85- return false
86-}
D internal/domain/docmodel/paragraph_motions_test.go +0 -175
@@ -1,175 +0,0 @@
1-package docmodel
2-
3-import (
4- "reflect"
5- "testing"
6-)
7-
8-func TestParagraphNextBlockOfKindProvidesVimCloseBraceSemantics(t *testing.T) {
9- doc := paragraphMotionDocument(t)
10-
11- tests := []struct {
12- name string
13- from Cursor
14- count int
15- want Cursor
16- ok bool
17- }{
18- {
19- name: "forward paragraph motion skips non paragraphs",
20- from: Cursor{BlockID: "h1"},
21- want: Cursor{BlockID: "p1"},
22- ok: true,
23- },
24- {
25- name: "count below one means one",
26- from: Cursor{BlockID: "h1"},
27- count: -1,
28- want: Cursor{BlockID: "p1"},
29- ok: true,
30- },
31- {
32- name: "count zero means one",
33- from: Cursor{BlockID: "code1"},
34- count: 0,
35- want: Cursor{BlockID: "p2"},
36- ok: true,
37- },
38- {
39- name: "strict forward paragraph motion does not match current paragraph",
40- from: Cursor{BlockID: "p1"},
41- count: 1,
42- want: Cursor{BlockID: "p2"},
43- ok: true,
44- },
45- {
46- name: "count selects nth next paragraph",
47- from: Cursor{BlockID: "p0"},
48- count: 2,
49- want: Cursor{BlockID: "p2"},
50- ok: true,
51- },
52- {
53- name: "no later paragraph returns false",
54- from: Cursor{BlockID: "p3"},
55- },
56- {
57- name: "count past later paragraphs returns false",
58- from: Cursor{BlockID: "p1"},
59- count: 3,
60- },
61- {
62- name: "zero cursor returns false",
63- from: Cursor{},
64- },
65- {
66- name: "missing cursor returns false",
67- from: Cursor{BlockID: "missing"},
68- },
69- }
70-
71- for _, tt := range tests {
72- t.Run(tt.name, func(t *testing.T) {
73- before := doc.Blocks()
74- got, ok := doc.NextBlockOfKind(tt.from, BlockKindParagraph, tt.count)
75- if ok != tt.ok || got != tt.want {
76- t.Fatalf("NextBlockOfKind(%#v, BlockKindParagraph, %d) = (%#v, %v), want (%#v, %v)", tt.from, tt.count, got, ok, tt.want, tt.ok)
77- }
78- if after := doc.Blocks(); !reflect.DeepEqual(after, before) {
79- t.Fatalf("NextBlockOfKind mutated document: before %#v after %#v", before, after)
80- }
81- })
82- }
83-}
84-
85-func TestParagraphPreviousBlockOfKindProvidesVimOpenBraceSemantics(t *testing.T) {
86- doc := paragraphMotionDocument(t)
87-
88- tests := []struct {
89- name string
90- from Cursor
91- count int
92- want Cursor
93- ok bool
94- }{
95- {
96- name: "backward paragraph motion skips non paragraphs",
97- from: Cursor{BlockID: "table1"},
98- want: Cursor{BlockID: "p3"},
99- ok: true,
100- },
101- {
102- name: "count below one means one",
103- from: Cursor{BlockID: "table1"},
104- count: -1,
105- want: Cursor{BlockID: "p3"},
106- ok: true,
107- },
108- {
109- name: "count zero means one",
110- from: Cursor{BlockID: "code1"},
111- count: 0,
112- want: Cursor{BlockID: "p1"},
113- ok: true,
114- },
115- {
116- name: "strict backward paragraph motion does not match current paragraph",
117- from: Cursor{BlockID: "p3"},
118- count: 1,
119- want: Cursor{BlockID: "p2"},
120- ok: true,
121- },
122- {
123- name: "count selects nth previous paragraph",
124- from: Cursor{BlockID: "p3"},
125- count: 2,
126- want: Cursor{BlockID: "p1"},
127- ok: true,
128- },
129- {
130- name: "no earlier paragraph returns false",
131- from: Cursor{BlockID: "p0"},
132- },
133- {
134- name: "count past earlier paragraphs returns false",
135- from: Cursor{BlockID: "p2"},
136- count: 3,
137- },
138- {
139- name: "zero cursor returns false",
140- from: Cursor{},
141- },
142- {
143- name: "missing cursor returns false",
144- from: Cursor{BlockID: "missing"},
145- },
146- }
147-
148- for _, tt := range tests {
149- t.Run(tt.name, func(t *testing.T) {
150- before := doc.Blocks()
151- got, ok := doc.PreviousBlockOfKind(tt.from, BlockKindParagraph, tt.count)
152- if ok != tt.ok || got != tt.want {
153- t.Fatalf("PreviousBlockOfKind(%#v, BlockKindParagraph, %d) = (%#v, %v), want (%#v, %v)", tt.from, tt.count, got, ok, tt.want, tt.ok)
154- }
155- if after := doc.Blocks(); !reflect.DeepEqual(after, before) {
156- t.Fatalf("PreviousBlockOfKind mutated document: before %#v after %#v", before, after)
157- }
158- })
159- }
160-}
161-
162-func paragraphMotionDocument(t *testing.T) Document {
163- t.Helper()
164-
165- return mustDocument(t, []Block{
166- {ID: "p0", Kind: BlockKindParagraph},
167- {ID: "h1", Kind: BlockKindHeading, HeadingLevel: 1},
168- {ID: "p1", Kind: BlockKindParagraph},
169- {ID: "code1", Kind: BlockKindCode},
170- {ID: "p2", Kind: BlockKindParagraph},
171- {ID: "image1", Kind: BlockKindImage},
172- {ID: "p3", Kind: BlockKindParagraph},
173- {ID: "table1", Kind: BlockKindTable},
174- })
175-}
@@ -1,175 +0,0 @@
1-package docmodel
2-
3-import (
4- "reflect"
5- "testing"
6-)
7-
8-func TestParagraphNextBlockOfKindProvidesVimCloseBraceSemantics(t *testing.T) {
9- doc := paragraphMotionDocument(t)
10-
11- tests := []struct {
12- name string
13- from Cursor
14- count int
15- want Cursor
16- ok bool
17- }{
18- {
19- name: "forward paragraph motion skips non paragraphs",
20- from: Cursor{BlockID: "h1"},
21- want: Cursor{BlockID: "p1"},
22- ok: true,
23- },
24- {
25- name: "count below one means one",
26- from: Cursor{BlockID: "h1"},
27- count: -1,
28- want: Cursor{BlockID: "p1"},
29- ok: true,
30- },
31- {
32- name: "count zero means one",
33- from: Cursor{BlockID: "code1"},
34- count: 0,
35- want: Cursor{BlockID: "p2"},
36- ok: true,
37- },
38- {
39- name: "strict forward paragraph motion does not match current paragraph",
40- from: Cursor{BlockID: "p1"},
41- count: 1,
42- want: Cursor{BlockID: "p2"},
43- ok: true,
44- },
45- {
46- name: "count selects nth next paragraph",
47- from: Cursor{BlockID: "p0"},
48- count: 2,
49- want: Cursor{BlockID: "p2"},
50- ok: true,
51- },
52- {
53- name: "no later paragraph returns false",
54- from: Cursor{BlockID: "p3"},
55- },
56- {
57- name: "count past later paragraphs returns false",
58- from: Cursor{BlockID: "p1"},
59- count: 3,
60- },
61- {
62- name: "zero cursor returns false",
63- from: Cursor{},
64- },
65- {
66- name: "missing cursor returns false",
67- from: Cursor{BlockID: "missing"},
68- },
69- }
70-
71- for _, tt := range tests {
72- t.Run(tt.name, func(t *testing.T) {
73- before := doc.Blocks()
74- got, ok := doc.NextBlockOfKind(tt.from, BlockKindParagraph, tt.count)
75- if ok != tt.ok || got != tt.want {
76- t.Fatalf("NextBlockOfKind(%#v, BlockKindParagraph, %d) = (%#v, %v), want (%#v, %v)", tt.from, tt.count, got, ok, tt.want, tt.ok)
77- }
78- if after := doc.Blocks(); !reflect.DeepEqual(after, before) {
79- t.Fatalf("NextBlockOfKind mutated document: before %#v after %#v", before, after)
80- }
81- })
82- }
83-}
84-
85-func TestParagraphPreviousBlockOfKindProvidesVimOpenBraceSemantics(t *testing.T) {
86- doc := paragraphMotionDocument(t)
87-
88- tests := []struct {
89- name string
90- from Cursor
91- count int
92- want Cursor
93- ok bool
94- }{
95- {
96- name: "backward paragraph motion skips non paragraphs",
97- from: Cursor{BlockID: "table1"},
98- want: Cursor{BlockID: "p3"},
99- ok: true,
100- },
101- {
102- name: "count below one means one",
103- from: Cursor{BlockID: "table1"},
104- count: -1,
105- want: Cursor{BlockID: "p3"},
106- ok: true,
107- },
108- {
109- name: "count zero means one",
110- from: Cursor{BlockID: "code1"},
111- count: 0,
112- want: Cursor{BlockID: "p1"},
113- ok: true,
114- },
115- {
116- name: "strict backward paragraph motion does not match current paragraph",
117- from: Cursor{BlockID: "p3"},
118- count: 1,
119- want: Cursor{BlockID: "p2"},
120- ok: true,
121- },
122- {
123- name: "count selects nth previous paragraph",
124- from: Cursor{BlockID: "p3"},
125- count: 2,
126- want: Cursor{BlockID: "p1"},
127- ok: true,
128- },
129- {
130- name: "no earlier paragraph returns false",
131- from: Cursor{BlockID: "p0"},
132- },
133- {
134- name: "count past earlier paragraphs returns false",
135- from: Cursor{BlockID: "p2"},
136- count: 3,
137- },
138- {
139- name: "zero cursor returns false",
140- from: Cursor{},
141- },
142- {
143- name: "missing cursor returns false",
144- from: Cursor{BlockID: "missing"},
145- },
146- }
147-
148- for _, tt := range tests {
149- t.Run(tt.name, func(t *testing.T) {
150- before := doc.Blocks()
151- got, ok := doc.PreviousBlockOfKind(tt.from, BlockKindParagraph, tt.count)
152- if ok != tt.ok || got != tt.want {
153- t.Fatalf("PreviousBlockOfKind(%#v, BlockKindParagraph, %d) = (%#v, %v), want (%#v, %v)", tt.from, tt.count, got, ok, tt.want, tt.ok)
154- }
155- if after := doc.Blocks(); !reflect.DeepEqual(after, before) {
156- t.Fatalf("PreviousBlockOfKind mutated document: before %#v after %#v", before, after)
157- }
158- })
159- }
160-}
161-
162-func paragraphMotionDocument(t *testing.T) Document {
163- t.Helper()
164-
165- return mustDocument(t, []Block{
166- {ID: "p0", Kind: BlockKindParagraph},
167- {ID: "h1", Kind: BlockKindHeading, HeadingLevel: 1},
168- {ID: "p1", Kind: BlockKindParagraph},
169- {ID: "code1", Kind: BlockKindCode},
170- {ID: "p2", Kind: BlockKindParagraph},
171- {ID: "image1", Kind: BlockKindImage},
172- {ID: "p3", Kind: BlockKindParagraph},
173- {ID: "table1", Kind: BlockKindTable},
174- })
175-}
D internal/domain/docmodel/section.go +0 -41
@@ -1,41 +0,0 @@
1-package docmodel
2-
3-// SectionRange returns the half-open range for the section containing from.
4-// The range starts at the containing heading and ends before the first
5-// subsequent heading whose level is less than or equal to the start heading's
6-// level. A zero EndExclusive cursor represents the end of the document.
7-func (d Document) SectionRange(from Cursor) (BlockRange, bool) {
8- fromIndex, ok := d.indexByID[from.BlockID]
9- if !ok {
10- return BlockRange{}, false
11- }
12-
13- startIndex, ok := d.containingHeadingIndex(fromIndex)
14- if !ok {
15- return BlockRange{}, false
16- }
17-
18- start := d.blocks[startIndex]
19- section := BlockRange{
20- StartInclusive: Cursor{BlockID: start.ID},
21- }
22-
23- for i := startIndex + 1; i < len(d.blocks); i++ {
24- block := d.blocks[i]
25- if block.IsHeading() && block.HeadingLevel <= start.HeadingLevel {
26- section.EndExclusive = Cursor{BlockID: block.ID}
27- return section, true
28- }
29- }
30-
31- return section, true
32-}
33-
34-func (d Document) containingHeadingIndex(fromIndex int) (int, bool) {
35- for i := fromIndex; i >= 0; i-- {
36- if d.blocks[i].IsHeading() {
37- return i, true
38- }
39- }
40- return 0, false
41-}
@@ -1,41 +0,0 @@
1-package docmodel
2-
3-// SectionRange returns the half-open range for the section containing from.
4-// The range starts at the containing heading and ends before the first
5-// subsequent heading whose level is less than or equal to the start heading's
6-// level. A zero EndExclusive cursor represents the end of the document.
7-func (d Document) SectionRange(from Cursor) (BlockRange, bool) {
8- fromIndex, ok := d.indexByID[from.BlockID]
9- if !ok {
10- return BlockRange{}, false
11- }
12-
13- startIndex, ok := d.containingHeadingIndex(fromIndex)
14- if !ok {
15- return BlockRange{}, false
16- }
17-
18- start := d.blocks[startIndex]
19- section := BlockRange{
20- StartInclusive: Cursor{BlockID: start.ID},
21- }
22-
23- for i := startIndex + 1; i < len(d.blocks); i++ {
24- block := d.blocks[i]
25- if block.IsHeading() && block.HeadingLevel <= start.HeadingLevel {
26- section.EndExclusive = Cursor{BlockID: block.ID}
27- return section, true
28- }
29- }
30-
31- return section, true
32-}
33-
34-func (d Document) containingHeadingIndex(fromIndex int) (int, bool) {
35- for i := fromIndex; i >= 0; i-- {
36- if d.blocks[i].IsHeading() {
37- return i, true
38- }
39- }
40- return 0, false
41-}
D internal/domain/docmodel/section_test.go +0 -197
@@ -1,197 +0,0 @@
1-package docmodel
2-
3-import (
4- "reflect"
5- "testing"
6-)
7-
8-func TestSectionRange(t *testing.T) {
9- doc := sectionRangeDocument(t)
10-
11- tests := []struct {
12- name string
13- from Cursor
14- want BlockRange
15- ok bool
16- }{
17- {
18- name: "top level section includes descendant headings and stops before next top level heading",
19- from: Cursor{BlockID: "h1"},
20- want: BlockRange{
21- StartInclusive: Cursor{BlockID: "h1"},
22- EndExclusive: Cursor{BlockID: "h1b"},
23- },
24- ok: true,
25- },
26- {
27- name: "non-heading anchor resolves to nearest preceding heading",
28- from: Cursor{BlockID: "p-h2"},
29- want: BlockRange{
30- StartInclusive: Cursor{BlockID: "h2"},
31- EndExclusive: Cursor{BlockID: "h2b"},
32- },
33- ok: true,
34- },
35- {
36- name: "non-heading anchor resolves to innermost preceding heading",
37- from: Cursor{BlockID: "p-h3"},
38- want: BlockRange{
39- StartInclusive: Cursor{BlockID: "h3"},
40- EndExclusive: Cursor{BlockID: "h3b"},
41- },
42- ok: true,
43- },
44- {
45- name: "nested section includes deeper descendant heading",
46- from: Cursor{BlockID: "h2"},
47- want: BlockRange{
48- StartInclusive: Cursor{BlockID: "h2"},
49- EndExclusive: Cursor{BlockID: "h2b"},
50- },
51- ok: true,
52- },
53- {
54- name: "descendant heading section ends before next heading at same level",
55- from: Cursor{BlockID: "h3"},
56- want: BlockRange{
57- StartInclusive: Cursor{BlockID: "h3"},
58- EndExclusive: Cursor{BlockID: "h3b"},
59- },
60- ok: true,
61- },
62- {
63- name: "skipped heading levels are valid descendants",
64- from: Cursor{BlockID: "h2-skip"},
65- want: BlockRange{
66- StartInclusive: Cursor{BlockID: "h2-skip"},
67- EndExclusive: Cursor{BlockID: "h2-sibling"},
68- },
69- ok: true,
70- },
71- {
72- name: "adjacent same-level heading yields empty body range ending at adjacent heading",
73- from: Cursor{BlockID: "h2-adjacent"},
74- want: BlockRange{
75- StartInclusive: Cursor{BlockID: "h2-adjacent"},
76- EndExclusive: Cursor{BlockID: "h2-after-adjacent"},
77- },
78- ok: true,
79- },
80- {
81- name: "lower-level heading ends nested section",
82- from: Cursor{BlockID: "h2-after-adjacent"},
83- want: BlockRange{
84- StartInclusive: Cursor{BlockID: "h2-after-adjacent"},
85- EndExclusive: Cursor{BlockID: "h1-final"},
86- },
87- ok: true,
88- },
89- {
90- name: "EOF section uses zero end cursor",
91- from: Cursor{BlockID: "h1-final"},
92- want: BlockRange{
93- StartInclusive: Cursor{BlockID: "h1-final"},
94- },
95- ok: true,
96- },
97- {
98- name: "non-heading anchor in EOF section uses zero end cursor",
99- from: Cursor{BlockID: "p-final"},
100- want: BlockRange{
101- StartInclusive: Cursor{BlockID: "h1-final"},
102- },
103- ok: true,
104- },
105- {
106- name: "before first heading returns false",
107- from: Cursor{BlockID: "intro"},
108- },
109- {
110- name: "zero cursor returns false",
111- from: Cursor{},
112- },
113- {
114- name: "missing cursor returns false",
115- from: Cursor{BlockID: "missing"},
116- },
117- }
118-
119- for _, tt := range tests {
120- t.Run(tt.name, func(t *testing.T) {
121- before := doc.Blocks()
122- got, ok := doc.SectionRange(tt.from)
123- if ok != tt.ok || got != tt.want {
124- t.Fatalf("SectionRange(%#v) = (%#v, %v), want (%#v, %v)", tt.from, got, ok, tt.want, tt.ok)
125- }
126- if after := doc.Blocks(); !reflect.DeepEqual(after, before) {
127- t.Fatalf("SectionRange mutated document: before %#v after %#v", before, after)
128- }
129- })
130- }
131-}
132-
133-func TestSectionRangeNoSectionDocuments(t *testing.T) {
134- tests := []struct {
135- name string
136- blocks []Block
137- from Cursor
138- }{
139- {
140- name: "empty document",
141- from: Cursor{BlockID: "missing"},
142- },
143- {
144- name: "headingless document",
145- blocks: []Block{
146- {ID: "p0", Kind: BlockKindParagraph},
147- {ID: "code", Kind: BlockKindCode},
148- {ID: "p1", Kind: BlockKindParagraph},
149- },
150- from: Cursor{BlockID: "code"},
151- },
152- }
153-
154- for _, tt := range tests {
155- t.Run(tt.name, func(t *testing.T) {
156- doc := mustDocument(t, tt.blocks)
157- before := doc.Blocks()
158-
159- got, ok := doc.SectionRange(tt.from)
160- if ok || got != (BlockRange{}) {
161- t.Fatalf("SectionRange(%#v) = (%#v, %v), want zero range and false", tt.from, got, ok)
162- }
163- if after := doc.Blocks(); !reflect.DeepEqual(after, before) {
164- t.Fatalf("SectionRange mutated document: before %#v after %#v", before, after)
165- }
166- })
167- }
168-}
169-
170-func sectionRangeDocument(t *testing.T) Document {
171- t.Helper()
172-
173- return mustDocument(t, []Block{
174- {ID: "intro", Kind: BlockKindParagraph},
175- {ID: "h1", Kind: BlockKindHeading, HeadingLevel: 1},
176- {ID: "p-h1", Kind: BlockKindParagraph},
177- {ID: "h2", Kind: BlockKindHeading, HeadingLevel: 2},
178- {ID: "p-h2", Kind: BlockKindParagraph},
179- {ID: "h3", Kind: BlockKindHeading, HeadingLevel: 3},
180- {ID: "p-h3", Kind: BlockKindParagraph},
181- {ID: "h3b", Kind: BlockKindHeading, HeadingLevel: 3},
182- {ID: "p-h3b", Kind: BlockKindParagraph},
183- {ID: "h2b", Kind: BlockKindHeading, HeadingLevel: 2},
184- {ID: "p-h2b", Kind: BlockKindParagraph},
185- {ID: "h1b", Kind: BlockKindHeading, HeadingLevel: 1},
186- {ID: "h2-skip", Kind: BlockKindHeading, HeadingLevel: 2},
187- {ID: "h5-skip", Kind: BlockKindHeading, HeadingLevel: 5},
188- {ID: "p-skip", Kind: BlockKindParagraph},
189- {ID: "h2-sibling", Kind: BlockKindHeading, HeadingLevel: 2},
190- {ID: "h2-adjacent", Kind: BlockKindHeading, HeadingLevel: 2},
191- {ID: "h2-after-adjacent", Kind: BlockKindHeading, HeadingLevel: 2},
192- {ID: "p-after-adjacent", Kind: BlockKindParagraph},
193- {ID: "h1-final", Kind: BlockKindHeading, HeadingLevel: 1},
194- {ID: "p-final", Kind: BlockKindParagraph},
195- {ID: "h2-final-child", Kind: BlockKindHeading, HeadingLevel: 2},
196- })
197-}
@@ -1,197 +0,0 @@
1-package docmodel
2-
3-import (
4- "reflect"
5- "testing"
6-)
7-
8-func TestSectionRange(t *testing.T) {
9- doc := sectionRangeDocument(t)
10-
11- tests := []struct {
12- name string
13- from Cursor
14- want BlockRange
15- ok bool
16- }{
17- {
18- name: "top level section includes descendant headings and stops before next top level heading",
19- from: Cursor{BlockID: "h1"},
20- want: BlockRange{
21- StartInclusive: Cursor{BlockID: "h1"},
22- EndExclusive: Cursor{BlockID: "h1b"},
23- },
24- ok: true,
25- },
26- {
27- name: "non-heading anchor resolves to nearest preceding heading",
28- from: Cursor{BlockID: "p-h2"},
29- want: BlockRange{
30- StartInclusive: Cursor{BlockID: "h2"},
31- EndExclusive: Cursor{BlockID: "h2b"},
32- },
33- ok: true,
34- },
35- {
36- name: "non-heading anchor resolves to innermost preceding heading",
37- from: Cursor{BlockID: "p-h3"},
38- want: BlockRange{
39- StartInclusive: Cursor{BlockID: "h3"},
40- EndExclusive: Cursor{BlockID: "h3b"},
41- },
42- ok: true,
43- },
44- {
45- name: "nested section includes deeper descendant heading",
46- from: Cursor{BlockID: "h2"},
47- want: BlockRange{
48- StartInclusive: Cursor{BlockID: "h2"},
49- EndExclusive: Cursor{BlockID: "h2b"},
50- },
51- ok: true,
52- },
53- {
54- name: "descendant heading section ends before next heading at same level",
55- from: Cursor{BlockID: "h3"},
56- want: BlockRange{
57- StartInclusive: Cursor{BlockID: "h3"},
58- EndExclusive: Cursor{BlockID: "h3b"},
59- },
60- ok: true,
61- },
62- {
63- name: "skipped heading levels are valid descendants",
64- from: Cursor{BlockID: "h2-skip"},
65- want: BlockRange{
66- StartInclusive: Cursor{BlockID: "h2-skip"},
67- EndExclusive: Cursor{BlockID: "h2-sibling"},
68- },
69- ok: true,
70- },
71- {
72- name: "adjacent same-level heading yields empty body range ending at adjacent heading",
73- from: Cursor{BlockID: "h2-adjacent"},
74- want: BlockRange{
75- StartInclusive: Cursor{BlockID: "h2-adjacent"},
76- EndExclusive: Cursor{BlockID: "h2-after-adjacent"},
77- },
78- ok: true,
79- },
80- {
81- name: "lower-level heading ends nested section",
82- from: Cursor{BlockID: "h2-after-adjacent"},
83- want: BlockRange{
84- StartInclusive: Cursor{BlockID: "h2-after-adjacent"},
85- EndExclusive: Cursor{BlockID: "h1-final"},
86- },
87- ok: true,
88- },
89- {
90- name: "EOF section uses zero end cursor",
91- from: Cursor{BlockID: "h1-final"},
92- want: BlockRange{
93- StartInclusive: Cursor{BlockID: "h1-final"},
94- },
95- ok: true,
96- },
97- {
98- name: "non-heading anchor in EOF section uses zero end cursor",
99- from: Cursor{BlockID: "p-final"},
100- want: BlockRange{
101- StartInclusive: Cursor{BlockID: "h1-final"},
102- },
103- ok: true,
104- },
105- {
106- name: "before first heading returns false",
107- from: Cursor{BlockID: "intro"},
108- },
109- {
110- name: "zero cursor returns false",
111- from: Cursor{},
112- },
113- {
114- name: "missing cursor returns false",
115- from: Cursor{BlockID: "missing"},
116- },
117- }
118-
119- for _, tt := range tests {
120- t.Run(tt.name, func(t *testing.T) {
121- before := doc.Blocks()
122- got, ok := doc.SectionRange(tt.from)
123- if ok != tt.ok || got != tt.want {
124- t.Fatalf("SectionRange(%#v) = (%#v, %v), want (%#v, %v)", tt.from, got, ok, tt.want, tt.ok)
125- }
126- if after := doc.Blocks(); !reflect.DeepEqual(after, before) {
127- t.Fatalf("SectionRange mutated document: before %#v after %#v", before, after)
128- }
129- })
130- }
131-}
132-
133-func TestSectionRangeNoSectionDocuments(t *testing.T) {
134- tests := []struct {
135- name string
136- blocks []Block
137- from Cursor
138- }{
139- {
140- name: "empty document",
141- from: Cursor{BlockID: "missing"},
142- },
143- {
144- name: "headingless document",
145- blocks: []Block{
146- {ID: "p0", Kind: BlockKindParagraph},
147- {ID: "code", Kind: BlockKindCode},
148- {ID: "p1", Kind: BlockKindParagraph},
149- },
150- from: Cursor{BlockID: "code"},
151- },
152- }
153-
154- for _, tt := range tests {
155- t.Run(tt.name, func(t *testing.T) {
156- doc := mustDocument(t, tt.blocks)
157- before := doc.Blocks()
158-
159- got, ok := doc.SectionRange(tt.from)
160- if ok || got != (BlockRange{}) {
161- t.Fatalf("SectionRange(%#v) = (%#v, %v), want zero range and false", tt.from, got, ok)
162- }
163- if after := doc.Blocks(); !reflect.DeepEqual(after, before) {
164- t.Fatalf("SectionRange mutated document: before %#v after %#v", before, after)
165- }
166- })
167- }
168-}
169-
170-func sectionRangeDocument(t *testing.T) Document {
171- t.Helper()
172-
173- return mustDocument(t, []Block{
174- {ID: "intro", Kind: BlockKindParagraph},
175- {ID: "h1", Kind: BlockKindHeading, HeadingLevel: 1},
176- {ID: "p-h1", Kind: BlockKindParagraph},
177- {ID: "h2", Kind: BlockKindHeading, HeadingLevel: 2},
178- {ID: "p-h2", Kind: BlockKindParagraph},
179- {ID: "h3", Kind: BlockKindHeading, HeadingLevel: 3},
180- {ID: "p-h3", Kind: BlockKindParagraph},
181- {ID: "h3b", Kind: BlockKindHeading, HeadingLevel: 3},
182- {ID: "p-h3b", Kind: BlockKindParagraph},
183- {ID: "h2b", Kind: BlockKindHeading, HeadingLevel: 2},
184- {ID: "p-h2b", Kind: BlockKindParagraph},
185- {ID: "h1b", Kind: BlockKindHeading, HeadingLevel: 1},
186- {ID: "h2-skip", Kind: BlockKindHeading, HeadingLevel: 2},
187- {ID: "h5-skip", Kind: BlockKindHeading, HeadingLevel: 5},
188- {ID: "p-skip", Kind: BlockKindParagraph},
189- {ID: "h2-sibling", Kind: BlockKindHeading, HeadingLevel: 2},
190- {ID: "h2-adjacent", Kind: BlockKindHeading, HeadingLevel: 2},
191- {ID: "h2-after-adjacent", Kind: BlockKindHeading, HeadingLevel: 2},
192- {ID: "p-after-adjacent", Kind: BlockKindParagraph},
193- {ID: "h1-final", Kind: BlockKindHeading, HeadingLevel: 1},
194- {ID: "p-final", Kind: BlockKindParagraph},
195- {ID: "h2-final-child", Kind: BlockKindHeading, HeadingLevel: 2},
196- })
197-}
M internal/infrastructure/config/schema_provider_test.go +0 -10
@@ -18,9 +18,6 @@ func TestConfigurationReferenceCoversSchemaKeys(t *testing.T) {
1818
1919 var missing []string
2020 for _, key := range NewSchemaProvider().GetSchema() {
21- if _, pending := schemaKeysPendingProductDocs[key.Key]; pending {
22- continue
23- }
2421 if configurationReferenceCoversKey(docKeys, key.Key) {
2522 continue
2623 }
@@ -31,13 +28,6 @@ func TestConfigurationReferenceCoversSchemaKeys(t *testing.T) {
3128 require.Empty(t, missing, "docs/reference/configuration.md should cover every SchemaProvider key")
3229 }
3330
34-// schemaKeysPendingProductDocs lists SchemaProvider keys intentionally omitted from
35-// product docs until a dedicated docs task lands them.
36-var schemaKeysPendingProductDocs = map[string]struct{}{
37- "workspace.vim_mode.sequence_timeout_ms": {},
38- "workspace.vim_mode.preload_accessibility": {},
39-}
40-
4131 func configurationReferenceKeys(t *testing.T) map[string]struct{} {
4232 t.Helper()
4333
@@ -18,9 +18,6 @@ func TestConfigurationReferenceCoversSchemaKeys(t *testing.T) {
18 18
19 var missing []string19 var missing []string
20 for _, key := range NewSchemaProvider().GetSchema() {20 for _, key := range NewSchemaProvider().GetSchema() {
21- if _, pending := schemaKeysPendingProductDocs[key.Key]; pending {
22- continue
23- }
24 if configurationReferenceCoversKey(docKeys, key.Key) {21 if configurationReferenceCoversKey(docKeys, key.Key) {
25 continue22 continue
26 }23 }
@@ -31,13 +28,6 @@ func TestConfigurationReferenceCoversSchemaKeys(t *testing.T) {
31 require.Empty(t, missing, "docs/reference/configuration.md should cover every SchemaProvider key")28 require.Empty(t, missing, "docs/reference/configuration.md should cover every SchemaProvider key")
32 }29 }
33 30
34-// schemaKeysPendingProductDocs lists SchemaProvider keys intentionally omitted from
35-// product docs until a dedicated docs task lands them.
36-var schemaKeysPendingProductDocs = map[string]struct{}{
37- "workspace.vim_mode.sequence_timeout_ms": {},
38- "workspace.vim_mode.preload_accessibility": {},
39-}
40-
41 func configurationReferenceKeys(t *testing.T) map[string]struct{} {31 func configurationReferenceKeys(t *testing.T) map[string]struct{} {
42 t.Helper()32 t.Helper()
43 33
M internal/infrastructure/webkit/injector.go +3 -0
@@ -194,8 +194,11 @@ func buildAccentDetectionScript(token string) string {
194194 const accentKeys = new Set([%s]);
195195 const editableFocusToken = %q;
196196 let pressedKey = null;
197+ let lastEditableFocusState = null;
197198
198199 function postEditableFocus(editable) {
200+ if (lastEditableFocusState === editable) return;
201+ lastEditableFocusState = editable;
199202 if (!editableFocusToken) return;
200203 if (!(window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.dumber)) return;
201204 window.webkit.messageHandlers.dumber.postMessage({
@@ -194,8 +194,11 @@ func buildAccentDetectionScript(token string) string {
194 const accentKeys = new Set([%s]);194 const accentKeys = new Set([%s]);
195 const editableFocusToken = %q;195 const editableFocusToken = %q;
196 let pressedKey = null;196 let pressedKey = null;
197+ let lastEditableFocusState = null;
197 198
198 function postEditableFocus(editable) {199 function postEditableFocus(editable) {
200+ if (lastEditableFocusState === editable) return;
201+ lastEditableFocusState = editable;
199 if (!editableFocusToken) return;202 if (!editableFocusToken) return;
200 if (!(window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.dumber)) return;203 if (!(window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.dumber)) return;
201 window.webkit.messageHandlers.dumber.postMessage({204 window.webkit.messageHandlers.dumber.postMessage({
M internal/infrastructure/webkit/injector_accent_test.go +2 -0
@@ -38,6 +38,8 @@ func TestAccentDetectionScriptPostsEditableFocusChangedMessages(t *testing.T) {
3838 assert.Contains(t, accentDetectionScript, "document.addEventListener('focusout'")
3939 assert.Contains(t, accentDetectionScript, `const editableFocusToken = "";`)
4040 assert.Contains(t, accentDetectionScript, `payload: { editable: editable, token: editableFocusToken }`)
41+ assert.Contains(t, accentDetectionScript, "let lastEditableFocusState = null;")
42+ assert.Contains(t, accentDetectionScript, "if (lastEditableFocusState === editable) return;")
4143 assert.Contains(t, accentDetectionScript, `postEditableFocus(true)`)
4244 assert.Contains(t, accentDetectionScript, `postEditableFocus(false)`)
4345 assert.Contains(t, accentDetectionScript, `e && e.isTrusted === false`)
@@ -38,6 +38,8 @@ func TestAccentDetectionScriptPostsEditableFocusChangedMessages(t *testing.T) {
38 assert.Contains(t, accentDetectionScript, "document.addEventListener('focusout'")38 assert.Contains(t, accentDetectionScript, "document.addEventListener('focusout'")
39 assert.Contains(t, accentDetectionScript, `const editableFocusToken = "";`)39 assert.Contains(t, accentDetectionScript, `const editableFocusToken = "";`)
40 assert.Contains(t, accentDetectionScript, `payload: { editable: editable, token: editableFocusToken }`)40 assert.Contains(t, accentDetectionScript, `payload: { editable: editable, token: editableFocusToken }`)
41+ assert.Contains(t, accentDetectionScript, "let lastEditableFocusState = null;")
42+ assert.Contains(t, accentDetectionScript, "if (lastEditableFocusState === editable) return;")
41 assert.Contains(t, accentDetectionScript, `postEditableFocus(true)`)43 assert.Contains(t, accentDetectionScript, `postEditableFocus(true)`)
42 assert.Contains(t, accentDetectionScript, `postEditableFocus(false)`)44 assert.Contains(t, accentDetectionScript, `postEditableFocus(false)`)
43 assert.Contains(t, accentDetectionScript, `e && e.isTrusted === false`)45 assert.Contains(t, accentDetectionScript, `e && e.isTrusted === false`)
M internal/ui/app.go +31 -36
@@ -124,6 +124,7 @@ type App struct {
124124 focusMgr *focus.Manager
125125
126126 vimModePolicyUC *usecase.VimModePolicyUseCase
127+ vimNavigationUC *usecase.VimNavigationUseCase
127128 pageEditableFocusByPane map[entity.PaneID]bool
128129
129130 resizeModeBorderTarget layout.Widget
@@ -217,6 +218,7 @@ func New(deps *Dependencies) (*App, error) {
217218 floatingSessions: make(map[floatingSessionKey]*floatingWorkspaceSession),
218219 browserWindows: make(map[string]*browserWindow),
219220 vimModePolicyUC: usecase.NewVimModePolicyUseCase(),
221+ vimNavigationUC: usecase.NewVimNavigationUseCase(),
220222 pageEditableFocusByPane: make(map[entity.PaneID]bool),
221223 dispatchOnMainThread: func(label string, fn func()) syncdispatch.SyncDispatchResult {
222224 if fn != nil {
@@ -2171,54 +2173,34 @@ func (a *App) navigatePageFocus(_ context.Context, bw *browserWindow, backward b
21712173 if paneID == "" || !a.pageEditableFocused(paneID) {
21722174 return false
21732175 }
2174- navigator, ok := wv.(port.PageFocusNavigator)
2175- if !ok {
2176+ navigationUC := a.vimNavigationUseCase()
2177+ if navigationUC == nil {
21762178 return false
21772179 }
2178- navigator.NavigatePageFocus(backward)
2179- return true
2180+ return navigationUC.NavigatePageFocus(wv, backward)
21802181 }
21812182
2182-func (a *App) navigateVimSequenceAction(ctx context.Context, bw *browserWindow, action string, count int) {
2183- if action == "focus-input" {
2184- _, wv := a.activeWebViewForBrowserWindow(bw)
2185- if focuser, ok := wv.(port.PageInputFocuser); ok {
2186- focuser.FocusNextInput()
2187- }
2188- return
2183+func (a *App) vimNavigationUseCase() *usecase.VimNavigationUseCase {
2184+ if a == nil {
2185+ return nil
21892186 }
2190-
2191- var request port.SemanticNavigationRequest
2192- switch action {
2193- case "heading-next":
2194- request = port.SemanticNavigationRequest{
2195- Target: port.SemanticNavigationTargetHeading,
2196- Direction: port.SemanticNavigationForward,
2197- Count: count,
2198- HighlightColor: a.vimNavigationHighlightColor(),
2199- }
2200- case "heading-prev":
2201- request = port.SemanticNavigationRequest{
2202- Target: port.SemanticNavigationTargetHeading,
2203- Direction: port.SemanticNavigationBackward,
2204- Count: count,
2205- HighlightColor: a.vimNavigationHighlightColor(),
2206- }
2207- default:
2208- return
2187+ if a.vimNavigationUC == nil {
2188+ a.vimNavigationUC = usecase.NewVimNavigationUseCase()
22092189 }
2190+ return a.vimNavigationUC
2191+}
22102192
2211- paneID, wv := a.activeWebViewForBrowserWindow(bw)
2212- navigator, ok := wv.(port.SemanticNavigable)
2213- if !ok {
2193+func (a *App) navigateVimSequenceAction(ctx context.Context, bw *browserWindow, action string, count int) {
2194+ _, wv := a.activeWebViewForBrowserWindow(bw)
2195+ navigationUC := a.vimNavigationUseCase()
2196+ if navigationUC == nil {
22142197 return
22152198 }
2216- if err := navigator.NavigateSemantic(ctx, request); err != nil {
2199+ if err := navigationUC.Execute(ctx, wv, action, count, a.vimNavigationHighlightColor()); err != nil {
22172200 logging.FromContext(ctx).Debug().
22182201 Err(err).
22192202 Str("action", action).
2220- Str("pane_id", string(paneID)).
2221- Msg("vim semantic navigation unavailable")
2203+ Msg("vim navigation unavailable")
22222204 }
22232205 }
22242206
@@ -2249,6 +2231,18 @@ func (a *App) enableAccessibilityForVimMode(ctx context.Context, bw *browserWind
22492231 Msg("webview accessibility enabled for vim mode")
22502232 }
22512233
2234+func (a *App) preloadAccessibilityForShownPane(paneID entity.PaneID) {
2235+ if a == nil || paneID == "" || !a.runtimeConfigSnapshot().UI.Workspace.VimMode.PreloadAccessibility || a.contentCoord == nil {
2236+ return
2237+ }
2238+ wv := a.contentCoord.GetWebView(paneID)
2239+ enabler, ok := wv.(port.AccessibilityEnabler)
2240+ if !ok {
2241+ return
2242+ }
2243+ enabler.EnableAccessibility()
2244+}
2245+
22522246 // omniboxNavigateForBrowserWindow returns an omnibox OnNavigate callback that routes
22532247 // navigation through navigate for the supplied browser window. The closure captures
22542248 // bw at creation time, so navigation targets the owning window even when another
@@ -3389,6 +3383,7 @@ func (a *App) initCoordinators(ctx context.Context) {
33893383 a.deps.OnFirstWebViewShown(ctx)
33903384 })
33913385 }
3386+ a.preloadAccessibilityForShownPane(paneID)
33923387
33933388 // This only updates focus when the shown pane belongs to the last-focused
33943389 // window's active workspace. activeWorkspace() delegates to
@@ -124,6 +124,7 @@ type App struct {
124 focusMgr *focus.Manager124 focusMgr *focus.Manager
125 125
126 vimModePolicyUC *usecase.VimModePolicyUseCase126 vimModePolicyUC *usecase.VimModePolicyUseCase
127+ vimNavigationUC *usecase.VimNavigationUseCase
127 pageEditableFocusByPane map[entity.PaneID]bool128 pageEditableFocusByPane map[entity.PaneID]bool
128 129
129 resizeModeBorderTarget layout.Widget130 resizeModeBorderTarget layout.Widget
@@ -217,6 +218,7 @@ func New(deps *Dependencies) (*App, error) {
217 floatingSessions: make(map[floatingSessionKey]*floatingWorkspaceSession),218 floatingSessions: make(map[floatingSessionKey]*floatingWorkspaceSession),
218 browserWindows: make(map[string]*browserWindow),219 browserWindows: make(map[string]*browserWindow),
219 vimModePolicyUC: usecase.NewVimModePolicyUseCase(),220 vimModePolicyUC: usecase.NewVimModePolicyUseCase(),
221+ vimNavigationUC: usecase.NewVimNavigationUseCase(),
220 pageEditableFocusByPane: make(map[entity.PaneID]bool),222 pageEditableFocusByPane: make(map[entity.PaneID]bool),
221 dispatchOnMainThread: func(label string, fn func()) syncdispatch.SyncDispatchResult {223 dispatchOnMainThread: func(label string, fn func()) syncdispatch.SyncDispatchResult {
222 if fn != nil {224 if fn != nil {
@@ -2171,54 +2173,34 @@ func (a *App) navigatePageFocus(_ context.Context, bw *browserWindow, backward b
2171 if paneID == "" || !a.pageEditableFocused(paneID) {2173 if paneID == "" || !a.pageEditableFocused(paneID) {
2172 return false2174 return false
2173 }2175 }
2174- navigator, ok := wv.(port.PageFocusNavigator)2176+ navigationUC := a.vimNavigationUseCase()
2175- if !ok {2177+ if navigationUC == nil {
2176 return false2178 return false
2177 }2179 }
2178- navigator.NavigatePageFocus(backward)2180+ return navigationUC.NavigatePageFocus(wv, backward)
2179- return true
2180 }2181 }
2181 2182
2182-func (a *App) navigateVimSequenceAction(ctx context.Context, bw *browserWindow, action string, count int) {2183+func (a *App) vimNavigationUseCase() *usecase.VimNavigationUseCase {
2183- if action == "focus-input" {2184+ if a == nil {
2184- _, wv := a.activeWebViewForBrowserWindow(bw)2185+ return nil
2185- if focuser, ok := wv.(port.PageInputFocuser); ok {
2186- focuser.FocusNextInput()
2187- }
2188- return
2189 }2186 }
2190-2187+ if a.vimNavigationUC == nil {
2191- var request port.SemanticNavigationRequest2188+ a.vimNavigationUC = usecase.NewVimNavigationUseCase()
2192- switch action {
2193- case "heading-next":
2194- request = port.SemanticNavigationRequest{
2195- Target: port.SemanticNavigationTargetHeading,
2196- Direction: port.SemanticNavigationForward,
2197- Count: count,
2198- HighlightColor: a.vimNavigationHighlightColor(),
2199- }
2200- case "heading-prev":
2201- request = port.SemanticNavigationRequest{
2202- Target: port.SemanticNavigationTargetHeading,
2203- Direction: port.SemanticNavigationBackward,
2204- Count: count,
2205- HighlightColor: a.vimNavigationHighlightColor(),
2206- }
2207- default:
2208- return
2209 }2189 }
2190+ return a.vimNavigationUC
2191+}
2210 2192
2211- paneID, wv := a.activeWebViewForBrowserWindow(bw)2193+func (a *App) navigateVimSequenceAction(ctx context.Context, bw *browserWindow, action string, count int) {
2212- navigator, ok := wv.(port.SemanticNavigable)2194+ _, wv := a.activeWebViewForBrowserWindow(bw)
2213- if !ok {2195+ navigationUC := a.vimNavigationUseCase()
2196+ if navigationUC == nil {
2214 return2197 return
2215 }2198 }
2216- if err := navigator.NavigateSemantic(ctx, request); err != nil {2199+ if err := navigationUC.Execute(ctx, wv, action, count, a.vimNavigationHighlightColor()); err != nil {
2217 logging.FromContext(ctx).Debug().2200 logging.FromContext(ctx).Debug().
2218 Err(err).2201 Err(err).
2219 Str("action", action).2202 Str("action", action).
2220- Str("pane_id", string(paneID)).2203+ Msg("vim navigation unavailable")
2221- Msg("vim semantic navigation unavailable")
2222 }2204 }
2223 }2205 }
2224 2206
@@ -2249,6 +2231,18 @@ func (a *App) enableAccessibilityForVimMode(ctx context.Context, bw *browserWind
2249 Msg("webview accessibility enabled for vim mode")2231 Msg("webview accessibility enabled for vim mode")
2250 }2232 }
2251 2233
2234+func (a *App) preloadAccessibilityForShownPane(paneID entity.PaneID) {
2235+ if a == nil || paneID == "" || !a.runtimeConfigSnapshot().UI.Workspace.VimMode.PreloadAccessibility || a.contentCoord == nil {
2236+ return
2237+ }
2238+ wv := a.contentCoord.GetWebView(paneID)
2239+ enabler, ok := wv.(port.AccessibilityEnabler)
2240+ if !ok {
2241+ return
2242+ }
2243+ enabler.EnableAccessibility()
2244+}
2245+
2252 // omniboxNavigateForBrowserWindow returns an omnibox OnNavigate callback that routes2246 // omniboxNavigateForBrowserWindow returns an omnibox OnNavigate callback that routes
2253 // navigation through navigate for the supplied browser window. The closure captures2247 // navigation through navigate for the supplied browser window. The closure captures
2254 // bw at creation time, so navigation targets the owning window even when another2248 // bw at creation time, so navigation targets the owning window even when another
@@ -3389,6 +3383,7 @@ func (a *App) initCoordinators(ctx context.Context) {
3389 a.deps.OnFirstWebViewShown(ctx)3383 a.deps.OnFirstWebViewShown(ctx)
3390 })3384 })
3391 }3385 }
3386+ a.preloadAccessibilityForShownPane(paneID)
3392 3387
3393 // This only updates focus when the shown pane belongs to the last-focused3388 // This only updates focus when the shown pane belongs to the last-focused
3394 // window's active workspace. activeWorkspace() delegates to3389 // window's active workspace. activeWorkspace() delegates to
M internal/ui/app_vim_mode_test.go +25 -0
@@ -454,6 +454,31 @@ func TestVimMode_Enter_AccessibilityNoopsWhenActiveWebViewMissing(t *testing.T)
454454 app.handleModeChange(context.Background(), bw, input.ModeNormal, input.ModeVim)
455455 }
456456
457+func TestVimMode_PreloadAccessibilityEnablesShownWebViewWhenConfigured(t *testing.T) {
458+ app, _, paneID := newVimModeAccessibilityFixture(t)
459+ wv := newAccessibilityEnablingWebView(t, 4)
460+ app.contentCoord.RegisterPopupWebView(paneID, wv)
461+ app.runtimeConfig = runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{
462+ UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{
463+ VimMode: entity.VimModeConfig{PreloadAccessibility: true},
464+ }},
465+ })
466+
467+ app.preloadAccessibilityForShownPane(paneID)
468+
469+ assert.Equal(t, 1, wv.enableAccessibilityCalls)
470+}
471+
472+func TestVimMode_PreloadAccessibilityNoopsWhenDisabled(t *testing.T) {
473+ app, _, paneID := newVimModeAccessibilityFixture(t)
474+ wv := newAccessibilityEnablingWebView(t, 5)
475+ app.contentCoord.RegisterPopupWebView(paneID, wv)
476+
477+ app.preloadAccessibilityForShownPane(paneID)
478+
479+ assert.Equal(t, 0, wv.enableAccessibilityCalls)
480+}
481+
457482 func TestVimNavigationHighlightColorUsesCurrentThemeAccent(t *testing.T) {
458483 resolved := entity.ResolvedTheme{
459484 PrefersDark: true,
@@ -454,6 +454,31 @@ func TestVimMode_Enter_AccessibilityNoopsWhenActiveWebViewMissing(t *testing.T)
454 app.handleModeChange(context.Background(), bw, input.ModeNormal, input.ModeVim)454 app.handleModeChange(context.Background(), bw, input.ModeNormal, input.ModeVim)
455 }455 }
456 456
457+func TestVimMode_PreloadAccessibilityEnablesShownWebViewWhenConfigured(t *testing.T) {
458+ app, _, paneID := newVimModeAccessibilityFixture(t)
459+ wv := newAccessibilityEnablingWebView(t, 4)
460+ app.contentCoord.RegisterPopupWebView(paneID, wv)
461+ app.runtimeConfig = runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{
462+ UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{
463+ VimMode: entity.VimModeConfig{PreloadAccessibility: true},
464+ }},
465+ })
466+
467+ app.preloadAccessibilityForShownPane(paneID)
468+
469+ assert.Equal(t, 1, wv.enableAccessibilityCalls)
470+}
471+
472+func TestVimMode_PreloadAccessibilityNoopsWhenDisabled(t *testing.T) {
473+ app, _, paneID := newVimModeAccessibilityFixture(t)
474+ wv := newAccessibilityEnablingWebView(t, 5)
475+ app.contentCoord.RegisterPopupWebView(paneID, wv)
476+
477+ app.preloadAccessibilityForShownPane(paneID)
478+
479+ assert.Equal(t, 0, wv.enableAccessibilityCalls)
480+}
481+
457 func TestVimNavigationHighlightColorUsesCurrentThemeAccent(t *testing.T) {482 func TestVimNavigationHighlightColorUsesCurrentThemeAccent(t *testing.T) {
458 resolved := entity.ResolvedTheme{483 resolved := entity.ResolvedTheme{
459 PrefersDark: true,484 PrefersDark: true,

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