git.lucas.co / cce-browser
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 }