web browser (Servo)
git clone https://git.lucas.co/cce-browser.git
src/vi.rs (27.4K)
1 //! Vi-style modal keys, after qutebrowser.
2 //!
3 //! The chrome-independent half: the modes, the normal-mode bindings and the
4 //! parser that turns keystrokes into them, hint labels, `:` commands, and the
5 //! scripts the page side runs. `main.rs` owns what these do (the tab set, the
6 //! URL bar, the drawing), and the WPE host owns how they reach the engine.
7 //!
8 //! Points that are choices, not accidents:
9 //!
10 //! * **qutebrowser's bindings, where this browser has the feature.** `J`/`K`
11 //! are next/previous tab and `d`/`u` close and reopen, as there — not
12 //! Vimium's. What the browser does not have (zoom, quickmarks, windows)
13 //! is not bound, rather than bound to nothing.
14 //! * **Unbound characters are swallowed, unbound named keys are not**
15 //! (qutebrowser's `forward_unbound_keys = auto`): a stray letter must not
16 //! type into a page that happens to have focus, but arrows, Page Up/Down,
17 //! Space, Enter and Tab still reach it.
18 //! * **Insert mode is entered by a click, not by focus.** A field the page
19 //! focuses on its own (a search box with `autofocus`) leaves normal mode
20 //! alone, so `j` still scrolls; a field the person clicks into — or picks
21 //! with a hint — takes the keyboard. `i` enters it explicitly.
22
23 use cce_ui::widget::{Key, KeyEvent};
24
25 /// What the keyboard means right now.
26 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
27 pub enum Mode {
28 /// Keys are commands.
29 Normal,
30 /// Keys go to the page, except Escape, which comes back to normal.
31 Insert,
32 /// Every key goes to the page, the chrome's own Ctrl chords included;
33 /// only Shift+Escape comes back.
34 Passthrough,
35 /// Labels are up over the page's clickable elements; typing one picks it.
36 Hint,
37 /// The `:` / `/` / `?` line has the keyboard.
38 Command,
39 }
40
41 impl Mode {
42 /// The status line's name for the mode; `None` for normal, which shows
43 /// nothing.
44 pub fn label(self) -> Option<&'static str> {
45 match self {
46 Mode::Normal | Mode::Command => None,
47 Mode::Insert => Some("-- INSERT --"),
48 Mode::Passthrough => Some("-- PASSTHROUGH --"),
49 Mode::Hint => Some("-- HINT --"),
50 }
51 }
52 }
53
54 /// What picking a hint does.
55 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
56 pub enum HintKind {
57 /// Click it, as the pointer would.
58 Follow,
59 /// Middle-click it: a link opens in a background tab.
60 Background,
61 /// Copy its link.
62 Yank,
63 }
64
65 /// Which line the command prompt is.
66 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
67 pub enum Prompt {
68 Command,
69 Search,
70 SearchBack,
71 }
72
73 impl Prompt {
74 pub fn sigil(self) -> char {
75 match self {
76 Prompt::Command => ':',
77 Prompt::Search => '/',
78 Prompt::SearchBack => '?',
79 }
80 }
81 }
82
83 /// A normal-mode command. A count, where one was typed, comes alongside.
84 #[derive(Debug, Clone, Copy, PartialEq)]
85 pub enum Action {
86 /// Scroll by this many lines (a line is half a wheel notch), eased.
87 ScrollLines(f32, f32),
88 /// Scroll by this fraction of the viewport: half a page, or a page.
89 ScrollPage(f32),
90 /// To the top / the bottom; with a count, to that percent.
91 Top,
92 Bottom,
93 Back,
94 Forward,
95 Reload,
96 /// Move this many tabs (a count multiplies).
97 TabNext,
98 TabPrev,
99 /// `gt`: the next tab, or with a count, tab number `count`.
100 TabGoto,
101 TabFirst,
102 TabLast,
103 /// Tab number n, counting from 1 (`Alt+n`).
104 TabFocus(usize),
105 TabClose,
106 TabOnly,
107 UndoClose,
108 /// Focus the URL bar: empty, or holding the current address (`edit`);
109 /// submitting loads here or in a new tab.
110 Open { tab: bool, edit: bool },
111 Hint(HintKind),
112 YankUrl,
113 YankTitle,
114 /// Open the clipboard's address, here or in a new tab.
115 Paste { tab: bool },
116 Insert,
117 /// Focus the page's first text field and enter insert mode.
118 FocusInput,
119 Passthrough,
120 Prompt(Prompt),
121 SearchNext,
122 SearchPrev,
123 Bookmark,
124 /// One of the `cce://` pages.
125 Page(&'static str),
126 /// Up one path segment; to the site root.
127 Up,
128 Root,
129 }
130
131 /// The normal-mode bindings: key sequence -> action. A sequence is a run of
132 /// tokens as [`token`] spells them: a plain character, or `<C-x>` / `<A-x>`.
133 pub const BINDINGS: &[(&str, Action)] = &[
134 ("j", Action::ScrollLines(0.0, 1.0)),
135 ("k", Action::ScrollLines(0.0, -1.0)),
136 ("h", Action::ScrollLines(-1.0, 0.0)),
137 ("l", Action::ScrollLines(1.0, 0.0)),
138 ("<C-d>", Action::ScrollPage(0.5)),
139 ("<C-u>", Action::ScrollPage(-0.5)),
140 ("<C-f>", Action::ScrollPage(1.0)),
141 ("<C-b>", Action::ScrollPage(-1.0)),
142 ("gg", Action::Top),
143 ("G", Action::Bottom),
144 ("H", Action::Back),
145 ("L", Action::Forward),
146 ("r", Action::Reload),
147 ("R", Action::Reload),
148 ("J", Action::TabNext),
149 ("K", Action::TabPrev),
150 ("gt", Action::TabGoto),
151 ("gT", Action::TabPrev),
152 ("g0", Action::TabFirst),
153 ("g^", Action::TabFirst),
154 ("g$", Action::TabLast),
155 ("<A-1>", Action::TabFocus(1)),
156 ("<A-2>", Action::TabFocus(2)),
157 ("<A-3>", Action::TabFocus(3)),
158 ("<A-4>", Action::TabFocus(4)),
159 ("<A-5>", Action::TabFocus(5)),
160 ("<A-6>", Action::TabFocus(6)),
161 ("<A-7>", Action::TabFocus(7)),
162 ("<A-8>", Action::TabFocus(8)),
163 ("<A-9>", Action::TabLast),
164 ("d", Action::TabClose),
165 ("co", Action::TabOnly),
166 ("u", Action::UndoClose),
167 ("o", Action::Open { tab: false, edit: false }),
168 ("O", Action::Open { tab: true, edit: false }),
169 ("go", Action::Open { tab: false, edit: true }),
170 ("gO", Action::Open { tab: true, edit: true }),
171 ("f", Action::Hint(HintKind::Follow)),
172 ("F", Action::Hint(HintKind::Background)),
173 (";b", Action::Hint(HintKind::Background)),
174 (";y", Action::Hint(HintKind::Yank)),
175 ("yy", Action::YankUrl),
176 ("yt", Action::YankTitle),
177 ("p", Action::Paste { tab: false }),
178 ("P", Action::Paste { tab: true }),
179 ("i", Action::Insert),
180 ("gi", Action::FocusInput),
181 ("<C-v>", Action::Passthrough),
182 (":", Action::Prompt(Prompt::Command)),
183 ("/", Action::Prompt(Prompt::Search)),
184 ("?", Action::Prompt(Prompt::SearchBack)),
185 ("n", Action::SearchNext),
186 ("N", Action::SearchPrev),
187 ("M", Action::Bookmark),
188 ("Sb", Action::Page("cce://bookmarks")),
189 ("Sh", Action::Page("cce://history")),
190 ("gu", Action::Up),
191 ("gU", Action::Root),
192 ];
193
194 /// A keystroke as the bindings spell it. `None` for keys that are not
195 /// commands at all — named keys, which unbound go on to the page — and for
196 /// a lone modifier.
197 pub fn token(e: &KeyEvent) -> Option<String> {
198 let Key::Character(c) = &e.logical_key else { return None };
199 let ch = c.chars().next()?.to_ascii_lowercase();
200 // Shift is spelled out on a chord, so Ctrl+Shift+D (the chrome's
201 // favorite toggle) is not taken for Ctrl+D.
202 let shift = if e.shift { "S-" } else { "" };
203 if e.ctrl {
204 Some(format!("<C-{shift}{ch}>"))
205 } else if e.alt {
206 Some(format!("<A-{shift}{ch}>"))
207 } else {
208 // The logical key already carries Shift: `G`, not `g`.
209 Some(c.clone())
210 }
211 }
212
213 /// Whether some binding begins with these exact keys.
214 fn is_prefix(keys: &str) -> bool {
215 BINDINGS.iter().any(|(k, _)| k.starts_with(keys))
216 }
217
218 /// What one keystroke did to the pending sequence.
219 #[derive(Debug, Clone, Copy, PartialEq)]
220 pub enum Fed {
221 /// Part of a longer binding (or a count); wait for more.
222 Pending,
223 /// A whole binding, with its count if one was typed.
224 Run(Action, Option<u32>),
225 /// Bound to nothing; the sequence is dropped.
226 Unbound,
227 }
228
229 /// The count and keys typed so far toward a binding.
230 #[derive(Debug, Default)]
231 pub struct Keys {
232 count: String,
233 keys: String,
234 }
235
236 impl Keys {
237 pub fn feed(&mut self, token: &str) -> Fed {
238 // A count leads, and a `0` only continues one.
239 let digit = token.len() == 1 && token.as_bytes()[0].is_ascii_digit();
240 if digit && self.keys.is_empty() && (token != "0" || !self.count.is_empty()) {
241 self.count.push_str(token);
242 return Fed::Pending;
243 }
244 let candidate = format!("{}{token}", self.keys);
245 if let Some((_, action)) = BINDINGS.iter().find(|(k, _)| *k == candidate) {
246 let count = self.count.parse().ok();
247 self.clear();
248 return Fed::Run(*action, count);
249 }
250 if is_prefix(&candidate) {
251 self.keys = candidate;
252 return Fed::Pending;
253 }
254 self.clear();
255 Fed::Unbound
256 }
257
258 /// Whether `token` would be used by [`Self::feed`] rather than dropped —
259 /// how a Ctrl chord the bindings do not know goes on to the chrome.
260 pub fn takes(&self, token: &str) -> bool {
261 is_prefix(&format!("{}{token}", self.keys))
262 }
263
264 pub fn clear(&mut self) {
265 self.count.clear();
266 self.keys.clear();
267 }
268
269 pub fn is_empty(&self) -> bool {
270 self.count.is_empty() && self.keys.is_empty()
271 }
272
273 /// What is pending, for the status line.
274 pub fn shown(&self) -> String {
275 format!("{}{}", self.count, self.keys)
276 }
277 }
278
279 /// The characters hint labels are made of — the home row, qutebrowser's
280 /// default.
281 pub const HINT_CHARS: &str = "asdfghjkl";
282
283 /// Labels for `n` hints: as short as `n` allows, and prefix-free, so typing
284 /// a whole label is always unambiguous and never needs a confirming key.
285 ///
286 /// Mixed lengths, the way qutebrowser does it: when `n` does not fill every
287 /// label of the longest length, the spare room goes to shorter ones. Each
288 /// short label takes one prefix away from the long ones, and with it
289 /// `chars - 1` long labels' worth of room.
290 pub fn hint_labels(n: usize, chars: &str) -> Vec<String> {
291 let chars: Vec<char> = chars.chars().collect();
292 let k = chars.len();
293 if n == 0 || k < 2 {
294 return Vec::new();
295 }
296 let mut needed = 1;
297 let mut room = k;
298 while room < n {
299 needed += 1;
300 room *= k;
301 }
302 let short = if needed > 1 { (room - n) / (k - 1) } else { 0 };
303 let spell = |mut i: usize, len: usize| -> String {
304 let mut s = vec![chars[0]; len];
305 for slot in s.iter_mut().rev() {
306 *slot = chars[i % k];
307 i /= k;
308 }
309 s.into_iter().collect()
310 };
311 let mut out = Vec::with_capacity(n);
312 for i in 0..short.min(n) {
313 out.push(spell(i, needed - 1));
314 }
315 // The prefixes after the short labels, each extended by every char.
316 let mut i = short * k;
317 while out.len() < n {
318 out.push(spell(i, needed));
319 i += 1;
320 }
321 out
322 }
323
324 /// One clickable element on the page, as the hint script reports it, in the
325 /// chrome's logical pixels (the page's CSS pixels — see `field_rect`).
326 #[derive(Debug, Clone, PartialEq)]
327 pub struct Hint {
328 /// The visible part of the element.
329 pub rect: (f32, f32, f32, f32),
330 /// Where a click on it lands: the visible part's middle.
331 pub at: (f32, f32),
332 pub href: Option<String>,
333 pub label: String,
334 }
335
336 /// The hint script's answer, labelled. Anything malformed is dropped.
337 pub fn parse_hints(json: &str) -> Vec<Hint> {
338 let Ok(serde_json::Value::Array(items)) = serde_json::from_str(json) else {
339 return Vec::new();
340 };
341 let num = |v: &serde_json::Value, k: &str| v[k].as_f64().map(|f| f as f32);
342 let mut hints: Vec<Hint> = items
343 .iter()
344 .filter_map(|v| {
345 let (x, y, w, h) = (num(v, "x")?, num(v, "y")?, num(v, "w")?, num(v, "h")?);
346 Some(Hint {
347 rect: (x, y, w, h),
348 at: (x + w / 2.0, y + h / 2.0),
349 href: v["href"].as_str().filter(|s| !s.is_empty()).map(str::to_string),
350 label: String::new(),
351 })
352 })
353 .collect();
354 let labels = hint_labels(hints.len(), HINT_CHARS);
355 for (h, l) in hints.iter_mut().zip(labels) {
356 h.label = l;
357 }
358 hints
359 }
360
361 /// A parsed `:` line.
362 #[derive(Debug, Clone, PartialEq)]
363 pub enum Cmd {
364 Run(Action),
365 /// Load what was typed: here, in a new tab, or in a background one.
366 Open { target: String, tab: bool, background: bool },
367 Quit,
368 Empty,
369 }
370
371 /// Parse a `:` line — qutebrowser's names, plus the short forms vim hands
372 /// people's fingers (`:o`, `:t`, `:q`, `:b 3`).
373 pub fn parse_command(line: &str) -> Result<Cmd, String> {
374 let line = line.trim();
375 let (name, rest) = line.split_once(char::is_whitespace).unwrap_or((line, ""));
376 let rest = rest.trim();
377 // `-t` / `-b` flags on `open`, as qutebrowser spells them.
378 let mut tab = false;
379 let mut background = false;
380 let mut words: Vec<&str> = Vec::new();
381 for w in rest.split_whitespace() {
382 match w {
383 "-t" | "--tab" => tab = true,
384 "-b" | "--bg" => background = true,
385 _ => words.push(w),
386 }
387 }
388 let target = words.join(" ");
389 let open = |tab: bool, background: bool| {
390 if target.is_empty() {
391 Ok(Cmd::Run(Action::Open { tab: tab || background, edit: false }))
392 } else {
393 Ok(Cmd::Open { target: target.clone(), tab, background })
394 }
395 };
396 let number = || {
397 rest.parse::<usize>()
398 .ok()
399 .filter(|n| *n > 0)
400 .ok_or_else(|| format!("{name}: needs a tab number"))
401 };
402 match name {
403 "" => Ok(Cmd::Empty),
404 "o" | "open" => open(tab, background),
405 "t" | "to" | "tabopen" | "tabnew" => open(true, background),
406 "bg" | "backgroundopen" => open(true, true),
407 "q" | "quit" | "qa" | "qall" | "wq" | "wqa" | "x" => Ok(Cmd::Quit),
408 "close" | "tab-close" | "tabclose" | "bd" | "bdelete" => Ok(Cmd::Run(Action::TabClose)),
409 "tab-only" | "only" | "tabonly" => Ok(Cmd::Run(Action::TabOnly)),
410 "undo" => Ok(Cmd::Run(Action::UndoClose)),
411 "tab-next" | "tabnext" | "tabn" | "bn" => Ok(Cmd::Run(Action::TabNext)),
412 "tab-prev" | "tabprev" | "tabp" | "tabN" | "bp" => Ok(Cmd::Run(Action::TabPrev)),
413 "tab-focus" | "buffer" | "b" => number().map(|n| Cmd::Run(Action::TabFocus(n))),
414 "back" => Ok(Cmd::Run(Action::Back)),
415 "forward" => Ok(Cmd::Run(Action::Forward)),
416 "reload" | "r" => Ok(Cmd::Run(Action::Reload)),
417 "yank" if rest.is_empty() || rest == "url" => Ok(Cmd::Run(Action::YankUrl)),
418 "yank" if rest == "title" => Ok(Cmd::Run(Action::YankTitle)),
419 "bookmark-add" | "bookmark" => Ok(Cmd::Run(Action::Bookmark)),
420 "bookmarks" => Ok(Cmd::Run(Action::Page("cce://bookmarks"))),
421 "history" => Ok(Cmd::Run(Action::Page("cce://history"))),
422 "downloads" => Ok(Cmd::Run(Action::Page("cce://downloads"))),
423 "favorites" => Ok(Cmd::Run(Action::Page("cce://favorites"))),
424 "mode-enter" if rest == "insert" => Ok(Cmd::Run(Action::Insert)),
425 "mode-enter" if rest == "passthrough" => Ok(Cmd::Run(Action::Passthrough)),
426 _ => Err(format!("Unknown command: {name}")),
427 }
428 }
429
430 /// Up one path segment (`gu`): `/a/b/` and `/a/b` both go to `/a/`, and the
431 /// query and fragment go with the step. `None` when already at the root.
432 pub fn url_up(url: &url::Url) -> Option<url::Url> {
433 let path = url.path().trim_end_matches('/');
434 if path.is_empty() && url.query().is_none() && url.fragment().is_none() {
435 return None;
436 }
437 let mut up = url.clone();
438 up.set_query(None);
439 up.set_fragment(None);
440 let parent = match path.rfind('/') {
441 Some(i) => &path[..=i],
442 None => "/",
443 };
444 up.set_path(parent);
445 Some(up)
446 }
447
448 /// The site root (`gU`).
449 pub fn url_root(url: &url::Url) -> Option<url::Url> {
450 let mut root = url.clone();
451 root.set_path("/");
452 root.set_query(None);
453 root.set_fragment(None);
454 (root != *url).then_some(root)
455 }
456
457 // ---- the page side ----
458 //
459 // Engine-agnostic text, but only the WPE host runs it. Everything runs in a
460 // private script world (`WORLD`): the DOM is shared, the page's globals are
461 // not, so a page can neither see these helpers nor post on the channel the
462 // chrome listens to.
463
464 /// The world the scripts below run in.
465 pub const WORLD: &str = "cce-vi";
466 /// The channel the focus watcher reports on.
467 pub const CHANNEL: &str = "cceVi";
468
469 /// The shared test for "this element takes typing", as a JS prelude.
470 const EDITABLE_JS: &str = r#"
471 const noText = ['button', 'submit', 'reset', 'checkbox', 'radio', 'image',
472 'file', 'hidden', 'range', 'color'];
473 const editable = (el) => {
474 if (!el) return false;
475 if (el.isContentEditable) return true;
476 if (el.disabled || el.readOnly) return false;
477 if (el.tagName === 'TEXTAREA') return true;
478 if (el.tagName === 'INPUT') return !noText.includes((el.type || 'text').toLowerCase());
479 return false;
480 };
481 "#;
482
483 /// Runs in every frame and reports whether the focused element takes text,
484 /// whenever focus moves. Only a document with focus speaks (every tab shares
485 /// the channel; only the shown one is focused), and a frame whose focus is
486 /// inside a child frame leaves it to the child.
487 pub fn focus_watch_js() -> String {
488 format!(
489 r#"(() => {{
490 const h = window.webkit && window.webkit.messageHandlers;
491 if (!h || !h.cceVi) return;
492 {EDITABLE_JS}
493 let timer = 0;
494 // After the event, not in it: mid-move, activeElement is still the old one.
495 const report = () => {{
496 clearTimeout(timer);
497 timer = setTimeout(() => {{
498 if (!document.hasFocus()) return;
499 let el = document.activeElement;
500 while (el && el.shadowRoot && el.shadowRoot.activeElement) el = el.shadowRoot.activeElement;
501 if (el && (el.tagName === 'IFRAME' || el.tagName === 'FRAME')) return;
502 try {{ h.cceVi.postMessage(JSON.stringify({{ t: 'focus', editable: editable(el) }})); }} catch (e) {{}}
503 }}, 0);
504 }};
505 document.addEventListener('focusin', report, true);
506 document.addEventListener('focusout', report, true);
507 }})();"#
508 )
509 }
510
511 /// Whether what has focus now takes typing, following focus down through
512 /// same-origin frames: `"yes"` or `""`. Asked after a click in normal mode,
513 /// since clicking a field that already had focus moves no focus, and so
514 /// tells the watcher nothing.
515 pub fn active_editable_js() -> String {
516 format!(
517 r#"(() => {{
518 {EDITABLE_JS}
519 let el = document.activeElement;
520 for (;;) {{
521 while (el && el.shadowRoot && el.shadowRoot.activeElement) el = el.shadowRoot.activeElement;
522 if (!el || (el.tagName !== 'IFRAME' && el.tagName !== 'FRAME')) break;
523 try {{ const d = el.contentDocument; if (!d) return ''; el = d.activeElement; }} catch (e) {{ return ''; }}
524 }}
525 return editable(el) ? 'yes' : '';
526 }})()"#
527 )
528 }
529
530 /// The clickable elements in view, as JSON: `[{x, y, w, h, href}]` in the
531 /// top viewport's CSS pixels, clipped to what is visible. Descends into
532 /// same-origin frames; a cross-origin frame's document is out of reach of a
533 /// script evaluated in the top frame. `links` keeps only real links (`F`,
534 /// `;y`), since only those open anything when middle-clicked or copied.
535 ///
536 /// An element covered at its middle by something unrelated (a modal, a
537 /// cookie banner) is left out: a click there would land on the cover.
538 pub fn hints_js(links: bool) -> String {
539 let sel = if links {
540 "a[href], area[href]"
541 } else {
542 "a, area, button, select, textarea, summary, \
543 input:not([type=\"hidden\"]), \
544 [contenteditable]:not([contenteditable=\"false\"]), \
545 [onclick], [onmousedown], [role=\"link\"], [role=\"button\"], \
546 [role=\"tab\"], [role=\"checkbox\"], [role=\"switch\"], [role=\"option\"], \
547 [role=\"menuitem\"], [role=\"menuitemcheckbox\"], [role=\"menuitemradio\"], \
548 [role=\"treeitem\"], [aria-haspopup], [ng-click], [data-ng-click], \
549 [tabindex]:not([tabindex=\"-1\"])"
550 };
551 format!(
552 r#"(() => {{
553 const SEL = {sel:?};
554 const out = [];
555 const seen = new Set();
556 const walk = (win, ox, oy, clip) => {{
557 const doc = win.document;
558 let els;
559 try {{ els = doc.querySelectorAll(SEL); }} catch (e) {{ return; }}
560 for (const el of els) {{
561 if (seen.has(el) || el.disabled) continue;
562 const st = win.getComputedStyle(el);
563 if (st.visibility !== 'visible' || st.display === 'none') continue;
564 for (const r of el.getClientRects()) {{
565 const x0 = Math.max(r.left + ox, clip[0]), y0 = Math.max(r.top + oy, clip[1]);
566 const x1 = Math.min(r.right + ox, clip[2]), y1 = Math.min(r.bottom + oy, clip[3]);
567 if (x1 - x0 < 2 || y1 - y0 < 2) continue;
568 const hit = doc.elementFromPoint((x0 + x1) / 2 - ox, (y0 + y1) / 2 - oy);
569 if (hit && hit !== el && !el.contains(hit) && !hit.contains(el)) continue;
570 seen.add(el);
571 out.push({{ x: x0, y: y0, w: x1 - x0, h: y1 - y0, href: el.href ? String(el.href) : '' }});
572 break;
573 }}
574 }}
575 for (const f of doc.querySelectorAll('iframe, frame')) {{
576 let inner;
577 try {{ inner = f.contentWindow; if (!inner || !inner.document) continue; }} catch (e) {{ continue; }}
578 const r = f.getBoundingClientRect();
579 const fx = r.left + ox + f.clientLeft, fy = r.top + oy + f.clientTop;
580 const c = [Math.max(fx, clip[0]), Math.max(fy, clip[1]),
581 Math.min(fx + f.clientWidth, clip[2]), Math.min(fy + f.clientHeight, clip[3])];
582 if (c[2] > c[0] && c[3] > c[1]) walk(inner, fx, fy, c);
583 }}
584 }};
585 walk(window, 0, 0, [0, 0, window.innerWidth, window.innerHeight]);
586 return JSON.stringify(out);
587 }})()"#
588 )
589 }
590
591 /// Scroll the page's main scroller: by a fraction of the viewport, or to a
592 /// fraction of its range. The scroller is whatever scrolls under the middle
593 /// of the view — many pages scroll an inner element and leave the document
594 /// still — falling back to the document's own. `smooth` follows the DE's
595 /// animations switch.
596 pub fn scroll_js(by: Option<f32>, to: Option<f32>, smooth: bool) -> String {
597 let behavior = if smooth { "smooth" } else { "instant" };
598 let by = by.map_or("null".to_string(), |f| f.to_string());
599 let to = to.map_or("null".to_string(), |f| f.to_string());
600 format!(
601 r#"(() => {{
602 const by = {by}, to = {to};
603 const scrolls = (el) => {{
604 const s = getComputedStyle(el);
605 return el.scrollHeight > el.clientHeight + 1 && /(auto|scroll|overlay)/.test(s.overflowY);
606 }};
607 const root = document.scrollingElement || document.documentElement;
608 let el = document.elementFromPoint(innerWidth / 2, innerHeight / 2);
609 while (el && el !== document.body && el !== document.documentElement && !scrolls(el)) el = el.parentElement;
610 if (!el || el === document.body || el === document.documentElement) el = root;
611 const view = el === root ? innerHeight : el.clientHeight;
612 const top = by !== null ? el.scrollTop + by * view
613 : to * (el.scrollHeight - view);
614 el.scrollTo({{ top, behavior: '{behavior}' }});
615 }})()"#
616 )
617 }
618
619 /// Focus the first visible text field (`gi`): `"yes"`, or `""` for none.
620 pub fn focus_input_js() -> String {
621 format!(
622 r#"(() => {{
623 {EDITABLE_JS}
624 for (const el of document.querySelectorAll('input, textarea, [contenteditable]')) {{
625 if (!editable(el)) continue;
626 const r = el.getBoundingClientRect();
627 if (r.width < 2 || r.height < 2 || r.bottom < 0 || r.top > innerHeight) continue;
628 if (getComputedStyle(el).visibility !== 'visible') continue;
629 el.focus();
630 return 'yes';
631 }}
632 return '';
633 }})()"#
634 )
635 }
636
637 /// A focus watcher report, or `None` for anything else on the channel.
638 pub fn parse_focus(json: &str) -> Option<bool> {
639 let v: serde_json::Value = serde_json::from_str(json).ok()?;
640 (v["t"] == "focus").then(|| v["editable"].as_bool()).flatten()
641 }
642
643 #[cfg(test)]
644 mod tests {
645 use super::*;
646
647 fn feed_all(keys: &mut Keys, seq: &[&str]) -> Fed {
648 let mut last = Fed::Unbound;
649 for t in seq {
650 last = keys.feed(t);
651 }
652 last
653 }
654
655 #[test]
656 fn sequences_counts_and_dead_ends() {
657 let mut k = Keys::default();
658 assert_eq!(k.feed("j"), Fed::Run(Action::ScrollLines(0.0, 1.0), None));
659 assert_eq!(k.feed("g"), Fed::Pending);
660 assert_eq!(k.shown(), "g");
661 assert_eq!(k.feed("g"), Fed::Run(Action::Top, None));
662 assert_eq!(feed_all(&mut k, &["1", "0", "j"]), Fed::Run(Action::ScrollLines(0.0, 1.0), Some(10)));
663 // A leading 0 is not a count, and binds to nothing.
664 assert_eq!(k.feed("0"), Fed::Unbound);
665 assert_eq!(feed_all(&mut k, &["3", "g", "t"]), Fed::Run(Action::TabGoto, Some(3)));
666 assert_eq!(feed_all(&mut k, &["g", "z"]), Fed::Unbound);
667 assert!(k.is_empty());
668 assert_eq!(k.feed("<C-d>"), Fed::Run(Action::ScrollPage(0.5), None));
669 assert!(!k.takes("<C-t>"));
670 assert!(k.takes("<C-u>"));
671 }
672
673 #[test]
674 fn every_binding_is_reachable() {
675 // A binding that is a prefix of another would shadow it for good.
676 for (a, _) in BINDINGS {
677 for (b, _) in BINDINGS {
678 assert!(a == b || !b.starts_with(a), "{a:?} shadows {b:?}");
679 }
680 }
681 }
682
683 #[test]
684 fn hint_labels_are_short_unique_and_prefix_free() {
685 for n in [0, 1, 5, 9, 10, 17, 80, 81, 82, 700] {
686 let labels = hint_labels(n, HINT_CHARS);
687 assert_eq!(labels.len(), n);
688 for a in &labels {
689 for b in &labels {
690 assert!(a == b || !b.starts_with(a.as_str()), "{a} prefixes {b} (n={n})");
691 }
692 }
693 let max = labels.iter().map(String::len).max().unwrap_or(0);
694 let need = (1..).find(|l| 9usize.pow(*l as u32) >= n.max(1)).unwrap();
695 assert_eq!(max, if n == 0 { 0 } else { need }, "n={n}");
696 }
697 // Ten hints: eight single letters, the last letter's two-letter run.
698 assert_eq!(hint_labels(10, HINT_CHARS)[..9], ["a", "s", "d", "f", "g", "h", "j", "k", "la"]);
699 }
700
701 #[test]
702 fn hints_parse_and_junk_is_dropped() {
703 let h = parse_hints(r#"[{"x":10,"y":20,"w":30,"h":10,"href":"https://a.example/"},{"x":"no"},{"x":0,"y":0,"w":4,"h":4,"href":""}]"#);
704 assert_eq!(h.len(), 2);
705 assert_eq!(h[0].at, (25.0, 25.0));
706 assert_eq!(h[0].href.as_deref(), Some("https://a.example/"));
707 assert_eq!(h[1].href, None);
708 assert_eq!((h[0].label.as_str(), h[1].label.as_str()), ("a", "s"));
709 assert!(parse_hints("not json").is_empty());
710 }
711
712 #[test]
713 fn commands_parse() {
714 assert_eq!(parse_command("o example.com"), Ok(Cmd::Open { target: "example.com".into(), tab: false, background: false }));
715 assert_eq!(parse_command("open -t rust book"), Ok(Cmd::Open { target: "rust book".into(), tab: true, background: false }));
716 assert_eq!(parse_command(" t "), Ok(Cmd::Run(Action::Open { tab: true, edit: false })));
717 assert_eq!(parse_command("b 3"), Ok(Cmd::Run(Action::TabFocus(3))));
718 assert!(parse_command("b x").is_err());
719 assert_eq!(parse_command("q"), Ok(Cmd::Quit));
720 assert_eq!(parse_command(""), Ok(Cmd::Empty));
721 assert_eq!(parse_command("frobnicate"), Err("Unknown command: frobnicate".into()));
722 }
723
724 #[test]
725 fn up_and_root() {
726 let u = |s: &str| url::Url::parse(s).unwrap();
727 assert_eq!(url_up(&u("https://a.example/x/y/?q=1")), Some(u("https://a.example/x/")));
728 assert_eq!(url_up(&u("https://a.example/x/y")), Some(u("https://a.example/x/")));
729 assert_eq!(url_up(&u("https://a.example/x")), Some(u("https://a.example/")));
730 assert_eq!(url_up(&u("https://a.example/")), None);
731 assert_eq!(url_root(&u("https://a.example/x/y#z")), Some(u("https://a.example/")));
732 assert_eq!(url_root(&u("https://a.example/")), None);
733 }
734
735 #[test]
736 fn focus_reports_parse() {
737 assert_eq!(parse_focus(r#"{"t":"focus","editable":true}"#), Some(true));
738 assert_eq!(parse_focus(r#"{"t":"focus","editable":false}"#), Some(false));
739 assert_eq!(parse_focus(r#"{"t":"other"}"#), None);
740 }
741 }