detached dfd29f7

feat(cef): add caret and visual line modes to the vim page runtime

The visual flow starts on text-anchor hints (or a visible selection), places a
visible blinking caret, and switches between caret, character, and line
selection. Word motions run character by character, y copies and returns to
the caret, and Escape steps back. The runtime reports mode changes to Go, and
copy messages of the visual flow no longer spend the interaction token.
commit
dfd29f71f414d4a5c1d0e9118ea3cba991cb6d91
author
brice · · 2026-10-04 15:02 UTC
committer
brice · · 2026-10-10 08:27 UTC
parent
3424122
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +1313 -239
M internal/application/dto/vim_page_interaction.go +28 -1
@@ -11,7 +11,9 @@ const (
1111 VimPageHintFollowNew
1212 // VimPageHintYankURL labels visible links and copies the chosen URL.
1313 VimPageHintYankURL
14- // VimPageVisual starts a visual selection that motions extend and y copies.
14+ // VimPageVisual starts the caret and visual selection flow: text-anchor
15+ // hints, a caret that motions move, and charwise or linewise selections
16+ // that y copies without leaving the caret.
1517 VimPageVisual
1618 // VimPageYankObject copies one structural text object immediately.
1719 VimPageYankObject
@@ -28,6 +30,31 @@ func (k VimPageInteractionKind) CapturesKeys() bool {
2830 }
2931 }
3032
33+// VimPageMode is the sub-mode a key-capturing page interaction reports as it
34+// moves between states, so the UI can label it.
35+type VimPageMode string
36+
37+const (
38+ // VimPageModeHints is showing text-anchor hints to place the caret.
39+ VimPageModeHints VimPageMode = "hints"
40+ // VimPageModeCaret is moving a visible caret without selecting.
41+ VimPageModeCaret VimPageMode = "caret"
42+ // VimPageModeVisual is extending a charwise selection.
43+ VimPageModeVisual VimPageMode = "visual"
44+ // VimPageModeVisualLine is extending a linewise selection.
45+ VimPageModeVisualLine VimPageMode = "visual-line"
46+)
47+
48+// Valid reports whether the mode is one the page runtime may report.
49+func (m VimPageMode) Valid() bool {
50+ switch m {
51+ case VimPageModeHints, VimPageModeCaret, VimPageModeVisual, VimPageModeVisualLine:
52+ return true
53+ default:
54+ return false
55+ }
56+}
57+
3158 // VimPageTextObject identifies the structural block copied by a text-object yank.
3259 type VimPageTextObject int
3360
@@ -11,7 +11,9 @@ const (
11 VimPageHintFollowNew11 VimPageHintFollowNew
12 // VimPageHintYankURL labels visible links and copies the chosen URL.12 // VimPageHintYankURL labels visible links and copies the chosen URL.
13 VimPageHintYankURL13 VimPageHintYankURL
14- // VimPageVisual starts a visual selection that motions extend and y copies.14+ // VimPageVisual starts the caret and visual selection flow: text-anchor
15+ // hints, a caret that motions move, and charwise or linewise selections
16+ // that y copies without leaving the caret.
15 VimPageVisual17 VimPageVisual
16 // VimPageYankObject copies one structural text object immediately.18 // VimPageYankObject copies one structural text object immediately.
17 VimPageYankObject19 VimPageYankObject
@@ -28,6 +30,31 @@ func (k VimPageInteractionKind) CapturesKeys() bool {
28 }30 }
29 }31 }
30 32
33+// VimPageMode is the sub-mode a key-capturing page interaction reports as it
34+// moves between states, so the UI can label it.
35+type VimPageMode string
36+
37+const (
38+ // VimPageModeHints is showing text-anchor hints to place the caret.
39+ VimPageModeHints VimPageMode = "hints"
40+ // VimPageModeCaret is moving a visible caret without selecting.
41+ VimPageModeCaret VimPageMode = "caret"
42+ // VimPageModeVisual is extending a charwise selection.
43+ VimPageModeVisual VimPageMode = "visual"
44+ // VimPageModeVisualLine is extending a linewise selection.
45+ VimPageModeVisualLine VimPageMode = "visual-line"
46+)
47+
48+// Valid reports whether the mode is one the page runtime may report.
49+func (m VimPageMode) Valid() bool {
50+ switch m {
51+ case VimPageModeHints, VimPageModeCaret, VimPageModeVisual, VimPageModeVisualLine:
52+ return true
53+ default:
54+ return false
55+ }
56+}
57+
31 // VimPageTextObject identifies the structural block copied by a text-object yank.58 // VimPageTextObject identifies the structural block copied by a text-object yank.
32 type VimPageTextObject int59 type VimPageTextObject int
33 60
M internal/application/port/webview.go +5 -0
@@ -164,6 +164,11 @@ type WebViewCallbacks struct {
164164 // OnVimPageInteractionEnded is called when an in-page Vim interaction
165165 // (link hints or visual selection) finishes or is canceled by the page.
166166 OnVimPageInteractionEnded func()
167+
168+ // OnVimPageModeChanged is called when a key-capturing in-page Vim
169+ // interaction moves to another sub-mode (hints, caret, visual) without
170+ // ending.
171+ OnVimPageModeChanged func(mode dto.VimPageMode)
167172 }
168173
169174 // FindOptions configures search behavior.
@@ -164,6 +164,11 @@ type WebViewCallbacks struct {
164 // OnVimPageInteractionEnded is called when an in-page Vim interaction164 // OnVimPageInteractionEnded is called when an in-page Vim interaction
165 // (link hints or visual selection) finishes or is canceled by the page.165 // (link hints or visual selection) finishes or is canceled by the page.
166 OnVimPageInteractionEnded func()166 OnVimPageInteractionEnded func()
167+
168+ // OnVimPageModeChanged is called when a key-capturing in-page Vim
169+ // interaction moves to another sub-mode (hints, caret, visual) without
170+ // ending.
171+ OnVimPageModeChanged func(mode dto.VimPageMode)
167 }172 }
168 173
169 // FindOptions configures search behavior.174 // FindOptions configures search behavior.
A internal/infrastructure/cef/testdata/vim_page_harness.js +209 -0
@@ -0,0 +1,209 @@
1+// DOM shim for the Vim page runtime tests. It models a page of single-line
2+// paragraphs (10px per character, 20px lines) with Chromium-like Selection and
3+// Range semantics: crossing a paragraph boundary is one character step that
4+// adds one "\n" to the selected text. Native "word" granularity is a trap: the
5+// runtime must implement word motions itself (Vimium #1441).
6+const posted = [];
7+const scrolls = [];
8+const listeners = {};
9+const CHAR_WIDTH = 10;
10+const LINE_HEIGHT = 20;
11+const rect = (x, y, w, h) => ({ left: x, top: y, right: x + w, bottom: y + h, width: w, height: h });
12+
13+class Node_ {
14+ constructor(type, tag) {
15+ this.nodeType = type; this.tagName = tag; this.children = []; this.parentElement = null;
16+ // style.cssText assignments are parsed so tests can read properties.
17+ const style = {};
18+ Object.defineProperty(style, "cssText", { enumerable: false, set(text) {
19+ for (const decl of text.split(";")) { const i = decl.indexOf(":"); if (i > 0) style[decl.slice(0, i).trim()] = decl.slice(i + 1).trim(); }
20+ } });
21+ this.style = style; this.attrs = {}; this.isConnected = true; this.shadowRoot = null;
22+ }
23+ appendChild(child) { child.parentElement = this; this.children.push(child); return child; }
24+ append(...items) { for (const item of items) if (item instanceof Node_) this.appendChild(item); }
25+ remove() { if (this.parentElement) this.parentElement.children = this.parentElement.children.filter((c) => c !== this); this.parentElement = null; }
26+ setAttribute(name, value) { this.attrs[name] = value; }
27+ closest() { return null; }
28+ contains(other) { for (let n = other; n; n = n.parentElement) if (n === this) return true; return false; }
29+ getClientRects() { return this.rect ? [this.rect] : []; }
30+ getBoundingClientRect() { return this.rect || rect(0, 0, 0, 0); }
31+ getRootNode() { return document; }
32+ querySelectorAll(selector) {
33+ const out = [];
34+ const visit = (node) => { for (const c of node.children) { if (c.nodeType === 1) { if (matches(c, selector)) out.push(c); visit(c); } } };
35+ visit(this);
36+ return out;
37+ }
38+ focus() { document.activeElement = this; }
39+ click() { posted.push({ clicked: this.href }); }
40+}
41+function matches(el, selector) {
42+ return selector.split(",").some((part) => {
43+ part = part.trim();
44+ if (part === "*") return true;
45+ if (part === "a[href]") return el.tagName === "A" && el.href;
46+ if (part.startsWith("[")) return false;
47+ const base = part.replace(/:not\(.*\)$/, "");
48+ return el.tagName === base.toUpperCase();
49+ });
50+}
51+class Element extends Node_ { constructor(tag) { super(1, tag.toUpperCase()); } }
52+class HTMLElement extends Element {}
53+class HTMLAnchorElement extends HTMLElement { constructor(href) { super("a"); this.href = href; this.target = ""; } }
54+class HTMLInputElement extends HTMLElement {}
55+class HTMLTextAreaElement extends HTMLElement {}
56+class HTMLSelectElement extends HTMLElement {}
57+class Text_ extends Node_ { constructor(data) { super(3, "#text"); this.data = data; } }
58+
59+const html = new HTMLElement("html");
60+const body = html.appendChild(new HTMLElement("body"));
61+const texts = [];
62+function paragraph(data, top) {
63+ const p = body.appendChild(new HTMLElement("p"));
64+ p.rect = rect(10, top, 600, LINE_HEIGHT);
65+ const text = p.appendChild(new Text_(data));
66+ texts.push(text);
67+ return { p, text };
68+}
69+const LONG = "The quick brown fox jumps over the lazy dog near the river";
70+const first = paragraph("Alpha bravo charlie", 10);
71+const second = paragraph(LONG, 40);
72+const third = paragraph("Third line, end.", 70);
73+const far = paragraph("Far below the fold", 2000);
74+const p = first.p; // legacy name used by the yank test
75+const one = body.appendChild(new HTMLAnchorElement("file:///one.html")); one.rect = rect(10, 120, 40, 20);
76+const two = body.appendChild(new HTMLAnchorElement("https://example.com/two")); two.rect = rect(80, 120, 40, 20);
77+const TEXT = first.text.data;
78+
79+const index = (node) => texts.indexOf(node);
80+const order = (node, offset) => index(node) * 100000 + offset;
81+class Range_ {
82+ constructor() { this.startContainer = this.endContainer = null; this.startOffset = this.endOffset = 0; }
83+ get collapsed() { return this.startContainer === this.endContainer && this.startOffset === this.endOffset; }
84+ setStart(node, offset) {
85+ this.startContainer = node; this.startOffset = offset;
86+ if (!this.endContainer || order(node, offset) > order(this.endContainer, this.endOffset)) { this.endContainer = node; this.endOffset = offset; }
87+ }
88+ setEnd(node, offset) {
89+ this.endContainer = node; this.endOffset = offset;
90+ if (!this.startContainer || order(node, offset) < order(this.startContainer, this.startOffset)) { this.startContainer = node; this.startOffset = offset; }
91+ }
92+ collapse(toStart) { if (toStart) { this.endContainer = this.startContainer; this.endOffset = this.startOffset; } else { this.startContainer = this.endContainer; this.startOffset = this.endOffset; } }
93+ selectNodeContents(node) { this.startContainer = this.endContainer = node; this.startOffset = 0; this.endOffset = node.data ? node.data.length : 0; }
94+ getClientRects() {
95+ const node = this.startContainer;
96+ const box = node && node.parentElement && node.parentElement.rect;
97+ if (!box) return [];
98+ const from = this.startOffset;
99+ const to = this.endContainer === node ? this.endOffset : node.data.length;
100+ return [rect(box.left + from * CHAR_WIDTH, box.top, (to - from) * CHAR_WIDTH, LINE_HEIGHT)];
101+ }
102+ getBoundingClientRect() { return this.getClientRects()[0] || rect(0, 0, 0, 0); }
103+}
104+
105+const selection = {
106+ anchorNode: null, anchorOffset: 0, focusNode: null, focusOffset: 0, rangeCount: 0,
107+ get isCollapsed() { return this.rangeCount === 0 || (this.anchorNode === this.focusNode && this.anchorOffset === this.focusOffset); },
108+ setBaseAndExtent(an, ao, fn, fo) { this.anchorNode = an; this.anchorOffset = ao; this.focusNode = fn; this.focusOffset = fo; this.rangeCount = 1; },
109+ collapse(node, offset) { this.setBaseAndExtent(node, offset, node, offset); },
110+ setFocus(node, offset) { this.focusNode = node; this.focusOffset = offset; },
111+ modify(alter, direction, granularity) {
112+ if (granularity === "word") throw new Error("native word granularity must not be used");
113+ const step = direction === "forward" ? 1 : -1;
114+ let i = index(this.focusNode);
115+ let o = this.focusOffset;
116+ const len = (n) => texts[n].data.length;
117+ if (granularity === "character") {
118+ if (step > 0) { if (o < len(i)) o++; else if (i < texts.length - 1) { i++; o = 0; } }
119+ else if (o > 0) o--; else if (i > 0) { i--; o = len(i); }
120+ } else if (granularity === "lineboundary") {
121+ o = step > 0 ? len(i) : 0;
122+ } else if (granularity === "line") {
123+ const j = i + step;
124+ if (j >= 0 && j < texts.length) { i = j; o = Math.min(o, len(j)); } else o = step > 0 ? len(i) : 0;
125+ } else if (granularity === "documentboundary") {
126+ i = step > 0 ? texts.length - 1 : 0; o = step > 0 ? len(i) : 0;
127+ } else if (granularity === "paragraph") {
128+ if (step > 0) { if (o < len(i)) o = len(i); else if (i < texts.length - 1) { i++; o = len(i); } }
129+ else if (o > 0) o = 0; else if (i > 0) { i--; o = 0; }
130+ } else if (granularity === "sentence") {
131+ o = step > 0 ? len(i) : 0;
132+ }
133+ this.focusNode = texts[i]; this.focusOffset = o;
134+ if (alter === "move") { this.anchorNode = this.focusNode; this.anchorOffset = this.focusOffset; }
135+ },
136+ removeAllRanges() { this.rangeCount = 0; this.anchorNode = this.focusNode = null; },
137+ addRange(range) { this.setBaseAndExtent(range.startContainer, range.startOffset, range.endContainer, range.endOffset); },
138+ getRangeAt() {
139+ const range = new Range_();
140+ const forward = order(this.anchorNode, this.anchorOffset) <= order(this.focusNode, this.focusOffset);
141+ const [s, e] = forward ? [[this.anchorNode, this.anchorOffset], [this.focusNode, this.focusOffset]] : [[this.focusNode, this.focusOffset], [this.anchorNode, this.anchorOffset]];
142+ range.startContainer = s[0]; range.startOffset = s[1]; range.endContainer = e[0]; range.endOffset = e[1];
143+ return range;
144+ },
145+ toString() {
146+ if (!this.rangeCount) return "";
147+ const r = this.getRangeAt();
148+ const a = index(r.startContainer);
149+ const b = index(r.endContainer);
150+ if (a === b) return r.startContainer.data.slice(r.startOffset, r.endOffset);
151+ const parts = [r.startContainer.data.slice(r.startOffset)];
152+ for (let n = a + 1; n < b; n++) parts.push(texts[n].data);
153+ parts.push(r.endContainer.data.slice(0, r.endOffset));
154+ return parts.join("\n");
155+ },
156+};
157+
158+global.Node = { TEXT_NODE: 3, ELEMENT_NODE: 1 };
159+global.NodeFilter = { SHOW_ELEMENT: 1, SHOW_TEXT: 4 };
160+global.Element = Element; global.HTMLElement = HTMLElement; global.HTMLAnchorElement = HTMLAnchorElement;
161+global.HTMLInputElement = HTMLInputElement; global.HTMLTextAreaElement = HTMLTextAreaElement; global.HTMLSelectElement = HTMLSelectElement;
162+global.document = {
163+ body, documentElement: html, activeElement: body,
164+ createElement: (tag) => new HTMLElement(tag),
165+ createRange: () => new Range_(),
166+ createTreeWalker: (root) => {
167+ const nodes = []; const visit = (n) => { nodes.push(n); for (const c of n.children) visit(c); }; visit(root);
168+ let i = 0; return { currentNode: nodes[0], nextNode: () => nodes[++i] || null };
169+ },
170+ querySelectorAll: (selector) => html.querySelectorAll(selector),
171+ elementFromPoint: (x, y) => [two, one, ...texts.map((t) => t.parentElement)].find((el) => el.rect && x >= el.rect.left && x < el.rect.right && y >= el.rect.top && y < el.rect.bottom) || body,
172+};
173+global.window = {
174+ innerWidth: 800, innerHeight: 600,
175+ scrollBy(x, y) { scrolls.push([x, y]); },
176+ addEventListener(type, fn) { (listeners[type] = listeners[type] || []).push(fn); },
177+ removeEventListener(type, fn) { listeners[type] = (listeners[type] || []).filter((f) => f !== fn); },
178+ getSelection: () => selection,
179+ getComputedStyle: () => ({ display: "block", visibility: "visible" }),
180+ fetch: (url, init) => { posted.push(JSON.parse(Buffer.from(init.headers["X-Dumber-Body"], "base64").toString("latin1"))); return Promise.resolve(); },
181+};
182+global.btoa = (s) => Buffer.from(s, "binary").toString("base64");
183+global.unescape = (s) => decodeURIComponent(s);
184+global.setTimeout = (fn) => { fn(); return 0; };
185+
186+// Helpers for drivers.
187+const pos = (node, offset) => index(node) + ":" + offset;
188+const snap = () => ({
189+ text: selection.toString(),
190+ anchor: selection.rangeCount ? pos(selection.anchorNode, selection.anchorOffset) : null,
191+ focus: selection.rangeCount ? pos(selection.focusNode, selection.focusOffset) : null,
192+});
193+const overlays = (name) => html.children.filter((c) => c.attrs && name in c.attrs);
194+const cursor = () => overlays("data-dumber-vim-caret")[0] || null;
195+const hintLabels = () => overlays("data-dumber-vim-hints").flatMap((root) => root.children.map((s) => s.textContent));
196+const fire = (type) => (listeners[type] || []).slice().forEach((fn) => fn({}));
197+const modes = () => posted.filter((m) => m.type === "mode").map((m) => m.mode);
198+const vp = () => window.__dumberVimPage;
199+const keys = (token, ...list) => list.forEach((k) => window.__dumberVimPage.key(token, k));
200+const rec = (name, extra) => posted.push(Object.assign({ r: name }, extra));
201+// startCaret begins the visual flow and places the caret at the start of the
202+// paragraph with the given hint label ("s", "a", "d" for the first, second,
203+// third visible paragraph).
204+const startCaret = (token, label, color) => {
205+ window.__dumberVimPage.start(token, { kind: "visual", color: color || "#fbbf24" });
206+ window.__dumberVimPage.key(token, label);
207+};
208+// S records a named snapshot of the selection (and any extra fields).
209+const S = (n, extra) => posted.push(Object.assign({ n }, snap(), extra));
@@ -0,0 +1,209 @@
1+// DOM shim for the Vim page runtime tests. It models a page of single-line
2+// paragraphs (10px per character, 20px lines) with Chromium-like Selection and
3+// Range semantics: crossing a paragraph boundary is one character step that
4+// adds one "\n" to the selected text. Native "word" granularity is a trap: the
5+// runtime must implement word motions itself (Vimium #1441).
6+const posted = [];
7+const scrolls = [];
8+const listeners = {};
9+const CHAR_WIDTH = 10;
10+const LINE_HEIGHT = 20;
11+const rect = (x, y, w, h) => ({ left: x, top: y, right: x + w, bottom: y + h, width: w, height: h });
12+
13+class Node_ {
14+ constructor(type, tag) {
15+ this.nodeType = type; this.tagName = tag; this.children = []; this.parentElement = null;
16+ // style.cssText assignments are parsed so tests can read properties.
17+ const style = {};
18+ Object.defineProperty(style, "cssText", { enumerable: false, set(text) {
19+ for (const decl of text.split(";")) { const i = decl.indexOf(":"); if (i > 0) style[decl.slice(0, i).trim()] = decl.slice(i + 1).trim(); }
20+ } });
21+ this.style = style; this.attrs = {}; this.isConnected = true; this.shadowRoot = null;
22+ }
23+ appendChild(child) { child.parentElement = this; this.children.push(child); return child; }
24+ append(...items) { for (const item of items) if (item instanceof Node_) this.appendChild(item); }
25+ remove() { if (this.parentElement) this.parentElement.children = this.parentElement.children.filter((c) => c !== this); this.parentElement = null; }
26+ setAttribute(name, value) { this.attrs[name] = value; }
27+ closest() { return null; }
28+ contains(other) { for (let n = other; n; n = n.parentElement) if (n === this) return true; return false; }
29+ getClientRects() { return this.rect ? [this.rect] : []; }
30+ getBoundingClientRect() { return this.rect || rect(0, 0, 0, 0); }
31+ getRootNode() { return document; }
32+ querySelectorAll(selector) {
33+ const out = [];
34+ const visit = (node) => { for (const c of node.children) { if (c.nodeType === 1) { if (matches(c, selector)) out.push(c); visit(c); } } };
35+ visit(this);
36+ return out;
37+ }
38+ focus() { document.activeElement = this; }
39+ click() { posted.push({ clicked: this.href }); }
40+}
41+function matches(el, selector) {
42+ return selector.split(",").some((part) => {
43+ part = part.trim();
44+ if (part === "*") return true;
45+ if (part === "a[href]") return el.tagName === "A" && el.href;
46+ if (part.startsWith("[")) return false;
47+ const base = part.replace(/:not\(.*\)$/, "");
48+ return el.tagName === base.toUpperCase();
49+ });
50+}
51+class Element extends Node_ { constructor(tag) { super(1, tag.toUpperCase()); } }
52+class HTMLElement extends Element {}
53+class HTMLAnchorElement extends HTMLElement { constructor(href) { super("a"); this.href = href; this.target = ""; } }
54+class HTMLInputElement extends HTMLElement {}
55+class HTMLTextAreaElement extends HTMLElement {}
56+class HTMLSelectElement extends HTMLElement {}
57+class Text_ extends Node_ { constructor(data) { super(3, "#text"); this.data = data; } }
58+
59+const html = new HTMLElement("html");
60+const body = html.appendChild(new HTMLElement("body"));
61+const texts = [];
62+function paragraph(data, top) {
63+ const p = body.appendChild(new HTMLElement("p"));
64+ p.rect = rect(10, top, 600, LINE_HEIGHT);
65+ const text = p.appendChild(new Text_(data));
66+ texts.push(text);
67+ return { p, text };
68+}
69+const LONG = "The quick brown fox jumps over the lazy dog near the river";
70+const first = paragraph("Alpha bravo charlie", 10);
71+const second = paragraph(LONG, 40);
72+const third = paragraph("Third line, end.", 70);
73+const far = paragraph("Far below the fold", 2000);
74+const p = first.p; // legacy name used by the yank test
75+const one = body.appendChild(new HTMLAnchorElement("file:///one.html")); one.rect = rect(10, 120, 40, 20);
76+const two = body.appendChild(new HTMLAnchorElement("https://example.com/two")); two.rect = rect(80, 120, 40, 20);
77+const TEXT = first.text.data;
78+
79+const index = (node) => texts.indexOf(node);
80+const order = (node, offset) => index(node) * 100000 + offset;
81+class Range_ {
82+ constructor() { this.startContainer = this.endContainer = null; this.startOffset = this.endOffset = 0; }
83+ get collapsed() { return this.startContainer === this.endContainer && this.startOffset === this.endOffset; }
84+ setStart(node, offset) {
85+ this.startContainer = node; this.startOffset = offset;
86+ if (!this.endContainer || order(node, offset) > order(this.endContainer, this.endOffset)) { this.endContainer = node; this.endOffset = offset; }
87+ }
88+ setEnd(node, offset) {
89+ this.endContainer = node; this.endOffset = offset;
90+ if (!this.startContainer || order(node, offset) < order(this.startContainer, this.startOffset)) { this.startContainer = node; this.startOffset = offset; }
91+ }
92+ collapse(toStart) { if (toStart) { this.endContainer = this.startContainer; this.endOffset = this.startOffset; } else { this.startContainer = this.endContainer; this.startOffset = this.endOffset; } }
93+ selectNodeContents(node) { this.startContainer = this.endContainer = node; this.startOffset = 0; this.endOffset = node.data ? node.data.length : 0; }
94+ getClientRects() {
95+ const node = this.startContainer;
96+ const box = node && node.parentElement && node.parentElement.rect;
97+ if (!box) return [];
98+ const from = this.startOffset;
99+ const to = this.endContainer === node ? this.endOffset : node.data.length;
100+ return [rect(box.left + from * CHAR_WIDTH, box.top, (to - from) * CHAR_WIDTH, LINE_HEIGHT)];
101+ }
102+ getBoundingClientRect() { return this.getClientRects()[0] || rect(0, 0, 0, 0); }
103+}
104+
105+const selection = {
106+ anchorNode: null, anchorOffset: 0, focusNode: null, focusOffset: 0, rangeCount: 0,
107+ get isCollapsed() { return this.rangeCount === 0 || (this.anchorNode === this.focusNode && this.anchorOffset === this.focusOffset); },
108+ setBaseAndExtent(an, ao, fn, fo) { this.anchorNode = an; this.anchorOffset = ao; this.focusNode = fn; this.focusOffset = fo; this.rangeCount = 1; },
109+ collapse(node, offset) { this.setBaseAndExtent(node, offset, node, offset); },
110+ setFocus(node, offset) { this.focusNode = node; this.focusOffset = offset; },
111+ modify(alter, direction, granularity) {
112+ if (granularity === "word") throw new Error("native word granularity must not be used");
113+ const step = direction === "forward" ? 1 : -1;
114+ let i = index(this.focusNode);
115+ let o = this.focusOffset;
116+ const len = (n) => texts[n].data.length;
117+ if (granularity === "character") {
118+ if (step > 0) { if (o < len(i)) o++; else if (i < texts.length - 1) { i++; o = 0; } }
119+ else if (o > 0) o--; else if (i > 0) { i--; o = len(i); }
120+ } else if (granularity === "lineboundary") {
121+ o = step > 0 ? len(i) : 0;
122+ } else if (granularity === "line") {
123+ const j = i + step;
124+ if (j >= 0 && j < texts.length) { i = j; o = Math.min(o, len(j)); } else o = step > 0 ? len(i) : 0;
125+ } else if (granularity === "documentboundary") {
126+ i = step > 0 ? texts.length - 1 : 0; o = step > 0 ? len(i) : 0;
127+ } else if (granularity === "paragraph") {
128+ if (step > 0) { if (o < len(i)) o = len(i); else if (i < texts.length - 1) { i++; o = len(i); } }
129+ else if (o > 0) o = 0; else if (i > 0) { i--; o = 0; }
130+ } else if (granularity === "sentence") {
131+ o = step > 0 ? len(i) : 0;
132+ }
133+ this.focusNode = texts[i]; this.focusOffset = o;
134+ if (alter === "move") { this.anchorNode = this.focusNode; this.anchorOffset = this.focusOffset; }
135+ },
136+ removeAllRanges() { this.rangeCount = 0; this.anchorNode = this.focusNode = null; },
137+ addRange(range) { this.setBaseAndExtent(range.startContainer, range.startOffset, range.endContainer, range.endOffset); },
138+ getRangeAt() {
139+ const range = new Range_();
140+ const forward = order(this.anchorNode, this.anchorOffset) <= order(this.focusNode, this.focusOffset);
141+ const [s, e] = forward ? [[this.anchorNode, this.anchorOffset], [this.focusNode, this.focusOffset]] : [[this.focusNode, this.focusOffset], [this.anchorNode, this.anchorOffset]];
142+ range.startContainer = s[0]; range.startOffset = s[1]; range.endContainer = e[0]; range.endOffset = e[1];
143+ return range;
144+ },
145+ toString() {
146+ if (!this.rangeCount) return "";
147+ const r = this.getRangeAt();
148+ const a = index(r.startContainer);
149+ const b = index(r.endContainer);
150+ if (a === b) return r.startContainer.data.slice(r.startOffset, r.endOffset);
151+ const parts = [r.startContainer.data.slice(r.startOffset)];
152+ for (let n = a + 1; n < b; n++) parts.push(texts[n].data);
153+ parts.push(r.endContainer.data.slice(0, r.endOffset));
154+ return parts.join("\n");
155+ },
156+};
157+
158+global.Node = { TEXT_NODE: 3, ELEMENT_NODE: 1 };
159+global.NodeFilter = { SHOW_ELEMENT: 1, SHOW_TEXT: 4 };
160+global.Element = Element; global.HTMLElement = HTMLElement; global.HTMLAnchorElement = HTMLAnchorElement;
161+global.HTMLInputElement = HTMLInputElement; global.HTMLTextAreaElement = HTMLTextAreaElement; global.HTMLSelectElement = HTMLSelectElement;
162+global.document = {
163+ body, documentElement: html, activeElement: body,
164+ createElement: (tag) => new HTMLElement(tag),
165+ createRange: () => new Range_(),
166+ createTreeWalker: (root) => {
167+ const nodes = []; const visit = (n) => { nodes.push(n); for (const c of n.children) visit(c); }; visit(root);
168+ let i = 0; return { currentNode: nodes[0], nextNode: () => nodes[++i] || null };
169+ },
170+ querySelectorAll: (selector) => html.querySelectorAll(selector),
171+ elementFromPoint: (x, y) => [two, one, ...texts.map((t) => t.parentElement)].find((el) => el.rect && x >= el.rect.left && x < el.rect.right && y >= el.rect.top && y < el.rect.bottom) || body,
172+};
173+global.window = {
174+ innerWidth: 800, innerHeight: 600,
175+ scrollBy(x, y) { scrolls.push([x, y]); },
176+ addEventListener(type, fn) { (listeners[type] = listeners[type] || []).push(fn); },
177+ removeEventListener(type, fn) { listeners[type] = (listeners[type] || []).filter((f) => f !== fn); },
178+ getSelection: () => selection,
179+ getComputedStyle: () => ({ display: "block", visibility: "visible" }),
180+ fetch: (url, init) => { posted.push(JSON.parse(Buffer.from(init.headers["X-Dumber-Body"], "base64").toString("latin1"))); return Promise.resolve(); },
181+};
182+global.btoa = (s) => Buffer.from(s, "binary").toString("base64");
183+global.unescape = (s) => decodeURIComponent(s);
184+global.setTimeout = (fn) => { fn(); return 0; };
185+
186+// Helpers for drivers.
187+const pos = (node, offset) => index(node) + ":" + offset;
188+const snap = () => ({
189+ text: selection.toString(),
190+ anchor: selection.rangeCount ? pos(selection.anchorNode, selection.anchorOffset) : null,
191+ focus: selection.rangeCount ? pos(selection.focusNode, selection.focusOffset) : null,
192+});
193+const overlays = (name) => html.children.filter((c) => c.attrs && name in c.attrs);
194+const cursor = () => overlays("data-dumber-vim-caret")[0] || null;
195+const hintLabels = () => overlays("data-dumber-vim-hints").flatMap((root) => root.children.map((s) => s.textContent));
196+const fire = (type) => (listeners[type] || []).slice().forEach((fn) => fn({}));
197+const modes = () => posted.filter((m) => m.type === "mode").map((m) => m.mode);
198+const vp = () => window.__dumberVimPage;
199+const keys = (token, ...list) => list.forEach((k) => window.__dumberVimPage.key(token, k));
200+const rec = (name, extra) => posted.push(Object.assign({ r: name }, extra));
201+// startCaret begins the visual flow and places the caret at the start of the
202+// paragraph with the given hint label ("s", "a", "d" for the first, second,
203+// third visible paragraph).
204+const startCaret = (token, label, color) => {
205+ window.__dumberVimPage.start(token, { kind: "visual", color: color || "#fbbf24" });
206+ window.__dumberVimPage.key(token, label);
207+};
208+// S records a named snapshot of the selection (and any extra fields).
209+const S = (n, extra) => posted.push(Object.assign({ n }, snap(), extra));
M internal/infrastructure/cef/vim_page.go +52 -8
@@ -33,10 +33,13 @@ type vimPageBridgePayload struct {
3333 URL string `json:"url,omitempty"`
3434 // Reason explains an early end (for example no visible targets).
3535 Reason string `json:"reason,omitempty"`
36+ // Mode names the sub-mode of a "mode" message.
37+ Mode string `json:"mode,omitempty"`
3638 }
3739
3840 const (
3941 vimPageMessageCopy = "copy"
42+ vimPageMessageMode = "mode"
4043 vimPageMessageOpenNew = "open-new"
4144 vimPageMessageEnd = "end"
4245 )
@@ -61,6 +64,10 @@ func decodeVimPageBridgePayload(body []byte) (vimPageBridgePayload, error) {
6164 if payload.URL == "" {
6265 return payload, errors.New("missing url")
6366 }
67+ case vimPageMessageMode:
68+ if !dto.VimPageMode(payload.Mode).Valid() {
69+ return payload, fmt.Errorf("unknown mode %q", payload.Mode)
70+ }
6471 case vimPageMessageEnd:
6572 default:
6673 return payload, fmt.Errorf("unknown type %q", payload.Type)
@@ -211,6 +218,37 @@ func (wv *WebView) notifyVimPageInteractionEnded(generation uint64) {
211218 })
212219 }
213220
221+// vimPageResultEndsInteraction reports whether a result spends the token.
222+// The caret and visual flow is long-lived: its mode changes and copies leave
223+// the interaction running so copies can be chained; every other result is
224+// terminal, so a token grants exactly one of them.
225+func vimPageResultEndsInteraction(kind dto.VimPageInteractionKind, messageType string) bool {
226+ if kind != dto.VimPageVisual {
227+ return true
228+ }
229+ return messageType != vimPageMessageMode && messageType != vimPageMessageCopy
230+}
231+
232+// notifyVimPageModeChanged reports a sub-mode change on GTK unless a newer
233+// interaction was armed in the meantime.
234+func (wv *WebView) notifyVimPageModeChanged(generation uint64, mode dto.VimPageMode) {
235+ wv.mu.RLock()
236+ cb := wv.callbacks
237+ wv.mu.RUnlock()
238+ if cb == nil || cb.OnVimPageModeChanged == nil {
239+ return
240+ }
241+ changed := cb.OnVimPageModeChanged
242+ wv.runOnGTK(func() {
243+ wv.mu.RLock()
244+ current := wv.vimPageGeneration
245+ wv.mu.RUnlock()
246+ if current == generation {
247+ changed(mode)
248+ }
249+ })
250+}
251+
214252 // vimPageResultAllowed ties each result type to the interaction the user
215253 // started, so a hijacked token only yields the kind of result requested.
216254 func vimPageResultAllowed(kind dto.VimPageInteractionKind, messageType string) bool {
@@ -221,6 +259,8 @@ func vimPageResultAllowed(kind dto.VimPageInteractionKind, messageType string) b
221259 return kind == dto.VimPageHintFollow || kind == dto.VimPageHintFollowNew
222260 case vimPageMessageCopy:
223261 return kind == dto.VimPageYankObject || kind == dto.VimPageHintYankURL || kind == dto.VimPageVisual
262+ case vimPageMessageMode:
263+ return kind == dto.VimPageVisual
224264 default:
225265 return false
226266 }
@@ -296,8 +336,8 @@ func (wv *WebView) handleVimPageResult(payload vimPageBridgePayload) {
296336 kind := wv.vimPageKind
297337 generation := wv.vimPageGeneration
298338 valid := armed != "" && subtle.ConstantTimeCompare([]byte(armed), []byte(payload.Token)) == 1
299- if valid {
300- // Every runtime message is terminal: a token grants exactly one result.
339+ ends := vimPageResultEndsInteraction(kind, payload.Type)
340+ if valid && ends {
301341 wv.vimPageToken = ""
302342 wv.vimPageKind = 0
303343 }
@@ -307,12 +347,14 @@ func (wv *WebView) handleVimPageResult(payload vimPageBridgePayload) {
307347 return
308348 }
309349
310- logging.FromContext(wv.ctx).Debug().
311- Str("type", payload.Type).
312- Str("reason", payload.Reason).
313- Msg("cef: vim page interaction finished")
314- if kind.CapturesKeys() {
315- wv.notifyVimPageInteractionEnded(generation)
350+ if ends {
351+ logging.FromContext(wv.ctx).Debug().
352+ Str("type", payload.Type).
353+ Str("reason", payload.Reason).
354+ Msg("cef: vim page interaction finished")
355+ if kind.CapturesKeys() {
356+ wv.notifyVimPageInteractionEnded(generation)
357+ }
316358 }
317359 if !vimPageResultAllowed(kind, payload.Type) {
318360 logging.FromContext(wv.ctx).Debug().Str("type", payload.Type).Msg("cef: vim page result rejected — not allowed for interaction")
@@ -320,6 +362,8 @@ func (wv *WebView) handleVimPageResult(payload vimPageBridgePayload) {
320362 }
321363
322364 switch payload.Type {
365+ case vimPageMessageMode:
366+ wv.notifyVimPageModeChanged(generation, dto.VimPageMode(payload.Mode))
323367 case vimPageMessageCopy:
324368 if wv.engine != nil {
325369 wv.engine.handleExplicitClipboardBridgeText(wv.id, "copy", payload.Text)
@@ -33,10 +33,13 @@ type vimPageBridgePayload struct {
33 URL string `json:"url,omitempty"`33 URL string `json:"url,omitempty"`
34 // Reason explains an early end (for example no visible targets).34 // Reason explains an early end (for example no visible targets).
35 Reason string `json:"reason,omitempty"`35 Reason string `json:"reason,omitempty"`
36+ // Mode names the sub-mode of a "mode" message.
37+ Mode string `json:"mode,omitempty"`
36 }38 }
37 39
38 const (40 const (
39 vimPageMessageCopy = "copy"41 vimPageMessageCopy = "copy"
42+ vimPageMessageMode = "mode"
40 vimPageMessageOpenNew = "open-new"43 vimPageMessageOpenNew = "open-new"
41 vimPageMessageEnd = "end"44 vimPageMessageEnd = "end"
42 )45 )
@@ -61,6 +64,10 @@ func decodeVimPageBridgePayload(body []byte) (vimPageBridgePayload, error) {
61 if payload.URL == "" {64 if payload.URL == "" {
62 return payload, errors.New("missing url")65 return payload, errors.New("missing url")
63 }66 }
67+ case vimPageMessageMode:
68+ if !dto.VimPageMode(payload.Mode).Valid() {
69+ return payload, fmt.Errorf("unknown mode %q", payload.Mode)
70+ }
64 case vimPageMessageEnd:71 case vimPageMessageEnd:
65 default:72 default:
66 return payload, fmt.Errorf("unknown type %q", payload.Type)73 return payload, fmt.Errorf("unknown type %q", payload.Type)
@@ -211,6 +218,37 @@ func (wv *WebView) notifyVimPageInteractionEnded(generation uint64) {
211 })218 })
212 }219 }
213 220
221+// vimPageResultEndsInteraction reports whether a result spends the token.
222+// The caret and visual flow is long-lived: its mode changes and copies leave
223+// the interaction running so copies can be chained; every other result is
224+// terminal, so a token grants exactly one of them.
225+func vimPageResultEndsInteraction(kind dto.VimPageInteractionKind, messageType string) bool {
226+ if kind != dto.VimPageVisual {
227+ return true
228+ }
229+ return messageType != vimPageMessageMode && messageType != vimPageMessageCopy
230+}
231+
232+// notifyVimPageModeChanged reports a sub-mode change on GTK unless a newer
233+// interaction was armed in the meantime.
234+func (wv *WebView) notifyVimPageModeChanged(generation uint64, mode dto.VimPageMode) {
235+ wv.mu.RLock()
236+ cb := wv.callbacks
237+ wv.mu.RUnlock()
238+ if cb == nil || cb.OnVimPageModeChanged == nil {
239+ return
240+ }
241+ changed := cb.OnVimPageModeChanged
242+ wv.runOnGTK(func() {
243+ wv.mu.RLock()
244+ current := wv.vimPageGeneration
245+ wv.mu.RUnlock()
246+ if current == generation {
247+ changed(mode)
248+ }
249+ })
250+}
251+
214 // vimPageResultAllowed ties each result type to the interaction the user252 // vimPageResultAllowed ties each result type to the interaction the user
215 // started, so a hijacked token only yields the kind of result requested.253 // started, so a hijacked token only yields the kind of result requested.
216 func vimPageResultAllowed(kind dto.VimPageInteractionKind, messageType string) bool {254 func vimPageResultAllowed(kind dto.VimPageInteractionKind, messageType string) bool {
@@ -221,6 +259,8 @@ func vimPageResultAllowed(kind dto.VimPageInteractionKind, messageType string) b
221 return kind == dto.VimPageHintFollow || kind == dto.VimPageHintFollowNew259 return kind == dto.VimPageHintFollow || kind == dto.VimPageHintFollowNew
222 case vimPageMessageCopy:260 case vimPageMessageCopy:
223 return kind == dto.VimPageYankObject || kind == dto.VimPageHintYankURL || kind == dto.VimPageVisual261 return kind == dto.VimPageYankObject || kind == dto.VimPageHintYankURL || kind == dto.VimPageVisual
262+ case vimPageMessageMode:
263+ return kind == dto.VimPageVisual
224 default:264 default:
225 return false265 return false
226 }266 }
@@ -296,8 +336,8 @@ func (wv *WebView) handleVimPageResult(payload vimPageBridgePayload) {
296 kind := wv.vimPageKind336 kind := wv.vimPageKind
297 generation := wv.vimPageGeneration337 generation := wv.vimPageGeneration
298 valid := armed != "" && subtle.ConstantTimeCompare([]byte(armed), []byte(payload.Token)) == 1338 valid := armed != "" && subtle.ConstantTimeCompare([]byte(armed), []byte(payload.Token)) == 1
299- if valid {339+ ends := vimPageResultEndsInteraction(kind, payload.Type)
300- // Every runtime message is terminal: a token grants exactly one result.340+ if valid && ends {
301 wv.vimPageToken = ""341 wv.vimPageToken = ""
302 wv.vimPageKind = 0342 wv.vimPageKind = 0
303 }343 }
@@ -307,12 +347,14 @@ func (wv *WebView) handleVimPageResult(payload vimPageBridgePayload) {
307 return347 return
308 }348 }
309 349
310- logging.FromContext(wv.ctx).Debug().350+ if ends {
311- Str("type", payload.Type).351+ logging.FromContext(wv.ctx).Debug().
312- Str("reason", payload.Reason).352+ Str("type", payload.Type).
313- Msg("cef: vim page interaction finished")353+ Str("reason", payload.Reason).
314- if kind.CapturesKeys() {354+ Msg("cef: vim page interaction finished")
315- wv.notifyVimPageInteractionEnded(generation)355+ if kind.CapturesKeys() {
356+ wv.notifyVimPageInteractionEnded(generation)
357+ }
316 }358 }
317 if !vimPageResultAllowed(kind, payload.Type) {359 if !vimPageResultAllowed(kind, payload.Type) {
318 logging.FromContext(wv.ctx).Debug().Str("type", payload.Type).Msg("cef: vim page result rejected — not allowed for interaction")360 logging.FromContext(wv.ctx).Debug().Str("type", payload.Type).Msg("cef: vim page result rejected — not allowed for interaction")
@@ -320,6 +362,8 @@ func (wv *WebView) handleVimPageResult(payload vimPageBridgePayload) {
320 }362 }
321 363
322 switch payload.Type {364 switch payload.Type {
365+ case vimPageMessageMode:
366+ wv.notifyVimPageModeChanged(generation, dto.VimPageMode(payload.Mode))
323 case vimPageMessageCopy:367 case vimPageMessageCopy:
324 if wv.engine != nil {368 if wv.engine != nil {
325 wv.engine.handleExplicitClipboardBridgeText(wv.id, "copy", payload.Text)369 wv.engine.handleExplicitClipboardBridgeText(wv.id, "copy", payload.Text)
M internal/infrastructure/cef/vim_page_runtime.js +498 -79
@@ -1,8 +1,19 @@
1-// Dumber Vim Mode page runtime: link hints, visual selection, text-object
2-// yanks. Installed once per document; Go calls window.__dumberVimPage.* with a
3-// per-interaction token. Each interaction reports exactly one terminal message
4-// (copy, open-new, or end) to dumb:///api/vim-page; Go accepts it only while
5-// that token is armed, so the token grants at most one result.
1+// Dumber Vim Mode page runtime: link hints, caret and visual selection,
2+// text-object yanks. Installed once per document; Go calls
3+// window.__dumberVimPage.* with a per-interaction token and accepts results
4+// at dumb:///api/vim-page only while that token is armed.
5+//
6+// Link hints and yanks report exactly one terminal message (copy, open-new, or
7+// end). The caret/visual interaction is long-lived: it reports "mode" messages
8+// as it moves between hints, caret, visual, and visual line, "copy" messages
9+// that do not end it, and finally one "end".
10+//
11+// The caret and visual selection logic follows Vimium's mode_visual.js
12+// (MIT, Copyright (c) Phil Crosby, Ilya Sukhar): character-by-character word
13+// motions to avoid the native selection.modify word bugs on Linux (Vimium
14+// #1441), focus-direction detection, and the line-mode extension. The text
15+// anchor hints and the visible caret follow the flow of Surfingkeys' visual
16+// mode.
617 (() => {
718 if (window.__dumberVimPage) return;
819
@@ -20,18 +31,22 @@
2031 table: ["table"],
2132 list: ["ul,ol,dl"],
2233 };
23- const VISUAL_MOTIONS = {
34+ // Motions that map directly to Selection.modify. w, e, b, ^ and gg are
35+ // handled in code.
36+ const CARET_MOTIONS = {
2437 h: ["backward", "character"], "<Left>": ["backward", "character"],
2538 l: ["forward", "character"], "<Right>": ["forward", "character"],
2639 j: ["forward", "line"], "<Down>": ["forward", "line"],
2740 k: ["backward", "line"], "<Up>": ["backward", "line"],
28- w: ["forward", "word"], e: ["forward", "word"], b: ["backward", "word"],
29- "0": ["backward", "lineboundary"], "^": ["backward", "lineboundary"],
30- "$": ["forward", "lineboundary"],
41+ "0": ["backward", "lineboundary"], "$": ["forward", "lineboundary"],
3142 "(": ["backward", "sentence"], ")": ["forward", "sentence"],
3243 "{": ["backward", "paragraph"], "}": ["forward", "paragraph"],
3344 G: ["forward", "documentboundary"],
3445 };
46+ const WORD_CHARACTER = /[\p{L}\p{N}_]/u;
47+ const MAX_MOTION_STEPS = 20000;
48+ const LONG_TEXT_LENGTH = 50;
49+ const MODE_NAMES = { hints: "hints", caret: "caret", visual: "visual", line: "visual-line" };
3550
3651 let state = null;
3752
@@ -128,40 +143,51 @@
128143 if (seen.has(element) || element.disabled) continue;
129144 const rect = visibleRect(element);
130145 if (!rect) continue;
131- const x = Math.min(Math.max(rect.left + rect.width / 2, 0), window.innerWidth - 1);
132- const y = Math.min(Math.max(rect.top + rect.height / 2, 0), window.innerHeight - 1);
133- const root = element.getRootNode();
134- const hit = (root.elementFromPoint ? root : document).elementFromPoint(x, y);
135- if (hit && !element.contains(hit) && !hit.contains(element) && !(hit.shadowRoot && hit.shadowRoot.contains(element))) continue;
146+ if (!reachable(element, rect)) continue;
136147 seen.add(element);
137148 targets.push({ element, rect });
138149 }
139150 return targets;
140151 }
141152
142- function startHints(kind, color) {
143- const targets = hintTargets(kind !== "hint-follow");
144- if (targets.length === 0) return false;
145- const labels = hintLabels(targets.length);
153+ // buildHintOverlay renders one label per item at its rect and arms the
154+ // typed-label filter. offsetLeft puts labels before the rect so they do not
155+ // cover the text a hint points at.
156+ function buildHintOverlay(items, color, offsetLeft) {
157+ const labels = hintLabels(items.length);
146158 const root = document.createElement("div");
147159 root.setAttribute("data-dumber-vim-hints", "");
148160 root.style.cssText = "position:fixed;inset:0;pointer-events:none;z-index:2147483647;";
149- const hints = targets.map((target, index) => {
161+ const labelWidth = labels.length > 0 ? labels[0].length * 8 + 10 : 0;
162+ const hints = items.map((item, index) => {
150163 const node = document.createElement("span");
164+ const left = offsetLeft && item.rect.left >= labelWidth ? item.rect.left - labelWidth : Math.max(item.rect.left, 0);
151165 node.style.cssText = [
152- "position:fixed", "left:" + Math.max(target.rect.left, 0) + "px",
153- "top:" + Math.max(target.rect.top, 0) + "px", "padding:1px 4px", "border-radius:3px",
166+ "position:fixed", "left:" + left + "px",
167+ "top:" + Math.max(item.rect.top, 0) + "px", "padding:1px 4px", "border-radius:3px",
154168 "font:bold 11px/1.3 monospace", "text-transform:uppercase", "color:#111",
155169 "background:" + color, "box-shadow:0 1px 3px rgba(0,0,0,.4)",
156170 ].join(";");
157171 node.textContent = labels[index];
158172 root.appendChild(node);
159- return { label: labels[index], element: target.element, node };
173+ return { label: labels[index], item, node };
160174 });
161175 document.documentElement.appendChild(root);
162176 state.root = root;
163177 state.hints = hints;
164178 state.typed = "";
179+ }
180+
181+ function removeHintOverlay() {
182+ if (state.root) state.root.remove();
183+ state.root = null;
184+ state.hints = [];
185+ }
186+
187+ function startHints(kind, color) {
188+ const targets = hintTargets(kind !== "hint-follow");
189+ if (targets.length === 0) return false;
190+ buildHintOverlay(targets, color, false);
165191 return true;
166192 }
167193
@@ -191,8 +217,12 @@
191217 return { type: "end" };
192218 }
193219
220+ // hintKey handles typing labels for link hints and for the caret's
221+ // text-anchor hints (the "visual" kind), where Escape places the caret at a
222+ // default position instead of ending.
194223 function hintKey(key) {
195- if (key === "<Escape>") return finish({ type: "end" });
224+ const textAnchors = state.kind === "visual";
225+ if (key === "<Escape>") return textAnchors ? placeCaretAtFallback() : finish({ type: "end" });
196226 if (key === "<BackSpace>") {
197227 state.typed = state.typed.slice(0, -1);
198228 return renderHintFilter();
@@ -203,106 +233,484 @@
203233 if (matches.length === 0) return;
204234 state.typed = typed;
205235 if (matches.length === 1 && matches[0].label === typed) {
206- const kind = state.kind;
207- const element = matches[0].element;
236+ const item = matches[0].item;
208237 // Remove the overlay before activation so the click hits the page.
209- if (state.root) state.root.remove();
210- finish(activateHint(kind, element));
238+ removeHintOverlay();
239+ if (textAnchors) return enterCaretAt(item.node, item.offset);
240+ finish(activateHint(state.kind, item.element));
211241 return;
212242 }
213243 renderHintFilter();
214244 }
215245
216- // --- Visual selection -----------------------------------------------------
246+ // --- Caret and visual selection ------------------------------------------
247+ //
248+ // One interaction moves between four phases: "hints" (text-anchor labels),
249+ // "caret" (collapsed selection plus a visible cursor), "visual" (charwise
250+ // selection), and "line" (linewise selection). Each phase change is reported
251+ // to Go as a "mode" message so the indicator follows it.
217252
218- function firstVisibleTextPosition() {
219- for (const node of deepTextNodes(document.body || document.documentElement)) {
220- if (!visibleRect(node.parentElement)) continue;
221- const range = document.createRange();
222- range.selectNodeContents(node);
223- const rect = range.getBoundingClientRect();
224- if (rect.bottom <= 0 || rect.top >= window.innerHeight) continue;
225- return { node, offset: Math.max(node.data.search(/\S/), 0) };
253+ const SKIP_TEXT_PARENTS = /^(SCRIPT|STYLE|NOSCRIPT|TEMPLATE|TEXTAREA|OPTION)$/;
254+
255+ function setPhase(phase) {
256+ if (state.phase === phase) return;
257+ state.phase = phase;
258+ post(state.token, { type: "mode", mode: MODE_NAMES[phase] });
259+ }
260+
261+ // reachable reports whether element is what the user would hit when pointing
262+ // at rect, so hints skip content covered by overlays.
263+ function reachable(element, rect) {
264+ const x = Math.min(Math.max(rect.left + rect.width / 2, 0), window.innerWidth - 1);
265+ const y = Math.min(Math.max(rect.top + rect.height / 2, 0), window.innerHeight - 1);
266+ const root = element.getRootNode();
267+ const hit = (root.elementFromPoint ? root : document).elementFromPoint(x, y);
268+ return !hit || element.contains(hit) || hit.contains(element) || !!(hit.shadowRoot && hit.shadowRoot.contains(element));
269+ }
270+
271+ // blockOf returns the nearest non-inline ancestor, so a paragraph with inline
272+ // markup offers one anchor instead of one per fragment.
273+ function blockOf(element) {
274+ let current = element;
275+ while (current.parentElement && /^(inline|contents)$/.test(window.getComputedStyle(current).display || "")) {
276+ current = current.parentElement;
277+ }
278+ return current;
279+ }
280+
281+ function offsetAtPoint(node, x, y) {
282+ let container = null;
283+ let offset = 0;
284+ if (document.caretRangeFromPoint) {
285+ const range = document.caretRangeFromPoint(x, y);
286+ if (range) { container = range.startContainer; offset = range.startOffset; }
287+ } else if (document.caretPositionFromPoint) {
288+ const position = document.caretPositionFromPoint(x, y);
289+ if (position) { container = position.offsetNode; offset = position.offset; }
290+ }
291+ return container === node ? offset : -1;
292+ }
293+
294+ // firstVisibleAnchor finds where visible text of node starts: the first
295+ // non-blank character when its first line is visible, otherwise the start of
296+ // the first fully visible line.
297+ function firstVisibleAnchor(node, element) {
298+ const range = document.createRange();
299+ range.selectNodeContents(node);
300+ const rects = Array.from(range.getClientRects());
301+ for (let i = 0; i < rects.length; i++) {
302+ const rect = rects[i];
303+ if (rect.width < 1 || rect.height < 1) continue;
304+ if (rect.top < 0 || rect.left < 0 || rect.bottom > window.innerHeight || rect.right > window.innerWidth) continue;
305+ const offset = i === 0 ? Math.max(node.data.search(/\S/), 0) : offsetAtPoint(node, rect.left + 1, rect.top + rect.height / 2);
306+ if (offset < 0 || !visibleRect(element) || !reachable(element, rect)) continue;
307+ return { node, offset, rect, element };
226308 }
227309 return null;
228310 }
229311
230- // Visual mode paints the selection with the accent so it stands out even on
231- // pages that restyle ::selection; the style is removed when visual ends.
312+ // collectTextAnchors lists visible text starts in document order. perBlock
313+ // keeps the first anchor of each block; minLength skips short texts.
314+ function collectTextAnchors({ perBlock, minLength, limit }) {
315+ const anchors = [];
316+ const blocks = new Set();
317+ for (const node of deepTextNodes(document.body || document.documentElement)) {
318+ const element = node.parentElement;
319+ if (!element || SKIP_TEXT_PARENTS.test(element.tagName) || isEditable(element)) continue;
320+ if (node.data.trim().length < minLength) continue;
321+ const anchor = firstVisibleAnchor(node, element);
322+ if (!anchor) continue;
323+ if (perBlock) {
324+ const block = blockOf(element);
325+ if (blocks.has(block)) continue;
326+ blocks.add(block);
327+ }
328+ anchors.push(anchor);
329+ if (anchors.length >= limit) break;
330+ }
331+ return anchors;
332+ }
333+
334+ // Esc during the text-anchor hints: Vimium's heuristic picks the first
335+ // visible text of at least LONG_TEXT_LENGTH characters (shorter ones are
336+ // likely banners); any visible text is the fallback.
337+ function fallbackAnchor() {
338+ const long = collectTextAnchors({ perBlock: false, minLength: LONG_TEXT_LENGTH, limit: 1 });
339+ if (long.length > 0) return long[0];
340+ return collectTextAnchors({ perBlock: false, minLength: 1, limit: 1 })[0] || null;
341+ }
342+
343+ function placeCaretAtFallback() {
344+ const anchor = fallbackAnchor();
345+ removeHintOverlay();
346+ if (!anchor) return finish({ type: "end", reason: "no-visible-text" });
347+ enterCaretAt(anchor.node, anchor.offset);
348+ }
349+
350+ // The accent paints selections so they stand out on pages that restyle
351+ // ::selection; the style is removed with the interaction.
232352 function styleVisual(color) {
233353 const style = document.createElement("style");
234354 style.setAttribute("data-dumber-vim-visual", "");
235355 style.textContent = "::selection { background: " + color + " !important; color: #111 !important; }";
236356 document.documentElement.appendChild(style);
237- state.root = style;
357+ state.style = style;
358+ }
359+
360+ // focusRect measures the selection focus. A collapsed range can report an
361+ // empty rect at node boundaries, so the adjacent character is measured then.
362+ function focusRect(selection) {
363+ const node = selection.focusNode;
364+ if (!node) return null;
365+ const offset = selection.focusOffset;
366+ const range = document.createRange();
367+ range.setStart(node, offset);
368+ range.collapse(true);
369+ const first = (r) => Array.from(r.getClientRects()).find((rect) => rect.height > 0) || null;
370+ const direct = first(range);
371+ if (direct) return direct;
372+ if (node.nodeType === Node.TEXT_NODE) {
373+ if (offset < node.data.length) {
374+ range.setEnd(node, offset + 1);
375+ const next = first(range);
376+ if (next) return { left: next.left, right: next.left, top: next.top, bottom: next.bottom, width: 0, height: next.height };
377+ }
378+ if (offset > 0) {
379+ const before = document.createRange();
380+ before.setStart(node, offset - 1);
381+ before.setEnd(node, offset);
382+ const previous = first(before);
383+ if (previous) return { left: previous.right, right: previous.right, top: previous.top, bottom: previous.bottom, width: 0, height: previous.height };
384+ }
385+ }
386+ const element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
387+ const rect = element && element.getBoundingClientRect();
388+ return rect && rect.height > 0 ? rect : null;
389+ }
390+
391+ // The caret overlay is a blinking accent bar at the focus. It is page
392+ // content, so it follows scrolling and is removed with the interaction.
393+ function beginCaretUI(selection) {
394+ if (!state.style) styleVisual(state.color);
395+ if (!state.cursor) {
396+ const cursor = document.createElement("div");
397+ cursor.setAttribute("data-dumber-vim-caret", "");
398+ cursor.style.cssText = [
399+ "position:fixed", "width:4px", "pointer-events:none", "z-index:2147483647",
400+ "background:" + state.color, "border-radius:2px",
401+ "box-shadow:0 0 0 2px rgba(17,17,17,.85), 0 0 10px 2px " + state.color,
402+ ].join(";");
403+ document.documentElement.appendChild(cursor);
404+ if (typeof cursor.animate === "function") {
405+ cursor.animate([{ opacity: 1 }, { opacity: 1, offset: 0.6 }, { opacity: 0.15 }], { duration: 1000, iterations: Infinity });
406+ }
407+ state.cursor = cursor;
408+ state.onViewportChange = () => updateCursor(window.getSelection());
409+ window.addEventListener("scroll", state.onViewportChange, { capture: true, passive: true });
410+ window.addEventListener("resize", state.onViewportChange, { passive: true });
411+ }
412+ updateCursor(selection);
413+ }
414+
415+ function updateCursor(selection) {
416+ if (!state || !state.cursor || !selection) return;
417+ const rect = focusRect(selection);
418+ const visible = rect && rect.bottom > 0 && rect.top < window.innerHeight && rect.right > -4 && rect.left < window.innerWidth;
419+ state.cursor.style.display = visible ? "" : "none";
420+ if (!visible) return;
421+ // Taller than the text line so the caret reads at a glance.
422+ const height = Math.max(rect.height, 14) + 6;
423+ state.cursor.style.left = Math.round(rect.left - 2) + "px";
424+ state.cursor.style.top = Math.round(rect.top + (rect.height - height) / 2) + "px";
425+ state.cursor.style.height = Math.round(height) + "px";
426+ }
427+
428+ function revealFocus(selection) {
429+ const rect = focusRect(selection);
430+ if (rect) {
431+ const margin = 40;
432+ if (rect.top < margin) window.scrollBy(0, rect.top - margin);
433+ else if (rect.bottom > window.innerHeight - margin) window.scrollBy(0, rect.bottom - window.innerHeight + margin);
434+ }
435+ updateCursor(selection);
436+ }
437+
438+ function enterCaretAt(node, offset) {
439+ const selection = window.getSelection();
440+ selection.setBaseAndExtent(node, offset, node, offset);
441+ state.count = "";
442+ state.pendingG = false;
443+ beginCaretUI(selection);
444+ setPhase("caret");
445+ revealFocus(selection);
446+ }
447+
448+ // A visible range selection is used as is, as in Vimium; a selection that
449+ // scrolled out of view is stale and ignored.
450+ function hasVisibleRangeSelection(selection) {
451+ if (selection.rangeCount === 0 || selection.isCollapsed) return false;
452+ const rect = selection.getRangeAt(0).getBoundingClientRect();
453+ return rect.bottom > 0 && rect.top < window.innerHeight && rect.right > 0 && rect.left < window.innerWidth;
238454 }
239455
240456 function startVisual(color) {
241457 const selection = window.getSelection();
242458 if (!selection) return false;
243- if (selection.rangeCount === 0 || selection.isCollapsed) {
244- const start = firstVisibleTextPosition();
245- if (!start) return false;
246- selection.setBaseAndExtent(start.node, start.offset, start.node, start.offset);
247- selection.modify("extend", "forward", "character");
248- }
459+ state.color = color;
249460 state.count = "";
250461 state.pendingG = false;
251- styleVisual(color);
462+ if (hasVisibleRangeSelection(selection)) {
463+ beginCaretUI(selection);
464+ setPhase("visual");
465+ return true;
466+ }
467+ const anchors = collectTextAnchors({ perBlock: true, minLength: 1, limit: HINT_CHARS.length * HINT_CHARS.length });
468+ if (anchors.length === 0) return false;
469+ buildHintOverlay(anchors, color, true);
470+ setPhase("hints");
252471 return true;
253472 }
254473
255- function revealSelectionFocus(selection) {
256- if (!selection.focusNode) return;
474+ // --- Selection primitives (after Vimium's Movement class) ---
475+
476+ function isBackward(selection) {
477+ if (selection.anchorNode === selection.focusNode && selection.anchorOffset === selection.focusOffset) return false;
257478 const range = document.createRange();
258- range.setStart(selection.focusNode, selection.focusOffset);
259- range.collapse(true);
260- let rect = range.getBoundingClientRect();
261- if (rect.top === 0 && rect.bottom === 0) {
262- const element = selection.focusNode.nodeType === Node.ELEMENT_NODE
263- ? selection.focusNode : selection.focusNode.parentElement;
264- if (!element) return;
265- rect = element.getBoundingClientRect();
479+ range.setStart(selection.anchorNode, selection.anchorOffset);
480+ range.setEnd(selection.focusNode, selection.focusOffset);
481+ return range.collapsed;
482+ }
483+
484+ function swapEnds(selection) {
485+ selection.setBaseAndExtent(selection.focusNode, selection.focusOffset, selection.anchorNode, selection.anchorOffset);
486+ }
487+
488+ function collapseToFocus(selection) {
489+ selection.collapse(selection.focusNode, selection.focusOffset);
490+ }
491+
492+ // extendByOne extends the focus one character and reports how the selected
493+ // text length changed; 0 means the focus could not move.
494+ function extendByOne(selection, direction) {
495+ const length = selection.toString().length;
496+ selection.modify("extend", direction, "character");
497+ return selection.toString().length - length;
498+ }
499+
500+ // nextCharacter returns the character after the focus without moving it.
501+ function nextCharacter(selection) {
502+ const before = selection.toString();
503+ if (before.length === 0 || !isBackward(selection)) {
504+ selection.modify("extend", "forward", "character");
505+ const after = selection.toString();
506+ if (after === before) return undefined;
507+ selection.modify("extend", "backward", "character");
508+ return after[after.length - 1];
509+ }
510+ return before[0];
511+ }
512+
513+ // previousCharacter returns the character before the focus without moving it.
514+ function previousCharacter(selection) {
515+ const before = selection.toString();
516+ if (before.length === 0 || isBackward(selection)) {
517+ selection.modify("extend", "backward", "character");
518+ const after = selection.toString();
519+ if (after === before) return undefined;
520+ selection.modify("extend", "forward", "character");
521+ return after[0];
522+ }
523+ return before[before.length - 1];
524+ }
525+
526+ function isWordCharacter(character) {
527+ return character !== undefined && WORD_CHARACTER.test(character);
528+ }
529+
530+ // Word motions run character by character: the native word granularity
531+ // differs between Linux and other platforms (Vimium #1441).
532+ function wordForward(selection, wordFirst) {
533+ const skipWord = () => {
534+ for (let i = 0; i < MAX_MOTION_STEPS && isWordCharacter(nextCharacter(selection)); i++) {
535+ if (extendByOne(selection, "forward") === 0) return false;
536+ }
537+ return true;
538+ };
539+ const skipOther = () => {
540+ for (let i = 0; i < MAX_MOTION_STEPS; i++) {
541+ const character = nextCharacter(selection);
542+ if (character === undefined || isWordCharacter(character)) return true;
543+ if (extendByOne(selection, "forward") === 0) return false;
544+ }
545+ return true;
546+ };
547+ // w: finish the current word, then skip to the next one. e: skip to a
548+ // word, then finish it.
549+ if (wordFirst) {
550+ if (skipWord()) skipOther();
551+ } else if (skipOther()) {
552+ skipWord();
553+ }
554+ }
555+
556+ function wordBackward(selection) {
557+ for (let i = 0; i < MAX_MOTION_STEPS; i++) {
558+ const character = previousCharacter(selection);
559+ if (character === undefined || isWordCharacter(character)) break;
560+ if (extendByOne(selection, "backward") === 0) return;
561+ }
562+ for (let i = 0; i < MAX_MOTION_STEPS && isWordCharacter(previousCharacter(selection)); i++) {
563+ if (extendByOne(selection, "backward") === 0) return;
564+ }
565+ }
566+
567+ function firstNonBlank(selection, alter) {
568+ selection.modify(alter, "backward", "lineboundary");
569+ if (alter === "move") {
570+ // Measure with extend, then settle the caret where it ended.
571+ for (let i = 0; i < MAX_MOTION_STEPS; i++) {
572+ const character = nextCharacter(selection);
573+ if (character === undefined || !/[ \t\u00a0]/.test(character)) break;
574+ if (extendByOne(selection, "forward") === 0) break;
575+ }
576+ collapseToFocus(selection);
577+ return;
578+ }
579+ for (let i = 0; i < MAX_MOTION_STEPS; i++) {
580+ const character = nextCharacter(selection);
581+ if (character === undefined || !/[ \t\u00a0]/.test(character)) break;
582+ if (extendByOne(selection, "forward") === 0) break;
266583 }
267- const margin = 40;
268- if (rect.top < margin) window.scrollBy(0, rect.top - margin);
269- else if (rect.bottom > window.innerHeight - margin) window.scrollBy(0, rect.bottom - window.innerHeight + margin);
270584 }
271585
272- function visualKey(key) {
586+ // extendToLines grows the selection so both ends sit on line boundaries.
587+ function extendToLines(selection) {
588+ const forward = !isBackward(selection);
589+ for (const direction of forward ? ["forward", "backward"] : ["backward", "forward"]) {
590+ selection.modify("extend", direction, "lineboundary");
591+ swapEnds(selection);
592+ }
593+ }
594+
595+ // moveOnce applies one motion. In caret phase the selection stays collapsed:
596+ // native granularities use "move", and the character-by-character motions
597+ // extend and then collapse to the focus.
598+ function moveOnce(selection, key) {
599+ const caret = state.phase === "caret";
600+ const alter = caret ? "move" : "extend";
601+ if (CARET_MOTIONS[key]) {
602+ selection.modify(alter, CARET_MOTIONS[key][0], CARET_MOTIONS[key][1]);
603+ return true;
604+ }
605+ switch (key) {
606+ case "w": wordForward(selection, true); break;
607+ case "e": wordForward(selection, false); break;
608+ case "b": wordBackward(selection); break;
609+ case "^": return firstNonBlank(selection, alter), true;
610+ case "gg": selection.modify(alter, "backward", "documentboundary"); return true;
611+ default: return false;
612+ }
613+ if (caret) collapseToFocus(selection);
614+ return true;
615+ }
616+
617+ function runMotion(selection, key, count) {
618+ for (let i = 0; i < count; i++) {
619+ if (state.phase === "line") {
620+ const saved = [selection.anchorNode, selection.anchorOffset, selection.focusNode, selection.focusOffset];
621+ if (!moveOnce(selection, key)) return;
622+ if (selection.isCollapsed) selection.setBaseAndExtent(...saved);
623+ extendToLines(selection);
624+ } else if (!moveOnce(selection, key)) {
625+ return;
626+ }
627+ }
628+ }
629+
630+ // --- Phase transitions ---
631+
632+ function toCaret(selection) {
633+ collapseToFocus(selection);
634+ setPhase("caret");
635+ revealFocus(selection);
636+ }
637+
638+ function toVisual(selection) {
639+ // Include the character under the caret, or the one before at the end.
640+ if (extendByOne(selection, "forward") === 0) extendByOne(selection, "backward");
641+ setPhase("visual");
642+ revealFocus(selection);
643+ }
644+
645+ function toLine(selection) {
646+ extendToLines(selection);
647+ setPhase("line");
648+ revealFocus(selection);
649+ }
650+
651+ // yankSelection copies the selection and returns to the caret at its focus,
652+ // so further copies can be chained without restarting.
653+ function yankSelection(selection) {
654+ const text = selection.toString();
655+ if (text) post(state.token, { type: "copy", text });
656+ toCaret(selection);
657+ }
658+
659+ function caretKey(key) {
273660 const selection = window.getSelection();
274661 if (!selection || selection.rangeCount === 0) return finish({ type: "end" });
275- if (key === "<Escape>" || key === "v") {
276- selection.removeAllRanges();
277- return finish({ type: "end" });
662+ const phase = state.phase;
663+ if (key === "<Escape>") {
664+ state.count = "";
665+ state.pendingG = false;
666+ if (phase === "caret") {
667+ selection.removeAllRanges();
668+ return finish({ type: "end" });
669+ }
670+ return toCaret(selection);
671+ }
672+ if (key === "v") {
673+ state.count = "";
674+ state.pendingG = false;
675+ if (phase === "caret") return toVisual(selection);
676+ if (phase === "visual") return toCaret(selection);
677+ setPhase("visual");
678+ return updateCursor(selection);
679+ }
680+ if (key === "V") {
681+ state.count = "";
682+ state.pendingG = false;
683+ return phase === "line" ? toCaret(selection) : toLine(selection);
278684 }
279- if (key === "y" || key === "<Return>") {
280- const text = selection.toString();
281- selection.removeAllRanges();
282- return finish(text ? { type: "copy", text } : { type: "end" });
685+ if ((key === "y" || key === "<Return>")) {
686+ state.count = "";
687+ state.pendingG = false;
688+ return phase === "caret" ? undefined : yankSelection(selection);
283689 }
284690 if (key === "o") {
285- selection.setBaseAndExtent(selection.focusNode, selection.focusOffset, selection.anchorNode, selection.anchorOffset);
286- return revealSelectionFocus(selection);
691+ state.count = "";
692+ state.pendingG = false;
693+ if (phase === "caret") return;
694+ swapEnds(selection);
695+ return revealFocus(selection);
287696 }
288697 if (/^[1-9]$/.test(key) || (key === "0" && state.count !== "")) {
289698 state.count += key;
290699 return;
291700 }
292- let motion = VISUAL_MOTIONS[key];
701+ let motion = key;
293702 if (key === "g") {
294703 if (!state.pendingG) {
295704 state.pendingG = true;
296705 return;
297706 }
298- motion = ["backward", "documentboundary"];
707+ motion = "gg";
299708 }
300709 state.pendingG = false;
301710 const count = Math.min(parseInt(state.count || "1", 10), 500);
302711 state.count = "";
303- if (!motion) return;
304- for (let i = 0; i < count; i++) selection.modify("extend", motion[0], motion[1]);
305- revealSelectionFocus(selection);
712+ runMotion(selection, motion, count);
713+ revealFocus(selection);
306714 }
307715
308716 // --- Text objects ---------------------------------------------------------
@@ -358,7 +766,13 @@
358766 // --- Lifecycle ------------------------------------------------------------
359767
360768 function cleanup() {
361- if (state && state.root) state.root.remove();
769+ if (state) {
770+ for (const node of [state.root, state.style, state.cursor]) if (node) node.remove();
771+ if (state.onViewportChange) {
772+ window.removeEventListener("scroll", state.onViewportChange, { capture: true });
773+ window.removeEventListener("resize", state.onViewportChange);
774+ }
775+ }
362776 state = null;
363777 }
364778
@@ -381,7 +795,7 @@
381795 post(token, text ? { type: "copy", text } : { type: "end" });
382796 return;
383797 }
384- state = { kind: request.kind, token };
798+ state = { kind: request.kind, token, color };
385799 const started = request.kind === "visual" ? startVisual(color) : startHints(request.kind, color);
386800 if (!started) finish({ type: "end", reason: request.kind === "visual" ? "no-visible-text" : "no-visible-targets" });
387801 },
@@ -392,12 +806,17 @@
392806 post(token, { type: "end" });
393807 return;
394808 }
395- if (state.kind === "visual") return visualKey(key);
396- return hintKey(key);
809+ try {
810+ if (state.phase === "hints" || state.kind !== "visual") return hintKey(key);
811+ return caretKey(key);
812+ } catch (_) {
813+ // A page quirk must never leave the user's keys captured.
814+ if (state) finish({ type: "end", reason: "error" });
815+ }
397816 },
398817 cancel(token) {
399818 if (!state || state.token !== token) return;
400- if (state && state.kind === "visual") {
819+ if (state.kind === "visual" && state.phase !== "hints") {
401820 const selection = window.getSelection();
402821 if (selection) selection.removeAllRanges();
403822 }
@@ -1,8 +1,19 @@
1-// Dumber Vim Mode page runtime: link hints, visual selection, text-object1+// Dumber Vim Mode page runtime: link hints, caret and visual selection,
2-// yanks. Installed once per document; Go calls window.__dumberVimPage.* with a2+// text-object yanks. Installed once per document; Go calls
3-// per-interaction token. Each interaction reports exactly one terminal message3+// window.__dumberVimPage.* with a per-interaction token and accepts results
4-// (copy, open-new, or end) to dumb:///api/vim-page; Go accepts it only while4+// at dumb:///api/vim-page only while that token is armed.
5-// that token is armed, so the token grants at most one result.5+//
6+// Link hints and yanks report exactly one terminal message (copy, open-new, or
7+// end). The caret/visual interaction is long-lived: it reports "mode" messages
8+// as it moves between hints, caret, visual, and visual line, "copy" messages
9+// that do not end it, and finally one "end".
10+//
11+// The caret and visual selection logic follows Vimium's mode_visual.js
12+// (MIT, Copyright (c) Phil Crosby, Ilya Sukhar): character-by-character word
13+// motions to avoid the native selection.modify word bugs on Linux (Vimium
14+// #1441), focus-direction detection, and the line-mode extension. The text
15+// anchor hints and the visible caret follow the flow of Surfingkeys' visual
16+// mode.
6 (() => {17 (() => {
7 if (window.__dumberVimPage) return;18 if (window.__dumberVimPage) return;
8 19
@@ -20,18 +31,22 @@
20 table: ["table"],31 table: ["table"],
21 list: ["ul,ol,dl"],32 list: ["ul,ol,dl"],
22 };33 };
23- const VISUAL_MOTIONS = {34+ // Motions that map directly to Selection.modify. w, e, b, ^ and gg are
35+ // handled in code.
36+ const CARET_MOTIONS = {
24 h: ["backward", "character"], "<Left>": ["backward", "character"],37 h: ["backward", "character"], "<Left>": ["backward", "character"],
25 l: ["forward", "character"], "<Right>": ["forward", "character"],38 l: ["forward", "character"], "<Right>": ["forward", "character"],
26 j: ["forward", "line"], "<Down>": ["forward", "line"],39 j: ["forward", "line"], "<Down>": ["forward", "line"],
27 k: ["backward", "line"], "<Up>": ["backward", "line"],40 k: ["backward", "line"], "<Up>": ["backward", "line"],
28- w: ["forward", "word"], e: ["forward", "word"], b: ["backward", "word"],41+ "0": ["backward", "lineboundary"], "$": ["forward", "lineboundary"],
29- "0": ["backward", "lineboundary"], "^": ["backward", "lineboundary"],
30- "$": ["forward", "lineboundary"],
31 "(": ["backward", "sentence"], ")": ["forward", "sentence"],42 "(": ["backward", "sentence"], ")": ["forward", "sentence"],
32 "{": ["backward", "paragraph"], "}": ["forward", "paragraph"],43 "{": ["backward", "paragraph"], "}": ["forward", "paragraph"],
33 G: ["forward", "documentboundary"],44 G: ["forward", "documentboundary"],
34 };45 };
46+ const WORD_CHARACTER = /[\p{L}\p{N}_]/u;
47+ const MAX_MOTION_STEPS = 20000;
48+ const LONG_TEXT_LENGTH = 50;
49+ const MODE_NAMES = { hints: "hints", caret: "caret", visual: "visual", line: "visual-line" };
35 50
36 let state = null;51 let state = null;
37 52
@@ -128,40 +143,51 @@
128 if (seen.has(element) || element.disabled) continue;143 if (seen.has(element) || element.disabled) continue;
129 const rect = visibleRect(element);144 const rect = visibleRect(element);
130 if (!rect) continue;145 if (!rect) continue;
131- const x = Math.min(Math.max(rect.left + rect.width / 2, 0), window.innerWidth - 1);146+ if (!reachable(element, rect)) continue;
132- const y = Math.min(Math.max(rect.top + rect.height / 2, 0), window.innerHeight - 1);
133- const root = element.getRootNode();
134- const hit = (root.elementFromPoint ? root : document).elementFromPoint(x, y);
135- if (hit && !element.contains(hit) && !hit.contains(element) && !(hit.shadowRoot && hit.shadowRoot.contains(element))) continue;
136 seen.add(element);147 seen.add(element);
137 targets.push({ element, rect });148 targets.push({ element, rect });
138 }149 }
139 return targets;150 return targets;
140 }151 }
141 152
142- function startHints(kind, color) {153+ // buildHintOverlay renders one label per item at its rect and arms the
143- const targets = hintTargets(kind !== "hint-follow");154+ // typed-label filter. offsetLeft puts labels before the rect so they do not
144- if (targets.length === 0) return false;155+ // cover the text a hint points at.
145- const labels = hintLabels(targets.length);156+ function buildHintOverlay(items, color, offsetLeft) {
157+ const labels = hintLabels(items.length);
146 const root = document.createElement("div");158 const root = document.createElement("div");
147 root.setAttribute("data-dumber-vim-hints", "");159 root.setAttribute("data-dumber-vim-hints", "");
148 root.style.cssText = "position:fixed;inset:0;pointer-events:none;z-index:2147483647;";160 root.style.cssText = "position:fixed;inset:0;pointer-events:none;z-index:2147483647;";
149- const hints = targets.map((target, index) => {161+ const labelWidth = labels.length > 0 ? labels[0].length * 8 + 10 : 0;
162+ const hints = items.map((item, index) => {
150 const node = document.createElement("span");163 const node = document.createElement("span");
164+ const left = offsetLeft && item.rect.left >= labelWidth ? item.rect.left - labelWidth : Math.max(item.rect.left, 0);
151 node.style.cssText = [165 node.style.cssText = [
152- "position:fixed", "left:" + Math.max(target.rect.left, 0) + "px",166+ "position:fixed", "left:" + left + "px",
153- "top:" + Math.max(target.rect.top, 0) + "px", "padding:1px 4px", "border-radius:3px",167+ "top:" + Math.max(item.rect.top, 0) + "px", "padding:1px 4px", "border-radius:3px",
154 "font:bold 11px/1.3 monospace", "text-transform:uppercase", "color:#111",168 "font:bold 11px/1.3 monospace", "text-transform:uppercase", "color:#111",
155 "background:" + color, "box-shadow:0 1px 3px rgba(0,0,0,.4)",169 "background:" + color, "box-shadow:0 1px 3px rgba(0,0,0,.4)",
156 ].join(";");170 ].join(";");
157 node.textContent = labels[index];171 node.textContent = labels[index];
158 root.appendChild(node);172 root.appendChild(node);
159- return { label: labels[index], element: target.element, node };173+ return { label: labels[index], item, node };
160 });174 });
161 document.documentElement.appendChild(root);175 document.documentElement.appendChild(root);
162 state.root = root;176 state.root = root;
163 state.hints = hints;177 state.hints = hints;
164 state.typed = "";178 state.typed = "";
179+ }
180+
181+ function removeHintOverlay() {
182+ if (state.root) state.root.remove();
183+ state.root = null;
184+ state.hints = [];
185+ }
186+
187+ function startHints(kind, color) {
188+ const targets = hintTargets(kind !== "hint-follow");
189+ if (targets.length === 0) return false;
190+ buildHintOverlay(targets, color, false);
165 return true;191 return true;
166 }192 }
167 193
@@ -191,8 +217,12 @@
191 return { type: "end" };217 return { type: "end" };
192 }218 }
193 219
220+ // hintKey handles typing labels for link hints and for the caret's
221+ // text-anchor hints (the "visual" kind), where Escape places the caret at a
222+ // default position instead of ending.
194 function hintKey(key) {223 function hintKey(key) {
195- if (key === "<Escape>") return finish({ type: "end" });224+ const textAnchors = state.kind === "visual";
225+ if (key === "<Escape>") return textAnchors ? placeCaretAtFallback() : finish({ type: "end" });
196 if (key === "<BackSpace>") {226 if (key === "<BackSpace>") {
197 state.typed = state.typed.slice(0, -1);227 state.typed = state.typed.slice(0, -1);
198 return renderHintFilter();228 return renderHintFilter();
@@ -203,106 +233,484 @@
203 if (matches.length === 0) return;233 if (matches.length === 0) return;
204 state.typed = typed;234 state.typed = typed;
205 if (matches.length === 1 && matches[0].label === typed) {235 if (matches.length === 1 && matches[0].label === typed) {
206- const kind = state.kind;236+ const item = matches[0].item;
207- const element = matches[0].element;
208 // Remove the overlay before activation so the click hits the page.237 // Remove the overlay before activation so the click hits the page.
209- if (state.root) state.root.remove();238+ removeHintOverlay();
210- finish(activateHint(kind, element));239+ if (textAnchors) return enterCaretAt(item.node, item.offset);
240+ finish(activateHint(state.kind, item.element));
211 return;241 return;
212 }242 }
213 renderHintFilter();243 renderHintFilter();
214 }244 }
215 245
216- // --- Visual selection -----------------------------------------------------246+ // --- Caret and visual selection ------------------------------------------
247+ //
248+ // One interaction moves between four phases: "hints" (text-anchor labels),
249+ // "caret" (collapsed selection plus a visible cursor), "visual" (charwise
250+ // selection), and "line" (linewise selection). Each phase change is reported
251+ // to Go as a "mode" message so the indicator follows it.
217 252
218- function firstVisibleTextPosition() {253+ const SKIP_TEXT_PARENTS = /^(SCRIPT|STYLE|NOSCRIPT|TEMPLATE|TEXTAREA|OPTION)$/;
219- for (const node of deepTextNodes(document.body || document.documentElement)) {254+
220- if (!visibleRect(node.parentElement)) continue;255+ function setPhase(phase) {
221- const range = document.createRange();256+ if (state.phase === phase) return;
222- range.selectNodeContents(node);257+ state.phase = phase;
223- const rect = range.getBoundingClientRect();258+ post(state.token, { type: "mode", mode: MODE_NAMES[phase] });
224- if (rect.bottom <= 0 || rect.top >= window.innerHeight) continue;259+ }
225- return { node, offset: Math.max(node.data.search(/\S/), 0) };260+
261+ // reachable reports whether element is what the user would hit when pointing
262+ // at rect, so hints skip content covered by overlays.
263+ function reachable(element, rect) {
264+ const x = Math.min(Math.max(rect.left + rect.width / 2, 0), window.innerWidth - 1);
265+ const y = Math.min(Math.max(rect.top + rect.height / 2, 0), window.innerHeight - 1);
266+ const root = element.getRootNode();
267+ const hit = (root.elementFromPoint ? root : document).elementFromPoint(x, y);
268+ return !hit || element.contains(hit) || hit.contains(element) || !!(hit.shadowRoot && hit.shadowRoot.contains(element));
269+ }
270+
271+ // blockOf returns the nearest non-inline ancestor, so a paragraph with inline
272+ // markup offers one anchor instead of one per fragment.
273+ function blockOf(element) {
274+ let current = element;
275+ while (current.parentElement && /^(inline|contents)$/.test(window.getComputedStyle(current).display || "")) {
276+ current = current.parentElement;
277+ }
278+ return current;
279+ }
280+
281+ function offsetAtPoint(node, x, y) {
282+ let container = null;
283+ let offset = 0;
284+ if (document.caretRangeFromPoint) {
285+ const range = document.caretRangeFromPoint(x, y);
286+ if (range) { container = range.startContainer; offset = range.startOffset; }
287+ } else if (document.caretPositionFromPoint) {
288+ const position = document.caretPositionFromPoint(x, y);
289+ if (position) { container = position.offsetNode; offset = position.offset; }
290+ }
291+ return container === node ? offset : -1;
292+ }
293+
294+ // firstVisibleAnchor finds where visible text of node starts: the first
295+ // non-blank character when its first line is visible, otherwise the start of
296+ // the first fully visible line.
297+ function firstVisibleAnchor(node, element) {
298+ const range = document.createRange();
299+ range.selectNodeContents(node);
300+ const rects = Array.from(range.getClientRects());
301+ for (let i = 0; i < rects.length; i++) {
302+ const rect = rects[i];
303+ if (rect.width < 1 || rect.height < 1) continue;
304+ if (rect.top < 0 || rect.left < 0 || rect.bottom > window.innerHeight || rect.right > window.innerWidth) continue;
305+ const offset = i === 0 ? Math.max(node.data.search(/\S/), 0) : offsetAtPoint(node, rect.left + 1, rect.top + rect.height / 2);
306+ if (offset < 0 || !visibleRect(element) || !reachable(element, rect)) continue;
307+ return { node, offset, rect, element };
226 }308 }
227 return null;309 return null;
228 }310 }
229 311
230- // Visual mode paints the selection with the accent so it stands out even on312+ // collectTextAnchors lists visible text starts in document order. perBlock
231- // pages that restyle ::selection; the style is removed when visual ends.313+ // keeps the first anchor of each block; minLength skips short texts.
314+ function collectTextAnchors({ perBlock, minLength, limit }) {
315+ const anchors = [];
316+ const blocks = new Set();
317+ for (const node of deepTextNodes(document.body || document.documentElement)) {
318+ const element = node.parentElement;
319+ if (!element || SKIP_TEXT_PARENTS.test(element.tagName) || isEditable(element)) continue;
320+ if (node.data.trim().length < minLength) continue;
321+ const anchor = firstVisibleAnchor(node, element);
322+ if (!anchor) continue;
323+ if (perBlock) {
324+ const block = blockOf(element);
325+ if (blocks.has(block)) continue;
326+ blocks.add(block);
327+ }
328+ anchors.push(anchor);
329+ if (anchors.length >= limit) break;
330+ }
331+ return anchors;
332+ }
333+
334+ // Esc during the text-anchor hints: Vimium's heuristic picks the first
335+ // visible text of at least LONG_TEXT_LENGTH characters (shorter ones are
336+ // likely banners); any visible text is the fallback.
337+ function fallbackAnchor() {
338+ const long = collectTextAnchors({ perBlock: false, minLength: LONG_TEXT_LENGTH, limit: 1 });
339+ if (long.length > 0) return long[0];
340+ return collectTextAnchors({ perBlock: false, minLength: 1, limit: 1 })[0] || null;
341+ }
342+
343+ function placeCaretAtFallback() {
344+ const anchor = fallbackAnchor();
345+ removeHintOverlay();
346+ if (!anchor) return finish({ type: "end", reason: "no-visible-text" });
347+ enterCaretAt(anchor.node, anchor.offset);
348+ }
349+
350+ // The accent paints selections so they stand out on pages that restyle
351+ // ::selection; the style is removed with the interaction.
232 function styleVisual(color) {352 function styleVisual(color) {
233 const style = document.createElement("style");353 const style = document.createElement("style");
234 style.setAttribute("data-dumber-vim-visual", "");354 style.setAttribute("data-dumber-vim-visual", "");
235 style.textContent = "::selection { background: " + color + " !important; color: #111 !important; }";355 style.textContent = "::selection { background: " + color + " !important; color: #111 !important; }";
236 document.documentElement.appendChild(style);356 document.documentElement.appendChild(style);
237- state.root = style;357+ state.style = style;
358+ }
359+
360+ // focusRect measures the selection focus. A collapsed range can report an
361+ // empty rect at node boundaries, so the adjacent character is measured then.
362+ function focusRect(selection) {
363+ const node = selection.focusNode;
364+ if (!node) return null;
365+ const offset = selection.focusOffset;
366+ const range = document.createRange();
367+ range.setStart(node, offset);
368+ range.collapse(true);
369+ const first = (r) => Array.from(r.getClientRects()).find((rect) => rect.height > 0) || null;
370+ const direct = first(range);
371+ if (direct) return direct;
372+ if (node.nodeType === Node.TEXT_NODE) {
373+ if (offset < node.data.length) {
374+ range.setEnd(node, offset + 1);
375+ const next = first(range);
376+ if (next) return { left: next.left, right: next.left, top: next.top, bottom: next.bottom, width: 0, height: next.height };
377+ }
378+ if (offset > 0) {
379+ const before = document.createRange();
380+ before.setStart(node, offset - 1);
381+ before.setEnd(node, offset);
382+ const previous = first(before);
383+ if (previous) return { left: previous.right, right: previous.right, top: previous.top, bottom: previous.bottom, width: 0, height: previous.height };
384+ }
385+ }
386+ const element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
387+ const rect = element && element.getBoundingClientRect();
388+ return rect && rect.height > 0 ? rect : null;
389+ }
390+
391+ // The caret overlay is a blinking accent bar at the focus. It is page
392+ // content, so it follows scrolling and is removed with the interaction.
393+ function beginCaretUI(selection) {
394+ if (!state.style) styleVisual(state.color);
395+ if (!state.cursor) {
396+ const cursor = document.createElement("div");
397+ cursor.setAttribute("data-dumber-vim-caret", "");
398+ cursor.style.cssText = [
399+ "position:fixed", "width:4px", "pointer-events:none", "z-index:2147483647",
400+ "background:" + state.color, "border-radius:2px",
401+ "box-shadow:0 0 0 2px rgba(17,17,17,.85), 0 0 10px 2px " + state.color,
402+ ].join(";");
403+ document.documentElement.appendChild(cursor);
404+ if (typeof cursor.animate === "function") {
405+ cursor.animate([{ opacity: 1 }, { opacity: 1, offset: 0.6 }, { opacity: 0.15 }], { duration: 1000, iterations: Infinity });
406+ }
407+ state.cursor = cursor;
408+ state.onViewportChange = () => updateCursor(window.getSelection());
409+ window.addEventListener("scroll", state.onViewportChange, { capture: true, passive: true });
410+ window.addEventListener("resize", state.onViewportChange, { passive: true });
411+ }
412+ updateCursor(selection);
413+ }
414+
415+ function updateCursor(selection) {
416+ if (!state || !state.cursor || !selection) return;
417+ const rect = focusRect(selection);
418+ const visible = rect && rect.bottom > 0 && rect.top < window.innerHeight && rect.right > -4 && rect.left < window.innerWidth;
419+ state.cursor.style.display = visible ? "" : "none";
420+ if (!visible) return;
421+ // Taller than the text line so the caret reads at a glance.
422+ const height = Math.max(rect.height, 14) + 6;
423+ state.cursor.style.left = Math.round(rect.left - 2) + "px";
424+ state.cursor.style.top = Math.round(rect.top + (rect.height - height) / 2) + "px";
425+ state.cursor.style.height = Math.round(height) + "px";
426+ }
427+
428+ function revealFocus(selection) {
429+ const rect = focusRect(selection);
430+ if (rect) {
431+ const margin = 40;
432+ if (rect.top < margin) window.scrollBy(0, rect.top - margin);
433+ else if (rect.bottom > window.innerHeight - margin) window.scrollBy(0, rect.bottom - window.innerHeight + margin);
434+ }
435+ updateCursor(selection);
436+ }
437+
438+ function enterCaretAt(node, offset) {
439+ const selection = window.getSelection();
440+ selection.setBaseAndExtent(node, offset, node, offset);
441+ state.count = "";
442+ state.pendingG = false;
443+ beginCaretUI(selection);
444+ setPhase("caret");
445+ revealFocus(selection);
446+ }
447+
448+ // A visible range selection is used as is, as in Vimium; a selection that
449+ // scrolled out of view is stale and ignored.
450+ function hasVisibleRangeSelection(selection) {
451+ if (selection.rangeCount === 0 || selection.isCollapsed) return false;
452+ const rect = selection.getRangeAt(0).getBoundingClientRect();
453+ return rect.bottom > 0 && rect.top < window.innerHeight && rect.right > 0 && rect.left < window.innerWidth;
238 }454 }
239 455
240 function startVisual(color) {456 function startVisual(color) {
241 const selection = window.getSelection();457 const selection = window.getSelection();
242 if (!selection) return false;458 if (!selection) return false;
243- if (selection.rangeCount === 0 || selection.isCollapsed) {459+ state.color = color;
244- const start = firstVisibleTextPosition();
245- if (!start) return false;
246- selection.setBaseAndExtent(start.node, start.offset, start.node, start.offset);
247- selection.modify("extend", "forward", "character");
248- }
249 state.count = "";460 state.count = "";
250 state.pendingG = false;461 state.pendingG = false;
251- styleVisual(color);462+ if (hasVisibleRangeSelection(selection)) {
463+ beginCaretUI(selection);
464+ setPhase("visual");
465+ return true;
466+ }
467+ const anchors = collectTextAnchors({ perBlock: true, minLength: 1, limit: HINT_CHARS.length * HINT_CHARS.length });
468+ if (anchors.length === 0) return false;
469+ buildHintOverlay(anchors, color, true);
470+ setPhase("hints");
252 return true;471 return true;
253 }472 }
254 473
255- function revealSelectionFocus(selection) {474+ // --- Selection primitives (after Vimium's Movement class) ---
256- if (!selection.focusNode) return;475+
476+ function isBackward(selection) {
477+ if (selection.anchorNode === selection.focusNode && selection.anchorOffset === selection.focusOffset) return false;
257 const range = document.createRange();478 const range = document.createRange();
258- range.setStart(selection.focusNode, selection.focusOffset);479+ range.setStart(selection.anchorNode, selection.anchorOffset);
259- range.collapse(true);480+ range.setEnd(selection.focusNode, selection.focusOffset);
260- let rect = range.getBoundingClientRect();481+ return range.collapsed;
261- if (rect.top === 0 && rect.bottom === 0) {482+ }
262- const element = selection.focusNode.nodeType === Node.ELEMENT_NODE483+
263- ? selection.focusNode : selection.focusNode.parentElement;484+ function swapEnds(selection) {
264- if (!element) return;485+ selection.setBaseAndExtent(selection.focusNode, selection.focusOffset, selection.anchorNode, selection.anchorOffset);
265- rect = element.getBoundingClientRect();486+ }
487+
488+ function collapseToFocus(selection) {
489+ selection.collapse(selection.focusNode, selection.focusOffset);
490+ }
491+
492+ // extendByOne extends the focus one character and reports how the selected
493+ // text length changed; 0 means the focus could not move.
494+ function extendByOne(selection, direction) {
495+ const length = selection.toString().length;
496+ selection.modify("extend", direction, "character");
497+ return selection.toString().length - length;
498+ }
499+
500+ // nextCharacter returns the character after the focus without moving it.
501+ function nextCharacter(selection) {
502+ const before = selection.toString();
503+ if (before.length === 0 || !isBackward(selection)) {
504+ selection.modify("extend", "forward", "character");
505+ const after = selection.toString();
506+ if (after === before) return undefined;
507+ selection.modify("extend", "backward", "character");
508+ return after[after.length - 1];
509+ }
510+ return before[0];
511+ }
512+
513+ // previousCharacter returns the character before the focus without moving it.
514+ function previousCharacter(selection) {
515+ const before = selection.toString();
516+ if (before.length === 0 || isBackward(selection)) {
517+ selection.modify("extend", "backward", "character");
518+ const after = selection.toString();
519+ if (after === before) return undefined;
520+ selection.modify("extend", "forward", "character");
521+ return after[0];
522+ }
523+ return before[before.length - 1];
524+ }
525+
526+ function isWordCharacter(character) {
527+ return character !== undefined && WORD_CHARACTER.test(character);
528+ }
529+
530+ // Word motions run character by character: the native word granularity
531+ // differs between Linux and other platforms (Vimium #1441).
532+ function wordForward(selection, wordFirst) {
533+ const skipWord = () => {
534+ for (let i = 0; i < MAX_MOTION_STEPS && isWordCharacter(nextCharacter(selection)); i++) {
535+ if (extendByOne(selection, "forward") === 0) return false;
536+ }
537+ return true;
538+ };
539+ const skipOther = () => {
540+ for (let i = 0; i < MAX_MOTION_STEPS; i++) {
541+ const character = nextCharacter(selection);
542+ if (character === undefined || isWordCharacter(character)) return true;
543+ if (extendByOne(selection, "forward") === 0) return false;
544+ }
545+ return true;
546+ };
547+ // w: finish the current word, then skip to the next one. e: skip to a
548+ // word, then finish it.
549+ if (wordFirst) {
550+ if (skipWord()) skipOther();
551+ } else if (skipOther()) {
552+ skipWord();
553+ }
554+ }
555+
556+ function wordBackward(selection) {
557+ for (let i = 0; i < MAX_MOTION_STEPS; i++) {
558+ const character = previousCharacter(selection);
559+ if (character === undefined || isWordCharacter(character)) break;
560+ if (extendByOne(selection, "backward") === 0) return;
561+ }
562+ for (let i = 0; i < MAX_MOTION_STEPS && isWordCharacter(previousCharacter(selection)); i++) {
563+ if (extendByOne(selection, "backward") === 0) return;
564+ }
565+ }
566+
567+ function firstNonBlank(selection, alter) {
568+ selection.modify(alter, "backward", "lineboundary");
569+ if (alter === "move") {
570+ // Measure with extend, then settle the caret where it ended.
571+ for (let i = 0; i < MAX_MOTION_STEPS; i++) {
572+ const character = nextCharacter(selection);
573+ if (character === undefined || !/[ \t\u00a0]/.test(character)) break;
574+ if (extendByOne(selection, "forward") === 0) break;
575+ }
576+ collapseToFocus(selection);
577+ return;
578+ }
579+ for (let i = 0; i < MAX_MOTION_STEPS; i++) {
580+ const character = nextCharacter(selection);
581+ if (character === undefined || !/[ \t\u00a0]/.test(character)) break;
582+ if (extendByOne(selection, "forward") === 0) break;
266 }583 }
267- const margin = 40;
268- if (rect.top < margin) window.scrollBy(0, rect.top - margin);
269- else if (rect.bottom > window.innerHeight - margin) window.scrollBy(0, rect.bottom - window.innerHeight + margin);
270 }584 }
271 585
272- function visualKey(key) {586+ // extendToLines grows the selection so both ends sit on line boundaries.
587+ function extendToLines(selection) {
588+ const forward = !isBackward(selection);
589+ for (const direction of forward ? ["forward", "backward"] : ["backward", "forward"]) {
590+ selection.modify("extend", direction, "lineboundary");
591+ swapEnds(selection);
592+ }
593+ }
594+
595+ // moveOnce applies one motion. In caret phase the selection stays collapsed:
596+ // native granularities use "move", and the character-by-character motions
597+ // extend and then collapse to the focus.
598+ function moveOnce(selection, key) {
599+ const caret = state.phase === "caret";
600+ const alter = caret ? "move" : "extend";
601+ if (CARET_MOTIONS[key]) {
602+ selection.modify(alter, CARET_MOTIONS[key][0], CARET_MOTIONS[key][1]);
603+ return true;
604+ }
605+ switch (key) {
606+ case "w": wordForward(selection, true); break;
607+ case "e": wordForward(selection, false); break;
608+ case "b": wordBackward(selection); break;
609+ case "^": return firstNonBlank(selection, alter), true;
610+ case "gg": selection.modify(alter, "backward", "documentboundary"); return true;
611+ default: return false;
612+ }
613+ if (caret) collapseToFocus(selection);
614+ return true;
615+ }
616+
617+ function runMotion(selection, key, count) {
618+ for (let i = 0; i < count; i++) {
619+ if (state.phase === "line") {
620+ const saved = [selection.anchorNode, selection.anchorOffset, selection.focusNode, selection.focusOffset];
621+ if (!moveOnce(selection, key)) return;
622+ if (selection.isCollapsed) selection.setBaseAndExtent(...saved);
623+ extendToLines(selection);
624+ } else if (!moveOnce(selection, key)) {
625+ return;
626+ }
627+ }
628+ }
629+
630+ // --- Phase transitions ---
631+
632+ function toCaret(selection) {
633+ collapseToFocus(selection);
634+ setPhase("caret");
635+ revealFocus(selection);
636+ }
637+
638+ function toVisual(selection) {
639+ // Include the character under the caret, or the one before at the end.
640+ if (extendByOne(selection, "forward") === 0) extendByOne(selection, "backward");
641+ setPhase("visual");
642+ revealFocus(selection);
643+ }
644+
645+ function toLine(selection) {
646+ extendToLines(selection);
647+ setPhase("line");
648+ revealFocus(selection);
649+ }
650+
651+ // yankSelection copies the selection and returns to the caret at its focus,
652+ // so further copies can be chained without restarting.
653+ function yankSelection(selection) {
654+ const text = selection.toString();
655+ if (text) post(state.token, { type: "copy", text });
656+ toCaret(selection);
657+ }
658+
659+ function caretKey(key) {
273 const selection = window.getSelection();660 const selection = window.getSelection();
274 if (!selection || selection.rangeCount === 0) return finish({ type: "end" });661 if (!selection || selection.rangeCount === 0) return finish({ type: "end" });
275- if (key === "<Escape>" || key === "v") {662+ const phase = state.phase;
276- selection.removeAllRanges();663+ if (key === "<Escape>") {
277- return finish({ type: "end" });664+ state.count = "";
665+ state.pendingG = false;
666+ if (phase === "caret") {
667+ selection.removeAllRanges();
668+ return finish({ type: "end" });
669+ }
670+ return toCaret(selection);
671+ }
672+ if (key === "v") {
673+ state.count = "";
674+ state.pendingG = false;
675+ if (phase === "caret") return toVisual(selection);
676+ if (phase === "visual") return toCaret(selection);
677+ setPhase("visual");
678+ return updateCursor(selection);
679+ }
680+ if (key === "V") {
681+ state.count = "";
682+ state.pendingG = false;
683+ return phase === "line" ? toCaret(selection) : toLine(selection);
278 }684 }
279- if (key === "y" || key === "<Return>") {685+ if ((key === "y" || key === "<Return>")) {
280- const text = selection.toString();686+ state.count = "";
281- selection.removeAllRanges();687+ state.pendingG = false;
282- return finish(text ? { type: "copy", text } : { type: "end" });688+ return phase === "caret" ? undefined : yankSelection(selection);
283 }689 }
284 if (key === "o") {690 if (key === "o") {
285- selection.setBaseAndExtent(selection.focusNode, selection.focusOffset, selection.anchorNode, selection.anchorOffset);691+ state.count = "";
286- return revealSelectionFocus(selection);692+ state.pendingG = false;
693+ if (phase === "caret") return;
694+ swapEnds(selection);
695+ return revealFocus(selection);
287 }696 }
288 if (/^[1-9]$/.test(key) || (key === "0" && state.count !== "")) {697 if (/^[1-9]$/.test(key) || (key === "0" && state.count !== "")) {
289 state.count += key;698 state.count += key;
290 return;699 return;
291 }700 }
292- let motion = VISUAL_MOTIONS[key];701+ let motion = key;
293 if (key === "g") {702 if (key === "g") {
294 if (!state.pendingG) {703 if (!state.pendingG) {
295 state.pendingG = true;704 state.pendingG = true;
296 return;705 return;
297 }706 }
298- motion = ["backward", "documentboundary"];707+ motion = "gg";
299 }708 }
300 state.pendingG = false;709 state.pendingG = false;
301 const count = Math.min(parseInt(state.count || "1", 10), 500);710 const count = Math.min(parseInt(state.count || "1", 10), 500);
302 state.count = "";711 state.count = "";
303- if (!motion) return;712+ runMotion(selection, motion, count);
304- for (let i = 0; i < count; i++) selection.modify("extend", motion[0], motion[1]);713+ revealFocus(selection);
305- revealSelectionFocus(selection);
306 }714 }
307 715
308 // --- Text objects ---------------------------------------------------------716 // --- Text objects ---------------------------------------------------------
@@ -358,7 +766,13 @@
358 // --- Lifecycle ------------------------------------------------------------766 // --- Lifecycle ------------------------------------------------------------
359 767
360 function cleanup() {768 function cleanup() {
361- if (state && state.root) state.root.remove();769+ if (state) {
770+ for (const node of [state.root, state.style, state.cursor]) if (node) node.remove();
771+ if (state.onViewportChange) {
772+ window.removeEventListener("scroll", state.onViewportChange, { capture: true });
773+ window.removeEventListener("resize", state.onViewportChange);
774+ }
775+ }
362 state = null;776 state = null;
363 }777 }
364 778
@@ -381,7 +795,7 @@
381 post(token, text ? { type: "copy", text } : { type: "end" });795 post(token, text ? { type: "copy", text } : { type: "end" });
382 return;796 return;
383 }797 }
384- state = { kind: request.kind, token };798+ state = { kind: request.kind, token, color };
385 const started = request.kind === "visual" ? startVisual(color) : startHints(request.kind, color);799 const started = request.kind === "visual" ? startVisual(color) : startHints(request.kind, color);
386 if (!started) finish({ type: "end", reason: request.kind === "visual" ? "no-visible-text" : "no-visible-targets" });800 if (!started) finish({ type: "end", reason: request.kind === "visual" ? "no-visible-text" : "no-visible-targets" });
387 },801 },
@@ -392,12 +806,17 @@
392 post(token, { type: "end" });806 post(token, { type: "end" });
393 return;807 return;
394 }808 }
395- if (state.kind === "visual") return visualKey(key);809+ try {
396- return hintKey(key);810+ if (state.phase === "hints" || state.kind !== "visual") return hintKey(key);
811+ return caretKey(key);
812+ } catch (_) {
813+ // A page quirk must never leave the user's keys captured.
814+ if (state) finish({ type: "end", reason: "error" });
815+ }
397 },816 },
398 cancel(token) {817 cancel(token) {
399 if (!state || state.token !== token) return;818 if (!state || state.token !== token) return;
400- if (state && state.kind === "visual") {819+ if (state.kind === "visual" && state.phase !== "hints") {
401 const selection = window.getSelection();820 const selection = window.getSelection();
402 if (selection) selection.removeAllRanges();821 if (selection) selection.removeAllRanges();
403 }822 }
M internal/infrastructure/cef/vim_page_runtime_test.go +466 -151
@@ -1,6 +1,7 @@
11 package cef
22
33 import (
4+ _ "embed"
45 "encoding/json"
56 "os/exec"
67 "strings"
@@ -9,108 +10,13 @@ import (
910 "github.com/stretchr/testify/require"
1011 )
1112
12-// vimPageRuntimeHarness is a minimal DOM shim for the embedded runtime: one
13-// paragraph of text and two links, a Selection with Chromium-like
14-// setBaseAndExtent/modify semantics over that text, and a fetch that records
15-// every message posted to the Go bridge.
16-const vimPageRuntimeHarness = `
17-const posted = [];
18-const TEXT = "Alpha bravo charlie";
19-const rect = (x, y, w, h) => ({ left: x, top: y, right: x + w, bottom: y + h, width: w, height: h });
20-class Node_ {
21- constructor(type, tag) {
22- this.nodeType = type; this.tagName = tag; this.children = []; this.parentElement = null;
23- this.style = {}; this.attrs = {}; this.isConnected = true; this.shadowRoot = null;
24- this.classList = { add() {}, remove() {} };
25- }
26- appendChild(child) { child.parentElement = this; this.children.push(child); return child; }
27- append(...items) { for (const item of items) if (item instanceof Node_) this.appendChild(item); }
28- remove() { if (this.parentElement) this.parentElement.children = this.parentElement.children.filter((c) => c !== this); }
29- setAttribute(name, value) { this.attrs[name] = value; }
30- closest() { return null; }
31- contains(other) { for (let n = other; n; n = n.parentElement) if (n === this) return true; return false; }
32- getClientRects() { return this.rect ? [this.rect] : []; }
33- getBoundingClientRect() { return this.rect || rect(0, 0, 0, 0); }
34- getRootNode() { return document; }
35- querySelectorAll(selector) {
36- const out = [];
37- const visit = (node) => { for (const c of node.children) { if (c.nodeType === 1) { if (matches(c, selector)) out.push(c); visit(c); } } };
38- visit(this);
39- return out;
40- }
41- focus() { document.activeElement = this; }
42- click() { posted.push({ clicked: this.href }); }
43-}
44-function matches(el, selector) {
45- return selector.split(",").some((part) => {
46- part = part.trim();
47- if (part === "*") return true;
48- if (part === "a[href]") return el.tagName === "A" && el.href;
49- if (part.startsWith("[")) return false;
50- const base = part.replace(/:not\(.*\)$/, "");
51- return el.tagName === base.toUpperCase();
52- });
53-}
54-class Element extends Node_ { constructor(tag) { super(1, tag.toUpperCase()); } }
55-class HTMLElement extends Element {}
56-class HTMLAnchorElement extends HTMLElement { constructor(href) { super("a"); this.href = href; this.target = ""; } }
57-class HTMLInputElement extends HTMLElement {}
58-class HTMLTextAreaElement extends HTMLElement {}
59-class HTMLSelectElement extends HTMLElement {}
60-const Element_ = HTMLElement;
61-class Text_ extends Node_ { constructor(data) { super(3, "#text"); this.data = data; } }
62-const html = new Element_("html");
63-const body = html.appendChild(new Element_("body"));
64-const p = body.appendChild(new Element_("p")); p.rect = rect(10, 10, 300, 20);
65-const text = p.appendChild(new Text_(TEXT));
66-const one = body.appendChild(new HTMLAnchorElement("file:///one.html")); one.rect = rect(10, 50, 40, 20);
67-const two = body.appendChild(new HTMLAnchorElement("https://example.com/two")); two.rect = rect(80, 50, 40, 20);
68-const selection = {
69- anchorNode: null, anchorOffset: 0, focusNode: null, focusOffset: 0, rangeCount: 0,
70- get isCollapsed() { return this.rangeCount === 0 || (this.anchorNode === this.focusNode && this.anchorOffset === this.focusOffset); },
71- setBaseAndExtent(an, ao, fn, fo) { this.anchorNode = an; this.anchorOffset = ao; this.focusNode = fn; this.focusOffset = fo; this.rangeCount = 1; },
72- modify(_alter, direction, granularity) {
73- const step = direction === "forward" ? 1 : -1;
74- if (granularity === "character") this.focusOffset = Math.max(0, Math.min(TEXT.length, this.focusOffset + step));
75- else if (granularity === "word") {
76- let i = this.focusOffset;
77- if (step > 0) { while (i < TEXT.length && TEXT[i] !== " ") i++; while (i < TEXT.length && TEXT[i] === " ") i++; }
78- else { while (i > 0 && TEXT[i - 1] === " ") i--; while (i > 0 && TEXT[i - 1] !== " ") i--; }
79- this.focusOffset = i;
80- } else if (granularity === "lineboundary" || granularity === "documentboundary" || granularity === "line") {
81- this.focusOffset = step > 0 ? TEXT.length : 0;
82- }
83- },
84- removeAllRanges() { this.rangeCount = 0; this.anchorNode = this.focusNode = null; },
85- addRange() { this.rangeCount = 1; },
86- getRangeAt() { return {}; },
87- toString() { return this.rangeCount ? TEXT.slice(Math.min(this.anchorOffset, this.focusOffset), Math.max(this.anchorOffset, this.focusOffset)) : ""; },
88-};
89-global.Node = { TEXT_NODE: 3, ELEMENT_NODE: 1 };
90-global.NodeFilter = { SHOW_ELEMENT: 1, SHOW_TEXT: 4 };
91-global.Element = Element; global.HTMLElement = HTMLElement; global.HTMLAnchorElement = HTMLAnchorElement;
92-global.HTMLInputElement = HTMLInputElement; global.HTMLTextAreaElement = HTMLTextAreaElement; global.HTMLSelectElement = HTMLSelectElement;
93-global.document = {
94- body, documentElement: html, activeElement: body,
95- createElement: (tag) => new HTMLElement(tag),
96- createRange: () => ({ setStart() {}, collapse() {}, selectNodeContents() {}, getBoundingClientRect: () => rect(10, 10, 300, 20) }),
97- createTreeWalker: (root) => {
98- const nodes = []; const visit = (n) => { nodes.push(n); for (const c of n.children) visit(c); }; visit(root);
99- let i = 0; return { currentNode: nodes[0], nextNode: () => nodes[++i] || null };
100- },
101- querySelectorAll: (selector) => html.querySelectorAll(selector),
102- elementFromPoint: (x, y) => [two, one, p].find((el) => el.rect && x >= el.rect.left && x < el.rect.right && y >= el.rect.top && y < el.rect.bottom) || body,
103-};
104-global.window = {
105- innerWidth: 800, innerHeight: 600, scrollBy() {},
106- getSelection: () => selection,
107- getComputedStyle: () => ({ display: "block", visibility: "visible" }),
108- fetch: (url, init) => { posted.push(JSON.parse(Buffer.from(init.headers["X-Dumber-Body"], "base64").toString("latin1"))); return Promise.resolve(); },
109-};
110-global.btoa = (s) => Buffer.from(s, "binary").toString("base64");
111-global.unescape = (s) => decodeURIComponent(s);
112-global.setTimeout = (fn) => { fn(); return 0; };
113-`
13+// vimPageRuntimeHarness is a DOM shim for the embedded runtime: three visible
14+// paragraphs and one below the fold, two links, a Selection and Range with
15+// Chromium-like semantics, and a fetch that records every message posted to
16+// the Go bridge. See testdata/vim_page_harness.js.
17+//
18+//go:embed testdata/vim_page_harness.js
19+var vimPageRuntimeHarness string
11420
11521 // runVimPageRuntime executes the embedded runtime inside the harness, then
11622 // the given driver script, and returns every message posted to the bridge
@@ -130,61 +36,471 @@ func runVimPageRuntime(t *testing.T, driver string) []map[string]any {
13036 return posted
13137 }
13238
133-func TestVimPageRuntimeVisualExtendsAndYanks(t *testing.T) {
39+// named returns the driver snapshot recorded under name.
40+func named(t *testing.T, posted []map[string]any, name string) map[string]any {
41+ t.Helper()
42+ for _, entry := range posted {
43+ if entry["n"] == name {
44+ return entry
45+ }
46+ }
47+ require.FailNow(t, "snapshot not recorded", name)
48+ return nil
49+}
50+
51+// bridgeMessages returns the runtime's own bridge messages, without snapshots.
52+func bridgeMessages(posted []map[string]any) []map[string]any {
53+ var out []map[string]any
54+ for _, entry := range posted {
55+ if _, ok := entry["token"]; ok {
56+ out = append(out, entry)
57+ }
58+ }
59+ return out
60+}
61+
62+func modeSequence(posted []map[string]any) []any {
63+ var out []any
64+ for _, entry := range bridgeMessages(posted) {
65+ if entry["type"] == "mode" {
66+ out = append(out, entry["mode"])
67+ }
68+ }
69+ return out
70+}
71+
72+func TestVimPageRuntimeStartDoesNotEndImmediately(t *testing.T) {
73+ posted := runVimPageRuntime(t, `
74+window.__dumberVimPage.start("tok", { kind: "visual" });
75+window.__dumberVimPage.start("hint", { kind: "hint-follow" });
76+`)
77+ for _, message := range bridgeMessages(posted) {
78+ require.NotEqual(t, "end", message["type"], "starting with visible content never ends the interaction")
79+ }
80+}
81+
82+func TestVimPageRuntimeVisualStartsWithTextAnchorHints(t *testing.T) {
13483 posted := runVimPageRuntime(t, `
135-const vp = window.__dumberVimPage;
136-vp.start("tok", { kind: "visual", color: "#fbbf24" });
137-posted.push({ started: selection.toString() });
138-for (const key of ["l", "l", "w"]) vp.key("tok", key);
139-posted.push({ extended: selection.toString() });
140-vp.key("tok", "y");
141-posted.push({ after: selection.rangeCount });
84+window.__dumberVimPage.start("tok", { kind: "visual", color: "#fbbf24" });
85+posted.push({ n: "hints", labels: hintLabels(), selection: selection.rangeCount, caret: cursor() !== null });
14286 `)
143- require.Len(t, posted, 4)
144- require.Equal(t, "A", posted[0]["started"], "visual starts on the first visible character")
145- require.Equal(t, "Alpha ", posted[1]["extended"], "motions extend the selection")
146- require.Equal(t, map[string]any{"token": "tok", "type": "copy", "text": "Alpha "}, posted[2])
147- require.EqualValues(t, 0, posted[3]["after"], "yank clears the selection")
87+ hints := named(t, posted, "hints")
88+ require.Equal(t, []any{"s", "a", "d"}, hints["labels"], "one label per visible text block, none for text below the fold")
89+ require.EqualValues(t, 0, hints["selection"])
90+ require.Equal(t, false, hints["caret"], "no caret until an anchor is picked")
91+ require.Equal(t, []any{"hints"}, modeSequence(posted))
14892 }
14993
150-func TestVimPageRuntimeVisualExitsOnVAndEscapeWithoutCopy(t *testing.T) {
151- for _, key := range []string{"v", "<Escape>"} {
152- posted := runVimPageRuntime(t, `
153-const vp = window.__dumberVimPage;
154-vp.start("tok", { kind: "visual" });
155-vp.key("tok", "l");
156-vp.key("tok", `+jsString(key)+`);
94+func TestVimPageRuntimeTextAnchorLabelPlacesCaret(t *testing.T) {
95+ posted := runVimPageRuntime(t, `
96+startCaret("tok", "a");
97+S("caret", { hints: hintLabels(), cursor: cursor() && cursor().style });
15798 `)
158- require.Len(t, posted, 1, "key %s", key)
159- require.Equal(t, "end", posted[0]["type"], "key %s", key)
99+ caret := named(t, posted, "caret")
100+ require.Empty(t, caret["text"])
101+ require.Equal(t, "1:0", caret["anchor"], "the label puts the caret at the start of that text")
102+ require.Equal(t, "1:0", caret["focus"])
103+ require.Empty(t, caret["hints"], "the hint overlay is gone once the caret is placed")
104+ style := caret["cursor"].(map[string]any)
105+ require.Equal(t, "fixed", style["position"])
106+ require.Equal(t, "#fbbf24", style["background"], "the caret uses the accent color")
107+ require.Equal(t, "none", style["pointer-events"])
108+ require.Equal(t, "2147483647", style["z-index"])
109+ require.Equal(t, "8px", style["left"])
110+ require.Equal(t, "37px", style["top"], "the caret is taller than the line, centered on it")
111+ require.Equal(t, "26px", style["height"])
112+ require.Equal(t, "4px", style["width"])
113+ require.Equal(t, []any{"hints", "caret"}, modeSequence(posted))
114+ require.Empty(t, bridgeMessagesOfType(posted, "end"))
115+}
116+
117+func bridgeMessagesOfType(posted []map[string]any, messageType string) []map[string]any {
118+ var out []map[string]any
119+ for _, message := range bridgeMessages(posted) {
120+ if message["type"] == messageType {
121+ out = append(out, message)
122+ }
160123 }
124+ return out
161125 }
162126
163-func TestVimPageRuntimeStartDoesNotEndImmediately(t *testing.T) {
127+func TestVimPageRuntimeEscapeInTextAnchorHintsPicksFirstLongText(t *testing.T) {
164128 posted := runVimPageRuntime(t, `
165129 window.__dumberVimPage.start("tok", { kind: "visual" });
166-window.__dumberVimPage.start("hint", { kind: "hint-follow" });
130+vp().key("tok", "<Escape>");
131+S("caret");
132+`)
133+ caret := named(t, posted, "caret")
134+ require.Equal(t, "1:0", caret["focus"], "the first visible text of at least 50 characters is chosen over short banners")
135+ require.Equal(t, []any{"hints", "caret"}, modeSequence(posted))
136+}
137+
138+func TestVimPageRuntimeEscapeInTextAnchorHintsFallsBackToAnyVisibleText(t *testing.T) {
139+ posted := runVimPageRuntime(t, `
140+second.text.data = "short";
141+window.__dumberVimPage.start("tok", { kind: "visual" });
142+vp().key("tok", "<Escape>");
143+S("caret");
144+`)
145+ require.Equal(t, "0:0", named(t, posted, "caret")["focus"])
146+}
147+
148+func TestVimPageRuntimeVisualEndsWithReasonWhenNoVisibleText(t *testing.T) {
149+ posted := runVimPageRuntime(t, `
150+for (const t of texts) t.parentElement.rect = rect(10, 5000, 600, 20);
151+window.__dumberVimPage.start("tok", { kind: "visual" });
152+`)
153+ require.Equal(t, []map[string]any{{"token": "tok", "type": "end", "reason": "no-visible-text"}}, bridgeMessages(posted))
154+}
155+
156+func TestVimPageRuntimeEscapeFallbackEndsWhenNothingVisible(t *testing.T) {
157+ posted := runVimPageRuntime(t, `
158+window.__dumberVimPage.start("tok", { kind: "visual" });
159+for (const t of texts) t.parentElement.rect = rect(10, 5000, 600, 20);
160+vp().key("tok", "<Escape>");
161+`)
162+ ends := bridgeMessagesOfType(posted, "end")
163+ require.Len(t, ends, 1)
164+ require.Equal(t, "no-visible-text", ends[0]["reason"])
165+}
166+
167+func TestVimPageRuntimeVisibleSelectionStartsVisualDirectly(t *testing.T) {
168+ posted := runVimPageRuntime(t, `
169+selection.setBaseAndExtent(first.text, 0, first.text, 5);
170+window.__dumberVimPage.start("tok", { kind: "visual" });
171+S("start", { hints: hintLabels(), cursor: cursor() !== null });
172+vp().key("tok", "l");
173+S("extended");
167174 `)
168- require.Empty(t, posted, "starting an interaction with visible content posts nothing until a key ends it")
175+ start := named(t, posted, "start")
176+ require.Equal(t, "Alpha", start["text"], "an existing visible selection is used as is")
177+ require.Empty(t, start["hints"])
178+ require.Equal(t, true, start["cursor"])
179+ require.Equal(t, "Alpha ", named(t, posted, "extended")["text"])
180+ require.Equal(t, []any{"visual"}, modeSequence(posted))
181+}
182+
183+func TestVimPageRuntimeSelectionOutsideViewportIsIgnored(t *testing.T) {
184+ posted := runVimPageRuntime(t, `
185+selection.setBaseAndExtent(far.text, 0, far.text, 3);
186+window.__dumberVimPage.start("tok", { kind: "visual" });
187+posted.push({ n: "hints", labels: hintLabels() });
188+`)
189+ require.Equal(t, []any{"s", "a", "d"}, named(t, posted, "hints")["labels"])
190+ require.Equal(t, []any{"hints"}, modeSequence(posted))
191+}
192+
193+func TestVimPageRuntimeCaretMotionsMoveWithoutSelecting(t *testing.T) {
194+ tests := []struct {
195+ name string
196+ start string // hint label
197+ keys []string
198+ want string // focus
199+ }{
200+ {"l", "s", []string{"l", "l"}, "0:2"},
201+ {"counted l", "s", []string{"3", "l"}, "0:3"},
202+ {"h clamps", "s", []string{"l", "h", "h"}, "0:0"},
203+ {"w", "s", []string{"w"}, "0:6"},
204+ {"2w", "s", []string{"2", "w"}, "0:12"},
205+ {"w crosses line", "s", []string{"3", "w"}, "1:0"},
206+ {"e", "s", []string{"e"}, "0:5"},
207+ {"b", "s", []string{"w", "w", "b"}, "0:6"},
208+ {"b at start", "s", []string{"b"}, "0:0"},
209+ {"$", "s", []string{"$"}, "0:19"},
210+ {"0", "s", []string{"$", "0"}, "0:0"},
211+ {"^", "s", []string{"$", "^"}, "0:0"},
212+ {"j", "s", []string{"l", "l", "j"}, "1:2"},
213+ {"k", "a", []string{"l", "k"}, "0:1"},
214+ {"G", "s", []string{"G"}, "3:18"},
215+ {"gg", "a", []string{"G", "g", "g"}, "0:0"},
216+ {"}", "s", []string{"}"}, "0:19"},
217+ {"{", "a", []string{"l", "{"}, "1:0"},
218+ {")", "s", []string{")"}, "0:19"},
219+ {"(", "s", []string{"$", "("}, "0:0"},
220+ }
221+ for _, tt := range tests {
222+ t.Run(tt.name, func(t *testing.T) {
223+ keys, err := json.Marshal(tt.keys)
224+ require.NoError(t, err)
225+ posted := runVimPageRuntime(t, `
226+startCaret("tok", `+jsString(tt.start)+`);
227+keys("tok", ...`+string(keys)+`);
228+S("end");
229+`)
230+ end := named(t, posted, "end")
231+ require.Equal(t, tt.want, end["focus"])
232+ require.Equal(t, end["focus"], end["anchor"], "the caret stays collapsed")
233+ require.Empty(t, end["text"])
234+ require.Empty(t, bridgeMessagesOfType(posted, "end"))
235+ })
236+ }
237+}
238+
239+func TestVimPageRuntimeWordMotionsSkipPunctuation(t *testing.T) {
240+ posted := runVimPageRuntime(t, `
241+startCaret("tok", "d");
242+keys("tok", "w");
243+S("w1");
244+keys("tok", "w");
245+S("w2");
246+keys("tok", "b");
247+S("b1");
248+`)
249+ // "Third line, end.": w from "Third" -> "line"; w -> "end" (the comma and
250+ // space are skipped); b -> back to "line".
251+ require.Equal(t, "2:6", named(t, posted, "w1")["focus"])
252+ require.Equal(t, "2:12", named(t, posted, "w2")["focus"])
253+ require.Equal(t, "2:6", named(t, posted, "b1")["focus"])
254+}
255+
256+func TestVimPageRuntimeCaretMotionScrollsFocusIntoView(t *testing.T) {
257+ posted := runVimPageRuntime(t, `
258+startCaret("tok", "s");
259+scrolls.length = 0;
260+keys("tok", "G");
261+posted.push({ n: "scrolled", scrolls });
262+`)
263+ scrolled := named(t, posted, "scrolled")["scrolls"].([]any)
264+ require.NotEmpty(t, scrolled, "moving the caret below the fold scrolls it into view")
265+ require.Greater(t, scrolled[0].([]any)[1].(float64), float64(1000))
266+}
267+
268+func TestVimPageRuntimeCaretFollowsScrollAndIsRemovedOnEnd(t *testing.T) {
269+ posted := runVimPageRuntime(t, `
270+startCaret("tok", "s");
271+const bar = cursor();
272+first.p.rect = rect(10, -300, 600, 20); // the page scrolled the paragraph away
273+fire("scroll");
274+const hiddenWhileOffscreen = bar.style.display;
275+first.p.rect = rect(10, 100, 600, 20);
276+fire("scroll");
277+posted.push({ n: "follow", hidden: hiddenWhileOffscreen, top: bar.style.top, display: bar.style.display });
278+keys("tok", "<Escape>");
279+posted.push({ n: "cleanup", caret: cursor() === null, styles: overlays("data-dumber-vim-visual").length, scrollListeners: (listeners.scroll || []).length, resize: (listeners.resize || []).length, selection: selection.rangeCount });
280+`)
281+ follow := named(t, posted, "follow")
282+ require.Equal(t, "none", follow["hidden"], "the caret hides while its text is off screen")
283+ require.Equal(t, "97px", follow["top"], "the caret follows its text when the page scrolls")
284+ require.Empty(t, follow["display"])
285+ cleanup := named(t, posted, "cleanup")
286+ require.Equal(t, true, cleanup["caret"], "the caret overlay is removed on cleanup")
287+ require.EqualValues(t, 0, cleanup["styles"])
288+ require.EqualValues(t, 0, cleanup["scrollListeners"])
289+ require.EqualValues(t, 0, cleanup["resize"])
290+ require.EqualValues(t, 0, cleanup["selection"])
291+}
292+
293+func TestVimPageRuntimeVisualFromCaretExtendsAndYankReturnsToCaret(t *testing.T) {
294+ posted := runVimPageRuntime(t, `
295+startCaret("tok", "s");
296+keys("tok", "v");
297+S("v");
298+keys("tok", "l", "l");
299+S("extended");
300+keys("tok", "w");
301+S("word");
302+keys("tok", "y");
303+S("after");
304+keys("tok", "l");
305+S("moved");
306+keys("tok", "y");
307+S("caretYank");
308+keys("tok", "v", "e");
309+S("second");
310+keys("tok", "<Return>");
311+S("afterReturn");
312+`)
313+ require.Equal(t, "A", named(t, posted, "v")["text"], "v starts charwise from the caret")
314+ require.Equal(t, "Alp", named(t, posted, "extended")["text"])
315+ require.Equal(t, "Alpha ", named(t, posted, "word")["text"])
316+
317+ after := named(t, posted, "after")
318+ require.Empty(t, after["text"], "y returns to a collapsed caret")
319+ require.Equal(t, "0:6", after["focus"], "at the focus end of the selection")
320+ require.Equal(t, after["focus"], after["anchor"])
321+ require.Equal(t, "0:7", named(t, posted, "moved")["focus"], "the caret keeps moving after a yank")
322+ require.Equal(t, "0:7", named(t, posted, "caretYank")["focus"], "y in caret mode does nothing")
323+
324+ copies := bridgeMessagesOfType(posted, "copy")
325+ require.Len(t, copies, 2, "y in caret mode copies nothing; Enter chains a second copy")
326+ require.Equal(t, map[string]any{"token": "tok", "type": "copy", "text": "Alpha "}, copies[0])
327+ require.Equal(t, "ravo", copies[1]["text"], "v includes the character under the caret and e extends to the word end")
328+ require.Empty(t, named(t, posted, "afterReturn")["text"])
329+ require.Empty(t, bridgeMessagesOfType(posted, "end"), "copying never ends the interaction")
330+ require.Equal(t, []any{"hints", "caret", "visual", "caret", "visual", "caret"}, modeSequence(posted))
331+}
332+
333+func TestVimPageRuntimeVisualOSwapsEnds(t *testing.T) {
334+ posted := runVimPageRuntime(t, `
335+startCaret("tok", "s");
336+keys("tok", "l", "l", "l", "v");
337+S("before");
338+keys("tok", "o");
339+S("swapped");
340+keys("tok", "h", "h");
341+S("shrunk");
342+keys("tok", "o", "l");
343+S("back");
344+`)
345+ before := named(t, posted, "before")
346+ require.Equal(t, "0:3", before["anchor"])
347+ require.Equal(t, "0:4", before["focus"])
348+ swapped := named(t, posted, "swapped")
349+ require.Equal(t, "0:4", swapped["anchor"])
350+ require.Equal(t, "0:3", swapped["focus"])
351+ require.Equal(t, "0:4", named(t, posted, "shrunk")["anchor"])
352+ require.Equal(t, "0:1", named(t, posted, "shrunk")["focus"])
353+ require.Equal(t, "lph", named(t, posted, "shrunk")["text"])
354+ back := named(t, posted, "back")
355+ require.Equal(t, "0:1", back["anchor"])
356+ require.Equal(t, "0:5", back["focus"], "after o the motions extend the other end")
357+}
358+
359+func TestVimPageRuntimeVisualLine(t *testing.T) {
360+ posted := runVimPageRuntime(t, `
361+startCaret("tok", "a");
362+keys("tok", "l", "l");
363+keys("tok", "V");
364+S("line");
365+keys("tok", "j");
366+S("down");
367+keys("tok", "k", "k");
368+S("up");
369+keys("tok", "o");
370+S("swapped");
371+keys("tok", "y");
372+S("yanked");
373+`)
374+ require.Equal(t, "The quick brown fox jumps over the lazy dog near the river", named(t, posted, "line")["text"], "V selects the whole line")
375+ require.Equal(t, "The quick brown fox jumps over the lazy dog near the river\nThird line, end.", named(t, posted, "down")["text"])
376+ up := named(t, posted, "up")
377+ require.Equal(t, "Alpha bravo charlie\nThe quick brown fox jumps over the lazy dog near the river", up["text"], "motions extend by whole lines")
378+ require.Equal(t, "1:58", up["anchor"])
379+ require.Equal(t, "0:0", up["focus"])
380+ require.Equal(t, "0:0", named(t, posted, "swapped")["anchor"], "o swaps the ends")
381+ yanked := named(t, posted, "yanked")
382+ require.Empty(t, yanked["text"])
383+ require.Equal(t, yanked["focus"], yanked["anchor"], "y returns to a caret at the focus")
384+ copies := bridgeMessagesOfType(posted, "copy")
385+ require.Len(t, copies, 1)
386+ require.Equal(t, "Alpha bravo charlie\nThe quick brown fox jumps over the lazy dog near the river", copies[0]["text"])
387+ require.Equal(t, []any{"hints", "caret", "visual-line", "caret"}, modeSequence(posted))
388+}
389+
390+func TestVimPageRuntimeModeSwitchingKeys(t *testing.T) {
391+ posted := runVimPageRuntime(t, `
392+startCaret("tok", "s");
393+keys("tok", "l", "l");
394+keys("tok", "v", "l");
395+S("visual");
396+keys("tok", "V");
397+S("line");
398+keys("tok", "v");
399+S("visualAgain");
400+keys("tok", "V");
401+keys("tok", "V");
402+S("caretFromLine");
403+keys("tok", "v");
404+keys("tok", "v");
405+S("caretFromVisual");
406+keys("tok", "v", "l", "<Escape>");
407+S("escapeVisual");
408+keys("tok", "V", "<Escape>");
409+S("escapeLine");
410+`)
411+ require.Equal(t, "ph", named(t, posted, "visual")["text"], "v includes the character under the caret, then l extends")
412+ require.Equal(t, "Alpha bravo charlie", named(t, posted, "line")["text"])
413+ require.Equal(t, "Alpha bravo charlie", named(t, posted, "visualAgain")["text"], "v in visual line switches to charwise without changing the range")
414+ require.Empty(t, named(t, posted, "caretFromLine")["text"], "V in visual line goes back to caret")
415+ require.Empty(t, named(t, posted, "caretFromVisual")["text"], "v in visual goes back to caret")
416+ require.Empty(t, named(t, posted, "escapeVisual")["text"], "Escape in visual goes back to caret")
417+ require.Empty(t, named(t, posted, "escapeLine")["text"], "Escape in visual line goes back to caret")
418+ require.Empty(t, bridgeMessagesOfType(posted, "end"), "none of these end the interaction")
419+ require.Equal(t, []any{
420+ "hints", "caret", "visual", "visual-line", "visual", "visual-line", "caret",
421+ "visual", "caret", "visual", "caret", "visual-line", "caret",
422+ }, modeSequence(posted))
423+}
424+
425+func TestVimPageRuntimeEscapeInCaretEndsInteraction(t *testing.T) {
426+ posted := runVimPageRuntime(t, `
427+startCaret("tok", "s");
428+keys("tok", "l", "<Escape>");
429+posted.push({ n: "after", selection: selection.rangeCount, caret: cursor() === null });
430+`)
431+ require.Equal(t, []map[string]any{{"token": "tok", "type": "end"}}, bridgeMessagesOfType(posted, "end"))
432+ after := named(t, posted, "after")
433+ require.EqualValues(t, 0, after["selection"])
434+ require.Equal(t, true, after["caret"])
435+}
436+
437+func TestVimPageRuntimeCancelRemovesCaretAndSelection(t *testing.T) {
438+ posted := runVimPageRuntime(t, `
439+startCaret("tok", "s");
440+keys("tok", "v", "l");
441+vp().cancel("tok");
442+posted.push({ n: "after", selection: selection.rangeCount, caret: cursor() === null, styles: overlays("data-dumber-vim-visual").length });
443+`)
444+ after := named(t, posted, "after")
445+ require.EqualValues(t, 0, after["selection"])
446+ require.Equal(t, true, after["caret"])
447+ require.EqualValues(t, 0, after["styles"])
448+ require.Empty(t, bridgeMessagesOfType(posted, "end"), "cancel is Go-initiated and reports nothing")
449+}
450+
451+func TestVimPageRuntimeCancelRemovesHintOverlay(t *testing.T) {
452+ posted := runVimPageRuntime(t, `
453+window.__dumberVimPage.start("tok", { kind: "visual" });
454+vp().cancel("tok");
455+posted.push({ n: "after", labels: hintLabels() });
456+`)
457+ require.Empty(t, named(t, posted, "after")["labels"])
458+}
459+
460+func TestVimPageRuntimeKeyErrorReleasesCapture(t *testing.T) {
461+ posted := runVimPageRuntime(t, `
462+startCaret("tok", "s");
463+selection.modify = () => { throw new Error("page quirk"); };
464+keys("tok", "l");
465+`)
466+ ends := bridgeMessagesOfType(posted, "end")
467+ require.Len(t, ends, 1, "a failing motion must not leave the keyboard captured")
468+ require.Equal(t, "error", ends[0]["reason"])
169469 }
170470
171471 func TestVimPageRuntimeHintsFollowAndYank(t *testing.T) {
172472 posted := runVimPageRuntime(t, `
173-const vp = window.__dumberVimPage;
174-vp.start("a", { kind: "hint-follow" });
175-posted.push({ labels: html.querySelectorAll("span").map((s) => s.textContent) });
176-vp.key("a", "a");
177-vp.start("b", { kind: "hint-yank-url" });
178-vp.key("b", "s");
179-vp.start("c", { kind: "hint-follow-new" });
180-vp.key("c", "a");
473+const vpage = window.__dumberVimPage;
474+vpage.start("a", { kind: "hint-follow" });
475+posted.push({ n: "labels", labels: hintLabels() });
476+vpage.key("a", "a");
477+vpage.start("b", { kind: "hint-yank-url" });
478+vpage.key("b", "s");
479+vpage.start("c", { kind: "hint-follow-new" });
480+vpage.key("c", "a");
481+vpage.start("d", { kind: "hint-follow" });
482+vpage.key("d", "<Escape>");
181483 `)
182- require.Len(t, posted, 5)
183- require.ElementsMatch(t, []any{"s", "a"}, posted[0]["labels"], "two visible links get one-letter labels")
184- require.Equal(t, "https://example.com/two", posted[1]["clicked"], "follow clicks the chosen link")
185- require.Equal(t, "end", posted[2]["type"])
186- require.Equal(t, map[string]any{"token": "b", "type": "copy", "text": "file:///one.html"}, posted[3])
187- require.Equal(t, map[string]any{"token": "c", "type": "open-new", "url": "https://example.com/two"}, posted[4])
484+ require.ElementsMatch(t, []any{"s", "a"}, named(t, posted, "labels")["labels"], "two visible links get one-letter labels")
485+ require.Equal(t, "https://example.com/two", named2(posted, "clicked"), "follow clicks the chosen link")
486+ messages := bridgeMessages(posted)
487+ require.Equal(t, []map[string]any{
488+ {"token": "a", "type": "end"},
489+ {"token": "b", "type": "copy", "text": "file:///one.html"},
490+ {"token": "c", "type": "open-new", "url": "https://example.com/two"},
491+ {"token": "d", "type": "end"},
492+ }, messages)
493+ require.NotContains(t, modeSequence(posted), "caret", "link hints never report caret modes")
494+}
495+
496+// named2 returns a field recorded by a harness side effect (such as a click).
497+func named2(posted []map[string]any, key string) any {
498+ for _, entry := range posted {
499+ if value, ok := entry[key]; ok {
500+ return value
501+ }
502+ }
503+ return nil
188504 }
189505
190506 func TestVimPageRuntimeYankParagraph(t *testing.T) {
@@ -192,19 +508,18 @@ func TestVimPageRuntimeYankParagraph(t *testing.T) {
192508 p.innerText = TEXT;
193509 window.__dumberVimPage.start("tok", { kind: "yank", object: "paragraph" });
194510 `)
195- require.Len(t, posted, 1)
196- require.Equal(t, "copy", posted[0]["type"])
197- require.True(t, strings.HasPrefix(posted[0]["text"].(string), "Alpha"))
511+ messages := bridgeMessages(posted)
512+ require.Len(t, messages, 1)
513+ require.Equal(t, "copy", messages[0]["type"])
514+ require.True(t, strings.HasPrefix(messages[0]["text"].(string), "Alpha"))
198515 }
199516
200517 func TestVimPageRuntimeStaleTokenKeyReleasesCapture(t *testing.T) {
201518 posted := runVimPageRuntime(t, `
202-const vp = window.__dumberVimPage;
203-vp.start("live", { kind: "visual" });
204-vp.key("stale", "j");
205-posted.push({ liveSelection: selection.rangeCount });
519+startCaret("live", "s");
520+vp().key("stale", "j");
521+posted.push({ n: "live", selection: selection.rangeCount });
206522 `)
207- require.Len(t, posted, 2)
208- require.Equal(t, map[string]any{"token": "stale", "type": "end"}, posted[0])
209- require.EqualValues(t, 1, posted[1]["liveSelection"], "a stale key must not disturb the live interaction")
523+ require.Equal(t, []map[string]any{{"token": "stale", "type": "end"}}, bridgeMessagesOfType(posted, "end"))
524+ require.EqualValues(t, 1, named(t, posted, "live")["selection"], "a stale key must not disturb the live interaction")
210525 }
@@ -1,6 +1,7 @@
1 package cef1 package cef
2 2
3 import (3 import (
4+ _ "embed"
4 "encoding/json"5 "encoding/json"
5 "os/exec"6 "os/exec"
6 "strings"7 "strings"
@@ -9,108 +10,13 @@ import (
9 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
10 )11 )
11 12
12-// vimPageRuntimeHarness is a minimal DOM shim for the embedded runtime: one13+// vimPageRuntimeHarness is a DOM shim for the embedded runtime: three visible
13-// paragraph of text and two links, a Selection with Chromium-like14+// paragraphs and one below the fold, two links, a Selection and Range with
14-// setBaseAndExtent/modify semantics over that text, and a fetch that records15+// Chromium-like semantics, and a fetch that records every message posted to
15-// every message posted to the Go bridge.16+// the Go bridge. See testdata/vim_page_harness.js.
16-const vimPageRuntimeHarness = `17+//
17-const posted = [];18+//go:embed testdata/vim_page_harness.js
18-const TEXT = "Alpha bravo charlie";19+var vimPageRuntimeHarness string
19-const rect = (x, y, w, h) => ({ left: x, top: y, right: x + w, bottom: y + h, width: w, height: h });
20-class Node_ {
21- constructor(type, tag) {
22- this.nodeType = type; this.tagName = tag; this.children = []; this.parentElement = null;
23- this.style = {}; this.attrs = {}; this.isConnected = true; this.shadowRoot = null;
24- this.classList = { add() {}, remove() {} };
25- }
26- appendChild(child) { child.parentElement = this; this.children.push(child); return child; }
27- append(...items) { for (const item of items) if (item instanceof Node_) this.appendChild(item); }
28- remove() { if (this.parentElement) this.parentElement.children = this.parentElement.children.filter((c) => c !== this); }
29- setAttribute(name, value) { this.attrs[name] = value; }
30- closest() { return null; }
31- contains(other) { for (let n = other; n; n = n.parentElement) if (n === this) return true; return false; }
32- getClientRects() { return this.rect ? [this.rect] : []; }
33- getBoundingClientRect() { return this.rect || rect(0, 0, 0, 0); }
34- getRootNode() { return document; }
35- querySelectorAll(selector) {
36- const out = [];
37- const visit = (node) => { for (const c of node.children) { if (c.nodeType === 1) { if (matches(c, selector)) out.push(c); visit(c); } } };
38- visit(this);
39- return out;
40- }
41- focus() { document.activeElement = this; }
42- click() { posted.push({ clicked: this.href }); }
43-}
44-function matches(el, selector) {
45- return selector.split(",").some((part) => {
46- part = part.trim();
47- if (part === "*") return true;
48- if (part === "a[href]") return el.tagName === "A" && el.href;
49- if (part.startsWith("[")) return false;
50- const base = part.replace(/:not\(.*\)$/, "");
51- return el.tagName === base.toUpperCase();
52- });
53-}
54-class Element extends Node_ { constructor(tag) { super(1, tag.toUpperCase()); } }
55-class HTMLElement extends Element {}
56-class HTMLAnchorElement extends HTMLElement { constructor(href) { super("a"); this.href = href; this.target = ""; } }
57-class HTMLInputElement extends HTMLElement {}
58-class HTMLTextAreaElement extends HTMLElement {}
59-class HTMLSelectElement extends HTMLElement {}
60-const Element_ = HTMLElement;
61-class Text_ extends Node_ { constructor(data) { super(3, "#text"); this.data = data; } }
62-const html = new Element_("html");
63-const body = html.appendChild(new Element_("body"));
64-const p = body.appendChild(new Element_("p")); p.rect = rect(10, 10, 300, 20);
65-const text = p.appendChild(new Text_(TEXT));
66-const one = body.appendChild(new HTMLAnchorElement("file:///one.html")); one.rect = rect(10, 50, 40, 20);
67-const two = body.appendChild(new HTMLAnchorElement("https://example.com/two")); two.rect = rect(80, 50, 40, 20);
68-const selection = {
69- anchorNode: null, anchorOffset: 0, focusNode: null, focusOffset: 0, rangeCount: 0,
70- get isCollapsed() { return this.rangeCount === 0 || (this.anchorNode === this.focusNode && this.anchorOffset === this.focusOffset); },
71- setBaseAndExtent(an, ao, fn, fo) { this.anchorNode = an; this.anchorOffset = ao; this.focusNode = fn; this.focusOffset = fo; this.rangeCount = 1; },
72- modify(_alter, direction, granularity) {
73- const step = direction === "forward" ? 1 : -1;
74- if (granularity === "character") this.focusOffset = Math.max(0, Math.min(TEXT.length, this.focusOffset + step));
75- else if (granularity === "word") {
76- let i = this.focusOffset;
77- if (step > 0) { while (i < TEXT.length && TEXT[i] !== " ") i++; while (i < TEXT.length && TEXT[i] === " ") i++; }
78- else { while (i > 0 && TEXT[i - 1] === " ") i--; while (i > 0 && TEXT[i - 1] !== " ") i--; }
79- this.focusOffset = i;
80- } else if (granularity === "lineboundary" || granularity === "documentboundary" || granularity === "line") {
81- this.focusOffset = step > 0 ? TEXT.length : 0;
82- }
83- },
84- removeAllRanges() { this.rangeCount = 0; this.anchorNode = this.focusNode = null; },
85- addRange() { this.rangeCount = 1; },
86- getRangeAt() { return {}; },
87- toString() { return this.rangeCount ? TEXT.slice(Math.min(this.anchorOffset, this.focusOffset), Math.max(this.anchorOffset, this.focusOffset)) : ""; },
88-};
89-global.Node = { TEXT_NODE: 3, ELEMENT_NODE: 1 };
90-global.NodeFilter = { SHOW_ELEMENT: 1, SHOW_TEXT: 4 };
91-global.Element = Element; global.HTMLElement = HTMLElement; global.HTMLAnchorElement = HTMLAnchorElement;
92-global.HTMLInputElement = HTMLInputElement; global.HTMLTextAreaElement = HTMLTextAreaElement; global.HTMLSelectElement = HTMLSelectElement;
93-global.document = {
94- body, documentElement: html, activeElement: body,
95- createElement: (tag) => new HTMLElement(tag),
96- createRange: () => ({ setStart() {}, collapse() {}, selectNodeContents() {}, getBoundingClientRect: () => rect(10, 10, 300, 20) }),
97- createTreeWalker: (root) => {
98- const nodes = []; const visit = (n) => { nodes.push(n); for (const c of n.children) visit(c); }; visit(root);
99- let i = 0; return { currentNode: nodes[0], nextNode: () => nodes[++i] || null };
100- },
101- querySelectorAll: (selector) => html.querySelectorAll(selector),
102- elementFromPoint: (x, y) => [two, one, p].find((el) => el.rect && x >= el.rect.left && x < el.rect.right && y >= el.rect.top && y < el.rect.bottom) || body,
103-};
104-global.window = {
105- innerWidth: 800, innerHeight: 600, scrollBy() {},
106- getSelection: () => selection,
107- getComputedStyle: () => ({ display: "block", visibility: "visible" }),
108- fetch: (url, init) => { posted.push(JSON.parse(Buffer.from(init.headers["X-Dumber-Body"], "base64").toString("latin1"))); return Promise.resolve(); },
109-};
110-global.btoa = (s) => Buffer.from(s, "binary").toString("base64");
111-global.unescape = (s) => decodeURIComponent(s);
112-global.setTimeout = (fn) => { fn(); return 0; };
113-`
114 20
115 // runVimPageRuntime executes the embedded runtime inside the harness, then21 // runVimPageRuntime executes the embedded runtime inside the harness, then
116 // the given driver script, and returns every message posted to the bridge22 // the given driver script, and returns every message posted to the bridge
@@ -130,61 +36,471 @@ func runVimPageRuntime(t *testing.T, driver string) []map[string]any {
130 return posted36 return posted
131 }37 }
132 38
133-func TestVimPageRuntimeVisualExtendsAndYanks(t *testing.T) {39+// named returns the driver snapshot recorded under name.
40+func named(t *testing.T, posted []map[string]any, name string) map[string]any {
41+ t.Helper()
42+ for _, entry := range posted {
43+ if entry["n"] == name {
44+ return entry
45+ }
46+ }
47+ require.FailNow(t, "snapshot not recorded", name)
48+ return nil
49+}
50+
51+// bridgeMessages returns the runtime's own bridge messages, without snapshots.
52+func bridgeMessages(posted []map[string]any) []map[string]any {
53+ var out []map[string]any
54+ for _, entry := range posted {
55+ if _, ok := entry["token"]; ok {
56+ out = append(out, entry)
57+ }
58+ }
59+ return out
60+}
61+
62+func modeSequence(posted []map[string]any) []any {
63+ var out []any
64+ for _, entry := range bridgeMessages(posted) {
65+ if entry["type"] == "mode" {
66+ out = append(out, entry["mode"])
67+ }
68+ }
69+ return out
70+}
71+
72+func TestVimPageRuntimeStartDoesNotEndImmediately(t *testing.T) {
73+ posted := runVimPageRuntime(t, `
74+window.__dumberVimPage.start("tok", { kind: "visual" });
75+window.__dumberVimPage.start("hint", { kind: "hint-follow" });
76+`)
77+ for _, message := range bridgeMessages(posted) {
78+ require.NotEqual(t, "end", message["type"], "starting with visible content never ends the interaction")
79+ }
80+}
81+
82+func TestVimPageRuntimeVisualStartsWithTextAnchorHints(t *testing.T) {
134 posted := runVimPageRuntime(t, `83 posted := runVimPageRuntime(t, `
135-const vp = window.__dumberVimPage;84+window.__dumberVimPage.start("tok", { kind: "visual", color: "#fbbf24" });
136-vp.start("tok", { kind: "visual", color: "#fbbf24" });85+posted.push({ n: "hints", labels: hintLabels(), selection: selection.rangeCount, caret: cursor() !== null });
137-posted.push({ started: selection.toString() });
138-for (const key of ["l", "l", "w"]) vp.key("tok", key);
139-posted.push({ extended: selection.toString() });
140-vp.key("tok", "y");
141-posted.push({ after: selection.rangeCount });
142 `)86 `)
143- require.Len(t, posted, 4)87+ hints := named(t, posted, "hints")
144- require.Equal(t, "A", posted[0]["started"], "visual starts on the first visible character")88+ require.Equal(t, []any{"s", "a", "d"}, hints["labels"], "one label per visible text block, none for text below the fold")
145- require.Equal(t, "Alpha ", posted[1]["extended"], "motions extend the selection")89+ require.EqualValues(t, 0, hints["selection"])
146- require.Equal(t, map[string]any{"token": "tok", "type": "copy", "text": "Alpha "}, posted[2])90+ require.Equal(t, false, hints["caret"], "no caret until an anchor is picked")
147- require.EqualValues(t, 0, posted[3]["after"], "yank clears the selection")91+ require.Equal(t, []any{"hints"}, modeSequence(posted))
148 }92 }
149 93
150-func TestVimPageRuntimeVisualExitsOnVAndEscapeWithoutCopy(t *testing.T) {94+func TestVimPageRuntimeTextAnchorLabelPlacesCaret(t *testing.T) {
151- for _, key := range []string{"v", "<Escape>"} {95+ posted := runVimPageRuntime(t, `
152- posted := runVimPageRuntime(t, `96+startCaret("tok", "a");
153-const vp = window.__dumberVimPage;97+S("caret", { hints: hintLabels(), cursor: cursor() && cursor().style });
154-vp.start("tok", { kind: "visual" });
155-vp.key("tok", "l");
156-vp.key("tok", `+jsString(key)+`);
157 `)98 `)
158- require.Len(t, posted, 1, "key %s", key)99+ caret := named(t, posted, "caret")
159- require.Equal(t, "end", posted[0]["type"], "key %s", key)100+ require.Empty(t, caret["text"])
101+ require.Equal(t, "1:0", caret["anchor"], "the label puts the caret at the start of that text")
102+ require.Equal(t, "1:0", caret["focus"])
103+ require.Empty(t, caret["hints"], "the hint overlay is gone once the caret is placed")
104+ style := caret["cursor"].(map[string]any)
105+ require.Equal(t, "fixed", style["position"])
106+ require.Equal(t, "#fbbf24", style["background"], "the caret uses the accent color")
107+ require.Equal(t, "none", style["pointer-events"])
108+ require.Equal(t, "2147483647", style["z-index"])
109+ require.Equal(t, "8px", style["left"])
110+ require.Equal(t, "37px", style["top"], "the caret is taller than the line, centered on it")
111+ require.Equal(t, "26px", style["height"])
112+ require.Equal(t, "4px", style["width"])
113+ require.Equal(t, []any{"hints", "caret"}, modeSequence(posted))
114+ require.Empty(t, bridgeMessagesOfType(posted, "end"))
115+}
116+
117+func bridgeMessagesOfType(posted []map[string]any, messageType string) []map[string]any {
118+ var out []map[string]any
119+ for _, message := range bridgeMessages(posted) {
120+ if message["type"] == messageType {
121+ out = append(out, message)
122+ }
160 }123 }
124+ return out
161 }125 }
162 126
163-func TestVimPageRuntimeStartDoesNotEndImmediately(t *testing.T) {127+func TestVimPageRuntimeEscapeInTextAnchorHintsPicksFirstLongText(t *testing.T) {
164 posted := runVimPageRuntime(t, `128 posted := runVimPageRuntime(t, `
165 window.__dumberVimPage.start("tok", { kind: "visual" });129 window.__dumberVimPage.start("tok", { kind: "visual" });
166-window.__dumberVimPage.start("hint", { kind: "hint-follow" });130+vp().key("tok", "<Escape>");
131+S("caret");
132+`)
133+ caret := named(t, posted, "caret")
134+ require.Equal(t, "1:0", caret["focus"], "the first visible text of at least 50 characters is chosen over short banners")
135+ require.Equal(t, []any{"hints", "caret"}, modeSequence(posted))
136+}
137+
138+func TestVimPageRuntimeEscapeInTextAnchorHintsFallsBackToAnyVisibleText(t *testing.T) {
139+ posted := runVimPageRuntime(t, `
140+second.text.data = "short";
141+window.__dumberVimPage.start("tok", { kind: "visual" });
142+vp().key("tok", "<Escape>");
143+S("caret");
144+`)
145+ require.Equal(t, "0:0", named(t, posted, "caret")["focus"])
146+}
147+
148+func TestVimPageRuntimeVisualEndsWithReasonWhenNoVisibleText(t *testing.T) {
149+ posted := runVimPageRuntime(t, `
150+for (const t of texts) t.parentElement.rect = rect(10, 5000, 600, 20);
151+window.__dumberVimPage.start("tok", { kind: "visual" });
152+`)
153+ require.Equal(t, []map[string]any{{"token": "tok", "type": "end", "reason": "no-visible-text"}}, bridgeMessages(posted))
154+}
155+
156+func TestVimPageRuntimeEscapeFallbackEndsWhenNothingVisible(t *testing.T) {
157+ posted := runVimPageRuntime(t, `
158+window.__dumberVimPage.start("tok", { kind: "visual" });
159+for (const t of texts) t.parentElement.rect = rect(10, 5000, 600, 20);
160+vp().key("tok", "<Escape>");
161+`)
162+ ends := bridgeMessagesOfType(posted, "end")
163+ require.Len(t, ends, 1)
164+ require.Equal(t, "no-visible-text", ends[0]["reason"])
165+}
166+
167+func TestVimPageRuntimeVisibleSelectionStartsVisualDirectly(t *testing.T) {
168+ posted := runVimPageRuntime(t, `
169+selection.setBaseAndExtent(first.text, 0, first.text, 5);
170+window.__dumberVimPage.start("tok", { kind: "visual" });
171+S("start", { hints: hintLabels(), cursor: cursor() !== null });
172+vp().key("tok", "l");
173+S("extended");
167 `)174 `)
168- require.Empty(t, posted, "starting an interaction with visible content posts nothing until a key ends it")175+ start := named(t, posted, "start")
176+ require.Equal(t, "Alpha", start["text"], "an existing visible selection is used as is")
177+ require.Empty(t, start["hints"])
178+ require.Equal(t, true, start["cursor"])
179+ require.Equal(t, "Alpha ", named(t, posted, "extended")["text"])
180+ require.Equal(t, []any{"visual"}, modeSequence(posted))
181+}
182+
183+func TestVimPageRuntimeSelectionOutsideViewportIsIgnored(t *testing.T) {
184+ posted := runVimPageRuntime(t, `
185+selection.setBaseAndExtent(far.text, 0, far.text, 3);
186+window.__dumberVimPage.start("tok", { kind: "visual" });
187+posted.push({ n: "hints", labels: hintLabels() });
188+`)
189+ require.Equal(t, []any{"s", "a", "d"}, named(t, posted, "hints")["labels"])
190+ require.Equal(t, []any{"hints"}, modeSequence(posted))
191+}
192+
193+func TestVimPageRuntimeCaretMotionsMoveWithoutSelecting(t *testing.T) {
194+ tests := []struct {
195+ name string
196+ start string // hint label
197+ keys []string
198+ want string // focus
199+ }{
200+ {"l", "s", []string{"l", "l"}, "0:2"},
201+ {"counted l", "s", []string{"3", "l"}, "0:3"},
202+ {"h clamps", "s", []string{"l", "h", "h"}, "0:0"},
203+ {"w", "s", []string{"w"}, "0:6"},
204+ {"2w", "s", []string{"2", "w"}, "0:12"},
205+ {"w crosses line", "s", []string{"3", "w"}, "1:0"},
206+ {"e", "s", []string{"e"}, "0:5"},
207+ {"b", "s", []string{"w", "w", "b"}, "0:6"},
208+ {"b at start", "s", []string{"b"}, "0:0"},
209+ {"$", "s", []string{"$"}, "0:19"},
210+ {"0", "s", []string{"$", "0"}, "0:0"},
211+ {"^", "s", []string{"$", "^"}, "0:0"},
212+ {"j", "s", []string{"l", "l", "j"}, "1:2"},
213+ {"k", "a", []string{"l", "k"}, "0:1"},
214+ {"G", "s", []string{"G"}, "3:18"},
215+ {"gg", "a", []string{"G", "g", "g"}, "0:0"},
216+ {"}", "s", []string{"}"}, "0:19"},
217+ {"{", "a", []string{"l", "{"}, "1:0"},
218+ {")", "s", []string{")"}, "0:19"},
219+ {"(", "s", []string{"$", "("}, "0:0"},
220+ }
221+ for _, tt := range tests {
222+ t.Run(tt.name, func(t *testing.T) {
223+ keys, err := json.Marshal(tt.keys)
224+ require.NoError(t, err)
225+ posted := runVimPageRuntime(t, `
226+startCaret("tok", `+jsString(tt.start)+`);
227+keys("tok", ...`+string(keys)+`);
228+S("end");
229+`)
230+ end := named(t, posted, "end")
231+ require.Equal(t, tt.want, end["focus"])
232+ require.Equal(t, end["focus"], end["anchor"], "the caret stays collapsed")
233+ require.Empty(t, end["text"])
234+ require.Empty(t, bridgeMessagesOfType(posted, "end"))
235+ })
236+ }
237+}
238+
239+func TestVimPageRuntimeWordMotionsSkipPunctuation(t *testing.T) {
240+ posted := runVimPageRuntime(t, `
241+startCaret("tok", "d");
242+keys("tok", "w");
243+S("w1");
244+keys("tok", "w");
245+S("w2");
246+keys("tok", "b");
247+S("b1");
248+`)
249+ // "Third line, end.": w from "Third" -> "line"; w -> "end" (the comma and
250+ // space are skipped); b -> back to "line".
251+ require.Equal(t, "2:6", named(t, posted, "w1")["focus"])
252+ require.Equal(t, "2:12", named(t, posted, "w2")["focus"])
253+ require.Equal(t, "2:6", named(t, posted, "b1")["focus"])
254+}
255+
256+func TestVimPageRuntimeCaretMotionScrollsFocusIntoView(t *testing.T) {
257+ posted := runVimPageRuntime(t, `
258+startCaret("tok", "s");
259+scrolls.length = 0;
260+keys("tok", "G");
261+posted.push({ n: "scrolled", scrolls });
262+`)
263+ scrolled := named(t, posted, "scrolled")["scrolls"].([]any)
264+ require.NotEmpty(t, scrolled, "moving the caret below the fold scrolls it into view")
265+ require.Greater(t, scrolled[0].([]any)[1].(float64), float64(1000))
266+}
267+
268+func TestVimPageRuntimeCaretFollowsScrollAndIsRemovedOnEnd(t *testing.T) {
269+ posted := runVimPageRuntime(t, `
270+startCaret("tok", "s");
271+const bar = cursor();
272+first.p.rect = rect(10, -300, 600, 20); // the page scrolled the paragraph away
273+fire("scroll");
274+const hiddenWhileOffscreen = bar.style.display;
275+first.p.rect = rect(10, 100, 600, 20);
276+fire("scroll");
277+posted.push({ n: "follow", hidden: hiddenWhileOffscreen, top: bar.style.top, display: bar.style.display });
278+keys("tok", "<Escape>");
279+posted.push({ n: "cleanup", caret: cursor() === null, styles: overlays("data-dumber-vim-visual").length, scrollListeners: (listeners.scroll || []).length, resize: (listeners.resize || []).length, selection: selection.rangeCount });
280+`)
281+ follow := named(t, posted, "follow")
282+ require.Equal(t, "none", follow["hidden"], "the caret hides while its text is off screen")
283+ require.Equal(t, "97px", follow["top"], "the caret follows its text when the page scrolls")
284+ require.Empty(t, follow["display"])
285+ cleanup := named(t, posted, "cleanup")
286+ require.Equal(t, true, cleanup["caret"], "the caret overlay is removed on cleanup")
287+ require.EqualValues(t, 0, cleanup["styles"])
288+ require.EqualValues(t, 0, cleanup["scrollListeners"])
289+ require.EqualValues(t, 0, cleanup["resize"])
290+ require.EqualValues(t, 0, cleanup["selection"])
291+}
292+
293+func TestVimPageRuntimeVisualFromCaretExtendsAndYankReturnsToCaret(t *testing.T) {
294+ posted := runVimPageRuntime(t, `
295+startCaret("tok", "s");
296+keys("tok", "v");
297+S("v");
298+keys("tok", "l", "l");
299+S("extended");
300+keys("tok", "w");
301+S("word");
302+keys("tok", "y");
303+S("after");
304+keys("tok", "l");
305+S("moved");
306+keys("tok", "y");
307+S("caretYank");
308+keys("tok", "v", "e");
309+S("second");
310+keys("tok", "<Return>");
311+S("afterReturn");
312+`)
313+ require.Equal(t, "A", named(t, posted, "v")["text"], "v starts charwise from the caret")
314+ require.Equal(t, "Alp", named(t, posted, "extended")["text"])
315+ require.Equal(t, "Alpha ", named(t, posted, "word")["text"])
316+
317+ after := named(t, posted, "after")
318+ require.Empty(t, after["text"], "y returns to a collapsed caret")
319+ require.Equal(t, "0:6", after["focus"], "at the focus end of the selection")
320+ require.Equal(t, after["focus"], after["anchor"])
321+ require.Equal(t, "0:7", named(t, posted, "moved")["focus"], "the caret keeps moving after a yank")
322+ require.Equal(t, "0:7", named(t, posted, "caretYank")["focus"], "y in caret mode does nothing")
323+
324+ copies := bridgeMessagesOfType(posted, "copy")
325+ require.Len(t, copies, 2, "y in caret mode copies nothing; Enter chains a second copy")
326+ require.Equal(t, map[string]any{"token": "tok", "type": "copy", "text": "Alpha "}, copies[0])
327+ require.Equal(t, "ravo", copies[1]["text"], "v includes the character under the caret and e extends to the word end")
328+ require.Empty(t, named(t, posted, "afterReturn")["text"])
329+ require.Empty(t, bridgeMessagesOfType(posted, "end"), "copying never ends the interaction")
330+ require.Equal(t, []any{"hints", "caret", "visual", "caret", "visual", "caret"}, modeSequence(posted))
331+}
332+
333+func TestVimPageRuntimeVisualOSwapsEnds(t *testing.T) {
334+ posted := runVimPageRuntime(t, `
335+startCaret("tok", "s");
336+keys("tok", "l", "l", "l", "v");
337+S("before");
338+keys("tok", "o");
339+S("swapped");
340+keys("tok", "h", "h");
341+S("shrunk");
342+keys("tok", "o", "l");
343+S("back");
344+`)
345+ before := named(t, posted, "before")
346+ require.Equal(t, "0:3", before["anchor"])
347+ require.Equal(t, "0:4", before["focus"])
348+ swapped := named(t, posted, "swapped")
349+ require.Equal(t, "0:4", swapped["anchor"])
350+ require.Equal(t, "0:3", swapped["focus"])
351+ require.Equal(t, "0:4", named(t, posted, "shrunk")["anchor"])
352+ require.Equal(t, "0:1", named(t, posted, "shrunk")["focus"])
353+ require.Equal(t, "lph", named(t, posted, "shrunk")["text"])
354+ back := named(t, posted, "back")
355+ require.Equal(t, "0:1", back["anchor"])
356+ require.Equal(t, "0:5", back["focus"], "after o the motions extend the other end")
357+}
358+
359+func TestVimPageRuntimeVisualLine(t *testing.T) {
360+ posted := runVimPageRuntime(t, `
361+startCaret("tok", "a");
362+keys("tok", "l", "l");
363+keys("tok", "V");
364+S("line");
365+keys("tok", "j");
366+S("down");
367+keys("tok", "k", "k");
368+S("up");
369+keys("tok", "o");
370+S("swapped");
371+keys("tok", "y");
372+S("yanked");
373+`)
374+ require.Equal(t, "The quick brown fox jumps over the lazy dog near the river", named(t, posted, "line")["text"], "V selects the whole line")
375+ require.Equal(t, "The quick brown fox jumps over the lazy dog near the river\nThird line, end.", named(t, posted, "down")["text"])
376+ up := named(t, posted, "up")
377+ require.Equal(t, "Alpha bravo charlie\nThe quick brown fox jumps over the lazy dog near the river", up["text"], "motions extend by whole lines")
378+ require.Equal(t, "1:58", up["anchor"])
379+ require.Equal(t, "0:0", up["focus"])
380+ require.Equal(t, "0:0", named(t, posted, "swapped")["anchor"], "o swaps the ends")
381+ yanked := named(t, posted, "yanked")
382+ require.Empty(t, yanked["text"])
383+ require.Equal(t, yanked["focus"], yanked["anchor"], "y returns to a caret at the focus")
384+ copies := bridgeMessagesOfType(posted, "copy")
385+ require.Len(t, copies, 1)
386+ require.Equal(t, "Alpha bravo charlie\nThe quick brown fox jumps over the lazy dog near the river", copies[0]["text"])
387+ require.Equal(t, []any{"hints", "caret", "visual-line", "caret"}, modeSequence(posted))
388+}
389+
390+func TestVimPageRuntimeModeSwitchingKeys(t *testing.T) {
391+ posted := runVimPageRuntime(t, `
392+startCaret("tok", "s");
393+keys("tok", "l", "l");
394+keys("tok", "v", "l");
395+S("visual");
396+keys("tok", "V");
397+S("line");
398+keys("tok", "v");
399+S("visualAgain");
400+keys("tok", "V");
401+keys("tok", "V");
402+S("caretFromLine");
403+keys("tok", "v");
404+keys("tok", "v");
405+S("caretFromVisual");
406+keys("tok", "v", "l", "<Escape>");
407+S("escapeVisual");
408+keys("tok", "V", "<Escape>");
409+S("escapeLine");
410+`)
411+ require.Equal(t, "ph", named(t, posted, "visual")["text"], "v includes the character under the caret, then l extends")
412+ require.Equal(t, "Alpha bravo charlie", named(t, posted, "line")["text"])
413+ require.Equal(t, "Alpha bravo charlie", named(t, posted, "visualAgain")["text"], "v in visual line switches to charwise without changing the range")
414+ require.Empty(t, named(t, posted, "caretFromLine")["text"], "V in visual line goes back to caret")
415+ require.Empty(t, named(t, posted, "caretFromVisual")["text"], "v in visual goes back to caret")
416+ require.Empty(t, named(t, posted, "escapeVisual")["text"], "Escape in visual goes back to caret")
417+ require.Empty(t, named(t, posted, "escapeLine")["text"], "Escape in visual line goes back to caret")
418+ require.Empty(t, bridgeMessagesOfType(posted, "end"), "none of these end the interaction")
419+ require.Equal(t, []any{
420+ "hints", "caret", "visual", "visual-line", "visual", "visual-line", "caret",
421+ "visual", "caret", "visual", "caret", "visual-line", "caret",
422+ }, modeSequence(posted))
423+}
424+
425+func TestVimPageRuntimeEscapeInCaretEndsInteraction(t *testing.T) {
426+ posted := runVimPageRuntime(t, `
427+startCaret("tok", "s");
428+keys("tok", "l", "<Escape>");
429+posted.push({ n: "after", selection: selection.rangeCount, caret: cursor() === null });
430+`)
431+ require.Equal(t, []map[string]any{{"token": "tok", "type": "end"}}, bridgeMessagesOfType(posted, "end"))
432+ after := named(t, posted, "after")
433+ require.EqualValues(t, 0, after["selection"])
434+ require.Equal(t, true, after["caret"])
435+}
436+
437+func TestVimPageRuntimeCancelRemovesCaretAndSelection(t *testing.T) {
438+ posted := runVimPageRuntime(t, `
439+startCaret("tok", "s");
440+keys("tok", "v", "l");
441+vp().cancel("tok");
442+posted.push({ n: "after", selection: selection.rangeCount, caret: cursor() === null, styles: overlays("data-dumber-vim-visual").length });
443+`)
444+ after := named(t, posted, "after")
445+ require.EqualValues(t, 0, after["selection"])
446+ require.Equal(t, true, after["caret"])
447+ require.EqualValues(t, 0, after["styles"])
448+ require.Empty(t, bridgeMessagesOfType(posted, "end"), "cancel is Go-initiated and reports nothing")
449+}
450+
451+func TestVimPageRuntimeCancelRemovesHintOverlay(t *testing.T) {
452+ posted := runVimPageRuntime(t, `
453+window.__dumberVimPage.start("tok", { kind: "visual" });
454+vp().cancel("tok");
455+posted.push({ n: "after", labels: hintLabels() });
456+`)
457+ require.Empty(t, named(t, posted, "after")["labels"])
458+}
459+
460+func TestVimPageRuntimeKeyErrorReleasesCapture(t *testing.T) {
461+ posted := runVimPageRuntime(t, `
462+startCaret("tok", "s");
463+selection.modify = () => { throw new Error("page quirk"); };
464+keys("tok", "l");
465+`)
466+ ends := bridgeMessagesOfType(posted, "end")
467+ require.Len(t, ends, 1, "a failing motion must not leave the keyboard captured")
468+ require.Equal(t, "error", ends[0]["reason"])
169 }469 }
170 470
171 func TestVimPageRuntimeHintsFollowAndYank(t *testing.T) {471 func TestVimPageRuntimeHintsFollowAndYank(t *testing.T) {
172 posted := runVimPageRuntime(t, `472 posted := runVimPageRuntime(t, `
173-const vp = window.__dumberVimPage;473+const vpage = window.__dumberVimPage;
174-vp.start("a", { kind: "hint-follow" });474+vpage.start("a", { kind: "hint-follow" });
175-posted.push({ labels: html.querySelectorAll("span").map((s) => s.textContent) });475+posted.push({ n: "labels", labels: hintLabels() });
176-vp.key("a", "a");476+vpage.key("a", "a");
177-vp.start("b", { kind: "hint-yank-url" });477+vpage.start("b", { kind: "hint-yank-url" });
178-vp.key("b", "s");478+vpage.key("b", "s");
179-vp.start("c", { kind: "hint-follow-new" });479+vpage.start("c", { kind: "hint-follow-new" });
180-vp.key("c", "a");480+vpage.key("c", "a");
481+vpage.start("d", { kind: "hint-follow" });
482+vpage.key("d", "<Escape>");
181 `)483 `)
182- require.Len(t, posted, 5)484+ require.ElementsMatch(t, []any{"s", "a"}, named(t, posted, "labels")["labels"], "two visible links get one-letter labels")
183- require.ElementsMatch(t, []any{"s", "a"}, posted[0]["labels"], "two visible links get one-letter labels")485+ require.Equal(t, "https://example.com/two", named2(posted, "clicked"), "follow clicks the chosen link")
184- require.Equal(t, "https://example.com/two", posted[1]["clicked"], "follow clicks the chosen link")486+ messages := bridgeMessages(posted)
185- require.Equal(t, "end", posted[2]["type"])487+ require.Equal(t, []map[string]any{
186- require.Equal(t, map[string]any{"token": "b", "type": "copy", "text": "file:///one.html"}, posted[3])488+ {"token": "a", "type": "end"},
187- require.Equal(t, map[string]any{"token": "c", "type": "open-new", "url": "https://example.com/two"}, posted[4])489+ {"token": "b", "type": "copy", "text": "file:///one.html"},
490+ {"token": "c", "type": "open-new", "url": "https://example.com/two"},
491+ {"token": "d", "type": "end"},
492+ }, messages)
493+ require.NotContains(t, modeSequence(posted), "caret", "link hints never report caret modes")
494+}
495+
496+// named2 returns a field recorded by a harness side effect (such as a click).
497+func named2(posted []map[string]any, key string) any {
498+ for _, entry := range posted {
499+ if value, ok := entry[key]; ok {
500+ return value
501+ }
502+ }
503+ return nil
188 }504 }
189 505
190 func TestVimPageRuntimeYankParagraph(t *testing.T) {506 func TestVimPageRuntimeYankParagraph(t *testing.T) {
@@ -192,19 +508,18 @@ func TestVimPageRuntimeYankParagraph(t *testing.T) {
192 p.innerText = TEXT;508 p.innerText = TEXT;
193 window.__dumberVimPage.start("tok", { kind: "yank", object: "paragraph" });509 window.__dumberVimPage.start("tok", { kind: "yank", object: "paragraph" });
194 `)510 `)
195- require.Len(t, posted, 1)511+ messages := bridgeMessages(posted)
196- require.Equal(t, "copy", posted[0]["type"])512+ require.Len(t, messages, 1)
197- require.True(t, strings.HasPrefix(posted[0]["text"].(string), "Alpha"))513+ require.Equal(t, "copy", messages[0]["type"])
514+ require.True(t, strings.HasPrefix(messages[0]["text"].(string), "Alpha"))
198 }515 }
199 516
200 func TestVimPageRuntimeStaleTokenKeyReleasesCapture(t *testing.T) {517 func TestVimPageRuntimeStaleTokenKeyReleasesCapture(t *testing.T) {
201 posted := runVimPageRuntime(t, `518 posted := runVimPageRuntime(t, `
202-const vp = window.__dumberVimPage;519+startCaret("live", "s");
203-vp.start("live", { kind: "visual" });520+vp().key("stale", "j");
204-vp.key("stale", "j");521+posted.push({ n: "live", selection: selection.rangeCount });
205-posted.push({ liveSelection: selection.rangeCount });
206 `)522 `)
207- require.Len(t, posted, 2)523+ require.Equal(t, []map[string]any{{"token": "stale", "type": "end"}}, bridgeMessagesOfType(posted, "end"))
208- require.Equal(t, map[string]any{"token": "stale", "type": "end"}, posted[0])524+ require.EqualValues(t, 1, named(t, posted, "live")["selection"], "a stale key must not disturb the live interaction")
209- require.EqualValues(t, 1, posted[1]["liveSelection"], "a stale key must not disturb the live interaction")
210 }525 }
M internal/infrastructure/cef/vim_page_test.go +55 -0
@@ -89,6 +89,10 @@ func TestDecodeVimPageBridgePayload(t *testing.T) {
8989 {"open", `{"type":"open-new","token":"t","url":"https://example.com/a"}`, false},
9090 {"open empty", `{"type":"open-new","token":"t"}`, true},
9191 {"end", `{"type":"end","token":"t"}`, false},
92+ {"mode caret", `{"type":"mode","token":"t","mode":"caret"}`, false},
93+ {"mode visual line", `{"type":"mode","token":"t","mode":"visual-line"}`, false},
94+ {"mode unknown", `{"type":"mode","token":"t","mode":"insert"}`, true},
95+ {"mode missing", `{"type":"mode","token":"t"}`, true},
9296 {"unknown", `{"type":"eval","token":"t"}`, true},
9397 {"malformed", `{`, true},
9498 }
@@ -120,6 +124,57 @@ func newVimResultWebView(opened *string, ended *int) *WebView {
120124 }}
121125 }
122126
127+// Mode messages and copies of the caret/visual flow are not terminal: the
128+// token stays armed so the user can keep moving and chain copies, and the UI
129+// is told about each sub-mode without being asked to release key capture.
130+func TestWebViewHandleVimPageResultVisualFlowStaysArmedAcrossModeAndCopy(t *testing.T) {
131+ var opened string
132+ ended := 0
133+ var modes []dto.VimPageMode
134+ wv := newVimResultWebView(&opened, &ended)
135+ wv.callbacks.OnVimPageModeChanged = func(mode dto.VimPageMode) { modes = append(modes, mode) }
136+ wv.vimPageToken, wv.vimPageKind = "tok", dto.VimPageVisual
137+
138+ wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageMode, Token: "tok", Mode: "caret"})
139+ wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageMode, Token: "tok", Mode: "visual-line"})
140+ wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageCopy, Token: "tok", Text: "copied"})
141+
142+ assert.Equal(t, []dto.VimPageMode{dto.VimPageModeCaret, dto.VimPageModeVisualLine}, modes)
143+ assert.Zero(t, ended, "mode and copy must not end the interaction")
144+ assert.Equal(t, "tok", wv.currentVimPageToken())
145+
146+ wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageEnd, Token: "tok"})
147+ assert.Equal(t, 1, ended)
148+ assert.Empty(t, wv.currentVimPageToken())
149+}
150+
151+func TestWebViewHandleVimPageResultCopyEndsHintYankAndYankObject(t *testing.T) {
152+ for _, kind := range []dto.VimPageInteractionKind{dto.VimPageHintYankURL, dto.VimPageYankObject} {
153+ var opened string
154+ ended := 0
155+ wv := newVimResultWebView(&opened, &ended)
156+ wv.vimPageToken, wv.vimPageKind = "tok", kind
157+
158+ wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageCopy, Token: "tok", Text: "x"})
159+
160+ assert.Empty(t, wv.currentVimPageToken(), "kind %d: copy stays terminal", kind)
161+ assert.Equal(t, map[bool]int{true: 1, false: 0}[kind.CapturesKeys()], ended, "kind %d", kind)
162+ }
163+}
164+
165+func TestWebViewHandleVimPageResultModeRejectedForLinkHints(t *testing.T) {
166+ var opened string
167+ ended := 0
168+ modes := 0
169+ wv := newVimResultWebView(&opened, &ended)
170+ wv.callbacks.OnVimPageModeChanged = func(dto.VimPageMode) { modes++ }
171+ wv.vimPageToken, wv.vimPageKind = "tok", dto.VimPageHintFollow
172+
173+ wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageMode, Token: "tok", Mode: "caret"})
174+
175+ assert.Zero(t, modes, "hint interactions never report sub-modes")
176+}
177+
123178 func TestWebViewHandleVimPageResultAcceptsArmedTokenOnce(t *testing.T) {
124179 var opened string
125180 ended := 0
@@ -89,6 +89,10 @@ func TestDecodeVimPageBridgePayload(t *testing.T) {
89 {"open", `{"type":"open-new","token":"t","url":"https://example.com/a"}`, false},89 {"open", `{"type":"open-new","token":"t","url":"https://example.com/a"}`, false},
90 {"open empty", `{"type":"open-new","token":"t"}`, true},90 {"open empty", `{"type":"open-new","token":"t"}`, true},
91 {"end", `{"type":"end","token":"t"}`, false},91 {"end", `{"type":"end","token":"t"}`, false},
92+ {"mode caret", `{"type":"mode","token":"t","mode":"caret"}`, false},
93+ {"mode visual line", `{"type":"mode","token":"t","mode":"visual-line"}`, false},
94+ {"mode unknown", `{"type":"mode","token":"t","mode":"insert"}`, true},
95+ {"mode missing", `{"type":"mode","token":"t"}`, true},
92 {"unknown", `{"type":"eval","token":"t"}`, true},96 {"unknown", `{"type":"eval","token":"t"}`, true},
93 {"malformed", `{`, true},97 {"malformed", `{`, true},
94 }98 }
@@ -120,6 +124,57 @@ func newVimResultWebView(opened *string, ended *int) *WebView {
120 }}124 }}
121 }125 }
122 126
127+// Mode messages and copies of the caret/visual flow are not terminal: the
128+// token stays armed so the user can keep moving and chain copies, and the UI
129+// is told about each sub-mode without being asked to release key capture.
130+func TestWebViewHandleVimPageResultVisualFlowStaysArmedAcrossModeAndCopy(t *testing.T) {
131+ var opened string
132+ ended := 0
133+ var modes []dto.VimPageMode
134+ wv := newVimResultWebView(&opened, &ended)
135+ wv.callbacks.OnVimPageModeChanged = func(mode dto.VimPageMode) { modes = append(modes, mode) }
136+ wv.vimPageToken, wv.vimPageKind = "tok", dto.VimPageVisual
137+
138+ wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageMode, Token: "tok", Mode: "caret"})
139+ wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageMode, Token: "tok", Mode: "visual-line"})
140+ wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageCopy, Token: "tok", Text: "copied"})
141+
142+ assert.Equal(t, []dto.VimPageMode{dto.VimPageModeCaret, dto.VimPageModeVisualLine}, modes)
143+ assert.Zero(t, ended, "mode and copy must not end the interaction")
144+ assert.Equal(t, "tok", wv.currentVimPageToken())
145+
146+ wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageEnd, Token: "tok"})
147+ assert.Equal(t, 1, ended)
148+ assert.Empty(t, wv.currentVimPageToken())
149+}
150+
151+func TestWebViewHandleVimPageResultCopyEndsHintYankAndYankObject(t *testing.T) {
152+ for _, kind := range []dto.VimPageInteractionKind{dto.VimPageHintYankURL, dto.VimPageYankObject} {
153+ var opened string
154+ ended := 0
155+ wv := newVimResultWebView(&opened, &ended)
156+ wv.vimPageToken, wv.vimPageKind = "tok", kind
157+
158+ wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageCopy, Token: "tok", Text: "x"})
159+
160+ assert.Empty(t, wv.currentVimPageToken(), "kind %d: copy stays terminal", kind)
161+ assert.Equal(t, map[bool]int{true: 1, false: 0}[kind.CapturesKeys()], ended, "kind %d", kind)
162+ }
163+}
164+
165+func TestWebViewHandleVimPageResultModeRejectedForLinkHints(t *testing.T) {
166+ var opened string
167+ ended := 0
168+ modes := 0
169+ wv := newVimResultWebView(&opened, &ended)
170+ wv.callbacks.OnVimPageModeChanged = func(dto.VimPageMode) { modes++ }
171+ wv.vimPageToken, wv.vimPageKind = "tok", dto.VimPageHintFollow
172+
173+ wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageMode, Token: "tok", Mode: "caret"})
174+
175+ assert.Zero(t, modes, "hint interactions never report sub-modes")
176+}
177+
123 func TestWebViewHandleVimPageResultAcceptsArmedTokenOnce(t *testing.T) {178 func TestWebViewHandleVimPageResultAcceptsArmedTokenOnce(t *testing.T) {
124 var opened string179 var opened string
125 ended := 0180 ended := 0

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