detached c69f26a

fix(cef): stop the vim page runtime caret drifting and tighten its hot paths

Return the caret to the last selected character when visual mode ends so
v then Escape no longer moves it right. Compare focus positions instead of
reading the whole selection per motion step, skip off-screen elements
before measuring them, find the Escape fallback anchor in one pass and
repaint the caret once per animation frame. Guard start and cancel like
key, clean up on pagehide, resolve SVG link hrefs and click them without
click(), and keep shadow DOM text out of caret anchors. The runtime
harness decodes UTF-8 and the tests assert native modify() calls.
commit
c69f26a32ced22e7e2d4d4aa3384ee98b0a2d4a2
author
brice · · 2026-10-04 16:11 UTC
committer
brice · · 2026-10-10 08:27 UTC
parent
1d8d6c0
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +477 -111
M internal/infrastructure/cef/testdata/vim_page_harness.js +40 -15
@@ -5,6 +5,12 @@
55 // runtime must implement word motions itself (Vimium #1441).
66 const posted = [];
77 const scrolls = [];
8+const modifyCalls = [];
9+const frames = new Map();
10+let nextFrame = 1;
11+let toStringCalls = 0;
12+let closestCalls = 0;
13+let rangeRectCalls = 0;
814 const listeners = {};
915 const CHAR_WIDTH = 10;
1016 const LINE_HEIGHT = 20;
@@ -24,7 +30,7 @@ class Node_ {
2430 append(...items) { for (const item of items) if (item instanceof Node_) this.appendChild(item); }
2531 remove() { if (this.parentElement) this.parentElement.children = this.parentElement.children.filter((c) => c !== this); this.parentElement = null; }
2632 setAttribute(name, value) { this.attrs[name] = value; }
27- closest() { return null; }
33+ closest() { closestCalls++; return null; }
2834 contains(other) { for (let n = other; n; n = n.parentElement) if (n === this) return true; return false; }
2935 getClientRects() { return this.rect ? [this.rect] : []; }
3036 getBoundingClientRect() { return this.rect || rect(0, 0, 0, 0); }
@@ -37,6 +43,7 @@ class Node_ {
3743 }
3844 focus() { document.activeElement = this; }
3945 click() { posted.push({ clicked: this.href }); }
46+ dispatchEvent(event) { posted.push({ dispatched: event.type, bubbles: event.bubbles, cancelable: event.cancelable, target: this.tagName }); return true; }
4047 }
4148 function matches(el, selector) {
4249 return selector.split(",").some((part) => {
@@ -51,6 +58,11 @@ function matches(el, selector) {
5158 class Element extends Node_ { constructor(tag) { super(1, tag.toUpperCase()); } }
5259 class HTMLElement extends Element {}
5360 class HTMLAnchorElement extends HTMLElement { constructor(href) { super("a"); this.href = href; this.target = ""; } }
61+// SVGAElement_ models an SVG <a>: href is an SVGAnimatedString, there is no
62+// click(), and the link is not an HTMLAnchorElement.
63+class SVGAElement_ extends Element {
64+ constructor(href) { super("a"); this.href = { baseVal: href }; this.target = { baseVal: "" }; this.click = undefined; }
65+}
5466 class HTMLInputElement extends HTMLElement {}
5567 class HTMLTextAreaElement extends HTMLElement {}
5668 class HTMLSelectElement extends HTMLElement {}
@@ -71,10 +83,20 @@ const first = paragraph("Alpha bravo charlie", 10);
7183 const second = paragraph(LONG, 40);
7284 const third = paragraph("Third line, end.", 70);
7385 const far = paragraph("Far below the fold", 2000);
74-const p = first.p; // legacy name used by the yank test
7586 const one = body.appendChild(new HTMLAnchorElement("file:///one.html")); one.rect = rect(10, 120, 40, 20);
7687 const two = body.appendChild(new HTMLAnchorElement("https://example.com/two")); two.rect = rect(80, 120, 40, 20);
77-const TEXT = first.text.data;
88+// shadowParagraph adds a host whose paragraph lives in an open shadow root;
89+// its text node joins the selection model at the end of the document order.
90+function shadowParagraph(data, top) {
91+ const host = body.appendChild(new HTMLElement("div"));
92+ const p = new HTMLElement("p");
93+ p.rect = rect(10, top, 600, LINE_HEIGHT);
94+ p.parentElement = null;
95+ const text = p.appendChild(new Text_(data));
96+ texts.push(text);
97+ host.shadowRoot = { children: [p], querySelectorAll: (selector) => Node_.prototype.querySelectorAll.call({ children: [p] }, selector), getRootNode: () => document };
98+ return { host, p, text };
99+}
78100
79101 const index = (node) => texts.indexOf(node);
80102 const order = (node, offset) => index(node) * 100000 + offset;
@@ -92,6 +114,7 @@ class Range_ {
92114 collapse(toStart) { if (toStart) { this.endContainer = this.startContainer; this.endOffset = this.startOffset; } else { this.startContainer = this.endContainer; this.startOffset = this.endOffset; } }
93115 selectNodeContents(node) { this.startContainer = this.endContainer = node; this.startOffset = 0; this.endOffset = node.data ? node.data.length : 0; }
94116 getClientRects() {
117+ rangeRectCalls++;
95118 const node = this.startContainer;
96119 const box = node && node.parentElement && node.parentElement.rect;
97120 if (!box) return [];
@@ -107,8 +130,8 @@ const selection = {
107130 get isCollapsed() { return this.rangeCount === 0 || (this.anchorNode === this.focusNode && this.anchorOffset === this.focusOffset); },
108131 setBaseAndExtent(an, ao, fn, fo) { this.anchorNode = an; this.anchorOffset = ao; this.focusNode = fn; this.focusOffset = fo; this.rangeCount = 1; },
109132 collapse(node, offset) { this.setBaseAndExtent(node, offset, node, offset); },
110- setFocus(node, offset) { this.focusNode = node; this.focusOffset = offset; },
111133 modify(alter, direction, granularity) {
134+ modifyCalls.push([alter, direction, granularity]);
112135 if (granularity === "word") throw new Error("native word granularity must not be used");
113136 const step = direction === "forward" ? 1 : -1;
114137 let i = index(this.focusNode);
@@ -124,12 +147,9 @@ const selection = {
124147 if (j >= 0 && j < texts.length) { i = j; o = Math.min(o, len(j)); } else o = step > 0 ? len(i) : 0;
125148 } else if (granularity === "documentboundary") {
126149 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;
132150 }
151+ // Sentence and paragraph granularities are native: this model leaves the
152+ // focus alone, and tests assert the modify() calls instead.
133153 this.focusNode = texts[i]; this.focusOffset = o;
134154 if (alter === "move") { this.anchorNode = this.focusNode; this.anchorOffset = this.focusOffset; }
135155 },
@@ -143,6 +163,7 @@ const selection = {
143163 return range;
144164 },
145165 toString() {
166+ toStringCalls++;
146167 if (!this.rangeCount) return "";
147168 const r = this.getRangeAt();
148169 const a = index(r.startContainer);
@@ -168,8 +189,10 @@ global.document = {
168189 let i = 0; return { currentNode: nodes[0], nextNode: () => nodes[++i] || null };
169190 },
170191 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,
192+ baseURI: "https://example.com/dir/page.html",
193+ elementFromPoint: (x, y) => [two, one, ...texts.map((t) => t.parentElement), ...body.children.slice().reverse()].find((el) => el.rect && x >= el.rect.left && x < el.rect.right && y >= el.rect.top && y < el.rect.bottom) || body,
172194 };
195+global.MouseEvent = class { constructor(type, init) { this.type = type; Object.assign(this, init); } };
173196 global.window = {
174197 innerWidth: 800, innerHeight: 600,
175198 scrollBy(x, y) { scrolls.push([x, y]); },
@@ -177,10 +200,12 @@ global.window = {
177200 removeEventListener(type, fn) { listeners[type] = (listeners[type] || []).filter((f) => f !== fn); },
178201 getSelection: () => selection,
179202 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(); },
203+ // The runtime sends btoa(unescape(encodeURIComponent(json))): base64 of the
204+ // UTF-8 bytes, which is what Go decodes.
205+ fetch: (url, init) => { posted.push(JSON.parse(Buffer.from(init.headers["X-Dumber-Body"], "base64").toString("utf8"))); return Promise.resolve(); },
206+ requestAnimationFrame(fn) { const id = nextFrame++; frames.set(id, fn); return id; },
207+ cancelAnimationFrame(id) { frames.delete(id); },
181208 };
182-global.btoa = (s) => Buffer.from(s, "binary").toString("base64");
183-global.unescape = (s) => decodeURIComponent(s);
184209 global.setTimeout = (fn) => { fn(); return 0; };
185210
186211 // Helpers for drivers.
@@ -194,10 +219,10 @@ const overlays = (name) => html.children.filter((c) => c.attrs && name in c.attr
194219 const cursor = () => overlays("data-dumber-vim-caret")[0] || null;
195220 const hintLabels = () => overlays("data-dumber-vim-hints").flatMap((root) => root.children.map((s) => s.textContent));
196221 const fire = (type) => (listeners[type] || []).slice().forEach((fn) => fn({}));
197-const modes = () => posted.filter((m) => m.type === "mode").map((m) => m.mode);
222+// flushFrames runs the animation frames requested so far.
223+const flushFrames = () => { const pending = Array.from(frames.values()); frames.clear(); pending.forEach((fn) => fn()); };
198224 const vp = () => window.__dumberVimPage;
199225 const keys = (token, ...list) => list.forEach((k) => window.__dumberVimPage.key(token, k));
200-const rec = (name, extra) => posted.push(Object.assign({ r: name }, extra));
201226 // startCaret begins the visual flow and places the caret at the start of the
202227 // paragraph with the given hint label ("s", "a", "d" for the first, second,
203228 // third visible paragraph).
@@ -5,6 +5,12 @@
5 // runtime must implement word motions itself (Vimium #1441).5 // runtime must implement word motions itself (Vimium #1441).
6 const posted = [];6 const posted = [];
7 const scrolls = [];7 const scrolls = [];
8+const modifyCalls = [];
9+const frames = new Map();
10+let nextFrame = 1;
11+let toStringCalls = 0;
12+let closestCalls = 0;
13+let rangeRectCalls = 0;
8 const listeners = {};14 const listeners = {};
9 const CHAR_WIDTH = 10;15 const CHAR_WIDTH = 10;
10 const LINE_HEIGHT = 20;16 const LINE_HEIGHT = 20;
@@ -24,7 +30,7 @@ class Node_ {
24 append(...items) { for (const item of items) if (item instanceof Node_) this.appendChild(item); }30 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; }31 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; }32 setAttribute(name, value) { this.attrs[name] = value; }
27- closest() { return null; }33+ closest() { closestCalls++; return null; }
28 contains(other) { for (let n = other; n; n = n.parentElement) if (n === this) return true; return false; }34 contains(other) { for (let n = other; n; n = n.parentElement) if (n === this) return true; return false; }
29 getClientRects() { return this.rect ? [this.rect] : []; }35 getClientRects() { return this.rect ? [this.rect] : []; }
30 getBoundingClientRect() { return this.rect || rect(0, 0, 0, 0); }36 getBoundingClientRect() { return this.rect || rect(0, 0, 0, 0); }
@@ -37,6 +43,7 @@ class Node_ {
37 }43 }
38 focus() { document.activeElement = this; }44 focus() { document.activeElement = this; }
39 click() { posted.push({ clicked: this.href }); }45 click() { posted.push({ clicked: this.href }); }
46+ dispatchEvent(event) { posted.push({ dispatched: event.type, bubbles: event.bubbles, cancelable: event.cancelable, target: this.tagName }); return true; }
40 }47 }
41 function matches(el, selector) {48 function matches(el, selector) {
42 return selector.split(",").some((part) => {49 return selector.split(",").some((part) => {
@@ -51,6 +58,11 @@ function matches(el, selector) {
51 class Element extends Node_ { constructor(tag) { super(1, tag.toUpperCase()); } }58 class Element extends Node_ { constructor(tag) { super(1, tag.toUpperCase()); } }
52 class HTMLElement extends Element {}59 class HTMLElement extends Element {}
53 class HTMLAnchorElement extends HTMLElement { constructor(href) { super("a"); this.href = href; this.target = ""; } }60 class HTMLAnchorElement extends HTMLElement { constructor(href) { super("a"); this.href = href; this.target = ""; } }
61+// SVGAElement_ models an SVG <a>: href is an SVGAnimatedString, there is no
62+// click(), and the link is not an HTMLAnchorElement.
63+class SVGAElement_ extends Element {
64+ constructor(href) { super("a"); this.href = { baseVal: href }; this.target = { baseVal: "" }; this.click = undefined; }
65+}
54 class HTMLInputElement extends HTMLElement {}66 class HTMLInputElement extends HTMLElement {}
55 class HTMLTextAreaElement extends HTMLElement {}67 class HTMLTextAreaElement extends HTMLElement {}
56 class HTMLSelectElement extends HTMLElement {}68 class HTMLSelectElement extends HTMLElement {}
@@ -71,10 +83,20 @@ const first = paragraph("Alpha bravo charlie", 10);
71 const second = paragraph(LONG, 40);83 const second = paragraph(LONG, 40);
72 const third = paragraph("Third line, end.", 70);84 const third = paragraph("Third line, end.", 70);
73 const far = paragraph("Far below the fold", 2000);85 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);86 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);87 const two = body.appendChild(new HTMLAnchorElement("https://example.com/two")); two.rect = rect(80, 120, 40, 20);
77-const TEXT = first.text.data;88+// shadowParagraph adds a host whose paragraph lives in an open shadow root;
89+// its text node joins the selection model at the end of the document order.
90+function shadowParagraph(data, top) {
91+ const host = body.appendChild(new HTMLElement("div"));
92+ const p = new HTMLElement("p");
93+ p.rect = rect(10, top, 600, LINE_HEIGHT);
94+ p.parentElement = null;
95+ const text = p.appendChild(new Text_(data));
96+ texts.push(text);
97+ host.shadowRoot = { children: [p], querySelectorAll: (selector) => Node_.prototype.querySelectorAll.call({ children: [p] }, selector), getRootNode: () => document };
98+ return { host, p, text };
99+}
78 100
79 const index = (node) => texts.indexOf(node);101 const index = (node) => texts.indexOf(node);
80 const order = (node, offset) => index(node) * 100000 + offset;102 const order = (node, offset) => index(node) * 100000 + offset;
@@ -92,6 +114,7 @@ class Range_ {
92 collapse(toStart) { if (toStart) { this.endContainer = this.startContainer; this.endOffset = this.startOffset; } else { this.startContainer = this.endContainer; this.startOffset = this.endOffset; } }114 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; }115 selectNodeContents(node) { this.startContainer = this.endContainer = node; this.startOffset = 0; this.endOffset = node.data ? node.data.length : 0; }
94 getClientRects() {116 getClientRects() {
117+ rangeRectCalls++;
95 const node = this.startContainer;118 const node = this.startContainer;
96 const box = node && node.parentElement && node.parentElement.rect;119 const box = node && node.parentElement && node.parentElement.rect;
97 if (!box) return [];120 if (!box) return [];
@@ -107,8 +130,8 @@ const selection = {
107 get isCollapsed() { return this.rangeCount === 0 || (this.anchorNode === this.focusNode && this.anchorOffset === this.focusOffset); },130 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; },131 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); },132 collapse(node, offset) { this.setBaseAndExtent(node, offset, node, offset); },
110- setFocus(node, offset) { this.focusNode = node; this.focusOffset = offset; },
111 modify(alter, direction, granularity) {133 modify(alter, direction, granularity) {
134+ modifyCalls.push([alter, direction, granularity]);
112 if (granularity === "word") throw new Error("native word granularity must not be used");135 if (granularity === "word") throw new Error("native word granularity must not be used");
113 const step = direction === "forward" ? 1 : -1;136 const step = direction === "forward" ? 1 : -1;
114 let i = index(this.focusNode);137 let i = index(this.focusNode);
@@ -124,12 +147,9 @@ const selection = {
124 if (j >= 0 && j < texts.length) { i = j; o = Math.min(o, len(j)); } else o = step > 0 ? len(i) : 0;147 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") {148 } else if (granularity === "documentboundary") {
126 i = step > 0 ? texts.length - 1 : 0; o = step > 0 ? len(i) : 0;149 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 }150 }
151+ // Sentence and paragraph granularities are native: this model leaves the
152+ // focus alone, and tests assert the modify() calls instead.
133 this.focusNode = texts[i]; this.focusOffset = o;153 this.focusNode = texts[i]; this.focusOffset = o;
134 if (alter === "move") { this.anchorNode = this.focusNode; this.anchorOffset = this.focusOffset; }154 if (alter === "move") { this.anchorNode = this.focusNode; this.anchorOffset = this.focusOffset; }
135 },155 },
@@ -143,6 +163,7 @@ const selection = {
143 return range;163 return range;
144 },164 },
145 toString() {165 toString() {
166+ toStringCalls++;
146 if (!this.rangeCount) return "";167 if (!this.rangeCount) return "";
147 const r = this.getRangeAt();168 const r = this.getRangeAt();
148 const a = index(r.startContainer);169 const a = index(r.startContainer);
@@ -168,8 +189,10 @@ global.document = {
168 let i = 0; return { currentNode: nodes[0], nextNode: () => nodes[++i] || null };189 let i = 0; return { currentNode: nodes[0], nextNode: () => nodes[++i] || null };
169 },190 },
170 querySelectorAll: (selector) => html.querySelectorAll(selector),191 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,192+ baseURI: "https://example.com/dir/page.html",
193+ elementFromPoint: (x, y) => [two, one, ...texts.map((t) => t.parentElement), ...body.children.slice().reverse()].find((el) => el.rect && x >= el.rect.left && x < el.rect.right && y >= el.rect.top && y < el.rect.bottom) || body,
172 };194 };
195+global.MouseEvent = class { constructor(type, init) { this.type = type; Object.assign(this, init); } };
173 global.window = {196 global.window = {
174 innerWidth: 800, innerHeight: 600,197 innerWidth: 800, innerHeight: 600,
175 scrollBy(x, y) { scrolls.push([x, y]); },198 scrollBy(x, y) { scrolls.push([x, y]); },
@@ -177,10 +200,12 @@ global.window = {
177 removeEventListener(type, fn) { listeners[type] = (listeners[type] || []).filter((f) => f !== fn); },200 removeEventListener(type, fn) { listeners[type] = (listeners[type] || []).filter((f) => f !== fn); },
178 getSelection: () => selection,201 getSelection: () => selection,
179 getComputedStyle: () => ({ display: "block", visibility: "visible" }),202 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(); },203+ // The runtime sends btoa(unescape(encodeURIComponent(json))): base64 of the
204+ // UTF-8 bytes, which is what Go decodes.
205+ fetch: (url, init) => { posted.push(JSON.parse(Buffer.from(init.headers["X-Dumber-Body"], "base64").toString("utf8"))); return Promise.resolve(); },
206+ requestAnimationFrame(fn) { const id = nextFrame++; frames.set(id, fn); return id; },
207+ cancelAnimationFrame(id) { frames.delete(id); },
181 };208 };
182-global.btoa = (s) => Buffer.from(s, "binary").toString("base64");
183-global.unescape = (s) => decodeURIComponent(s);
184 global.setTimeout = (fn) => { fn(); return 0; };209 global.setTimeout = (fn) => { fn(); return 0; };
185 210
186 // Helpers for drivers.211 // Helpers for drivers.
@@ -194,10 +219,10 @@ const overlays = (name) => html.children.filter((c) => c.attrs && name in c.attr
194 const cursor = () => overlays("data-dumber-vim-caret")[0] || null;219 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));220 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({}));221 const fire = (type) => (listeners[type] || []).slice().forEach((fn) => fn({}));
197-const modes = () => posted.filter((m) => m.type === "mode").map((m) => m.mode);222+// flushFrames runs the animation frames requested so far.
223+const flushFrames = () => { const pending = Array.from(frames.values()); frames.clear(); pending.forEach((fn) => fn()); };
198 const vp = () => window.__dumberVimPage;224 const vp = () => window.__dumberVimPage;
199 const keys = (token, ...list) => list.forEach((k) => window.__dumberVimPage.key(token, k));225 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 the226 // 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,227 // paragraph with the given hint label ("s", "a", "d" for the first, second,
203 // third visible paragraph).228 // third visible paragraph).
M internal/infrastructure/cef/vim_page_runtime.js +170 -74
@@ -60,16 +60,14 @@
6060 return results;
6161 }
6262
63- // deepTextNodes yields non-blank text nodes in document order, descending
64- // into open shadow roots.
65- function* deepTextNodes(root) {
66- const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT);
63+ // lightTextNodes yields non-blank text nodes in document order. It does not
64+ // enter shadow roots on purpose: Chromium reports Selection positions inside
65+ // a shadow tree relative to its host, so a caret anchored there would sit in
66+ // the wrong place. Link hints and text-object yanks still search shadow DOM.
67+ function* lightTextNodes(root) {
68+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
6769 for (let node = walker.currentNode; node; node = walker.nextNode()) {
68- if (node.nodeType === Node.TEXT_NODE) {
69- if (node.data.trim()) yield node;
70- } else if (node.shadowRoot) {
71- yield* deepTextNodes(node.shadowRoot);
72- }
70+ if (node.nodeType === Node.TEXT_NODE && node.data.trim()) yield node;
7371 }
7472 }
7573
@@ -86,14 +84,22 @@
8684 }).catch(() => {});
8785 }
8886
87+ function onScreen(rect) {
88+ return rect.bottom > 0 && rect.right > 0 && rect.top < window.innerHeight && rect.left < window.innerWidth;
89+ }
90+
91+ // visibleRect returns a client rect of element inside the viewport. The
92+ // cheap bounding-box test runs first: most elements of a long page are
93+ // off-screen, and closest/getComputedStyle are the expensive part.
8994 function visibleRect(element) {
9095 if (!(element instanceof Element) || !element.isConnected) return null;
96+ if (!onScreen(element.getBoundingClientRect())) return null;
9197 if (element.closest("[hidden],[inert],[aria-hidden=\"true\"]")) return null;
9298 const style = window.getComputedStyle(element);
9399 if (style.display === "none" || style.visibility === "hidden" || style.visibility === "collapse") return null;
94100 for (const rect of element.getClientRects()) {
95101 if (rect.width < 1 || rect.height < 1) continue;
96- if (rect.bottom <= 0 || rect.right <= 0 || rect.top >= window.innerHeight || rect.left >= window.innerWidth) continue;
102+ if (!onScreen(rect)) continue;
97103 return rect;
98104 }
99105 return null;
@@ -205,15 +211,42 @@
205211 }
206212 }
207213
214+ // linkURL resolves the destination of an HTML or SVG anchor. An SVG <a>
215+ // exposes href as an SVGAnimatedString that is not resolved against the
216+ // document, so it is resolved here; Go validates the result before opening it.
217+ function linkURL(element) {
218+ if (element instanceof HTMLAnchorElement) return element.href;
219+ const animated = element.href;
220+ if (!animated || typeof animated.baseVal !== "string" || !animated.baseVal) return "";
221+ try {
222+ return new URL(animated.baseVal, document.baseURI).href;
223+ } catch (_) {
224+ return "";
225+ }
226+ }
227+
228+ function linkTarget(element) {
229+ const target = element.target;
230+ return target && typeof target === "object" ? target.baseVal : target;
231+ }
232+
233+ function clickElement(element) {
234+ if (typeof element.click === "function") {
235+ element.click();
236+ return;
237+ }
238+ element.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window }));
239+ }
240+
208241 // activateHint returns the terminal message for the chosen hint.
209242 function activateHint(kind, element) {
210- const url = element instanceof HTMLAnchorElement ? element.href : "";
243+ const url = linkURL(element);
211244 if (kind === "hint-yank-url") return url ? { type: "copy", text: url } : { type: "end" };
212- if (kind === "hint-follow-new" || (url && element.target === "_blank")) {
245+ if (kind === "hint-follow-new" || (url && linkTarget(element) === "_blank")) {
213246 return url ? { type: "open-new", url } : { type: "end" };
214247 }
215- element.focus({ preventScroll: true });
216- if (!isEditable(element)) element.click();
248+ if (typeof element.focus === "function") element.focus({ preventScroll: true });
249+ if (!isEditable(element)) clickElement(element);
217250 return { type: "end" };
218251 }
219252
@@ -309,19 +342,30 @@
309342 return null;
310343 }
311344
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)) {
345+ // visibleTextAnchors yields visible text starts in document order. A text
346+ // node whose parent element is off-screen cannot be visible, so that cheap
347+ // check (cached per element) runs before the range measurements.
348+ function* visibleTextAnchors(minLength) {
349+ const onScreenElements = new Map();
350+ for (const node of lightTextNodes(document.body || document.documentElement)) {
318351 const element = node.parentElement;
319352 if (!element || SKIP_TEXT_PARENTS.test(element.tagName) || isEditable(element)) continue;
320353 if (node.data.trim().length < minLength) continue;
354+ if (!onScreenElements.has(element)) onScreenElements.set(element, onScreen(element.getBoundingClientRect()));
355+ if (!onScreenElements.get(element)) continue;
321356 const anchor = firstVisibleAnchor(node, element);
322- if (!anchor) continue;
357+ if (anchor) yield anchor;
358+ }
359+ }
360+
361+ // collectTextAnchors lists visible text starts in document order. perBlock
362+ // keeps the first anchor of each block.
363+ function collectTextAnchors({ perBlock, limit }) {
364+ const anchors = [];
365+ const blocks = new Set();
366+ for (const anchor of visibleTextAnchors(1)) {
323367 if (perBlock) {
324- const block = blockOf(element);
368+ const block = blockOf(anchor.element);
325369 if (blocks.has(block)) continue;
326370 blocks.add(block);
327371 }
@@ -333,11 +377,14 @@
333377
334378 // Esc during the text-anchor hints: Vimium's heuristic picks the first
335379 // visible text of at least LONG_TEXT_LENGTH characters (shorter ones are
336- // likely banners); any visible text is the fallback.
380+ // likely banners); any visible text is the fallback. One pass finds both.
337381 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;
382+ let first = null;
383+ for (const anchor of visibleTextAnchors(1)) {
384+ if (anchor.node.data.trim().length >= LONG_TEXT_LENGTH) return anchor;
385+ if (!first) first = anchor;
386+ }
387+ return first;
341388 }
342389
343390 function placeCaretAtFallback() {
@@ -405,7 +452,15 @@
405452 cursor.animate([{ opacity: 1 }, { opacity: 1, offset: 0.6 }, { opacity: 0.15 }], { duration: 1000, iterations: Infinity });
406453 }
407454 state.cursor = cursor;
408- state.onViewportChange = () => updateCursor(window.getSelection());
455+ // Scroll and resize fire at frame rate or faster; repaint once per frame.
456+ state.onViewportChange = () => {
457+ if (!state || state.frame) return;
458+ state.frame = window.requestAnimationFrame(() => {
459+ if (!state) return;
460+ state.frame = 0;
461+ updateCursor(window.getSelection());
462+ });
463+ };
409464 window.addEventListener("scroll", state.onViewportChange, { capture: true, passive: true });
410465 window.addEventListener("resize", state.onViewportChange, { passive: true });
411466 }
@@ -464,7 +519,7 @@
464519 setPhase("visual");
465520 return true;
466521 }
467- const anchors = collectTextAnchors({ perBlock: true, minLength: 1, limit: HINT_CHARS.length * HINT_CHARS.length });
522+ const anchors = collectTextAnchors({ perBlock: true, limit: HINT_CHARS.length * HINT_CHARS.length });
468523 if (anchors.length === 0) return false;
469524 buildHintOverlay(anchors, color, true);
470525 setPhase("hints");
@@ -489,38 +544,65 @@
489544 selection.collapse(selection.focusNode, selection.focusOffset);
490545 }
491546
492- // extendByOne extends the focus one character and reports how the selected
493- // text length changed; 0 means the focus could not move.
547+ function focusMoved(selection, node, offset) {
548+ return selection.focusNode !== node || selection.focusOffset !== offset;
549+ }
550+
551+ // extendByOne extends the focus one character and reports whether it moved
552+ // (1) or could not (0). It compares focus positions: reading the whole
553+ // selection's text per step made long selections quadratic.
494554 function extendByOne(selection, direction) {
495- const length = selection.toString().length;
555+ const node = selection.focusNode;
556+ const offset = selection.focusOffset;
496557 selection.modify("extend", direction, "character");
497- return selection.toString().length - length;
558+ return focusMoved(selection, node, offset) ? 1 : 0;
498559 }
499560
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];
561+ // characterAtFocus reads the character next to the focus from its text node.
562+ // It returns null when the focus is not inside a text node or sits on its
563+ // boundary, where the neighbor lives in another node.
564+ function characterAtFocus(selection, forward) {
565+ const node = selection.focusNode;
566+ const offset = selection.focusOffset;
567+ if (!node || node.nodeType !== Node.TEXT_NODE) return null;
568+ const data = node.data;
569+ if (forward) return offset < data.length ? String.fromCodePoint(data.codePointAt(offset)) : null;
570+ if (offset <= 0) return null;
571+ const unit = data.charCodeAt(offset - 1);
572+ const paired = offset > 1 && unit >= 0xdc00 && unit <= 0xdfff;
573+ return paired ? data.slice(offset - 2, offset) : data[offset - 1];
574+ }
575+
576+ // probeCharacter finds the character across a node boundary by stepping the
577+ // focus over it and back. Landing on a text node boundary or outside a text
578+ // node means a block boundary was crossed, which counts as a line break.
579+ function probeCharacter(selection, direction) {
580+ const node = selection.focusNode;
581+ const offset = selection.focusOffset;
582+ selection.modify("extend", direction, "character");
583+ if (!focusMoved(selection, node, offset)) return undefined;
584+ const landed = selection.focusNode;
585+ const landedOffset = selection.focusOffset;
586+ let character = "\n";
587+ if (landed && landed.nodeType === Node.TEXT_NODE) {
588+ if (direction === "forward" && landedOffset > 0) character = landed.data[landedOffset - 1];
589+ else if (direction === "backward" && landedOffset < landed.data.length) character = landed.data[landedOffset];
509590 }
510- return before[0];
591+ selection.modify("extend", direction === "forward" ? "backward" : "forward", "character");
592+ return character;
593+ }
594+
595+ // nextCharacter returns the character after the focus without moving it, or
596+ // undefined at the end of the document.
597+ function nextCharacter(selection) {
598+ const character = characterAtFocus(selection, true);
599+ return character === null ? probeCharacter(selection, "forward") : character;
511600 }
512601
513602 // previousCharacter returns the character before the focus without moving it.
514603 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];
604+ const character = characterAtFocus(selection, false);
605+ return character === null ? probeCharacter(selection, "backward") : character;
524606 }
525607
526608 function isWordCharacter(character) {
@@ -566,21 +648,13 @@
566648
567649 function firstNonBlank(selection, alter) {
568650 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- }
579651 for (let i = 0; i < MAX_MOTION_STEPS; i++) {
580652 const character = nextCharacter(selection);
581653 if (character === undefined || !/[ \t\u00a0]/.test(character)) break;
582654 if (extendByOne(selection, "forward") === 0) break;
583655 }
656+ // A caret measures with extend, then settles where the measuring ended.
657+ if (alter === "move") collapseToFocus(selection);
584658 }
585659
586660 // extendToLines grows the selection so both ends sit on line boundaries.
@@ -629,7 +703,11 @@
629703
630704 // --- Phase transitions ---
631705
706+ // toCaret collapses a selection onto its last selected character, so the
707+ // caret returns to the character that v included instead of drifting one
708+ // position right on every v then Escape.
632709 function toCaret(selection) {
710+ if (!selection.isCollapsed && !isBackward(selection)) extendByOne(selection, "backward");
633711 collapseToFocus(selection);
634712 setPhase("caret");
635713 revealFocus(selection);
@@ -637,7 +715,11 @@
637715
638716 function toVisual(selection) {
639717 // Include the character under the caret, or the one before at the end.
640- if (extendByOne(selection, "forward") === 0) extendByOne(selection, "backward");
718+ if (extendByOne(selection, "forward") === 0 && extendByOne(selection, "backward") !== 0) {
719+ // At the end of the text the last character is selected backward; flip
720+ // it so the focus stays at the end, like a forward selection.
721+ swapEnds(selection);
722+ }
641723 setPhase("visual");
642724 revealFocus(selection);
643725 }
@@ -768,6 +850,7 @@
768850 function cleanup() {
769851 if (state) {
770852 for (const node of [state.root, state.style, state.cursor]) if (node) node.remove();
853+ if (state.frame) window.cancelAnimationFrame(state.frame);
771854 if (state.onViewportChange) {
772855 window.removeEventListener("scroll", state.onViewportChange, { capture: true });
773856 window.removeEventListener("resize", state.onViewportChange);
@@ -783,21 +866,30 @@
783866 if (token) post(token, message);
784867 }
785868
869+ // A document leaving the page (navigation, bfcache) takes the overlay along.
870+ window.addEventListener("pagehide", () => { if (state) cleanup(); });
871+
786872 Object.defineProperty(window, "__dumberVimPage", {
787873 configurable: false,
788874 enumerable: false,
789875 value: Object.freeze({
790876 start(token, request) {
791- cleanup();
792- const color = request.color || "#fbbf24";
793- if (request.kind === "yank") {
794- const text = objectText(request.object, color).trim();
795- post(token, text ? { type: "copy", text } : { type: "end" });
796- return;
877+ try {
878+ cleanup();
879+ const color = request.color || "#fbbf24";
880+ if (request.kind === "yank") {
881+ const text = objectText(request.object, color).trim();
882+ post(token, text ? { type: "copy", text } : { type: "end" });
883+ return;
884+ }
885+ state = { kind: request.kind, token, color };
886+ const started = request.kind === "visual" ? startVisual(color) : startHints(request.kind, color);
887+ if (!started) finish({ type: "end", reason: request.kind === "visual" ? "no-visible-text" : "no-visible-targets" });
888+ } catch (_) {
889+ // A page quirk must never leave the user's keys captured.
890+ cleanup();
891+ post(token, { type: "end", reason: "error" });
797892 }
798- state = { kind: request.kind, token, color };
799- const started = request.kind === "visual" ? startVisual(color) : startHints(request.kind, color);
800- if (!started) finish({ type: "end", reason: request.kind === "visual" ? "no-visible-text" : "no-visible-targets" });
801893 },
802894 key(token, key) {
803895 if (!state || state.token !== token) {
@@ -816,9 +908,13 @@
816908 },
817909 cancel(token) {
818910 if (!state || state.token !== token) return;
819- if (state.kind === "visual" && state.phase !== "hints") {
820- const selection = window.getSelection();
821- if (selection) selection.removeAllRanges();
911+ try {
912+ if (state.kind === "visual" && state.phase !== "hints") {
913+ const selection = window.getSelection();
914+ if (selection) selection.removeAllRanges();
915+ }
916+ } catch (_) {
917+ // Fall through: the overlay must go even when the selection cannot.
822918 }
823919 cleanup();
824920 },
@@ -60,16 +60,14 @@
60 return results;60 return results;
61 }61 }
62 62
63- // deepTextNodes yields non-blank text nodes in document order, descending63+ // lightTextNodes yields non-blank text nodes in document order. It does not
64- // into open shadow roots.64+ // enter shadow roots on purpose: Chromium reports Selection positions inside
65- function* deepTextNodes(root) {65+ // a shadow tree relative to its host, so a caret anchored there would sit in
66- const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT);66+ // the wrong place. Link hints and text-object yanks still search shadow DOM.
67+ function* lightTextNodes(root) {
68+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
67 for (let node = walker.currentNode; node; node = walker.nextNode()) {69 for (let node = walker.currentNode; node; node = walker.nextNode()) {
68- if (node.nodeType === Node.TEXT_NODE) {70+ if (node.nodeType === Node.TEXT_NODE && node.data.trim()) yield node;
69- if (node.data.trim()) yield node;
70- } else if (node.shadowRoot) {
71- yield* deepTextNodes(node.shadowRoot);
72- }
73 }71 }
74 }72 }
75 73
@@ -86,14 +84,22 @@
86 }).catch(() => {});84 }).catch(() => {});
87 }85 }
88 86
87+ function onScreen(rect) {
88+ return rect.bottom > 0 && rect.right > 0 && rect.top < window.innerHeight && rect.left < window.innerWidth;
89+ }
90+
91+ // visibleRect returns a client rect of element inside the viewport. The
92+ // cheap bounding-box test runs first: most elements of a long page are
93+ // off-screen, and closest/getComputedStyle are the expensive part.
89 function visibleRect(element) {94 function visibleRect(element) {
90 if (!(element instanceof Element) || !element.isConnected) return null;95 if (!(element instanceof Element) || !element.isConnected) return null;
96+ if (!onScreen(element.getBoundingClientRect())) return null;
91 if (element.closest("[hidden],[inert],[aria-hidden=\"true\"]")) return null;97 if (element.closest("[hidden],[inert],[aria-hidden=\"true\"]")) return null;
92 const style = window.getComputedStyle(element);98 const style = window.getComputedStyle(element);
93 if (style.display === "none" || style.visibility === "hidden" || style.visibility === "collapse") return null;99 if (style.display === "none" || style.visibility === "hidden" || style.visibility === "collapse") return null;
94 for (const rect of element.getClientRects()) {100 for (const rect of element.getClientRects()) {
95 if (rect.width < 1 || rect.height < 1) continue;101 if (rect.width < 1 || rect.height < 1) continue;
96- if (rect.bottom <= 0 || rect.right <= 0 || rect.top >= window.innerHeight || rect.left >= window.innerWidth) continue;102+ if (!onScreen(rect)) continue;
97 return rect;103 return rect;
98 }104 }
99 return null;105 return null;
@@ -205,15 +211,42 @@
205 }211 }
206 }212 }
207 213
214+ // linkURL resolves the destination of an HTML or SVG anchor. An SVG <a>
215+ // exposes href as an SVGAnimatedString that is not resolved against the
216+ // document, so it is resolved here; Go validates the result before opening it.
217+ function linkURL(element) {
218+ if (element instanceof HTMLAnchorElement) return element.href;
219+ const animated = element.href;
220+ if (!animated || typeof animated.baseVal !== "string" || !animated.baseVal) return "";
221+ try {
222+ return new URL(animated.baseVal, document.baseURI).href;
223+ } catch (_) {
224+ return "";
225+ }
226+ }
227+
228+ function linkTarget(element) {
229+ const target = element.target;
230+ return target && typeof target === "object" ? target.baseVal : target;
231+ }
232+
233+ function clickElement(element) {
234+ if (typeof element.click === "function") {
235+ element.click();
236+ return;
237+ }
238+ element.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window }));
239+ }
240+
208 // activateHint returns the terminal message for the chosen hint.241 // activateHint returns the terminal message for the chosen hint.
209 function activateHint(kind, element) {242 function activateHint(kind, element) {
210- const url = element instanceof HTMLAnchorElement ? element.href : "";243+ const url = linkURL(element);
211 if (kind === "hint-yank-url") return url ? { type: "copy", text: url } : { type: "end" };244 if (kind === "hint-yank-url") return url ? { type: "copy", text: url } : { type: "end" };
212- if (kind === "hint-follow-new" || (url && element.target === "_blank")) {245+ if (kind === "hint-follow-new" || (url && linkTarget(element) === "_blank")) {
213 return url ? { type: "open-new", url } : { type: "end" };246 return url ? { type: "open-new", url } : { type: "end" };
214 }247 }
215- element.focus({ preventScroll: true });248+ if (typeof element.focus === "function") element.focus({ preventScroll: true });
216- if (!isEditable(element)) element.click();249+ if (!isEditable(element)) clickElement(element);
217 return { type: "end" };250 return { type: "end" };
218 }251 }
219 252
@@ -309,19 +342,30 @@
309 return null;342 return null;
310 }343 }
311 344
312- // collectTextAnchors lists visible text starts in document order. perBlock345+ // visibleTextAnchors yields visible text starts in document order. A text
313- // keeps the first anchor of each block; minLength skips short texts.346+ // node whose parent element is off-screen cannot be visible, so that cheap
314- function collectTextAnchors({ perBlock, minLength, limit }) {347+ // check (cached per element) runs before the range measurements.
315- const anchors = [];348+ function* visibleTextAnchors(minLength) {
316- const blocks = new Set();349+ const onScreenElements = new Map();
317- for (const node of deepTextNodes(document.body || document.documentElement)) {350+ for (const node of lightTextNodes(document.body || document.documentElement)) {
318 const element = node.parentElement;351 const element = node.parentElement;
319 if (!element || SKIP_TEXT_PARENTS.test(element.tagName) || isEditable(element)) continue;352 if (!element || SKIP_TEXT_PARENTS.test(element.tagName) || isEditable(element)) continue;
320 if (node.data.trim().length < minLength) continue;353 if (node.data.trim().length < minLength) continue;
354+ if (!onScreenElements.has(element)) onScreenElements.set(element, onScreen(element.getBoundingClientRect()));
355+ if (!onScreenElements.get(element)) continue;
321 const anchor = firstVisibleAnchor(node, element);356 const anchor = firstVisibleAnchor(node, element);
322- if (!anchor) continue;357+ if (anchor) yield anchor;
358+ }
359+ }
360+
361+ // collectTextAnchors lists visible text starts in document order. perBlock
362+ // keeps the first anchor of each block.
363+ function collectTextAnchors({ perBlock, limit }) {
364+ const anchors = [];
365+ const blocks = new Set();
366+ for (const anchor of visibleTextAnchors(1)) {
323 if (perBlock) {367 if (perBlock) {
324- const block = blockOf(element);368+ const block = blockOf(anchor.element);
325 if (blocks.has(block)) continue;369 if (blocks.has(block)) continue;
326 blocks.add(block);370 blocks.add(block);
327 }371 }
@@ -333,11 +377,14 @@
333 377
334 // Esc during the text-anchor hints: Vimium's heuristic picks the first378 // Esc during the text-anchor hints: Vimium's heuristic picks the first
335 // visible text of at least LONG_TEXT_LENGTH characters (shorter ones are379 // visible text of at least LONG_TEXT_LENGTH characters (shorter ones are
336- // likely banners); any visible text is the fallback.380+ // likely banners); any visible text is the fallback. One pass finds both.
337 function fallbackAnchor() {381 function fallbackAnchor() {
338- const long = collectTextAnchors({ perBlock: false, minLength: LONG_TEXT_LENGTH, limit: 1 });382+ let first = null;
339- if (long.length > 0) return long[0];383+ for (const anchor of visibleTextAnchors(1)) {
340- return collectTextAnchors({ perBlock: false, minLength: 1, limit: 1 })[0] || null;384+ if (anchor.node.data.trim().length >= LONG_TEXT_LENGTH) return anchor;
385+ if (!first) first = anchor;
386+ }
387+ return first;
341 }388 }
342 389
343 function placeCaretAtFallback() {390 function placeCaretAtFallback() {
@@ -405,7 +452,15 @@
405 cursor.animate([{ opacity: 1 }, { opacity: 1, offset: 0.6 }, { opacity: 0.15 }], { duration: 1000, iterations: Infinity });452 cursor.animate([{ opacity: 1 }, { opacity: 1, offset: 0.6 }, { opacity: 0.15 }], { duration: 1000, iterations: Infinity });
406 }453 }
407 state.cursor = cursor;454 state.cursor = cursor;
408- state.onViewportChange = () => updateCursor(window.getSelection());455+ // Scroll and resize fire at frame rate or faster; repaint once per frame.
456+ state.onViewportChange = () => {
457+ if (!state || state.frame) return;
458+ state.frame = window.requestAnimationFrame(() => {
459+ if (!state) return;
460+ state.frame = 0;
461+ updateCursor(window.getSelection());
462+ });
463+ };
409 window.addEventListener("scroll", state.onViewportChange, { capture: true, passive: true });464 window.addEventListener("scroll", state.onViewportChange, { capture: true, passive: true });
410 window.addEventListener("resize", state.onViewportChange, { passive: true });465 window.addEventListener("resize", state.onViewportChange, { passive: true });
411 }466 }
@@ -464,7 +519,7 @@
464 setPhase("visual");519 setPhase("visual");
465 return true;520 return true;
466 }521 }
467- const anchors = collectTextAnchors({ perBlock: true, minLength: 1, limit: HINT_CHARS.length * HINT_CHARS.length });522+ const anchors = collectTextAnchors({ perBlock: true, limit: HINT_CHARS.length * HINT_CHARS.length });
468 if (anchors.length === 0) return false;523 if (anchors.length === 0) return false;
469 buildHintOverlay(anchors, color, true);524 buildHintOverlay(anchors, color, true);
470 setPhase("hints");525 setPhase("hints");
@@ -489,38 +544,65 @@
489 selection.collapse(selection.focusNode, selection.focusOffset);544 selection.collapse(selection.focusNode, selection.focusOffset);
490 }545 }
491 546
492- // extendByOne extends the focus one character and reports how the selected547+ function focusMoved(selection, node, offset) {
493- // text length changed; 0 means the focus could not move.548+ return selection.focusNode !== node || selection.focusOffset !== offset;
549+ }
550+
551+ // extendByOne extends the focus one character and reports whether it moved
552+ // (1) or could not (0). It compares focus positions: reading the whole
553+ // selection's text per step made long selections quadratic.
494 function extendByOne(selection, direction) {554 function extendByOne(selection, direction) {
495- const length = selection.toString().length;555+ const node = selection.focusNode;
556+ const offset = selection.focusOffset;
496 selection.modify("extend", direction, "character");557 selection.modify("extend", direction, "character");
497- return selection.toString().length - length;558+ return focusMoved(selection, node, offset) ? 1 : 0;
498 }559 }
499 560
500- // nextCharacter returns the character after the focus without moving it.561+ // characterAtFocus reads the character next to the focus from its text node.
501- function nextCharacter(selection) {562+ // It returns null when the focus is not inside a text node or sits on its
502- const before = selection.toString();563+ // boundary, where the neighbor lives in another node.
503- if (before.length === 0 || !isBackward(selection)) {564+ function characterAtFocus(selection, forward) {
504- selection.modify("extend", "forward", "character");565+ const node = selection.focusNode;
505- const after = selection.toString();566+ const offset = selection.focusOffset;
506- if (after === before) return undefined;567+ if (!node || node.nodeType !== Node.TEXT_NODE) return null;
507- selection.modify("extend", "backward", "character");568+ const data = node.data;
508- return after[after.length - 1];569+ if (forward) return offset < data.length ? String.fromCodePoint(data.codePointAt(offset)) : null;
570+ if (offset <= 0) return null;
571+ const unit = data.charCodeAt(offset - 1);
572+ const paired = offset > 1 && unit >= 0xdc00 && unit <= 0xdfff;
573+ return paired ? data.slice(offset - 2, offset) : data[offset - 1];
574+ }
575+
576+ // probeCharacter finds the character across a node boundary by stepping the
577+ // focus over it and back. Landing on a text node boundary or outside a text
578+ // node means a block boundary was crossed, which counts as a line break.
579+ function probeCharacter(selection, direction) {
580+ const node = selection.focusNode;
581+ const offset = selection.focusOffset;
582+ selection.modify("extend", direction, "character");
583+ if (!focusMoved(selection, node, offset)) return undefined;
584+ const landed = selection.focusNode;
585+ const landedOffset = selection.focusOffset;
586+ let character = "\n";
587+ if (landed && landed.nodeType === Node.TEXT_NODE) {
588+ if (direction === "forward" && landedOffset > 0) character = landed.data[landedOffset - 1];
589+ else if (direction === "backward" && landedOffset < landed.data.length) character = landed.data[landedOffset];
509 }590 }
510- return before[0];591+ selection.modify("extend", direction === "forward" ? "backward" : "forward", "character");
592+ return character;
593+ }
594+
595+ // nextCharacter returns the character after the focus without moving it, or
596+ // undefined at the end of the document.
597+ function nextCharacter(selection) {
598+ const character = characterAtFocus(selection, true);
599+ return character === null ? probeCharacter(selection, "forward") : character;
511 }600 }
512 601
513 // previousCharacter returns the character before the focus without moving it.602 // previousCharacter returns the character before the focus without moving it.
514 function previousCharacter(selection) {603 function previousCharacter(selection) {
515- const before = selection.toString();604+ const character = characterAtFocus(selection, false);
516- if (before.length === 0 || isBackward(selection)) {605+ return character === null ? probeCharacter(selection, "backward") : character;
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 }606 }
525 607
526 function isWordCharacter(character) {608 function isWordCharacter(character) {
@@ -566,21 +648,13 @@
566 648
567 function firstNonBlank(selection, alter) {649 function firstNonBlank(selection, alter) {
568 selection.modify(alter, "backward", "lineboundary");650 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++) {651 for (let i = 0; i < MAX_MOTION_STEPS; i++) {
580 const character = nextCharacter(selection);652 const character = nextCharacter(selection);
581 if (character === undefined || !/[ \t\u00a0]/.test(character)) break;653 if (character === undefined || !/[ \t\u00a0]/.test(character)) break;
582 if (extendByOne(selection, "forward") === 0) break;654 if (extendByOne(selection, "forward") === 0) break;
583 }655 }
656+ // A caret measures with extend, then settles where the measuring ended.
657+ if (alter === "move") collapseToFocus(selection);
584 }658 }
585 659
586 // extendToLines grows the selection so both ends sit on line boundaries.660 // extendToLines grows the selection so both ends sit on line boundaries.
@@ -629,7 +703,11 @@
629 703
630 // --- Phase transitions ---704 // --- Phase transitions ---
631 705
706+ // toCaret collapses a selection onto its last selected character, so the
707+ // caret returns to the character that v included instead of drifting one
708+ // position right on every v then Escape.
632 function toCaret(selection) {709 function toCaret(selection) {
710+ if (!selection.isCollapsed && !isBackward(selection)) extendByOne(selection, "backward");
633 collapseToFocus(selection);711 collapseToFocus(selection);
634 setPhase("caret");712 setPhase("caret");
635 revealFocus(selection);713 revealFocus(selection);
@@ -637,7 +715,11 @@
637 715
638 function toVisual(selection) {716 function toVisual(selection) {
639 // Include the character under the caret, or the one before at the end.717 // Include the character under the caret, or the one before at the end.
640- if (extendByOne(selection, "forward") === 0) extendByOne(selection, "backward");718+ if (extendByOne(selection, "forward") === 0 && extendByOne(selection, "backward") !== 0) {
719+ // At the end of the text the last character is selected backward; flip
720+ // it so the focus stays at the end, like a forward selection.
721+ swapEnds(selection);
722+ }
641 setPhase("visual");723 setPhase("visual");
642 revealFocus(selection);724 revealFocus(selection);
643 }725 }
@@ -768,6 +850,7 @@
768 function cleanup() {850 function cleanup() {
769 if (state) {851 if (state) {
770 for (const node of [state.root, state.style, state.cursor]) if (node) node.remove();852 for (const node of [state.root, state.style, state.cursor]) if (node) node.remove();
853+ if (state.frame) window.cancelAnimationFrame(state.frame);
771 if (state.onViewportChange) {854 if (state.onViewportChange) {
772 window.removeEventListener("scroll", state.onViewportChange, { capture: true });855 window.removeEventListener("scroll", state.onViewportChange, { capture: true });
773 window.removeEventListener("resize", state.onViewportChange);856 window.removeEventListener("resize", state.onViewportChange);
@@ -783,21 +866,30 @@
783 if (token) post(token, message);866 if (token) post(token, message);
784 }867 }
785 868
869+ // A document leaving the page (navigation, bfcache) takes the overlay along.
870+ window.addEventListener("pagehide", () => { if (state) cleanup(); });
871+
786 Object.defineProperty(window, "__dumberVimPage", {872 Object.defineProperty(window, "__dumberVimPage", {
787 configurable: false,873 configurable: false,
788 enumerable: false,874 enumerable: false,
789 value: Object.freeze({875 value: Object.freeze({
790 start(token, request) {876 start(token, request) {
791- cleanup();877+ try {
792- const color = request.color || "#fbbf24";878+ cleanup();
793- if (request.kind === "yank") {879+ const color = request.color || "#fbbf24";
794- const text = objectText(request.object, color).trim();880+ if (request.kind === "yank") {
795- post(token, text ? { type: "copy", text } : { type: "end" });881+ const text = objectText(request.object, color).trim();
796- return;882+ post(token, text ? { type: "copy", text } : { type: "end" });
883+ return;
884+ }
885+ state = { kind: request.kind, token, color };
886+ const started = request.kind === "visual" ? startVisual(color) : startHints(request.kind, color);
887+ if (!started) finish({ type: "end", reason: request.kind === "visual" ? "no-visible-text" : "no-visible-targets" });
888+ } catch (_) {
889+ // A page quirk must never leave the user's keys captured.
890+ cleanup();
891+ post(token, { type: "end", reason: "error" });
797 }892 }
798- state = { kind: request.kind, token, color };
799- const started = request.kind === "visual" ? startVisual(color) : startHints(request.kind, color);
800- if (!started) finish({ type: "end", reason: request.kind === "visual" ? "no-visible-text" : "no-visible-targets" });
801 },893 },
802 key(token, key) {894 key(token, key) {
803 if (!state || state.token !== token) {895 if (!state || state.token !== token) {
@@ -816,9 +908,13 @@
816 },908 },
817 cancel(token) {909 cancel(token) {
818 if (!state || state.token !== token) return;910 if (!state || state.token !== token) return;
819- if (state.kind === "visual" && state.phase !== "hints") {911+ try {
820- const selection = window.getSelection();912+ if (state.kind === "visual" && state.phase !== "hints") {
821- if (selection) selection.removeAllRanges();913+ const selection = window.getSelection();
914+ if (selection) selection.removeAllRanges();
915+ }
916+ } catch (_) {
917+ // Fall through: the overlay must go even when the selection cannot.
822 }918 }
823 cleanup();919 cleanup();
824 },920 },
M internal/infrastructure/cef/vim_page_runtime_test.go +267 -22
@@ -3,6 +3,7 @@ package cef
33 import (
44 _ "embed"
55 "encoding/json"
6+ "os"
67 "os/exec"
78 "strings"
89 "testing"
@@ -25,6 +26,9 @@ func runVimPageRuntime(t *testing.T, driver string) []map[string]any {
2526 t.Helper()
2627 node, err := exec.LookPath("node")
2728 if err != nil {
29+ if os.Getenv("DUMBER_REQUIRE_NODE") == "1" {
30+ t.Fatal("node is required (DUMBER_REQUIRE_NODE=1) to execute the vim page runtime")
31+ }
2832 t.Skip("node is required to execute the vim page runtime")
2933 }
3034 script := vimPageRuntimeHarness + "\n" + vimPageRuntimeTemplateJS + "\n" + driver +
@@ -69,16 +73,6 @@ func modeSequence(posted []map[string]any) []any {
6973 return out
7074 }
7175
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-
8276 func TestVimPageRuntimeVisualStartsWithTextAnchorHints(t *testing.T) {
8377 posted := runVimPageRuntime(t, `
8478 window.__dumberVimPage.start("tok", { kind: "visual", color: "#fbbf24" });
@@ -213,10 +207,6 @@ func TestVimPageRuntimeCaretMotionsMoveWithoutSelecting(t *testing.T) {
213207 {"k", "a", []string{"l", "k"}, "0:1"},
214208 {"G", "s", []string{"G"}, "3:18"},
215209 {"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"},
220210 }
221211 for _, tt := range tests {
222212 t.Run(tt.name, func(t *testing.T) {
@@ -236,6 +226,42 @@ S("end");
236226 }
237227 }
238228
229+// Sentence and paragraph motions are native Selection.modify granularities
230+// the DOM shim cannot emulate, so the contract checked here is the call: the
231+// alter mode follows the phase and the direction and granularity are exact.
232+func TestVimPageRuntimeNativeGranularityMotions(t *testing.T) {
233+ tests := []struct {
234+ key string
235+ direction string
236+ granularity string
237+ }{
238+ {"(", "backward", "sentence"},
239+ {")", "forward", "sentence"},
240+ {"{", "backward", "paragraph"},
241+ {"}", "forward", "paragraph"},
242+ {"0", "backward", "lineboundary"},
243+ {"$", "forward", "lineboundary"},
244+ {"G", "forward", "documentboundary"},
245+ }
246+ for _, tt := range tests {
247+ for _, phase := range []struct{ name, setup, alter string }{
248+ {"caret", "", "move"},
249+ {"visual", `keys("tok", "v");`, "extend"},
250+ } {
251+ t.Run(phase.name+" "+tt.key, func(t *testing.T) {
252+ posted := runVimPageRuntime(t, `
253+startCaret("tok", "s");
254+`+phase.setup+`
255+modifyCalls.length = 0;
256+keys("tok", `+jsString(tt.key)+`);
257+posted.push({ n: "calls", calls: modifyCalls });
258+`)
259+ require.Equal(t, []any{[]any{phase.alter, tt.direction, tt.granularity}}, named(t, posted, "calls")["calls"])
260+ })
261+ }
262+ }
263+}
264+
239265 func TestVimPageRuntimeWordMotionsSkipPunctuation(t *testing.T) {
240266 posted := runVimPageRuntime(t, `
241267 startCaret("tok", "d");
@@ -271,9 +297,11 @@ startCaret("tok", "s");
271297 const bar = cursor();
272298 first.p.rect = rect(10, -300, 600, 20); // the page scrolled the paragraph away
273299 fire("scroll");
300+flushFrames();
274301 const hiddenWhileOffscreen = bar.style.display;
275302 first.p.rect = rect(10, 100, 600, 20);
276303 fire("scroll");
304+flushFrames();
277305 posted.push({ n: "follow", hidden: hiddenWhileOffscreen, top: bar.style.top, display: bar.style.display });
278306 keys("tok", "<Escape>");
279307 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 });
@@ -316,15 +344,15 @@ S("afterReturn");
316344
317345 after := named(t, posted, "after")
318346 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")
347+ require.Equal(t, "0:5", after["focus"], "on the last selected character, where v put the caret")
320348 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")
349+ require.Equal(t, "0:6", named(t, posted, "moved")["focus"], "the caret keeps moving after a yank")
350+ require.Equal(t, "0:6", named(t, posted, "caretYank")["focus"], "y in caret mode does nothing")
323351
324352 copies := bridgeMessagesOfType(posted, "copy")
325353 require.Len(t, copies, 2, "y in caret mode copies nothing; Enter chains a second copy")
326354 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")
355+ require.Equal(t, "bravo", copies[1]["text"], "v includes the character under the caret and e extends to the word end")
328356 require.Empty(t, named(t, posted, "afterReturn")["text"])
329357 require.Empty(t, bridgeMessagesOfType(posted, "end"), "copying never ends the interaction")
330358 require.Equal(t, []any{"hints", "caret", "visual", "caret", "visual", "caret"}, modeSequence(posted))
@@ -482,7 +510,7 @@ vpage.start("d", { kind: "hint-follow" });
482510 vpage.key("d", "<Escape>");
483511 `)
484512 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")
513+ require.Equal(t, "https://example.com/two", recordedField(posted, "clicked"), "follow clicks the chosen link")
486514 messages := bridgeMessages(posted)
487515 require.Equal(t, []map[string]any{
488516 {"token": "a", "type": "end"},
@@ -493,8 +521,8 @@ vpage.key("d", "<Escape>");
493521 require.NotContains(t, modeSequence(posted), "caret", "link hints never report caret modes")
494522 }
495523
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 {
524+// recordedField returns a field recorded by a harness side effect (such as a click).
525+func recordedField(posted []map[string]any, key string) any {
498526 for _, entry := range posted {
499527 if value, ok := entry[key]; ok {
500528 return value
@@ -505,7 +533,7 @@ func named2(posted []map[string]any, key string) any {
505533
506534 func TestVimPageRuntimeYankParagraph(t *testing.T) {
507535 posted := runVimPageRuntime(t, `
508-p.innerText = TEXT;
536+first.p.innerText = first.text.data;
509537 window.__dumberVimPage.start("tok", { kind: "yank", object: "paragraph" });
510538 `)
511539 messages := bridgeMessages(posted)
@@ -523,3 +551,220 @@ posted.push({ n: "live", selection: selection.rangeCount });
523551 require.Equal(t, []map[string]any{{"token": "stale", "type": "end"}}, bridgeMessagesOfType(posted, "end"))
524552 require.EqualValues(t, 1, named(t, posted, "live")["selection"], "a stale key must not disturb the live interaction")
525553 }
554+
555+// Entering visual selects the character under the caret and leaving it must
556+// put the caret back on that character; the caret used to move one position
557+// right on every v then Escape.
558+func TestVimPageRuntimeVisualRoundTripDoesNotDriftCaret(t *testing.T) {
559+ posted := runVimPageRuntime(t, `
560+startCaret("tok", "s");
561+keys("tok", "l", "l");
562+S("start");
563+for (let i = 0; i < 5; i++) keys("tok", "v", "<Escape>");
564+S("afterEscapes");
565+for (let i = 0; i < 3; i++) keys("tok", "v", "v");
566+S("afterToggles");
567+for (let i = 0; i < 3; i++) keys("tok", "v", "y");
568+S("afterYanks");
569+keys("tok", "V", "V");
570+S("afterLine");
571+`)
572+ start := named(t, posted, "start")
573+ require.Equal(t, "0:2", start["focus"])
574+ for _, name := range []string{"afterEscapes", "afterToggles", "afterYanks"} {
575+ after := named(t, posted, name)
576+ require.Equal(t, start["focus"], after["focus"], name)
577+ require.Equal(t, start["anchor"], after["anchor"], name)
578+ require.Empty(t, after["text"], name)
579+ }
580+ copies := bridgeMessagesOfType(posted, "copy")
581+ require.Len(t, copies, 3)
582+ for _, c := range copies {
583+ require.Equal(t, "p", c["text"], "each yank copies the character under the caret")
584+ }
585+ require.Equal(t, "0:18", named(t, posted, "afterLine")["focus"], "V then V returns to the end of the line")
586+}
587+
588+func TestVimPageRuntimeVisualRoundTripAtEndOfTextKeepsCaret(t *testing.T) {
589+ posted := runVimPageRuntime(t, `
590+startCaret("tok", "d");
591+keys("tok", "G");
592+S("end");
593+keys("tok", "v");
594+S("visual");
595+keys("tok", "<Escape>");
596+S("back");
597+`)
598+ end := named(t, posted, "end")
599+ require.Equal(t, "3:18", end["focus"])
600+ visual := named(t, posted, "visual")
601+ require.Equal(t, "d", visual["text"], "at the end of the text v selects the last character")
602+ require.Equal(t, "3:18", visual["focus"])
603+ require.Equal(t, "3:17", named(t, posted, "back")["focus"], "the caret returns onto the last character")
604+}
605+
606+// Motions must not read the whole selection text per step: that made long
607+// selections quadratic.
608+func TestVimPageRuntimeMotionsDoNotReadSelectionText(t *testing.T) {
609+ posted := runVimPageRuntime(t, `
610+startCaret("tok", "s");
611+keys("tok", "v");
612+toStringCalls = 0;
613+keys("tok", "w", "w", "e", "b", "$", "^");
614+posted.push({ n: "reads", calls: toStringCalls });
615+S("sel");
616+`)
617+ require.EqualValues(t, 0, named(t, posted, "reads")["calls"])
618+ require.NotEmpty(t, named(t, posted, "sel")["focus"])
619+}
620+
621+func TestVimPageRuntimeStartAndCancelFailuresReleaseCapture(t *testing.T) {
622+ posted := runVimPageRuntime(t, `
623+const realGetSelection = window.getSelection;
624+window.getSelection = () => { throw new Error("page quirk"); };
625+window.__dumberVimPage.start("bad", { kind: "visual" });
626+window.getSelection = realGetSelection;
627+posted.push({ n: "afterStart", hints: hintLabels().length, caret: cursor() === null });
628+
629+startCaret("tok", "s");
630+selection.removeAllRanges = () => { throw new Error("page quirk"); };
631+vp().cancel("tok");
632+posted.push({ n: "afterCancel", caret: cursor() === null, styles: overlays("data-dumber-vim-visual").length, listeners: (listeners.scroll || []).length });
633+`)
634+ ends := bridgeMessagesOfType(posted, "end")
635+ require.Equal(t, []map[string]any{{"token": "bad", "type": "end", "reason": "error"}}, ends, "a failing start reports the end")
636+ afterStart := named(t, posted, "afterStart")
637+ require.EqualValues(t, 0, afterStart["hints"])
638+ cancel := named(t, posted, "afterCancel")
639+ require.Equal(t, true, cancel["caret"], "cancel removes the overlay even when the selection cannot be cleared")
640+ require.EqualValues(t, 0, cancel["styles"])
641+ require.EqualValues(t, 0, cancel["listeners"])
642+}
643+
644+func TestVimPageRuntimePageHideCleansUp(t *testing.T) {
645+ posted := runVimPageRuntime(t, `
646+startCaret("tok", "s");
647+fire("pagehide");
648+posted.push({ n: "gone", caret: cursor() === null, scroll: (listeners.scroll || []).length, frames: frames.size });
649+fire("pagehide"); // without an interaction it is a no-op
650+`)
651+ gone := named(t, posted, "gone")
652+ require.Equal(t, true, gone["caret"])
653+ require.EqualValues(t, 0, gone["scroll"])
654+ require.Empty(t, bridgeMessagesOfType(posted, "end"), "the document is leaving; nothing is reported")
655+}
656+
657+// SVG anchors expose href as an SVGAnimatedString and have no click().
658+func TestVimPageRuntimeSVGLinks(t *testing.T) {
659+ posted := runVimPageRuntime(t, `
660+const svg = body.appendChild(new SVGAElement_("../rel.html"));
661+svg.rect = rect(150, 120, 40, 20);
662+const w = window.__dumberVimPage;
663+w.start("y", { kind: "hint-yank-url" });
664+w.key("y", "d");
665+w.start("n", { kind: "hint-follow-new" });
666+w.key("n", "d");
667+w.start("f", { kind: "hint-follow" });
668+w.key("f", "d");
669+`)
670+ require.Equal(t, []map[string]any{
671+ {"token": "y", "type": "copy", "text": "https://example.com/rel.html"},
672+ {"token": "n", "type": "open-new", "url": "https://example.com/rel.html"},
673+ {"token": "f", "type": "end"},
674+ }, bridgeMessages(posted), "relative SVG hrefs resolve against the document")
675+ require.Equal(t, "click", recordedField(posted, "dispatched"), "following dispatches a click when the element has no click()")
676+ require.Equal(t, true, recordedField(posted, "bubbles"))
677+ require.Equal(t, true, recordedField(posted, "cancelable"))
678+}
679+
680+// Chromium reports Selection positions inside a shadow tree relative to the
681+// host, so shadow text never anchors the caret, while link hints and text
682+// objects keep reaching into shadow roots.
683+func TestVimPageRuntimeShadowTextIsNotACaretAnchor(t *testing.T) {
684+ posted := runVimPageRuntime(t, `
685+shadowParagraph("Shadow text that is visible", 200);
686+window.__dumberVimPage.start("tok", { kind: "visual" });
687+posted.push({ n: "hints", labels: hintLabels() });
688+vp().key("tok", "<Escape>");
689+S("fallback");
690+vp().cancel("tok");
691+
692+for (const t of texts) if (t !== texts[texts.length - 1]) t.parentElement.rect = rect(10, 5000, 600, 20);
693+window.__dumberVimPage.start("only", { kind: "visual" });
694+`)
695+ require.Equal(t, []any{"s", "a", "d"}, named(t, posted, "hints")["labels"], "no label for the shadow paragraph")
696+ require.Equal(t, "1:0", named(t, posted, "fallback")["focus"], "the fallback anchor ignores shadow text too")
697+ ends := bridgeMessagesOfType(posted, "end")
698+ require.Len(t, ends, 1)
699+ require.Equal(t, "no-visible-text", ends[0]["reason"], "shadow text alone offers no anchor")
700+}
701+
702+func TestVimPageRuntimeYankObjectStillReadsShadowDOM(t *testing.T) {
703+ posted := runVimPageRuntime(t, `
704+for (const t of texts.slice(0, 4)) t.parentElement.rect = rect(10, 5000, 600, 20);
705+const shadow = shadowParagraph("Inside shadow", 200);
706+shadow.p.innerText = "Inside shadow";
707+window.__dumberVimPage.start("tok", { kind: "yank", object: "paragraph" });
708+`)
709+ require.Equal(t, []map[string]any{{"token": "tok", "type": "copy", "text": "Inside shadow"}}, bridgeMessages(posted))
710+}
711+
712+// Off-screen elements are rejected from their bounding box, before the costly
713+// closest() and range measurements run.
714+func TestVimPageRuntimeSkipsOffscreenWork(t *testing.T) {
715+ posted := runVimPageRuntime(t, `
716+for (let i = 0; i < 5; i++) {
717+ const link = body.appendChild(new HTMLAnchorElement("https://example.com/" + i));
718+ link.rect = rect(10, 4000 + i * 30, 40, 20);
719+}
720+closestCalls = 0;
721+window.__dumberVimPage.start("h", { kind: "hint-follow" });
722+posted.push({ n: "hints", closest: closestCalls, labels: hintLabels().length });
723+vp().cancel("h");
724+rangeRectCalls = 0;
725+window.__dumberVimPage.start("v", { kind: "visual" });
726+posted.push({ n: "anchors", ranges: rangeRectCalls, labels: hintLabels().length });
727+`)
728+ hints := named(t, posted, "hints")
729+ require.EqualValues(t, 2, hints["labels"])
730+ require.EqualValues(t, 2, hints["closest"], "only the two on-screen links reach closest()")
731+ anchors := named(t, posted, "anchors")
732+ require.EqualValues(t, 3, anchors["labels"])
733+ require.EqualValues(t, 3, anchors["ranges"], "the paragraph below the fold is never measured")
734+}
735+
736+// Scroll and resize repaint the caret once per animation frame, and a pending
737+// frame is canceled with the interaction.
738+func TestVimPageRuntimeBatchesViewportUpdatesPerFrame(t *testing.T) {
739+ posted := runVimPageRuntime(t, `
740+startCaret("tok", "s");
741+const bar = cursor();
742+first.p.rect = rect(10, 100, 600, 20);
743+for (let i = 0; i < 10; i++) { fire("scroll"); fire("resize"); }
744+const pending = frames.size;
745+const stale = bar.style.top;
746+flushFrames();
747+posted.push({ n: "batched", pending, stale, top: bar.style.top });
748+fire("scroll");
749+const queued = frames.size;
750+vp().cancel("tok");
751+posted.push({ n: "canceled", queued, left: frames.size });
752+`)
753+ batched := named(t, posted, "batched")
754+ require.EqualValues(t, 1, batched["pending"], "a burst of events schedules one repaint")
755+ require.NotEqual(t, batched["stale"], batched["top"], "the repaint happens in the frame")
756+ canceled := named(t, posted, "canceled")
757+ require.EqualValues(t, 1, canceled["queued"])
758+ require.EqualValues(t, 0, canceled["left"], "cleanup cancels a pending frame")
759+}
760+
761+// The bridge carries UTF-8: accents, CJK and astral characters must reach Go
762+// exactly as the page holds them.
763+func TestVimPageRuntimeCopiesNonASCIIText(t *testing.T) {
764+ const text = "héllo wörld — 日本語 😀 ñ"
765+ posted := runVimPageRuntime(t, `
766+first.p.innerText = `+jsString(text)+`;
767+window.__dumberVimPage.start("tok", { kind: "yank", object: "paragraph" });
768+`)
769+ require.Equal(t, []map[string]any{{"token": "tok", "type": "copy", "text": text}}, bridgeMessages(posted))
770+}
@@ -3,6 +3,7 @@ package cef
3 import (3 import (
4 _ "embed"4 _ "embed"
5 "encoding/json"5 "encoding/json"
6+ "os"
6 "os/exec"7 "os/exec"
7 "strings"8 "strings"
8 "testing"9 "testing"
@@ -25,6 +26,9 @@ func runVimPageRuntime(t *testing.T, driver string) []map[string]any {
25 t.Helper()26 t.Helper()
26 node, err := exec.LookPath("node")27 node, err := exec.LookPath("node")
27 if err != nil {28 if err != nil {
29+ if os.Getenv("DUMBER_REQUIRE_NODE") == "1" {
30+ t.Fatal("node is required (DUMBER_REQUIRE_NODE=1) to execute the vim page runtime")
31+ }
28 t.Skip("node is required to execute the vim page runtime")32 t.Skip("node is required to execute the vim page runtime")
29 }33 }
30 script := vimPageRuntimeHarness + "\n" + vimPageRuntimeTemplateJS + "\n" + driver +34 script := vimPageRuntimeHarness + "\n" + vimPageRuntimeTemplateJS + "\n" + driver +
@@ -69,16 +73,6 @@ func modeSequence(posted []map[string]any) []any {
69 return out73 return out
70 }74 }
71 75
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) {76 func TestVimPageRuntimeVisualStartsWithTextAnchorHints(t *testing.T) {
83 posted := runVimPageRuntime(t, `77 posted := runVimPageRuntime(t, `
84 window.__dumberVimPage.start("tok", { kind: "visual", color: "#fbbf24" });78 window.__dumberVimPage.start("tok", { kind: "visual", color: "#fbbf24" });
@@ -213,10 +207,6 @@ func TestVimPageRuntimeCaretMotionsMoveWithoutSelecting(t *testing.T) {
213 {"k", "a", []string{"l", "k"}, "0:1"},207 {"k", "a", []string{"l", "k"}, "0:1"},
214 {"G", "s", []string{"G"}, "3:18"},208 {"G", "s", []string{"G"}, "3:18"},
215 {"gg", "a", []string{"G", "g", "g"}, "0:0"},209 {"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 }210 }
221 for _, tt := range tests {211 for _, tt := range tests {
222 t.Run(tt.name, func(t *testing.T) {212 t.Run(tt.name, func(t *testing.T) {
@@ -236,6 +226,42 @@ S("end");
236 }226 }
237 }227 }
238 228
229+// Sentence and paragraph motions are native Selection.modify granularities
230+// the DOM shim cannot emulate, so the contract checked here is the call: the
231+// alter mode follows the phase and the direction and granularity are exact.
232+func TestVimPageRuntimeNativeGranularityMotions(t *testing.T) {
233+ tests := []struct {
234+ key string
235+ direction string
236+ granularity string
237+ }{
238+ {"(", "backward", "sentence"},
239+ {")", "forward", "sentence"},
240+ {"{", "backward", "paragraph"},
241+ {"}", "forward", "paragraph"},
242+ {"0", "backward", "lineboundary"},
243+ {"$", "forward", "lineboundary"},
244+ {"G", "forward", "documentboundary"},
245+ }
246+ for _, tt := range tests {
247+ for _, phase := range []struct{ name, setup, alter string }{
248+ {"caret", "", "move"},
249+ {"visual", `keys("tok", "v");`, "extend"},
250+ } {
251+ t.Run(phase.name+" "+tt.key, func(t *testing.T) {
252+ posted := runVimPageRuntime(t, `
253+startCaret("tok", "s");
254+`+phase.setup+`
255+modifyCalls.length = 0;
256+keys("tok", `+jsString(tt.key)+`);
257+posted.push({ n: "calls", calls: modifyCalls });
258+`)
259+ require.Equal(t, []any{[]any{phase.alter, tt.direction, tt.granularity}}, named(t, posted, "calls")["calls"])
260+ })
261+ }
262+ }
263+}
264+
239 func TestVimPageRuntimeWordMotionsSkipPunctuation(t *testing.T) {265 func TestVimPageRuntimeWordMotionsSkipPunctuation(t *testing.T) {
240 posted := runVimPageRuntime(t, `266 posted := runVimPageRuntime(t, `
241 startCaret("tok", "d");267 startCaret("tok", "d");
@@ -271,9 +297,11 @@ startCaret("tok", "s");
271 const bar = cursor();297 const bar = cursor();
272 first.p.rect = rect(10, -300, 600, 20); // the page scrolled the paragraph away298 first.p.rect = rect(10, -300, 600, 20); // the page scrolled the paragraph away
273 fire("scroll");299 fire("scroll");
300+flushFrames();
274 const hiddenWhileOffscreen = bar.style.display;301 const hiddenWhileOffscreen = bar.style.display;
275 first.p.rect = rect(10, 100, 600, 20);302 first.p.rect = rect(10, 100, 600, 20);
276 fire("scroll");303 fire("scroll");
304+flushFrames();
277 posted.push({ n: "follow", hidden: hiddenWhileOffscreen, top: bar.style.top, display: bar.style.display });305 posted.push({ n: "follow", hidden: hiddenWhileOffscreen, top: bar.style.top, display: bar.style.display });
278 keys("tok", "<Escape>");306 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 });307 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 });
@@ -316,15 +344,15 @@ S("afterReturn");
316 344
317 after := named(t, posted, "after")345 after := named(t, posted, "after")
318 require.Empty(t, after["text"], "y returns to a collapsed caret")346 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")347+ require.Equal(t, "0:5", after["focus"], "on the last selected character, where v put the caret")
320 require.Equal(t, after["focus"], after["anchor"])348 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")349+ require.Equal(t, "0:6", 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")350+ require.Equal(t, "0:6", named(t, posted, "caretYank")["focus"], "y in caret mode does nothing")
323 351
324 copies := bridgeMessagesOfType(posted, "copy")352 copies := bridgeMessagesOfType(posted, "copy")
325 require.Len(t, copies, 2, "y in caret mode copies nothing; Enter chains a second copy")353 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])354 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")355+ require.Equal(t, "bravo", 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"])356 require.Empty(t, named(t, posted, "afterReturn")["text"])
329 require.Empty(t, bridgeMessagesOfType(posted, "end"), "copying never ends the interaction")357 require.Empty(t, bridgeMessagesOfType(posted, "end"), "copying never ends the interaction")
330 require.Equal(t, []any{"hints", "caret", "visual", "caret", "visual", "caret"}, modeSequence(posted))358 require.Equal(t, []any{"hints", "caret", "visual", "caret", "visual", "caret"}, modeSequence(posted))
@@ -482,7 +510,7 @@ vpage.start("d", { kind: "hint-follow" });
482 vpage.key("d", "<Escape>");510 vpage.key("d", "<Escape>");
483 `)511 `)
484 require.ElementsMatch(t, []any{"s", "a"}, named(t, posted, "labels")["labels"], "two visible links get one-letter labels")512 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")513+ require.Equal(t, "https://example.com/two", recordedField(posted, "clicked"), "follow clicks the chosen link")
486 messages := bridgeMessages(posted)514 messages := bridgeMessages(posted)
487 require.Equal(t, []map[string]any{515 require.Equal(t, []map[string]any{
488 {"token": "a", "type": "end"},516 {"token": "a", "type": "end"},
@@ -493,8 +521,8 @@ vpage.key("d", "<Escape>");
493 require.NotContains(t, modeSequence(posted), "caret", "link hints never report caret modes")521 require.NotContains(t, modeSequence(posted), "caret", "link hints never report caret modes")
494 }522 }
495 523
496-// named2 returns a field recorded by a harness side effect (such as a click).524+// recordedField returns a field recorded by a harness side effect (such as a click).
497-func named2(posted []map[string]any, key string) any {525+func recordedField(posted []map[string]any, key string) any {
498 for _, entry := range posted {526 for _, entry := range posted {
499 if value, ok := entry[key]; ok {527 if value, ok := entry[key]; ok {
500 return value528 return value
@@ -505,7 +533,7 @@ func named2(posted []map[string]any, key string) any {
505 533
506 func TestVimPageRuntimeYankParagraph(t *testing.T) {534 func TestVimPageRuntimeYankParagraph(t *testing.T) {
507 posted := runVimPageRuntime(t, `535 posted := runVimPageRuntime(t, `
508-p.innerText = TEXT;536+first.p.innerText = first.text.data;
509 window.__dumberVimPage.start("tok", { kind: "yank", object: "paragraph" });537 window.__dumberVimPage.start("tok", { kind: "yank", object: "paragraph" });
510 `)538 `)
511 messages := bridgeMessages(posted)539 messages := bridgeMessages(posted)
@@ -523,3 +551,220 @@ posted.push({ n: "live", selection: selection.rangeCount });
523 require.Equal(t, []map[string]any{{"token": "stale", "type": "end"}}, bridgeMessagesOfType(posted, "end"))551 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")552 require.EqualValues(t, 1, named(t, posted, "live")["selection"], "a stale key must not disturb the live interaction")
525 }553 }
554+
555+// Entering visual selects the character under the caret and leaving it must
556+// put the caret back on that character; the caret used to move one position
557+// right on every v then Escape.
558+func TestVimPageRuntimeVisualRoundTripDoesNotDriftCaret(t *testing.T) {
559+ posted := runVimPageRuntime(t, `
560+startCaret("tok", "s");
561+keys("tok", "l", "l");
562+S("start");
563+for (let i = 0; i < 5; i++) keys("tok", "v", "<Escape>");
564+S("afterEscapes");
565+for (let i = 0; i < 3; i++) keys("tok", "v", "v");
566+S("afterToggles");
567+for (let i = 0; i < 3; i++) keys("tok", "v", "y");
568+S("afterYanks");
569+keys("tok", "V", "V");
570+S("afterLine");
571+`)
572+ start := named(t, posted, "start")
573+ require.Equal(t, "0:2", start["focus"])
574+ for _, name := range []string{"afterEscapes", "afterToggles", "afterYanks"} {
575+ after := named(t, posted, name)
576+ require.Equal(t, start["focus"], after["focus"], name)
577+ require.Equal(t, start["anchor"], after["anchor"], name)
578+ require.Empty(t, after["text"], name)
579+ }
580+ copies := bridgeMessagesOfType(posted, "copy")
581+ require.Len(t, copies, 3)
582+ for _, c := range copies {
583+ require.Equal(t, "p", c["text"], "each yank copies the character under the caret")
584+ }
585+ require.Equal(t, "0:18", named(t, posted, "afterLine")["focus"], "V then V returns to the end of the line")
586+}
587+
588+func TestVimPageRuntimeVisualRoundTripAtEndOfTextKeepsCaret(t *testing.T) {
589+ posted := runVimPageRuntime(t, `
590+startCaret("tok", "d");
591+keys("tok", "G");
592+S("end");
593+keys("tok", "v");
594+S("visual");
595+keys("tok", "<Escape>");
596+S("back");
597+`)
598+ end := named(t, posted, "end")
599+ require.Equal(t, "3:18", end["focus"])
600+ visual := named(t, posted, "visual")
601+ require.Equal(t, "d", visual["text"], "at the end of the text v selects the last character")
602+ require.Equal(t, "3:18", visual["focus"])
603+ require.Equal(t, "3:17", named(t, posted, "back")["focus"], "the caret returns onto the last character")
604+}
605+
606+// Motions must not read the whole selection text per step: that made long
607+// selections quadratic.
608+func TestVimPageRuntimeMotionsDoNotReadSelectionText(t *testing.T) {
609+ posted := runVimPageRuntime(t, `
610+startCaret("tok", "s");
611+keys("tok", "v");
612+toStringCalls = 0;
613+keys("tok", "w", "w", "e", "b", "$", "^");
614+posted.push({ n: "reads", calls: toStringCalls });
615+S("sel");
616+`)
617+ require.EqualValues(t, 0, named(t, posted, "reads")["calls"])
618+ require.NotEmpty(t, named(t, posted, "sel")["focus"])
619+}
620+
621+func TestVimPageRuntimeStartAndCancelFailuresReleaseCapture(t *testing.T) {
622+ posted := runVimPageRuntime(t, `
623+const realGetSelection = window.getSelection;
624+window.getSelection = () => { throw new Error("page quirk"); };
625+window.__dumberVimPage.start("bad", { kind: "visual" });
626+window.getSelection = realGetSelection;
627+posted.push({ n: "afterStart", hints: hintLabels().length, caret: cursor() === null });
628+
629+startCaret("tok", "s");
630+selection.removeAllRanges = () => { throw new Error("page quirk"); };
631+vp().cancel("tok");
632+posted.push({ n: "afterCancel", caret: cursor() === null, styles: overlays("data-dumber-vim-visual").length, listeners: (listeners.scroll || []).length });
633+`)
634+ ends := bridgeMessagesOfType(posted, "end")
635+ require.Equal(t, []map[string]any{{"token": "bad", "type": "end", "reason": "error"}}, ends, "a failing start reports the end")
636+ afterStart := named(t, posted, "afterStart")
637+ require.EqualValues(t, 0, afterStart["hints"])
638+ cancel := named(t, posted, "afterCancel")
639+ require.Equal(t, true, cancel["caret"], "cancel removes the overlay even when the selection cannot be cleared")
640+ require.EqualValues(t, 0, cancel["styles"])
641+ require.EqualValues(t, 0, cancel["listeners"])
642+}
643+
644+func TestVimPageRuntimePageHideCleansUp(t *testing.T) {
645+ posted := runVimPageRuntime(t, `
646+startCaret("tok", "s");
647+fire("pagehide");
648+posted.push({ n: "gone", caret: cursor() === null, scroll: (listeners.scroll || []).length, frames: frames.size });
649+fire("pagehide"); // without an interaction it is a no-op
650+`)
651+ gone := named(t, posted, "gone")
652+ require.Equal(t, true, gone["caret"])
653+ require.EqualValues(t, 0, gone["scroll"])
654+ require.Empty(t, bridgeMessagesOfType(posted, "end"), "the document is leaving; nothing is reported")
655+}
656+
657+// SVG anchors expose href as an SVGAnimatedString and have no click().
658+func TestVimPageRuntimeSVGLinks(t *testing.T) {
659+ posted := runVimPageRuntime(t, `
660+const svg = body.appendChild(new SVGAElement_("../rel.html"));
661+svg.rect = rect(150, 120, 40, 20);
662+const w = window.__dumberVimPage;
663+w.start("y", { kind: "hint-yank-url" });
664+w.key("y", "d");
665+w.start("n", { kind: "hint-follow-new" });
666+w.key("n", "d");
667+w.start("f", { kind: "hint-follow" });
668+w.key("f", "d");
669+`)
670+ require.Equal(t, []map[string]any{
671+ {"token": "y", "type": "copy", "text": "https://example.com/rel.html"},
672+ {"token": "n", "type": "open-new", "url": "https://example.com/rel.html"},
673+ {"token": "f", "type": "end"},
674+ }, bridgeMessages(posted), "relative SVG hrefs resolve against the document")
675+ require.Equal(t, "click", recordedField(posted, "dispatched"), "following dispatches a click when the element has no click()")
676+ require.Equal(t, true, recordedField(posted, "bubbles"))
677+ require.Equal(t, true, recordedField(posted, "cancelable"))
678+}
679+
680+// Chromium reports Selection positions inside a shadow tree relative to the
681+// host, so shadow text never anchors the caret, while link hints and text
682+// objects keep reaching into shadow roots.
683+func TestVimPageRuntimeShadowTextIsNotACaretAnchor(t *testing.T) {
684+ posted := runVimPageRuntime(t, `
685+shadowParagraph("Shadow text that is visible", 200);
686+window.__dumberVimPage.start("tok", { kind: "visual" });
687+posted.push({ n: "hints", labels: hintLabels() });
688+vp().key("tok", "<Escape>");
689+S("fallback");
690+vp().cancel("tok");
691+
692+for (const t of texts) if (t !== texts[texts.length - 1]) t.parentElement.rect = rect(10, 5000, 600, 20);
693+window.__dumberVimPage.start("only", { kind: "visual" });
694+`)
695+ require.Equal(t, []any{"s", "a", "d"}, named(t, posted, "hints")["labels"], "no label for the shadow paragraph")
696+ require.Equal(t, "1:0", named(t, posted, "fallback")["focus"], "the fallback anchor ignores shadow text too")
697+ ends := bridgeMessagesOfType(posted, "end")
698+ require.Len(t, ends, 1)
699+ require.Equal(t, "no-visible-text", ends[0]["reason"], "shadow text alone offers no anchor")
700+}
701+
702+func TestVimPageRuntimeYankObjectStillReadsShadowDOM(t *testing.T) {
703+ posted := runVimPageRuntime(t, `
704+for (const t of texts.slice(0, 4)) t.parentElement.rect = rect(10, 5000, 600, 20);
705+const shadow = shadowParagraph("Inside shadow", 200);
706+shadow.p.innerText = "Inside shadow";
707+window.__dumberVimPage.start("tok", { kind: "yank", object: "paragraph" });
708+`)
709+ require.Equal(t, []map[string]any{{"token": "tok", "type": "copy", "text": "Inside shadow"}}, bridgeMessages(posted))
710+}
711+
712+// Off-screen elements are rejected from their bounding box, before the costly
713+// closest() and range measurements run.
714+func TestVimPageRuntimeSkipsOffscreenWork(t *testing.T) {
715+ posted := runVimPageRuntime(t, `
716+for (let i = 0; i < 5; i++) {
717+ const link = body.appendChild(new HTMLAnchorElement("https://example.com/" + i));
718+ link.rect = rect(10, 4000 + i * 30, 40, 20);
719+}
720+closestCalls = 0;
721+window.__dumberVimPage.start("h", { kind: "hint-follow" });
722+posted.push({ n: "hints", closest: closestCalls, labels: hintLabels().length });
723+vp().cancel("h");
724+rangeRectCalls = 0;
725+window.__dumberVimPage.start("v", { kind: "visual" });
726+posted.push({ n: "anchors", ranges: rangeRectCalls, labels: hintLabels().length });
727+`)
728+ hints := named(t, posted, "hints")
729+ require.EqualValues(t, 2, hints["labels"])
730+ require.EqualValues(t, 2, hints["closest"], "only the two on-screen links reach closest()")
731+ anchors := named(t, posted, "anchors")
732+ require.EqualValues(t, 3, anchors["labels"])
733+ require.EqualValues(t, 3, anchors["ranges"], "the paragraph below the fold is never measured")
734+}
735+
736+// Scroll and resize repaint the caret once per animation frame, and a pending
737+// frame is canceled with the interaction.
738+func TestVimPageRuntimeBatchesViewportUpdatesPerFrame(t *testing.T) {
739+ posted := runVimPageRuntime(t, `
740+startCaret("tok", "s");
741+const bar = cursor();
742+first.p.rect = rect(10, 100, 600, 20);
743+for (let i = 0; i < 10; i++) { fire("scroll"); fire("resize"); }
744+const pending = frames.size;
745+const stale = bar.style.top;
746+flushFrames();
747+posted.push({ n: "batched", pending, stale, top: bar.style.top });
748+fire("scroll");
749+const queued = frames.size;
750+vp().cancel("tok");
751+posted.push({ n: "canceled", queued, left: frames.size });
752+`)
753+ batched := named(t, posted, "batched")
754+ require.EqualValues(t, 1, batched["pending"], "a burst of events schedules one repaint")
755+ require.NotEqual(t, batched["stale"], batched["top"], "the repaint happens in the frame")
756+ canceled := named(t, posted, "canceled")
757+ require.EqualValues(t, 1, canceled["queued"])
758+ require.EqualValues(t, 0, canceled["left"], "cleanup cancels a pending frame")
759+}
760+
761+// The bridge carries UTF-8: accents, CJK and astral characters must reach Go
762+// exactly as the page holds them.
763+func TestVimPageRuntimeCopiesNonASCIIText(t *testing.T) {
764+ const text = "héllo wörld — 日本語 😀 ñ"
765+ posted := runVimPageRuntime(t, `
766+first.p.innerText = `+jsString(text)+`;
767+window.__dumberVimPage.start("tok", { kind: "yank", object: "paragraph" });
768+`)
769+ require.Equal(t, []map[string]any{{"token": "tok", "type": "copy", "text": text}}, bridgeMessages(posted))
770+}

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