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