web browser (Servo)
git clone https://git.lucas.co/cce-browser.git
feat: vi mode, after qutebrowser
`browser.vi-mode` (off by default) makes the keyboard modal: normal,
insert, passthrough, hint and command modes, with qutebrowser's bindings
where the browser has the feature — j/k/h/l, Ctrl+D/U/F/B, gg/G with
counts, H/L, J/K/gt/gT/Alt+n, d/u/co, o/O/go/gO, yy/yt, p/P, i/gi, f/F/;y
hints, / ? n N search, :open/:tabopen/:q and friends.
Insert mode follows a click (a focus watcher in every frame plus a check
after each click), not a page's autofocus. Hints are drawn by the chrome
and followed with real pointer clicks, so F reuses the middle-click
background-tab route. Search is WebKit's find controller — the browser's
first find-in-page. The Servo host gets no-op stubs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 76 ++++-
examples/wpe_autofill.rs | 5 +
examples/wpe_crash.rs | 5 +
examples/wpe_ctx.rs | 5 +
examples/wpe_dark.rs | 5 +
examples/wpe_focus.rs | 5 +
examples/wpe_host.rs | 5 +
examples/wpe_input.rs | 5 +
examples/wpe_loop.rs | 5 +
examples/wpe_middle.rs | 5 +
examples/wpe_options.rs | 5 +
examples/wpe_paste.rs | 5 +
examples/wpe_select.rs | 5 +
examples/wpe_tabs.rs | 5 +
src/main.rs | 815 ++++++++++++++++++++++++++++++++++++++++++++++-
src/settings.rs | 5 +
src/vi.rs | 741 ++++++++++++++++++++++++++++++++++++++++++
src/webview.rs | 22 ++
src/wpe/host.rs | 213 +++++++++++++
19 files changed, 1928 insertions(+), 9 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index a814f97..17cc52f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -15,7 +15,7 @@ remote at all, so check `origin/master` after committing). Read the workspace-le
`../cce-compositor/WORKSPACE.md` first: workspace layout, the `cce-ui` toolkit, config
conventions, and the multi-repo rules all live there.
-Eighteen files, ~11.4k lines. The eleven that carry the design:
+Nineteen files, ~14.8k lines. The twelve that carry the design:
| file | what it owns |
| --- | --- |
@@ -27,6 +27,7 @@ Eighteen files, ~11.4k lines. The eleven that carry the design:
| `src/downloads.rs` | the chrome-side download pipeline (Servo has none) |
| `src/session.rs` | open-tab persistence: the tab set survives a restart |
| `src/settings.rs` | the per-app KDL config |
+| `src/vi.rs` | vi mode, after qutebrowser: the modes, the bindings and their parser, hint labels, `:` commands, and the page scripts |
| `src/accounts.rs` | accounts from cce-secrets: the Secret Service worker, which entries a host earns, saving a new login, and the never-save list |
| `src/wpe/formwatch.rs` | the page half of account autocomplete: the watcher every frame runs (fields, frame-offset relay, fill asks, sign-in capture) and the events it sends |
| `src/raindrop/` | bookmark sync with Raindrop.io's Unsorted: the three-way merge (`mod.rs`), the REST client (`api.rs`), the worker and status line (`sync.rs`) — design in RAINDROP-SYNC.md |
@@ -540,6 +541,77 @@ notches in quick succession land exactly three notches on (190 → 760), so the
distance a notch travels is unchanged — only its timing. (The 190 is WebKit's
own multiplier on a precise delta; the direct path has always moved that far.)
+## Vi mode
+
+`browser.vi-mode` (default **off**; a toggle on cce-system-interface's Browser
+page) turns the keyboard modal, after qutebrowser. `src/vi.rs` is the
+engine-free half — modes, the `BINDINGS` table, the count/sequence parser
+(`Keys`), hint labels, `:` parsing, and the scripts; `vi_*` in `main.rs` does
+the commands; the WPE host runs the scripts (`vi_eval`, `set_vi_enabled`)
+and the find controller (`find*`). On Servo those host calls are no-ops.
+
+Modes: **normal** (keys are commands), **insert** (keys go to the page;
+Escape leaves), **passthrough** (`Ctrl+V`: *every* key goes to the page, the
+chrome's Ctrl chords included; Shift+Escape leaves), **hint** (`f`, `F`,
+`;b`, `;y`), **command** (`:`, `/`, `?`). The status line is a plate at the
+bottom-left (lifted over a bottom bar); normal mode with nothing pending
+shows nothing.
+
+The bindings are qutebrowser's where the browser has the feature: `j/k/h/l`,
+`Ctrl+D/U/F/B`, `gg`/`G` (with a count, a percent), `H`/`L`, `r`, `J`/`K`
+and `gt`/`gT`/`g0`/`g$`/`Alt+n` for tabs (`3gt` is tab 3), `d` / `u` close
+and reopen, `co` close others, `o`/`O`/`go`/`gO` the URL bar (O submits into
+a new tab — `url_new_tab`), `yy`/`yt`, `p`/`P`, `i`, `gi`, `n`/`N`, `M`
+bookmark, `Sb`/`Sh`, `gu`/`gU`. `:` knows `open [-t|-b]`, `tabopen`, `back`,
+`forward`, `reload`, `tab-close`, `tab-only`, `undo`, `buffer N`, `yank
+[title]`, `bookmarks`/`history`/`downloads`/`favorites`, and `q` (which
+quits like a window close — the tabs are kept). Points that are choices:
+
+- **The vi stage sits ahead of the chrome's Ctrl chords** in
+ `handle_key_input`, so normal mode's `Ctrl+D/U/F/B` scroll (qutebrowser)
+ instead of bookmarking or opening pages — `M` and `Sb` are the vi route
+ there. A chord vi does not bind still reaches the chrome (`Keys::takes`),
+ and Ctrl+Shift is spelled apart (`<C-S-d>`), so the favorites chord
+ survives. It stays out while the URL bar or the bookmarks search has the
+ keyboard.
+- **Unbound letters are swallowed, unbound named keys are not** (qutebrowser's
+ `forward_unbound_keys = auto`): arrows, Page Up/Down, Space, Enter and Tab
+ still reach the page. A swallowed press's release is swallowed too
+ (`vi_swallowed`, keyed case-folded so `G` released as `g` matches), even
+ across the mode change the press caused.
+- **Insert mode follows a click, not focus.** A page autofocusing its search
+ box leaves normal mode alone, so `j` still scrolls. Two signals: a focus
+ watcher in every frame (private world `cce-vi`, channel `cceVi`) reports
+ whether the focused element takes text, and a report of "yes" within
+ `VI_CLICK_WINDOW` of a page click enters insert; and after every normal-mode
+ click the chrome asks the top frame (`active_editable_js`), because clicking
+ a field that *already* had focus moves no focus and the watcher stays
+ silent. A "no" report while in insert leaves it. A *load* (not a pushState —
+ a search field rewriting the URL per keystroke must keep the keyboard), a
+ tab switch, and closing the tab all drop back to normal; a navigation also
+ forgets the click, so the next page's autofocus is not "clicked into".
+- **Hints are drawn by the chrome and followed by real clicks.** The script
+ (`hints_js`) returns visible rects in the top viewport's CSS pixels — the
+ chrome's logical pixels — descending into same-origin frames and skipping
+ anything covered at its middle; the labels are prefix-free and as short as
+ the count allows (`hint_labels`, qutebrowser's mixed-length scheme). Picking
+ one sends a pointer move, press and release at the rect's middle, so the
+ page sees a person's click: user activation, focus, even a cross-origin
+ frame under it. `F` is the same with button 2, which `decide-policy` already
+ turns into a background tab. Cross-origin frames' *contents* get no labels
+ (the script runs in the top frame). The wheel, a click, a resize or a
+ navigation takes the labels down — they would no longer match the page.
+- **Scrolling has two routes.** `j/k/h/l` go through the eased wheel model
+ (half a notch a line), aimed at the page's middle (`scroll_origin`) rather
+ than the resting pointer; `Ctrl+D/U/F/B` and `gg`/`G` are `scroll_js`,
+ exact fractions of the viewport — WebKit scales wheel deltas (the 190 for
+ 76 above), so a half page by wheel would be a guess. The script scrolls
+ whatever scrolls under the view's middle, falling back to the document.
+- **Search is WebKit's find controller**: smart case, wrapping, the current
+ match from the last position; `n`/`N` step, Escape clears the highlights,
+ "Text not found" comes from `failed-to-find-text`. It is the only
+ find-in-page there is.
+
## Account autocomplete (cce-secrets)
A login field on a page gets a list of the accounts the keyring holds for that
@@ -782,7 +854,7 @@ clipboard path as the rest of the DE.
## Not implemented yet
-Worth knowing before assuming a bug: no find-in-page, no zoom, no favicons, and no
+Worth knowing before assuming a bug: no find-in-page outside vi mode's `/`, no zoom, no favicons, and no
history/URL autocomplete. (The context menu, JS dialogs and HTTP auth landed with the
WPE backend and are Servo-only gaps now.) Account autocomplete saves new logins but
never updates a changed password, and "never save" has no undo UI. Ctrl+Shift+O ("hand this page to
diff --git a/examples/wpe_autofill.rs b/examples/wpe_autofill.rs
index 2cf1964..fffce11 100644
--- a/examples/wpe_autofill.rs
+++ b/examples/wpe_autofill.rs
@@ -33,6 +33,11 @@ mod pages;
#[path = "../src/downloads.rs"]
mod downloads;
#[cfg(feature = "wpe")]
+// The host's vi channel and scripts.
+#[path = "../src/vi.rs"]
+#[allow(dead_code)]
+mod vi;
+
#[path = "../src/wpe/mod.rs"]
mod wpe;
diff --git a/examples/wpe_crash.rs b/examples/wpe_crash.rs
index d6c5e12..41e5d38 100644
--- a/examples/wpe_crash.rs
+++ b/examples/wpe_crash.rs
@@ -34,6 +34,11 @@ mod pages;
#[path = "../src/downloads.rs"]
mod downloads;
#[cfg(feature = "wpe")]
+// The host's vi channel and scripts.
+#[path = "../src/vi.rs"]
+#[allow(dead_code)]
+mod vi;
+
#[path = "../src/wpe/mod.rs"]
mod wpe;
diff --git a/examples/wpe_ctx.rs b/examples/wpe_ctx.rs
index 6387b24..e097217 100644
--- a/examples/wpe_ctx.rs
+++ b/examples/wpe_ctx.rs
@@ -15,6 +15,11 @@ mod pages;
#[path = "../src/downloads.rs"]
mod downloads;
#[cfg(feature = "wpe")]
+// The host's vi channel and scripts.
+#[path = "../src/vi.rs"]
+#[allow(dead_code)]
+mod vi;
+
#[path = "../src/wpe/mod.rs"]
mod wpe;
diff --git a/examples/wpe_dark.rs b/examples/wpe_dark.rs
index f5eeb86..38dede8 100644
--- a/examples/wpe_dark.rs
+++ b/examples/wpe_dark.rs
@@ -15,6 +15,11 @@ mod pages;
#[path = "../src/downloads.rs"]
mod downloads;
#[cfg(feature = "wpe")]
+// The host's vi channel and scripts.
+#[path = "../src/vi.rs"]
+#[allow(dead_code)]
+mod vi;
+
#[path = "../src/wpe/mod.rs"]
mod wpe;
diff --git a/examples/wpe_focus.rs b/examples/wpe_focus.rs
index 2e97fe7..76df616 100644
--- a/examples/wpe_focus.rs
+++ b/examples/wpe_focus.rs
@@ -26,6 +26,11 @@ mod pages;
#[path = "../src/downloads.rs"]
mod downloads;
#[cfg(feature = "wpe")]
+// The host's vi channel and scripts.
+#[path = "../src/vi.rs"]
+#[allow(dead_code)]
+mod vi;
+
#[path = "../src/wpe/mod.rs"]
mod wpe;
diff --git a/examples/wpe_host.rs b/examples/wpe_host.rs
index 7f7b980..1fdefe3 100644
--- a/examples/wpe_host.rs
+++ b/examples/wpe_host.rs
@@ -19,6 +19,11 @@ mod pages;
#[path = "../src/downloads.rs"]
mod downloads;
#[cfg(feature = "wpe")]
+// The host's vi channel and scripts.
+#[path = "../src/vi.rs"]
+#[allow(dead_code)]
+mod vi;
+
#[path = "../src/wpe/mod.rs"]
mod wpe;
diff --git a/examples/wpe_input.rs b/examples/wpe_input.rs
index 84f1ecf..b668f85 100644
--- a/examples/wpe_input.rs
+++ b/examples/wpe_input.rs
@@ -22,6 +22,11 @@ mod pages;
#[path = "../src/downloads.rs"]
mod downloads;
#[cfg(feature = "wpe")]
+// The host's vi channel and scripts.
+#[path = "../src/vi.rs"]
+#[allow(dead_code)]
+mod vi;
+
#[path = "../src/wpe/mod.rs"]
mod wpe;
diff --git a/examples/wpe_loop.rs b/examples/wpe_loop.rs
index 6b01171..9f8dafb 100644
--- a/examples/wpe_loop.rs
+++ b/examples/wpe_loop.rs
@@ -26,6 +26,11 @@ mod pages;
#[path = "../src/downloads.rs"]
mod downloads;
#[cfg(feature = "wpe")]
+// The host's vi channel and scripts.
+#[path = "../src/vi.rs"]
+#[allow(dead_code)]
+mod vi;
+
#[path = "../src/wpe/mod.rs"]
mod wpe;
diff --git a/examples/wpe_middle.rs b/examples/wpe_middle.rs
index 1355e3a..053acdf 100644
--- a/examples/wpe_middle.rs
+++ b/examples/wpe_middle.rs
@@ -27,6 +27,11 @@ mod pages;
#[path = "../src/downloads.rs"]
mod downloads;
#[cfg(feature = "wpe")]
+// The host's vi channel and scripts.
+#[path = "../src/vi.rs"]
+#[allow(dead_code)]
+mod vi;
+
#[path = "../src/wpe/mod.rs"]
mod wpe;
diff --git a/examples/wpe_options.rs b/examples/wpe_options.rs
index 5e10cd5..9aebf39 100644
--- a/examples/wpe_options.rs
+++ b/examples/wpe_options.rs
@@ -28,6 +28,11 @@ mod pages;
#[path = "../src/downloads.rs"]
mod downloads;
#[cfg(feature = "wpe")]
+// The host's vi channel and scripts.
+#[path = "../src/vi.rs"]
+#[allow(dead_code)]
+mod vi;
+
#[path = "../src/wpe/mod.rs"]
mod wpe;
diff --git a/examples/wpe_paste.rs b/examples/wpe_paste.rs
index 1c80894..7e44be0 100644
--- a/examples/wpe_paste.rs
+++ b/examples/wpe_paste.rs
@@ -23,6 +23,11 @@ mod pages;
#[path = "../src/downloads.rs"]
mod downloads;
#[cfg(feature = "wpe")]
+// The host's vi channel and scripts.
+#[path = "../src/vi.rs"]
+#[allow(dead_code)]
+mod vi;
+
#[path = "../src/wpe/mod.rs"]
mod wpe;
diff --git a/examples/wpe_select.rs b/examples/wpe_select.rs
index 0161b8c..9ec0198 100644
--- a/examples/wpe_select.rs
+++ b/examples/wpe_select.rs
@@ -24,6 +24,11 @@ mod pages;
#[path = "../src/downloads.rs"]
mod downloads;
#[cfg(feature = "wpe")]
+// The host's vi channel and scripts.
+#[path = "../src/vi.rs"]
+#[allow(dead_code)]
+mod vi;
+
#[path = "../src/wpe/mod.rs"]
mod wpe;
diff --git a/examples/wpe_tabs.rs b/examples/wpe_tabs.rs
index 5ec72d5..7454580 100644
--- a/examples/wpe_tabs.rs
+++ b/examples/wpe_tabs.rs
@@ -23,6 +23,11 @@ mod pages;
#[path = "../src/downloads.rs"]
mod downloads;
#[cfg(feature = "wpe")]
+// The host's vi channel and scripts.
+#[path = "../src/vi.rs"]
+#[allow(dead_code)]
+mod vi;
+
#[path = "../src/wpe/mod.rs"]
mod wpe;
diff --git a/src/main.rs b/src/main.rs
index f283325..4799e6c 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -15,6 +15,8 @@ mod pages;
mod raindrop;
mod session;
mod settings;
+/// Vi-style modal keys, after qutebrowser (`browser.vi-mode`).
+mod vi;
/// The retired Servo backend; compiled only under `--features servo`.
#[cfg(feature = "servo")]
mod webview;
@@ -174,6 +176,26 @@ const BTN_H: f32 = 26.0;
const URL_FONT: f32 = 14.0;
/// Pixels per wheel notch when the DE reports discrete line deltas.
const LINE_PX: f64 = 76.0;
+/// A vi `j`/`k` line, in wheel notches.
+const VI_LINE: f32 = 0.5;
+/// How soon after a click a field taking focus counts as clicked into — what
+/// separates the person entering a field from a page focusing one itself.
+const VI_CLICK_WINDOW: std::time::Duration = std::time::Duration::from_millis(1000);
+/// How long a vi status message stays up.
+const VI_MSG_TIME: std::time::Duration = std::time::Duration::from_secs(3);
+/// The vi status line: mode, pending keys, messages, the `:` line.
+const VI_LINE_H: f32 = 28.0;
+const VI_FONT: f32 = 13.0;
+/// Hint labels over the page: qutebrowser's yellow, because a label has to
+/// read over any page at all, and the chrome's dark plates vanish into a
+/// dark one.
+const HINT_H: f32 = 19.0;
+const HINT_FONT: f32 = 13.0;
+const HINT_BG: [f32; 4] = [0.98, 0.84, 0.30, 1.0];
+const HINT_RIM: [f32; 4] = [0.45, 0.34, 0.04, 1.0];
+const HINT_TEXT: [u8; 3] = [20, 18, 10];
+/// The part of a label already typed.
+const HINT_TYPED: [u8; 3] = [140, 112, 30];
const PAGE_BG: [f32; 4] = [0.10, 0.10, 0.11, 1.0];
@@ -714,6 +736,14 @@ struct BmLayout {
cap: usize,
}
+/// What a vi script evaluation in flight was for.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum ViAsk {
+ Hints,
+ FocusInput,
+ ClickCheck,
+}
+
#[derive(Debug, Clone)]
pub enum Message {
/// The accounts worker answered a load: the index, or why it failed.
@@ -856,6 +886,47 @@ struct BrowserApp {
nav_url: Option<String>,
/// Hovered pill in the favorites strip — a repaint, like the dot.
fav_hover: Option<usize>,
+ /// Where an eased scroll the keyboard started is aimed: the middle of
+ /// the page, not wherever the pointer happens to rest. A real wheel
+ /// event takes it back to the pointer.
+ scroll_origin: Option<(f32, f32)>,
+ /// Submitting the URL bar opens a new tab instead of loading here — vi's
+ /// `O` / `gO`.
+ url_new_tab: bool,
+ /// Recently closed tabs' addresses, newest last, for `u` / `:undo`.
+ closed_tabs: Vec<Url>,
+ /// Vi mode (`browser.vi-mode`, `src/vi.rs`). Everything below is idle
+ /// while the setting is off.
+ vi_mode: vi::Mode,
+ /// Count and keys typed toward a normal-mode binding.
+ vi_keys: vi::Keys,
+ /// Labels over the page in hint mode; empty while the page is still
+ /// being asked for them.
+ vi_hints: Vec<vi::Hint>,
+ vi_hint_kind: vi::HintKind,
+ vi_hint_typed: String,
+ /// The script evaluation whose answer is awaited, by tag; an answer
+ /// under any other tag is stale.
+ vi_pending: Option<(u32, ViAsk)>,
+ vi_tag: u32,
+ /// The `:` / `/` / `?` line.
+ vi_cmd: cce_ui::widget::LineEdit,
+ vi_prompt: vi::Prompt,
+ /// Lines submitted, `(is a search, text)`, oldest first; Up/Down walk
+ /// the ones of the open prompt's kind.
+ vi_history: Vec<(bool, String)>,
+ vi_history_at: Option<usize>,
+ /// A status message and when it goes.
+ vi_msg: Option<(String, std::time::Instant)>,
+ /// The last page click (or followed hint) — see `VI_CLICK_WINDOW`.
+ vi_click: Option<std::time::Instant>,
+ /// Keys whose press vi took, so their release is not handed to the page
+ /// unpaired — even when the press changed the mode (`i`).
+ vi_swallowed: Vec<String>,
+ /// The last search, for `n` / `N` and an empty `/`; and whether its
+ /// highlights are up.
+ vi_search: Option<String>,
+ vi_searching: bool,
}
@@ -1096,6 +1167,7 @@ impl BrowserApp {
self.scroll.x.jump_to(0.0);
self.scroll.y.jump_to(0.0);
self.scroll_sent = (0.0, 0.0);
+ self.scroll_origin = None;
}
/// Hand the engine what the glide moved since the last frame.
@@ -1127,12 +1199,8 @@ impl BrowserApp {
// would tell the engine every frame that a gesture had just ended.
cce_ui::widget::scroll_motion::set_scroll_phase(cce_ui::widget::ScrollPhase::Wheel);
let s = self.scale;
- self.host.wheel(
- -(dx as f64) * s,
- -(dy as f64) * s,
- self.pointer.0 * s as f32,
- self.pointer.1 * s as f32,
- );
+ let (px, py) = self.scroll_origin.unwrap_or(self.pointer);
+ self.host.wheel(-(dx as f64) * s, -(dy as f64) * s, px * s as f32, py * s as f32);
true
}
@@ -1938,6 +2006,7 @@ impl BrowserApp {
fn close_chrome(&mut self) {
self.chrome_open = false;
self.chrome_peek = None;
+ self.url_new_tab = false;
// The menu hangs off a bar that is going away.
self.bm_menu = None;
if self.url_focused {
@@ -2305,7 +2374,12 @@ impl BrowserApp {
fn navigate(&mut self) {
if let Some(url) = parse_url_input(&self.url.text, &self.settings.search_prefix) {
- self.host.load(url);
+ if std::mem::take(&mut self.url_new_tab) {
+ self.host.open_tab(url);
+ self.persist_session();
+ } else {
+ self.host.load(url);
+ }
self.url_focused = false;
self.url.selection = None;
self.loading = true;
@@ -2329,6 +2403,15 @@ impl BrowserApp {
self.ac_menu = None;
}
}
+ if new.vi_mode != self.settings.vi_mode {
+ self.host.set_vi_enabled(new.vi_mode);
+ self.vi_set_mode(vi::Mode::Normal);
+ self.vi_msg = None;
+ if self.vi_searching {
+ self.host.find_finish();
+ self.vi_searching = false;
+ }
+ }
self.raindrop_on.store(new.raindrop, std::sync::atomic::Ordering::SeqCst);
if new.raindrop && !self.raindrop_started {
raindrop::sync::spawn(self.host.bookmarks(), self.raindrop_on.clone());
@@ -2407,6 +2490,7 @@ impl BrowserApp {
self.host.open_tab(url);
self.url = cce_ui::widget::LineEdit::default();
self.url_focused = true;
+ self.url_new_tab = false;
// The focused field has to be on screen, so a new tab unfolds the
// menu even when it was opened by chord.
self.open_chrome();
@@ -2421,6 +2505,17 @@ impl BrowserApp {
{
self.opt_menu = None;
}
+ if let Some(url) = self.host.tab(index).and_then(|t| t.url.clone()) {
+ if url.as_str() != "about:blank" {
+ self.closed_tabs.push(url);
+ if self.closed_tabs.len() > 20 {
+ self.closed_tabs.remove(0);
+ }
+ }
+ }
+ if index == self.host.active_index() {
+ self.vi_page_changed();
+ }
if !self.host.close_tab(index) {
// Deliberately emptied: save the empty set so the next launch
// starts on the homepage instead of restoring what was closed.
@@ -2436,6 +2531,9 @@ impl BrowserApp {
fn switch_tab(&mut self, index: usize) {
// A glide aimed at this page must not land on the next one.
self.stop_scroll();
+ if index != self.host.active_index() {
+ self.vi_page_changed();
+ }
// The other tab has its own fields, and may have none.
#[cfg(feature = "wpe")]
{
@@ -3053,6 +3151,9 @@ impl BrowserApp {
let i = m.focused;
return m.fields.get_mut(i).map(|(_, e)| e);
}
+ if self.settings.vi_mode && self.vi_mode == vi::Mode::Command {
+ return Some(&mut self.vi_cmd);
+ }
if let Some(m) = self.bm_menu.as_mut() {
return Some(&mut m.query);
}
@@ -3066,6 +3167,9 @@ impl BrowserApp {
if self.modal.is_some() {
return false;
}
+ if self.settings.vi_mode && self.vi_mode == vi::Mode::Command {
+ return false;
+ }
self.bm_menu.is_some()
}
@@ -3079,6 +3183,7 @@ impl BrowserApp {
cce_ui::widget::EditOutcome::Submit => self.navigate(),
cce_ui::widget::EditOutcome::Cancel => {
self.url_focused = false;
+ self.url_new_tab = false;
self.url.selection = None;
self.sync_page_state();
}
@@ -3087,6 +3192,634 @@ impl BrowserApp {
}
}
+/// A key's identity across its press and release: the logical key, case
+/// folded, since Shift can be let go between the two (`G` comes up as `g`).
+fn key_id(event: &KeyEvent) -> String {
+ format!("{:?}", event.logical_key).to_lowercase()
+}
+
+// ---- vi mode (`src/vi.rs`) ----
+impl BrowserApp {
+ /// Change mode, dropping whatever the old one had up.
+ fn vi_set_mode(&mut self, mode: vi::Mode) {
+ if mode != vi::Mode::Hint {
+ self.vi_hints.clear();
+ self.vi_hint_typed.clear();
+ if matches!(self.vi_pending, Some((_, ViAsk::Hints))) {
+ self.vi_pending = None;
+ }
+ }
+ self.vi_keys.clear();
+ self.vi_mode = mode;
+ }
+
+ /// The page went away under vi: a navigation, or another tab shown.
+ /// Insert mode and hints belonged to it; so did any search highlight.
+ fn vi_page_changed(&mut self) {
+ if matches!(self.vi_mode, vi::Mode::Insert | vi::Mode::Hint) {
+ self.vi_set_mode(vi::Mode::Normal);
+ }
+ self.vi_pending = None;
+ self.vi_searching = false;
+ // A click that navigated must not let the next page's autofocus
+ // count as clicked into.
+ self.vi_click = None;
+ }
+
+ /// Show a message on the status line for a while. The wake is needed for
+ /// the same reason as the peek's: an idle page turns no loop.
+ fn vi_say(&mut self, text: impl Into<String>) {
+ self.vi_msg = Some((text.into(), std::time::Instant::now() + VI_MSG_TIME));
+ let tx = self.sender.clone();
+ std::thread::spawn(move || {
+ std::thread::sleep(VI_MSG_TIME);
+ let _ = tx.send(Message::Spin);
+ });
+ }
+
+ /// Ask the page something; the answer arrives in `vi_drain`.
+ fn vi_ask(&mut self, ask: ViAsk, script: &str) {
+ self.vi_tag = self.vi_tag.wrapping_add(1).max(1);
+ self.vi_pending = Some((self.vi_tag, ask));
+ self.host.vi_eval(script, self.vi_tag);
+ }
+
+ /// Take in what the page side said since the last pump: script answers,
+ /// focus moves, a search's outcome, and a message's time running out.
+ /// Returns whether anything shows differently.
+ fn vi_drain(&mut self) -> bool {
+ let mut changed = false;
+ while let Some((tag, text)) = self.host.take_vi_result() {
+ let Some((want, ask)) = self.vi_pending else { continue };
+ if tag != want {
+ continue;
+ }
+ self.vi_pending = None;
+ changed = true;
+ match ask {
+ ViAsk::Hints if self.vi_mode == vi::Mode::Hint => {
+ self.vi_hints = vi::parse_hints(&text);
+ if self.vi_hints.is_empty() {
+ self.vi_set_mode(vi::Mode::Normal);
+ self.vi_say("No elements found");
+ }
+ }
+ ViAsk::Hints => {}
+ ViAsk::FocusInput if text == "yes" => self.vi_set_mode(vi::Mode::Insert),
+ ViAsk::FocusInput => self.vi_say("No text field found"),
+ ViAsk::ClickCheck => {
+ if text == "yes" && self.vi_mode == vi::Mode::Normal {
+ self.vi_set_mode(vi::Mode::Insert);
+ }
+ }
+ }
+ }
+ if let Some(editable) = self.host.take_vi_focus() {
+ if self.settings.vi_mode {
+ let clicked = self.vi_click.is_some_and(|t| t.elapsed() < VI_CLICK_WINDOW);
+ match (self.vi_mode, editable) {
+ (vi::Mode::Normal, true) if clicked => self.vi_set_mode(vi::Mode::Insert),
+ (vi::Mode::Insert, false) => self.vi_set_mode(vi::Mode::Normal),
+ _ => {}
+ }
+ changed = true;
+ }
+ }
+ if self.host.take_find_result() == Some(0) {
+ let text = self.vi_search.clone().unwrap_or_default();
+ self.vi_say(format!("Text not found: {text}"));
+ changed = true;
+ }
+ if self.vi_msg.as_ref().is_some_and(|(_, at)| std::time::Instant::now() >= *at) {
+ self.vi_msg = None;
+ changed = true;
+ }
+ changed
+ }
+
+ /// Mark a press as taken, so its release is too.
+ fn vi_swallow(&mut self, event: &KeyEvent) {
+ if self.vi_swallowed.len() >= 16 {
+ self.vi_swallowed.remove(0);
+ }
+ self.vi_swallowed.push(key_id(event));
+ }
+
+ /// The vi stage of `handle_key_input`. `Some` when vi took the key —
+ /// carrying what the key did, which can be quitting — `None` to let it
+ /// go on down the funnel.
+ fn vi_key(&mut self, event: &KeyEvent, needs_rebuild: &mut bool) -> Option<Option<Message>> {
+ use vi::Mode;
+ if !self.settings.vi_mode {
+ return None;
+ }
+ let owns_keyboard = matches!(self.vi_mode, Mode::Hint | Mode::Command);
+ if event.state != ElementState::Pressed {
+ let id = key_id(event);
+ if let Some(i) = self.vi_swallowed.iter().position(|k| *k == id) {
+ self.vi_swallowed.remove(i);
+ return Some(None);
+ }
+ return owns_keyboard.then_some(None);
+ }
+ // A modifier on its own means nothing to vi.
+ if matches!(
+ event.logical_key,
+ Key::Named(NamedKey::Shift | NamedKey::Control | NamedKey::Alt | NamedKey::Super)
+ ) {
+ return owns_keyboard.then_some(None);
+ }
+ match self.vi_mode {
+ Mode::Command => {
+ self.vi_swallow(event);
+ *needs_rebuild = true;
+ return Some(self.vi_cmd_key(event));
+ }
+ Mode::Hint => {
+ self.vi_swallow(event);
+ *needs_rebuild = true;
+ self.vi_hint_key(event);
+ return Some(None);
+ }
+ _ => {}
+ }
+ // A field of the chrome's own has the keyboard; vi stays out of it.
+ if self.url_focused || self.bm_menu.is_some() {
+ return None;
+ }
+ let escape = event.logical_key == Key::Named(NamedKey::Escape);
+ match self.vi_mode {
+ Mode::Passthrough => {
+ if escape && event.shift {
+ self.vi_swallow(event);
+ self.vi_set_mode(Mode::Normal);
+ *needs_rebuild = true;
+ } else {
+ self.host.key_ui(event);
+ }
+ return Some(None);
+ }
+ Mode::Insert => {
+ if !escape {
+ return None;
+ }
+ self.vi_swallow(event);
+ self.vi_set_mode(Mode::Normal);
+ *needs_rebuild = true;
+ return Some(None);
+ }
+ _ => {}
+ }
+
+ // Normal mode. Escape clears what vi has going — pending keys, a
+ // message, a search's highlights — and only with none of that does
+ // it go on, to fold the bar or reach the page.
+ if escape {
+ let mut used = !self.vi_keys.is_empty() || self.vi_msg.take().is_some();
+ self.vi_keys.clear();
+ if self.vi_searching {
+ self.host.find_finish();
+ self.vi_searching = false;
+ used = true;
+ }
+ if !used {
+ return None;
+ }
+ self.vi_swallow(event);
+ *needs_rebuild = true;
+ return Some(None);
+ }
+ *needs_rebuild = true;
+ // Named keys are never commands: they end a sequence and go on.
+ let Some(token) = vi::token(event) else {
+ self.vi_keys.clear();
+ return None;
+ };
+ // A chord vi has no binding for is the chrome's, or the page's.
+ if (event.ctrl || event.alt) && !self.vi_keys.takes(&token) {
+ self.vi_keys.clear();
+ return None;
+ }
+ self.vi_swallow(event);
+ self.vi_msg = None;
+ match self.vi_keys.feed(&token) {
+ vi::Fed::Run(action, count) => Some(self.vi_run(action, count)),
+ vi::Fed::Pending | vi::Fed::Unbound => Some(None),
+ }
+ }
+
+ /// Do a normal-mode command.
+ fn vi_run(&mut self, action: vi::Action, count: Option<u32>) -> Option<Message> {
+ use vi::Action as A;
+ let n = count.unwrap_or(1).max(1) as usize;
+ let tabs = self.host.tab_count();
+ let cur = self.host.active_index();
+ match action {
+ A::ScrollLines(dx, dy) => self.vi_scroll_lines(dx * n as f32, dy * n as f32),
+ A::ScrollPage(f) => {
+ let js = vi::scroll_js(Some(f * n as f32), None, cce_ui::motion::enabled());
+ self.host.vi_eval(&js, 0);
+ }
+ A::Top | A::Bottom => {
+ let end = if action == A::Top { 0.0 } else { 1.0 };
+ let to = count.map_or(end, |c| c.min(100) as f32 / 100.0);
+ self.host.vi_eval(&vi::scroll_js(None, Some(to), false), 0);
+ }
+ A::Back => self.host.back(),
+ A::Forward => self.host.forward(),
+ A::Reload => self.host.reload(),
+ A::TabNext if tabs > 1 => self.switch_tab((cur + n) % tabs),
+ A::TabPrev if tabs > 1 => self.switch_tab((cur + tabs - n % tabs) % tabs),
+ A::TabGoto => match count {
+ Some(c) => return self.vi_run(A::TabFocus(c as usize), None),
+ None => return self.vi_run(A::TabNext, None),
+ },
+ A::TabFocus(i) if (1..=tabs).contains(&i) => self.switch_tab(i - 1),
+ A::TabFocus(i) => self.vi_say(format!("There is no tab {i}")),
+ A::TabFirst => self.switch_tab(0),
+ A::TabLast => self.switch_tab(tabs.saturating_sub(1)),
+ A::TabNext | A::TabPrev => {}
+ A::TabClose => return self.close_tab(cur),
+ A::TabOnly => {
+ for i in (0..tabs).rev().filter(|&i| i != cur) {
+ self.close_tab(i);
+ }
+ }
+ A::UndoClose => match self.closed_tabs.pop() {
+ Some(url) => {
+ self.host.open_tab(url);
+ self.sync_page_state();
+ self.persist_session();
+ }
+ None => self.vi_say("No closed tabs"),
+ },
+ A::Open { tab, edit } => {
+ self.open_chrome();
+ let text = if edit {
+ self.host.url().map(|u| u.to_string()).unwrap_or_default()
+ } else {
+ String::new()
+ };
+ self.url = cce_ui::widget::LineEdit::with_text(text);
+ self.url_focused = true;
+ self.url_new_tab = tab;
+ }
+ A::Hint(kind) => {
+ self.vi_set_mode(vi::Mode::Hint);
+ self.vi_hint_kind = kind;
+ self.vi_ask(ViAsk::Hints, &vi::hints_js(kind != vi::HintKind::Follow));
+ }
+ A::YankUrl => match self.host.url() {
+ Some(u) => {
+ cce_ui::widget::clipboard::copy_to_clipboard(u.as_str());
+ self.vi_say(format!("Yanked {u}"));
+ }
+ None => self.vi_say("No address to yank"),
+ },
+ A::YankTitle => match self.title.clone().filter(|t| !t.is_empty()) {
+ Some(t) => {
+ cce_ui::widget::clipboard::copy_to_clipboard(&t);
+ self.vi_say(format!("Yanked {t}"));
+ }
+ None => self.vi_say("No title to yank"),
+ },
+ A::Paste { tab } => {
+ let url = cce_ui::widget::clipboard::read_from_clipboard()
+ .map(|t| t.trim().to_string())
+ .filter(|t| !t.is_empty())
+ .and_then(|t| parse_url_input(&t, &self.settings.search_prefix));
+ match url {
+ Some(url) if tab => {
+ self.host.open_tab(url);
+ self.sync_page_state();
+ self.persist_session();
+ }
+ Some(url) => {
+ self.host.load(url);
+ self.loading = true;
+ }
+ None => self.vi_say("Nothing to open in the clipboard"),
+ }
+ }
+ A::Insert => self.vi_set_mode(vi::Mode::Insert),
+ A::FocusInput => self.vi_ask(ViAsk::FocusInput, &vi::focus_input_js()),
+ A::Passthrough => self.vi_set_mode(vi::Mode::Passthrough),
+ A::Prompt(p) => {
+ self.vi_set_mode(vi::Mode::Command);
+ self.vi_prompt = p;
+ self.vi_cmd = cce_ui::widget::LineEdit::default();
+ self.vi_history_at = None;
+ }
+ A::SearchNext | A::SearchPrev => {
+ if self.vi_searching {
+ for _ in 0..n {
+ if action == A::SearchNext {
+ self.host.find_next();
+ } else {
+ self.host.find_prev();
+ }
+ }
+ } else if let Some(text) = self.vi_search.clone() {
+ // A search from before a navigation: start it again here.
+ self.host.find(&text, action == A::SearchPrev);
+ self.vi_searching = true;
+ } else {
+ self.vi_say("No previous search");
+ }
+ }
+ A::Bookmark => {
+ self.host.toggle_bookmark();
+ let on = self.host.active_bookmarked();
+ self.vi_say(if on { "Bookmarked" } else { "Bookmark removed" });
+ }
+ A::Page(page) => self.open_internal_page(page),
+ A::Up | A::Root => {
+ let to = self.host.url().and_then(|u| {
+ if action == A::Up { vi::url_up(&u) } else { vi::url_root(&u) }
+ });
+ if let Some(url) = to {
+ self.host.load(url);
+ self.loading = true;
+ }
+ }
+ }
+ None
+ }
+
+ /// `j`/`k`/`h`/`l`: a wheel's worth of lines through the same eased
+ /// model a notch takes, aimed at the middle of the page.
+ fn vi_scroll_lines(&mut self, dx: f32, dy: f32) {
+ let center = (self.win.0 / 2.0, self.win.1 / 2.0);
+ // Lines down are a negative wheel delta (winit: positive = up).
+ let (lx, ly) = (-dx * VI_LINE, -dy * VI_LINE);
+ if cce_ui::widget::scroll_motion::scroll_settings().smooth {
+ use cce_ui::widget::scroll_motion::Bounds;
+ self.scroll_origin = Some(center);
+ self.scroll.apply(
+ &MouseScrollDelta::LineDelta(lx, ly),
+ (LINE_PX as f32, LINE_PX as f32),
+ Bounds::UNBOUNDED,
+ Bounds::UNBOUNDED,
+ );
+ } else {
+ cce_ui::widget::scroll_motion::set_scroll_phase(cce_ui::widget::ScrollPhase::Wheel);
+ let s = self.scale;
+ self.host.wheel(
+ lx as f64 * LINE_PX * s,
+ ly as f64 * LINE_PX * s,
+ center.0 * s as f32,
+ center.1 * s as f32,
+ );
+ }
+ }
+
+ /// Keys while the labels are up: letters narrow them down, a whole label
+ /// picks, Backspace takes a letter back, Escape gives up.
+ fn vi_hint_key(&mut self, event: &KeyEvent) {
+ match &event.logical_key {
+ Key::Named(NamedKey::Escape) => self.vi_set_mode(vi::Mode::Normal),
+ Key::Named(NamedKey::Backspace) => {
+ self.vi_hint_typed.pop();
+ }
+ Key::Character(c) if !event.ctrl && !event.alt => {
+ let next = format!("{}{}", self.vi_hint_typed, c.to_lowercase());
+ // A letter no label continues with is ignored, not an error.
+ if self.vi_hints.iter().any(|h| h.label.starts_with(&next)) {
+ let hit = self.vi_hints.iter().find(|h| h.label == next).cloned();
+ self.vi_hint_typed = next;
+ if let Some(h) = hit {
+ self.vi_follow(h);
+ }
+ }
+ }
+ _ => {}
+ }
+ }
+
+ fn vi_follow(&mut self, hint: vi::Hint) {
+ let kind = self.vi_hint_kind;
+ self.vi_set_mode(vi::Mode::Normal);
+ match kind {
+ // A real click, at the element: the page sees a person's click
+ // (popups allowed, focus moved, cross-origin frames and all), and
+ // a field picked this way is clicked into.
+ vi::HintKind::Follow => {
+ self.vi_click = Some(std::time::Instant::now());
+ self.click_page(MouseButton::Left, hint.at);
+ self.vi_ask(ViAsk::ClickCheck, &vi::active_editable_js());
+ }
+ // A middle-click: the link becomes a background tab by the same
+ // route a pointer's middle-click takes.
+ vi::HintKind::Background => self.click_page(MouseButton::Middle, hint.at),
+ vi::HintKind::Yank => match hint.href {
+ Some(href) => {
+ cce_ui::widget::clipboard::copy_to_clipboard(&href);
+ self.vi_say(format!("Yanked {href}"));
+ }
+ None => self.vi_say("That has no link to yank"),
+ },
+ }
+ }
+
+ /// Press and release `button` on the page at `at` (logical pixels).
+ fn click_page(&mut self, button: MouseButton, at: (f32, f32)) {
+ let s = self.scale as f32;
+ let (x, y) = (at.0 * s, at.1 * s);
+ self.host.mouse_move(x, y);
+ self.host.mouse_button_ui(button, true, x, y);
+ self.host.mouse_button_ui(button, false, x, y);
+ }
+
+ /// Keys for the `:` / `/` / `?` line. Editing is the shared `LineEdit`;
+ /// Up/Down walk earlier lines of the same kind, and Backspace on an empty
+ /// line leaves it, as in vim.
+ fn vi_cmd_key(&mut self, event: &KeyEvent) -> Option<Message> {
+ let search = self.vi_prompt != vi::Prompt::Command;
+ match event.logical_key {
+ Key::Named(NamedKey::ArrowUp | NamedKey::ArrowDown) => {
+ let up = event.logical_key == Key::Named(NamedKey::ArrowUp);
+ let past: Vec<String> = self
+ .vi_history
+ .iter()
+ .filter(|(s, _)| *s == search)
+ .map(|(_, t)| t.clone())
+ .collect();
+ if past.is_empty() {
+ return None;
+ }
+ let at = match (self.vi_history_at, up) {
+ (None, true) => Some(past.len() - 1),
+ (None, false) => None,
+ (Some(i), true) => Some(i.saturating_sub(1)),
+ (Some(i), false) => (i + 1 < past.len()).then_some(i + 1),
+ };
+ self.vi_history_at = at;
+ self.vi_cmd = cce_ui::widget::LineEdit::with_text(at.map_or(String::new(), |i| past[i].clone()));
+ return None;
+ }
+ Key::Named(NamedKey::Backspace) if self.vi_cmd.text.is_empty() => {
+ self.vi_set_mode(vi::Mode::Normal);
+ return None;
+ }
+ _ => {}
+ }
+ match self.vi_cmd.handle_key(event) {
+ cce_ui::widget::EditOutcome::Submit => {
+ let line = self.vi_cmd.text.clone();
+ self.vi_set_mode(vi::Mode::Normal);
+ if !line.trim().is_empty() {
+ self.vi_history.retain(|(s, t)| !(*s == search && *t == line));
+ self.vi_history.push((search, line.clone()));
+ if self.vi_history.len() > 100 {
+ self.vi_history.remove(0);
+ }
+ }
+ self.vi_submit(&line)
+ }
+ cce_ui::widget::EditOutcome::Cancel => {
+ self.vi_set_mode(vi::Mode::Normal);
+ None
+ }
+ _ => None,
+ }
+ }
+
+ fn vi_submit(&mut self, line: &str) -> Option<Message> {
+ match self.vi_prompt {
+ vi::Prompt::Search | vi::Prompt::SearchBack => {
+ // An empty search repeats the last one, as in vim.
+ let text = Some(line.to_string()).filter(|l| !l.is_empty()).or_else(|| self.vi_search.clone());
+ if let Some(text) = text {
+ self.host.find(&text, self.vi_prompt == vi::Prompt::SearchBack);
+ self.vi_search = Some(text);
+ self.vi_searching = true;
+ }
+ None
+ }
+ vi::Prompt::Command => match vi::parse_command(line) {
+ Ok(vi::Cmd::Run(action)) => self.vi_run(action, None),
+ Ok(vi::Cmd::Open { target, tab, background }) => {
+ match parse_url_input(&target, &self.settings.search_prefix) {
+ Some(url) if background => self.open_background_tab(url),
+ Some(url) if tab => {
+ self.host.open_tab(url);
+ self.sync_page_state();
+ self.persist_session();
+ }
+ Some(url) => {
+ self.host.load(url);
+ self.loading = true;
+ }
+ None => self.vi_say(format!("Cannot open {target}")),
+ }
+ None
+ }
+ // Like closing the window: the tabs are kept for next time.
+ Ok(vi::Cmd::Quit) => Some(Message::Quit),
+ Ok(vi::Cmd::Empty) => None,
+ Err(why) => {
+ self.vi_say(why);
+ None
+ }
+ },
+ }
+ }
+
+ /// Where the status line goes: the bottom-left corner, clear of the
+ /// dot (which is on the right), lifted over a bottom bar while it shows.
+ fn vi_line_rect(&self, width: f32) -> Rect {
+ let mut y = self.win.1 - bar_margin() - VI_LINE_H;
+ if self.settings.bar_position == settings::BarPosition::Bottom && self.chrome_ease() > 0.0 {
+ y = y.min(self.chrome_plate().0.y - item_gap() - VI_LINE_H);
+ }
+ let width = width.min(self.win.0 - 2.0 * bar_margin()).max(0.0);
+ Rect { x: bar_margin(), y, width, height: VI_LINE_H }
+ }
+
+ /// Hint labels over the page, and the status line: the mode, pending
+ /// keys, a message, or the `:` line being typed. Normal mode with
+ /// nothing to say draws nothing — the chrome stays a dot.
+ fn paint_vi(&mut self, pc: &mut PaintCtx, sans: &str) {
+ if !self.settings.vi_mode {
+ return;
+ }
+ if self.vi_mode == vi::Mode::Hint {
+ let typed = self.vi_hint_typed.clone();
+ let typed_w = measure_text_width(&typed, sans, HINT_FONT);
+ for h in self.vi_hints.iter().filter(|h| h.label.starts_with(&typed)) {
+ let w = measure_text_width(&h.label, sans, HINT_FONT) + 8.0;
+ let x = h.rect.0.clamp(0.0, (self.win.0 - w).max(0.0));
+ let y = h.rect.1.clamp(0.0, (self.win.1 - HINT_H).max(0.0));
+ pc.rounded_rect(
+ Rect { x: x - 1.0, y: y - 1.0, width: w + 2.0, height: HINT_H + 2.0 },
+ 5.0,
+ (true, true, true, true),
+ HINT_RIM,
+ );
+ pc.rounded_rect(Rect { x, y, width: w, height: HINT_H }, 4.0, (true, true, true, true), HINT_BG);
+ let ty = cce_ui::layout::align_text_y(y, HINT_H, HINT_FONT, 0.0);
+ if !typed.is_empty() {
+ pc.text(typed.clone(), x + 4.0, ty, HINT_FONT, HINT_TYPED);
+ }
+ pc.text(h.label[typed.len()..].to_string(), x + 4.0 + typed_w, ty, HINT_FONT, HINT_TEXT);
+ }
+ }
+
+ let material = cce_ui::scene::Material::opaque([0.13, 0.14, 0.16, 1.0]);
+ let bevel = cce_ui::layout::bevel_width().min(3.0);
+ if self.vi_mode == vi::Mode::Command {
+ let r = self.vi_line_rect((self.win.0 * 0.5).clamp(320.0, 720.0));
+ pc.plate(r, (8.0, 8.0, 8.0, 8.0), &material, bevel);
+ let sigil = self.vi_prompt.sigil().to_string();
+ let x = r.x + text_pad();
+ let ty = cce_ui::layout::align_text_y(r.y, r.height, URL_FONT, 0.0);
+ let text = self.vi_cmd.text.clone();
+ let cursor = self.vi_cmd.cursor;
+ let sel = self
+ .vi_cmd
+ .selection
+ .filter(|&(a, b)| a < b)
+ .map(|(a, b)| (self.x_of_boundary(&text, a), self.x_of_boundary(&text, b)));
+ let caret = self.x_of_boundary(&text, cursor);
+ let tx = x + measure_text_width(&sigil, sans, URL_FONT) + 3.0;
+ pc.clip(r, |pc| {
+ pc.text(sigil, x, ty, URL_FONT, [150, 190, 240]);
+ if let Some((x0, x1)) = sel {
+ pc.quad(Rect { x: tx + x0, y: r.y + 5.0, width: x1 - x0, height: r.height - 10.0 }, SEL_BG);
+ }
+ pc.text(text, tx, ty, URL_FONT, TEXT);
+ pc.quad(
+ Rect { x: tx + caret, y: r.y + 5.0, width: 1.0, height: r.height - 10.0 },
+ [0.85, 0.87, 0.92, 1.0],
+ );
+ });
+ return;
+ }
+
+ let (text, color) = if let Some((msg, _)) = &self.vi_msg {
+ (msg.clone(), TEXT)
+ } else {
+ let keys = self.vi_keys.shown();
+ match self.vi_mode.label() {
+ Some(label) => (label.to_string(), [150, 190, 240]),
+ None if !keys.is_empty() => (keys, TEXT),
+ None => return,
+ }
+ };
+ let avail = self.win.0 - 2.0 * bar_margin() - 2.0 * text_pad();
+ let text = Self::fit_text(&text, sans, VI_FONT, avail);
+ let r = self.vi_line_rect(measure_text_width(&text, sans, VI_FONT) + 2.0 * text_pad());
+ pc.plate(r, (8.0, 8.0, 8.0, 8.0), &material, bevel);
+ pc.text(
+ text,
+ r.x + text_pad(),
+ cce_ui::layout::align_text_y(r.y, r.height, VI_FONT, 0.0),
+ VI_FONT,
+ color,
+ );
+ }
+}
+
impl Application for BrowserApp {
type Message = Message;
@@ -3147,6 +3880,7 @@ impl Application for BrowserApp {
host.set_force_dark(settings.color_scheme.forces_dark());
#[cfg(feature = "wpe")]
host.set_accounts_enabled(settings.accounts);
+ host.set_vi_enabled(settings.vi_mode);
let accounts = accounts::Accounts::spawn(sender.clone());
let raindrop_on = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(settings.raindrop));
let raindrop_started = settings.raindrop;
@@ -3205,6 +3939,25 @@ impl Application for BrowserApp {
#[cfg(feature = "wpe")]
ac_menu: None,
nav_url: None,
+ scroll_origin: None,
+ url_new_tab: false,
+ closed_tabs: Vec::new(),
+ vi_mode: vi::Mode::Normal,
+ vi_keys: vi::Keys::default(),
+ vi_hints: Vec::new(),
+ vi_hint_kind: vi::HintKind::Follow,
+ vi_hint_typed: String::new(),
+ vi_pending: None,
+ vi_tag: 0,
+ vi_cmd: cce_ui::widget::LineEdit::default(),
+ vi_prompt: vi::Prompt::Command,
+ vi_history: Vec::new(),
+ vi_history_at: None,
+ vi_msg: None,
+ vi_click: None,
+ vi_swallowed: Vec::new(),
+ vi_search: None,
+ vi_searching: false,
}
}
@@ -3310,6 +4063,12 @@ impl Application for BrowserApp {
if now != self.nav_url {
self.nav_url = now;
self.stop_scroll();
+ // A load, not a pushState: a search field rewriting
+ // the address on every keystroke must not lose the
+ // keyboard to it.
+ if self.host.loading() {
+ self.vi_page_changed();
+ }
#[cfg(feature = "wpe")]
{
self.ac_menu = None;
@@ -3322,6 +4081,9 @@ impl Application for BrowserApp {
// so this is where an address change gets persisted.
self.persist_session();
}
+ if self.vi_drain() {
+ *needs_rebuild = true;
+ }
// Drained after the navigation check, so a field reported in
// the same pump that finished the load is not thrown away
// with the page it arrived on.
@@ -3473,6 +4235,7 @@ impl Application for BrowserApp {
// reach us; forget them rather than act on a stale Shift or drag.
if !focused {
self.shift_held = false;
+ self.vi_swallowed.clear();
// A select's list is a transient of the page, and goes with focus
// as a native one does.
#[cfg(feature = "wpe")]
@@ -3504,6 +4267,9 @@ impl Application for BrowserApp {
if self.win != (width, height) {
self.close_opt_menu();
}
+ if self.win != (width, height) && self.vi_mode == vi::Mode::Hint {
+ self.vi_set_mode(vi::Mode::Normal);
+ }
self.win = (width, height);
self.scale = scale;
let (w, h) = self.content_px();
@@ -3647,6 +4413,14 @@ impl Application for BrowserApp {
) -> Option<Self::Message> {
let pressed = state == ElementState::Pressed;
+ // A click ends hinting (the labels no longer match what is under
+ // them once anything moves) and the `:` line, as a click off any
+ // other field drops it.
+ if pressed && matches!(self.vi_mode, vi::Mode::Hint | vi::Mode::Command) {
+ self.vi_set_mode(vi::Mode::Normal);
+ *needs_rebuild = true;
+ }
+
// Before any of the branches that swallow a click: a button the page
// is holding gets its release no matter where it was let go, or the
// engine goes on believing it is still down.
@@ -3888,6 +4662,9 @@ impl Application for BrowserApp {
// caret, Shift extends to it, and a drag selects.
self.url_entry_press = !self.url_focused;
let extend = self.url_focused && self.shift_held;
+ if !self.url_focused {
+ self.url_new_tab = false;
+ }
self.url_focused = true;
self.url.press(at, extend);
} else {
@@ -3910,10 +4687,25 @@ impl Application for BrowserApp {
MouseButton::Forward if pressed => self.host.forward(),
_ => self.page_press(button, pressed, pos),
}
+ if button == MouseButton::Left && self.settings.vi_mode {
+ if pressed {
+ self.vi_click = Some(std::time::Instant::now());
+ } else if self.vi_mode == vi::Mode::Normal {
+ // Clicking a field that already had focus moves no focus,
+ // so the watcher has nothing to say; ask the page instead.
+ self.vi_ask(ViAsk::ClickCheck, &vi::active_editable_js());
+ }
+ }
None
}
fn handle_mouse_wheel(&mut self, delta: &MouseScrollDelta, pos: LogicalPosition, needs_rebuild: &mut bool) {
+ self.scroll_origin = None;
+ // The labels would stay put while the page moved under them.
+ if self.vi_mode == vi::Mode::Hint {
+ self.vi_set_mode(vi::Mode::Normal);
+ *needs_rebuild = true;
+ }
// A select's list takes the wheel: over it, it scrolls the list;
// anywhere else it is swallowed, so the select stays under it.
#[cfg(feature = "wpe")]
@@ -4168,6 +4960,13 @@ impl Application for BrowserApp {
return None;
}
+ // Vi mode, ahead of the chrome's chords: normal mode's Ctrl bindings
+ // (Ctrl+D/U/F/B scroll, Ctrl+V is passthrough) win over them, and
+ // passthrough hands the page even those.
+ if let Some(out) = self.vi_key(event, needs_rebuild) {
+ return out;
+ }
+
// Tab shortcuts work regardless of URL-bar focus.
if event.state == ElementState::Pressed && event.ctrl {
let count = self.host.tab_count();
@@ -4266,6 +5065,7 @@ impl Application for BrowserApp {
}
"l" => {
self.open_chrome();
+ self.url_new_tab = false;
self.url_focused = true;
self.select_all_url();
*needs_rebuild = true;
@@ -4532,6 +5332,7 @@ impl Application for BrowserApp {
Some(2.0),
);
+ self.paint_vi(&mut pc, &sans);
self.paint_bm_menu(&mut pc, &sans);
#[cfg(feature = "wpe")]
self.paint_ac_menu(&mut pc, &sans);
diff --git a/src/settings.rs b/src/settings.rs
index cd2bcc5..ebcea47 100644
--- a/src/settings.rs
+++ b/src/settings.rs
@@ -96,6 +96,9 @@ pub struct Settings {
/// (RAINDROP-SYNC.md). Off by default: it needs a token in the keyring,
/// and it writes to an account elsewhere.
pub raindrop: bool,
+ /// Vi-style modal keys, after qutebrowser (`src/vi.rs`). Off by default:
+ /// it changes what every letter typed at a page means.
+ pub vi_mode: bool,
/// Window edge the utility bar floats against.
pub bar_position: BarPosition,
/// What pages are told to prefer.
@@ -114,6 +117,7 @@ impl Default for Settings {
history: true,
accounts: true,
raindrop: false,
+ vi_mode: false,
bar_position: BarPosition::Top,
color_scheme: ColorScheme::Dark,
external_browser: None,
@@ -158,6 +162,7 @@ pub fn load() -> Settings {
history: b["history"].as_bool().unwrap_or(true),
accounts: b["accounts"].as_bool().unwrap_or(true),
raindrop: b["raindrop"].as_bool().unwrap_or(false),
+ vi_mode: b["vi-mode"].as_bool().unwrap_or(false),
bar_position: BarPosition::from_key(b["bar-position"].as_str().unwrap_or("top")),
color_scheme: ColorScheme::from_key(b["color-scheme"].as_str().unwrap_or("dark")),
external_browser: b["external-browser"]
diff --git a/src/vi.rs b/src/vi.rs
new file mode 100644
index 0000000..7ed47e1
--- /dev/null
+++ b/src/vi.rs
@@ -0,0 +1,741 @@
+//! Vi-style modal keys, after qutebrowser.
+//!
+//! The chrome-independent half: the modes, the normal-mode bindings and the
+//! parser that turns keystrokes into them, hint labels, `:` commands, and the
+//! scripts the page side runs. `main.rs` owns what these do (the tab set, the
+//! URL bar, the drawing), and the WPE host owns how they reach the engine.
+//!
+//! Points that are choices, not accidents:
+//!
+//! * **qutebrowser's bindings, where this browser has the feature.** `J`/`K`
+//! are next/previous tab and `d`/`u` close and reopen, as there — not
+//! Vimium's. What the browser does not have (zoom, quickmarks, windows)
+//! is not bound, rather than bound to nothing.
+//! * **Unbound characters are swallowed, unbound named keys are not**
+//! (qutebrowser's `forward_unbound_keys = auto`): a stray letter must not
+//! type into a page that happens to have focus, but arrows, Page Up/Down,
+//! Space, Enter and Tab still reach it.
+//! * **Insert mode is entered by a click, not by focus.** A field the page
+//! focuses on its own (a search box with `autofocus`) leaves normal mode
+//! alone, so `j` still scrolls; a field the person clicks into — or picks
+//! with a hint — takes the keyboard. `i` enters it explicitly.
+
+use cce_ui::widget::{Key, KeyEvent};
+
+/// What the keyboard means right now.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Mode {
+ /// Keys are commands.
+ Normal,
+ /// Keys go to the page, except Escape, which comes back to normal.
+ Insert,
+ /// Every key goes to the page, the chrome's own Ctrl chords included;
+ /// only Shift+Escape comes back.
+ Passthrough,
+ /// Labels are up over the page's clickable elements; typing one picks it.
+ Hint,
+ /// The `:` / `/` / `?` line has the keyboard.
+ Command,
+}
+
+impl Mode {
+ /// The status line's name for the mode; `None` for normal, which shows
+ /// nothing.
+ pub fn label(self) -> Option<&'static str> {
+ match self {
+ Mode::Normal | Mode::Command => None,
+ Mode::Insert => Some("-- INSERT --"),
+ Mode::Passthrough => Some("-- PASSTHROUGH --"),
+ Mode::Hint => Some("-- HINT --"),
+ }
+ }
+}
+
+/// What picking a hint does.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum HintKind {
+ /// Click it, as the pointer would.
+ Follow,
+ /// Middle-click it: a link opens in a background tab.
+ Background,
+ /// Copy its link.
+ Yank,
+}
+
+/// Which line the command prompt is.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Prompt {
+ Command,
+ Search,
+ SearchBack,
+}
+
+impl Prompt {
+ pub fn sigil(self) -> char {
+ match self {
+ Prompt::Command => ':',
+ Prompt::Search => '/',
+ Prompt::SearchBack => '?',
+ }
+ }
+}
+
+/// A normal-mode command. A count, where one was typed, comes alongside.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub enum Action {
+ /// Scroll by this many lines (a line is half a wheel notch), eased.
+ ScrollLines(f32, f32),
+ /// Scroll by this fraction of the viewport: half a page, or a page.
+ ScrollPage(f32),
+ /// To the top / the bottom; with a count, to that percent.
+ Top,
+ Bottom,
+ Back,
+ Forward,
+ Reload,
+ /// Move this many tabs (a count multiplies).
+ TabNext,
+ TabPrev,
+ /// `gt`: the next tab, or with a count, tab number `count`.
+ TabGoto,
+ TabFirst,
+ TabLast,
+ /// Tab number n, counting from 1 (`Alt+n`).
+ TabFocus(usize),
+ TabClose,
+ TabOnly,
+ UndoClose,
+ /// Focus the URL bar: empty, or holding the current address (`edit`);
+ /// submitting loads here or in a new tab.
+ Open { tab: bool, edit: bool },
+ Hint(HintKind),
+ YankUrl,
+ YankTitle,
+ /// Open the clipboard's address, here or in a new tab.
+ Paste { tab: bool },
+ Insert,
+ /// Focus the page's first text field and enter insert mode.
+ FocusInput,
+ Passthrough,
+ Prompt(Prompt),
+ SearchNext,
+ SearchPrev,
+ Bookmark,
+ /// One of the `cce://` pages.
+ Page(&'static str),
+ /// Up one path segment; to the site root.
+ Up,
+ Root,
+}
+
+/// The normal-mode bindings: key sequence -> action. A sequence is a run of
+/// tokens as [`token`] spells them: a plain character, or `<C-x>` / `<A-x>`.
+pub const BINDINGS: &[(&str, Action)] = &[
+ ("j", Action::ScrollLines(0.0, 1.0)),
+ ("k", Action::ScrollLines(0.0, -1.0)),
+ ("h", Action::ScrollLines(-1.0, 0.0)),
+ ("l", Action::ScrollLines(1.0, 0.0)),
+ ("<C-d>", Action::ScrollPage(0.5)),
+ ("<C-u>", Action::ScrollPage(-0.5)),
+ ("<C-f>", Action::ScrollPage(1.0)),
+ ("<C-b>", Action::ScrollPage(-1.0)),
+ ("gg", Action::Top),
+ ("G", Action::Bottom),
+ ("H", Action::Back),
+ ("L", Action::Forward),
+ ("r", Action::Reload),
+ ("R", Action::Reload),
+ ("J", Action::TabNext),
+ ("K", Action::TabPrev),
+ ("gt", Action::TabGoto),
+ ("gT", Action::TabPrev),
+ ("g0", Action::TabFirst),
+ ("g^", Action::TabFirst),
+ ("g$", Action::TabLast),
+ ("<A-1>", Action::TabFocus(1)),
+ ("<A-2>", Action::TabFocus(2)),
+ ("<A-3>", Action::TabFocus(3)),
+ ("<A-4>", Action::TabFocus(4)),
+ ("<A-5>", Action::TabFocus(5)),
+ ("<A-6>", Action::TabFocus(6)),
+ ("<A-7>", Action::TabFocus(7)),
+ ("<A-8>", Action::TabFocus(8)),
+ ("<A-9>", Action::TabLast),
+ ("d", Action::TabClose),
+ ("co", Action::TabOnly),
+ ("u", Action::UndoClose),
+ ("o", Action::Open { tab: false, edit: false }),
+ ("O", Action::Open { tab: true, edit: false }),
+ ("go", Action::Open { tab: false, edit: true }),
+ ("gO", Action::Open { tab: true, edit: true }),
+ ("f", Action::Hint(HintKind::Follow)),
+ ("F", Action::Hint(HintKind::Background)),
+ (";b", Action::Hint(HintKind::Background)),
+ (";y", Action::Hint(HintKind::Yank)),
+ ("yy", Action::YankUrl),
+ ("yt", Action::YankTitle),
+ ("p", Action::Paste { tab: false }),
+ ("P", Action::Paste { tab: true }),
+ ("i", Action::Insert),
+ ("gi", Action::FocusInput),
+ ("<C-v>", Action::Passthrough),
+ (":", Action::Prompt(Prompt::Command)),
+ ("/", Action::Prompt(Prompt::Search)),
+ ("?", Action::Prompt(Prompt::SearchBack)),
+ ("n", Action::SearchNext),
+ ("N", Action::SearchPrev),
+ ("M", Action::Bookmark),
+ ("Sb", Action::Page("cce://bookmarks")),
+ ("Sh", Action::Page("cce://history")),
+ ("gu", Action::Up),
+ ("gU", Action::Root),
+];
+
+/// A keystroke as the bindings spell it. `None` for keys that are not
+/// commands at all — named keys, which unbound go on to the page — and for
+/// a lone modifier.
+pub fn token(e: &KeyEvent) -> Option<String> {
+ let Key::Character(c) = &e.logical_key else { return None };
+ let ch = c.chars().next()?.to_ascii_lowercase();
+ // Shift is spelled out on a chord, so Ctrl+Shift+D (the chrome's
+ // favorite toggle) is not taken for Ctrl+D.
+ let shift = if e.shift { "S-" } else { "" };
+ if e.ctrl {
+ Some(format!("<C-{shift}{ch}>"))
+ } else if e.alt {
+ Some(format!("<A-{shift}{ch}>"))
+ } else {
+ // The logical key already carries Shift: `G`, not `g`.
+ Some(c.clone())
+ }
+}
+
+/// Whether some binding begins with these exact keys.
+fn is_prefix(keys: &str) -> bool {
+ BINDINGS.iter().any(|(k, _)| k.starts_with(keys))
+}
+
+/// What one keystroke did to the pending sequence.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub enum Fed {
+ /// Part of a longer binding (or a count); wait for more.
+ Pending,
+ /// A whole binding, with its count if one was typed.
+ Run(Action, Option<u32>),
+ /// Bound to nothing; the sequence is dropped.
+ Unbound,
+}
+
+/// The count and keys typed so far toward a binding.
+#[derive(Debug, Default)]
+pub struct Keys {
+ count: String,
+ keys: String,
+}
+
+impl Keys {
+ pub fn feed(&mut self, token: &str) -> Fed {
+ // A count leads, and a `0` only continues one.
+ let digit = token.len() == 1 && token.as_bytes()[0].is_ascii_digit();
+ if digit && self.keys.is_empty() && (token != "0" || !self.count.is_empty()) {
+ self.count.push_str(token);
+ return Fed::Pending;
+ }
+ let candidate = format!("{}{token}", self.keys);
+ if let Some((_, action)) = BINDINGS.iter().find(|(k, _)| *k == candidate) {
+ let count = self.count.parse().ok();
+ self.clear();
+ return Fed::Run(*action, count);
+ }
+ if is_prefix(&candidate) {
+ self.keys = candidate;
+ return Fed::Pending;
+ }
+ self.clear();
+ Fed::Unbound
+ }
+
+ /// Whether `token` would be used by [`Self::feed`] rather than dropped —
+ /// how a Ctrl chord the bindings do not know goes on to the chrome.
+ pub fn takes(&self, token: &str) -> bool {
+ is_prefix(&format!("{}{token}", self.keys))
+ }
+
+ pub fn clear(&mut self) {
+ self.count.clear();
+ self.keys.clear();
+ }
+
+ pub fn is_empty(&self) -> bool {
+ self.count.is_empty() && self.keys.is_empty()
+ }
+
+ /// What is pending, for the status line.
+ pub fn shown(&self) -> String {
+ format!("{}{}", self.count, self.keys)
+ }
+}
+
+/// The characters hint labels are made of — the home row, qutebrowser's
+/// default.
+pub const HINT_CHARS: &str = "asdfghjkl";
+
+/// Labels for `n` hints: as short as `n` allows, and prefix-free, so typing
+/// a whole label is always unambiguous and never needs a confirming key.
+///
+/// Mixed lengths, the way qutebrowser does it: when `n` does not fill every
+/// label of the longest length, the spare room goes to shorter ones. Each
+/// short label takes one prefix away from the long ones, and with it
+/// `chars - 1` long labels' worth of room.
+pub fn hint_labels(n: usize, chars: &str) -> Vec<String> {
+ let chars: Vec<char> = chars.chars().collect();
+ let k = chars.len();
+ if n == 0 || k < 2 {
+ return Vec::new();
+ }
+ let mut needed = 1;
+ let mut room = k;
+ while room < n {
+ needed += 1;
+ room *= k;
+ }
+ let short = if needed > 1 { (room - n) / (k - 1) } else { 0 };
+ let spell = |mut i: usize, len: usize| -> String {
+ let mut s = vec![chars[0]; len];
+ for slot in s.iter_mut().rev() {
+ *slot = chars[i % k];
+ i /= k;
+ }
+ s.into_iter().collect()
+ };
+ let mut out = Vec::with_capacity(n);
+ for i in 0..short.min(n) {
+ out.push(spell(i, needed - 1));
+ }
+ // The prefixes after the short labels, each extended by every char.
+ let mut i = short * k;
+ while out.len() < n {
+ out.push(spell(i, needed));
+ i += 1;
+ }
+ out
+}
+
+/// One clickable element on the page, as the hint script reports it, in the
+/// chrome's logical pixels (the page's CSS pixels — see `field_rect`).
+#[derive(Debug, Clone, PartialEq)]
+pub struct Hint {
+ /// The visible part of the element.
+ pub rect: (f32, f32, f32, f32),
+ /// Where a click on it lands: the visible part's middle.
+ pub at: (f32, f32),
+ pub href: Option<String>,
+ pub label: String,
+}
+
+/// The hint script's answer, labelled. Anything malformed is dropped.
+pub fn parse_hints(json: &str) -> Vec<Hint> {
+ let Ok(serde_json::Value::Array(items)) = serde_json::from_str(json) else {
+ return Vec::new();
+ };
+ let num = |v: &serde_json::Value, k: &str| v[k].as_f64().map(|f| f as f32);
+ let mut hints: Vec<Hint> = items
+ .iter()
+ .filter_map(|v| {
+ let (x, y, w, h) = (num(v, "x")?, num(v, "y")?, num(v, "w")?, num(v, "h")?);
+ Some(Hint {
+ rect: (x, y, w, h),
+ at: (x + w / 2.0, y + h / 2.0),
+ href: v["href"].as_str().filter(|s| !s.is_empty()).map(str::to_string),
+ label: String::new(),
+ })
+ })
+ .collect();
+ let labels = hint_labels(hints.len(), HINT_CHARS);
+ for (h, l) in hints.iter_mut().zip(labels) {
+ h.label = l;
+ }
+ hints
+}
+
+/// A parsed `:` line.
+#[derive(Debug, Clone, PartialEq)]
+pub enum Cmd {
+ Run(Action),
+ /// Load what was typed: here, in a new tab, or in a background one.
+ Open { target: String, tab: bool, background: bool },
+ Quit,
+ Empty,
+}
+
+/// Parse a `:` line — qutebrowser's names, plus the short forms vim hands
+/// people's fingers (`:o`, `:t`, `:q`, `:b 3`).
+pub fn parse_command(line: &str) -> Result<Cmd, String> {
+ let line = line.trim();
+ let (name, rest) = line.split_once(char::is_whitespace).unwrap_or((line, ""));
+ let rest = rest.trim();
+ // `-t` / `-b` flags on `open`, as qutebrowser spells them.
+ let mut tab = false;
+ let mut background = false;
+ let mut words: Vec<&str> = Vec::new();
+ for w in rest.split_whitespace() {
+ match w {
+ "-t" | "--tab" => tab = true,
+ "-b" | "--bg" => background = true,
+ _ => words.push(w),
+ }
+ }
+ let target = words.join(" ");
+ let open = |tab: bool, background: bool| {
+ if target.is_empty() {
+ Ok(Cmd::Run(Action::Open { tab: tab || background, edit: false }))
+ } else {
+ Ok(Cmd::Open { target: target.clone(), tab, background })
+ }
+ };
+ let number = || {
+ rest.parse::<usize>()
+ .ok()
+ .filter(|n| *n > 0)
+ .ok_or_else(|| format!("{name}: needs a tab number"))
+ };
+ match name {
+ "" => Ok(Cmd::Empty),
+ "o" | "open" => open(tab, background),
+ "t" | "to" | "tabopen" | "tabnew" => open(true, background),
+ "bg" | "backgroundopen" => open(true, true),
+ "q" | "quit" | "qa" | "qall" | "wq" | "wqa" | "x" => Ok(Cmd::Quit),
+ "close" | "tab-close" | "tabclose" | "bd" | "bdelete" => Ok(Cmd::Run(Action::TabClose)),
+ "tab-only" | "only" | "tabonly" => Ok(Cmd::Run(Action::TabOnly)),
+ "undo" => Ok(Cmd::Run(Action::UndoClose)),
+ "tab-next" | "tabnext" | "tabn" | "bn" => Ok(Cmd::Run(Action::TabNext)),
+ "tab-prev" | "tabprev" | "tabp" | "tabN" | "bp" => Ok(Cmd::Run(Action::TabPrev)),
+ "tab-focus" | "buffer" | "b" => number().map(|n| Cmd::Run(Action::TabFocus(n))),
+ "back" => Ok(Cmd::Run(Action::Back)),
+ "forward" => Ok(Cmd::Run(Action::Forward)),
+ "reload" | "r" => Ok(Cmd::Run(Action::Reload)),
+ "yank" if rest.is_empty() || rest == "url" => Ok(Cmd::Run(Action::YankUrl)),
+ "yank" if rest == "title" => Ok(Cmd::Run(Action::YankTitle)),
+ "bookmark-add" | "bookmark" => Ok(Cmd::Run(Action::Bookmark)),
+ "bookmarks" => Ok(Cmd::Run(Action::Page("cce://bookmarks"))),
+ "history" => Ok(Cmd::Run(Action::Page("cce://history"))),
+ "downloads" => Ok(Cmd::Run(Action::Page("cce://downloads"))),
+ "favorites" => Ok(Cmd::Run(Action::Page("cce://favorites"))),
+ "mode-enter" if rest == "insert" => Ok(Cmd::Run(Action::Insert)),
+ "mode-enter" if rest == "passthrough" => Ok(Cmd::Run(Action::Passthrough)),
+ _ => Err(format!("Unknown command: {name}")),
+ }
+}
+
+/// Up one path segment (`gu`): `/a/b/` and `/a/b` both go to `/a/`, and the
+/// query and fragment go with the step. `None` when already at the root.
+pub fn url_up(url: &url::Url) -> Option<url::Url> {
+ let path = url.path().trim_end_matches('/');
+ if path.is_empty() && url.query().is_none() && url.fragment().is_none() {
+ return None;
+ }
+ let mut up = url.clone();
+ up.set_query(None);
+ up.set_fragment(None);
+ let parent = match path.rfind('/') {
+ Some(i) => &path[..=i],
+ None => "/",
+ };
+ up.set_path(parent);
+ Some(up)
+}
+
+/// The site root (`gU`).
+pub fn url_root(url: &url::Url) -> Option<url::Url> {
+ let mut root = url.clone();
+ root.set_path("/");
+ root.set_query(None);
+ root.set_fragment(None);
+ (root != *url).then_some(root)
+}
+
+// ---- the page side ----
+//
+// Engine-agnostic text, but only the WPE host runs it. Everything runs in a
+// private script world (`WORLD`): the DOM is shared, the page's globals are
+// not, so a page can neither see these helpers nor post on the channel the
+// chrome listens to.
+
+/// The world the scripts below run in.
+pub const WORLD: &str = "cce-vi";
+/// The channel the focus watcher reports on.
+pub const CHANNEL: &str = "cceVi";
+
+/// The shared test for "this element takes typing", as a JS prelude.
+const EDITABLE_JS: &str = r#"
+ const noText = ['button', 'submit', 'reset', 'checkbox', 'radio', 'image',
+ 'file', 'hidden', 'range', 'color'];
+ const editable = (el) => {
+ if (!el) return false;
+ if (el.isContentEditable) return true;
+ if (el.disabled || el.readOnly) return false;
+ if (el.tagName === 'TEXTAREA') return true;
+ if (el.tagName === 'INPUT') return !noText.includes((el.type || 'text').toLowerCase());
+ return false;
+ };
+"#;
+
+/// Runs in every frame and reports whether the focused element takes text,
+/// whenever focus moves. Only a document with focus speaks (every tab shares
+/// the channel; only the shown one is focused), and a frame whose focus is
+/// inside a child frame leaves it to the child.
+pub fn focus_watch_js() -> String {
+ format!(
+ r#"(() => {{
+ const h = window.webkit && window.webkit.messageHandlers;
+ if (!h || !h.cceVi) return;
+ {EDITABLE_JS}
+ let timer = 0;
+ // After the event, not in it: mid-move, activeElement is still the old one.
+ const report = () => {{
+ clearTimeout(timer);
+ timer = setTimeout(() => {{
+ if (!document.hasFocus()) return;
+ let el = document.activeElement;
+ while (el && el.shadowRoot && el.shadowRoot.activeElement) el = el.shadowRoot.activeElement;
+ if (el && (el.tagName === 'IFRAME' || el.tagName === 'FRAME')) return;
+ try {{ h.cceVi.postMessage(JSON.stringify({{ t: 'focus', editable: editable(el) }})); }} catch (e) {{}}
+ }}, 0);
+ }};
+ document.addEventListener('focusin', report, true);
+ document.addEventListener('focusout', report, true);
+}})();"#
+ )
+}
+
+/// Whether what has focus now takes typing, following focus down through
+/// same-origin frames: `"yes"` or `""`. Asked after a click in normal mode,
+/// since clicking a field that already had focus moves no focus, and so
+/// tells the watcher nothing.
+pub fn active_editable_js() -> String {
+ format!(
+ r#"(() => {{
+ {EDITABLE_JS}
+ let el = document.activeElement;
+ for (;;) {{
+ while (el && el.shadowRoot && el.shadowRoot.activeElement) el = el.shadowRoot.activeElement;
+ if (!el || (el.tagName !== 'IFRAME' && el.tagName !== 'FRAME')) break;
+ try {{ const d = el.contentDocument; if (!d) return ''; el = d.activeElement; }} catch (e) {{ return ''; }}
+ }}
+ return editable(el) ? 'yes' : '';
+}})()"#
+ )
+}
+
+/// The clickable elements in view, as JSON: `[{x, y, w, h, href}]` in the
+/// top viewport's CSS pixels, clipped to what is visible. Descends into
+/// same-origin frames; a cross-origin frame's document is out of reach of a
+/// script evaluated in the top frame. `links` keeps only real links (`F`,
+/// `;y`), since only those open anything when middle-clicked or copied.
+///
+/// An element covered at its middle by something unrelated (a modal, a
+/// cookie banner) is left out: a click there would land on the cover.
+pub fn hints_js(links: bool) -> String {
+ let sel = if links {
+ "a[href], area[href]"
+ } else {
+ "a, area, button, select, textarea, summary, \
+ input:not([type=\"hidden\"]), \
+ [contenteditable]:not([contenteditable=\"false\"]), \
+ [onclick], [onmousedown], [role=\"link\"], [role=\"button\"], \
+ [role=\"tab\"], [role=\"checkbox\"], [role=\"switch\"], [role=\"option\"], \
+ [role=\"menuitem\"], [role=\"menuitemcheckbox\"], [role=\"menuitemradio\"], \
+ [role=\"treeitem\"], [aria-haspopup], [ng-click], [data-ng-click], \
+ [tabindex]:not([tabindex=\"-1\"])"
+ };
+ format!(
+ r#"(() => {{
+ const SEL = {sel:?};
+ const out = [];
+ const seen = new Set();
+ const walk = (win, ox, oy, clip) => {{
+ const doc = win.document;
+ let els;
+ try {{ els = doc.querySelectorAll(SEL); }} catch (e) {{ return; }}
+ for (const el of els) {{
+ if (seen.has(el) || el.disabled) continue;
+ const st = win.getComputedStyle(el);
+ if (st.visibility !== 'visible' || st.display === 'none') continue;
+ for (const r of el.getClientRects()) {{
+ const x0 = Math.max(r.left + ox, clip[0]), y0 = Math.max(r.top + oy, clip[1]);
+ const x1 = Math.min(r.right + ox, clip[2]), y1 = Math.min(r.bottom + oy, clip[3]);
+ if (x1 - x0 < 2 || y1 - y0 < 2) continue;
+ const hit = doc.elementFromPoint((x0 + x1) / 2 - ox, (y0 + y1) / 2 - oy);
+ if (hit && hit !== el && !el.contains(hit) && !hit.contains(el)) continue;
+ seen.add(el);
+ out.push({{ x: x0, y: y0, w: x1 - x0, h: y1 - y0, href: el.href ? String(el.href) : '' }});
+ break;
+ }}
+ }}
+ for (const f of doc.querySelectorAll('iframe, frame')) {{
+ let inner;
+ try {{ inner = f.contentWindow; if (!inner || !inner.document) continue; }} catch (e) {{ continue; }}
+ const r = f.getBoundingClientRect();
+ const fx = r.left + ox + f.clientLeft, fy = r.top + oy + f.clientTop;
+ const c = [Math.max(fx, clip[0]), Math.max(fy, clip[1]),
+ Math.min(fx + f.clientWidth, clip[2]), Math.min(fy + f.clientHeight, clip[3])];
+ if (c[2] > c[0] && c[3] > c[1]) walk(inner, fx, fy, c);
+ }}
+ }};
+ walk(window, 0, 0, [0, 0, window.innerWidth, window.innerHeight]);
+ return JSON.stringify(out);
+}})()"#
+ )
+}
+
+/// Scroll the page's main scroller: by a fraction of the viewport, or to a
+/// fraction of its range. The scroller is whatever scrolls under the middle
+/// of the view — many pages scroll an inner element and leave the document
+/// still — falling back to the document's own. `smooth` follows the DE's
+/// animations switch.
+pub fn scroll_js(by: Option<f32>, to: Option<f32>, smooth: bool) -> String {
+ let behavior = if smooth { "smooth" } else { "instant" };
+ let by = by.map_or("null".to_string(), |f| f.to_string());
+ let to = to.map_or("null".to_string(), |f| f.to_string());
+ format!(
+ r#"(() => {{
+ const by = {by}, to = {to};
+ const scrolls = (el) => {{
+ const s = getComputedStyle(el);
+ return el.scrollHeight > el.clientHeight + 1 && /(auto|scroll|overlay)/.test(s.overflowY);
+ }};
+ const root = document.scrollingElement || document.documentElement;
+ let el = document.elementFromPoint(innerWidth / 2, innerHeight / 2);
+ while (el && el !== document.body && el !== document.documentElement && !scrolls(el)) el = el.parentElement;
+ if (!el || el === document.body || el === document.documentElement) el = root;
+ const view = el === root ? innerHeight : el.clientHeight;
+ const top = by !== null ? el.scrollTop + by * view
+ : to * (el.scrollHeight - view);
+ el.scrollTo({{ top, behavior: '{behavior}' }});
+}})()"#
+ )
+}
+
+/// Focus the first visible text field (`gi`): `"yes"`, or `""` for none.
+pub fn focus_input_js() -> String {
+ format!(
+ r#"(() => {{
+ {EDITABLE_JS}
+ for (const el of document.querySelectorAll('input, textarea, [contenteditable]')) {{
+ if (!editable(el)) continue;
+ const r = el.getBoundingClientRect();
+ if (r.width < 2 || r.height < 2 || r.bottom < 0 || r.top > innerHeight) continue;
+ if (getComputedStyle(el).visibility !== 'visible') continue;
+ el.focus();
+ return 'yes';
+ }}
+ return '';
+}})()"#
+ )
+}
+
+/// A focus watcher report, or `None` for anything else on the channel.
+pub fn parse_focus(json: &str) -> Option<bool> {
+ let v: serde_json::Value = serde_json::from_str(json).ok()?;
+ (v["t"] == "focus").then(|| v["editable"].as_bool()).flatten()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn feed_all(keys: &mut Keys, seq: &[&str]) -> Fed {
+ let mut last = Fed::Unbound;
+ for t in seq {
+ last = keys.feed(t);
+ }
+ last
+ }
+
+ #[test]
+ fn sequences_counts_and_dead_ends() {
+ let mut k = Keys::default();
+ assert_eq!(k.feed("j"), Fed::Run(Action::ScrollLines(0.0, 1.0), None));
+ assert_eq!(k.feed("g"), Fed::Pending);
+ assert_eq!(k.shown(), "g");
+ assert_eq!(k.feed("g"), Fed::Run(Action::Top, None));
+ assert_eq!(feed_all(&mut k, &["1", "0", "j"]), Fed::Run(Action::ScrollLines(0.0, 1.0), Some(10)));
+ // A leading 0 is not a count, and binds to nothing.
+ assert_eq!(k.feed("0"), Fed::Unbound);
+ assert_eq!(feed_all(&mut k, &["3", "g", "t"]), Fed::Run(Action::TabGoto, Some(3)));
+ assert_eq!(feed_all(&mut k, &["g", "z"]), Fed::Unbound);
+ assert!(k.is_empty());
+ assert_eq!(k.feed("<C-d>"), Fed::Run(Action::ScrollPage(0.5), None));
+ assert!(!k.takes("<C-t>"));
+ assert!(k.takes("<C-u>"));
+ }
+
+ #[test]
+ fn every_binding_is_reachable() {
+ // A binding that is a prefix of another would shadow it for good.
+ for (a, _) in BINDINGS {
+ for (b, _) in BINDINGS {
+ assert!(a == b || !b.starts_with(a), "{a:?} shadows {b:?}");
+ }
+ }
+ }
+
+ #[test]
+ fn hint_labels_are_short_unique_and_prefix_free() {
+ for n in [0, 1, 5, 9, 10, 17, 80, 81, 82, 700] {
+ let labels = hint_labels(n, HINT_CHARS);
+ assert_eq!(labels.len(), n);
+ for a in &labels {
+ for b in &labels {
+ assert!(a == b || !b.starts_with(a.as_str()), "{a} prefixes {b} (n={n})");
+ }
+ }
+ let max = labels.iter().map(String::len).max().unwrap_or(0);
+ let need = (1..).find(|l| 9usize.pow(*l as u32) >= n.max(1)).unwrap();
+ assert_eq!(max, if n == 0 { 0 } else { need }, "n={n}");
+ }
+ // Ten hints: eight single letters, the last letter's two-letter run.
+ assert_eq!(hint_labels(10, HINT_CHARS)[..9], ["a", "s", "d", "f", "g", "h", "j", "k", "la"]);
+ }
+
+ #[test]
+ fn hints_parse_and_junk_is_dropped() {
+ 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":""}]"#);
+ assert_eq!(h.len(), 2);
+ assert_eq!(h[0].at, (25.0, 25.0));
+ assert_eq!(h[0].href.as_deref(), Some("https://a.example/"));
+ assert_eq!(h[1].href, None);
+ assert_eq!((h[0].label.as_str(), h[1].label.as_str()), ("a", "s"));
+ assert!(parse_hints("not json").is_empty());
+ }
+
+ #[test]
+ fn commands_parse() {
+ assert_eq!(parse_command("o example.com"), Ok(Cmd::Open { target: "example.com".into(), tab: false, background: false }));
+ assert_eq!(parse_command("open -t rust book"), Ok(Cmd::Open { target: "rust book".into(), tab: true, background: false }));
+ assert_eq!(parse_command(" t "), Ok(Cmd::Run(Action::Open { tab: true, edit: false })));
+ assert_eq!(parse_command("b 3"), Ok(Cmd::Run(Action::TabFocus(3))));
+ assert!(parse_command("b x").is_err());
+ assert_eq!(parse_command("q"), Ok(Cmd::Quit));
+ assert_eq!(parse_command(""), Ok(Cmd::Empty));
+ assert_eq!(parse_command("frobnicate"), Err("Unknown command: frobnicate".into()));
+ }
+
+ #[test]
+ fn up_and_root() {
+ let u = |s: &str| url::Url::parse(s).unwrap();
+ assert_eq!(url_up(&u("https://a.example/x/y/?q=1")), Some(u("https://a.example/x/")));
+ assert_eq!(url_up(&u("https://a.example/x/y")), Some(u("https://a.example/x/")));
+ assert_eq!(url_up(&u("https://a.example/x")), Some(u("https://a.example/")));
+ assert_eq!(url_up(&u("https://a.example/")), None);
+ assert_eq!(url_root(&u("https://a.example/x/y#z")), Some(u("https://a.example/")));
+ assert_eq!(url_root(&u("https://a.example/")), None);
+ }
+
+ #[test]
+ fn focus_reports_parse() {
+ assert_eq!(parse_focus(r#"{"t":"focus","editable":true}"#), Some(true));
+ assert_eq!(parse_focus(r#"{"t":"focus","editable":false}"#), Some(false));
+ assert_eq!(parse_focus(r#"{"t":"other"}"#), None);
+ }
+}
diff --git a/src/webview.rs b/src/webview.rs
index 24acad2..c930825 100644
--- a/src/webview.rs
+++ b/src/webview.rs
@@ -806,6 +806,28 @@ impl ServoHost {
self.mouse_button(b, pressed, x_px, y_px);
}
+ // ---- vi mode ----
+ //
+ // The WPE host's surface, as no-ops: Servo has no script worlds to run
+ // the focus watcher or the hint script in, and no find controller. Keys
+ // still bind; hints, `gi`, half-page scrolls and search do nothing.
+
+ pub fn set_vi_enabled(&mut self, _on: bool) {}
+ pub fn take_vi_focus(&self) -> Option<bool> {
+ None
+ }
+ pub fn vi_eval(&self, _script: &str, _tag: u32) {}
+ pub fn take_vi_result(&self) -> Option<(u32, String)> {
+ None
+ }
+ pub fn find(&self, _text: &str, _backwards: bool) {}
+ pub fn find_next(&self) {}
+ pub fn find_prev(&self) {}
+ pub fn find_finish(&self) {}
+ pub fn take_find_result(&self) -> Option<u32> {
+ None
+ }
+
/// A cce-ui key event, translated and forwarded. Same signature as the
/// WPE backend's `key`.
pub fn key_ui(&self, event: &cce_ui::widget::KeyEvent) {
diff --git a/src/wpe/host.rs b/src/wpe/host.rs
index 9479282..cf616e1 100644
--- a/src/wpe/host.rs
+++ b/src/wpe/host.rs
@@ -374,6 +374,9 @@ pub struct WebKitHost {
held_buttons: Cell<WPEModifiers::Type>,
/// A hang being watched to see whether it is a deadlock.
deadlock_watch: Option<DeadlockWatch>,
+ /// The injected vi focus watcher; `None` while vi mode is off, which is
+ /// also when no page carries it.
+ vi_watcher: Option<*mut WebKitUserScript>,
}
unsafe fn cstr(s: &str) -> CString {
@@ -530,11 +533,13 @@ impl WebKitHost {
window_focused: false,
held_buttons: Cell::new(0),
deadlock_watch: None,
+ vi_watcher: None,
};
// The account watcher's channel, in its own script world. Both
// halves are registered here, once, on the shared content
// manager every tab is built against.
host.register_account_channel();
+ host.register_vi_channel();
host.open_tab(url);
host
}
@@ -678,6 +683,146 @@ impl WebKitHost {
}
}
+ // ---- vi mode ----
+
+ /// Listen for the vi focus watcher, in its own private world — the same
+ /// guarantee as the account channel: page script cannot post on it.
+ fn register_vi_channel(&self) {
+ unsafe {
+ let name = cstr(crate::vi::CHANNEL);
+ let world = cstr(crate::vi::WORLD);
+ if webkit_user_content_manager_register_script_message_handler(
+ self.ucm,
+ name.as_ptr(),
+ world.as_ptr(),
+ ) == 0
+ {
+ log::warn!("could not register the vi message channel");
+ return;
+ }
+ let signal = cstr(&format!("script-message-received::{}", crate::vi::CHANNEL));
+ g_signal_connect_data(
+ self.ucm as *mut _,
+ signal.as_ptr(),
+ Some(std::mem::transmute::<usize, unsafe extern "C" fn()>(
+ on_vi_message as *const () as usize,
+ )),
+ Rc::into_raw(self.prompts.clone()) as gpointer,
+ Some(drop_prompts_ref),
+ 0,
+ );
+ }
+ }
+
+ /// Install or remove the vi focus watcher. Off, pages carry nothing.
+ pub fn set_vi_enabled(&mut self, on: bool) {
+ unsafe {
+ match (on, self.vi_watcher.take()) {
+ (true, None) => {
+ let source = cstr(&crate::vi::focus_watch_js());
+ let world = cstr(crate::vi::WORLD);
+ // Every frame: the field being clicked into is often in one.
+ let script = webkit_user_script_new_for_world(
+ source.as_ptr(),
+ WebKitUserContentInjectedFrames::WEBKIT_USER_CONTENT_INJECT_ALL_FRAMES,
+ WebKitUserScriptInjectionTime::WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START,
+ world.as_ptr(),
+ std::ptr::null(),
+ std::ptr::null(),
+ );
+ webkit_user_content_manager_add_script(self.ucm, script);
+ self.vi_watcher = Some(script);
+ }
+ (false, Some(script)) => {
+ webkit_user_content_manager_remove_script(self.ucm, script);
+ webkit_user_script_unref(script);
+ }
+ (true, Some(script)) => self.vi_watcher = Some(script),
+ (false, None) => {}
+ }
+ }
+ self.prompts.borrow_mut().vi_focus = None;
+ }
+
+ /// Whether the focused element takes text, if focus moved since last asked.
+ pub fn take_vi_focus(&self) -> Option<bool> {
+ self.prompts.borrow_mut().vi_focus.take()
+ }
+
+ /// Run `script` in the active tab's top frame, in the vi world, and queue
+ /// its result as a string under `tag` for [`Self::take_vi_result`]. A
+ /// failed script answers with an empty string, so a caller waiting on
+ /// it is never left waiting.
+ pub fn vi_eval(&self, script: &str, tag: u32) {
+ let Some(t) = self.tabs.get(self.active) else { return };
+ let ctx = Box::new((self.prompts.clone(), tag));
+ unsafe {
+ let (script, world) = (cstr(script), cstr(crate::vi::WORLD));
+ webkit_web_view_evaluate_javascript(
+ t.webview,
+ script.as_ptr(),
+ -1,
+ world.as_ptr(),
+ std::ptr::null(),
+ std::ptr::null_mut(),
+ Some(on_vi_eval),
+ Box::into_raw(ctx) as gpointer,
+ );
+ }
+ }
+
+ pub fn take_vi_result(&self) -> Option<(u32, String)> {
+ self.prompts.borrow_mut().vi_results.pop_front()
+ }
+
+ fn find_controller(&self) -> Option<*mut WebKitFindController> {
+ let t = self.tabs.get(self.active)?;
+ Some(unsafe { webkit_web_view_get_find_controller(t.webview) })
+ }
+
+ /// Find `text` in the active page, highlighting every match and
+ /// scrolling to the first. Smart case, as qutebrowser does it: case
+ /// matters only when the text has a capital in it.
+ pub fn find(&self, text: &str, backwards: bool) {
+ let Some(fc) = self.find_controller() else { return };
+ use WebKitFindOptions::*;
+ let mut opts = WEBKIT_FIND_OPTIONS_WRAP_AROUND;
+ if !text.chars().any(char::is_uppercase) {
+ opts |= WEBKIT_FIND_OPTIONS_CASE_INSENSITIVE;
+ }
+ if backwards {
+ opts |= WEBKIT_FIND_OPTIONS_BACKWARDS;
+ }
+ let c = unsafe { cstr(text) };
+ unsafe { webkit_find_controller_search(fc, c.as_ptr(), opts, 1000) };
+ }
+
+ /// The next match, in the search's own direction.
+ pub fn find_next(&self) {
+ if let Some(fc) = self.find_controller() {
+ unsafe { webkit_find_controller_search_next(fc) }
+ }
+ }
+
+ pub fn find_prev(&self) {
+ if let Some(fc) = self.find_controller() {
+ unsafe { webkit_find_controller_search_previous(fc) }
+ }
+ }
+
+ /// Drop the search and its highlights.
+ pub fn find_finish(&self) {
+ if let Some(fc) = self.find_controller() {
+ unsafe { webkit_find_controller_search_finish(fc) }
+ }
+ self.prompts.borrow_mut().find_result = None;
+ }
+
+ /// How the last search went: the match count, 0 for none.
+ pub fn take_find_result(&self) -> Option<u32> {
+ self.prompts.borrow_mut().find_result.take()
+ }
+
fn build_webview(&self, url: &Url, state: &Rc<TabState>) -> (*mut WebKitWebView, *mut WPEView) {
unsafe {
let (p_display, p_ucm, p_session) = (
@@ -750,6 +895,22 @@ impl WebKitHost {
on_decide_policy as *const () as usize,
&self.prompts,
);
+ // Find-in-page answers on the controller, not the view.
+ let fc = webkit_web_view_get_find_controller(wv);
+ for (signal, cb) in [
+ ("found-text", on_found_text as *const () as usize),
+ ("failed-to-find-text", on_failed_to_find as *const () as usize),
+ ] {
+ let name = cstr(signal);
+ g_signal_connect_data(
+ fc as *mut _,
+ name.as_ptr(),
+ Some(std::mem::transmute::<usize, unsafe extern "C" fn()>(cb)),
+ Rc::into_raw(self.prompts.clone()) as gpointer,
+ Some(drop_prompts_ref),
+ 0,
+ );
+ }
let (lw, lh) = self.logical_size();
wpe_view_resized(view, lw, lh);
wpe_view_set_visible(view, 1);
@@ -2022,6 +2183,13 @@ pub(super) struct Prompts {
/// every one is answered exactly once: with a credential, or with
/// nothing when it is displaced or dropped.
fill_asks: std::collections::VecDeque<(String, *mut WebKitScriptMessageReply)>,
+ /// The vi focus watcher's latest word: whether the focused element takes
+ /// text. Only the newest matters, so a slot, not a queue.
+ vi_focus: Option<bool>,
+ /// Answers to [`WebKitHost::vi_eval`], by the caller's tag.
+ vi_results: std::collections::VecDeque<(u32, String)>,
+ /// The last find-in-page outcome: the match count, 0 for none.
+ find_result: Option<u32>,
}
/// What was under the pointer when the page asked for a context menu, read
@@ -2094,6 +2262,51 @@ unsafe fn connect_raw(
);
}
+/// A report from the vi focus watcher. Anything else is dropped.
+unsafe extern "C" fn on_vi_message(
+ _ucm: *mut WebKitUserContentManager,
+ value: *mut JSCValue,
+ data: gpointer,
+) {
+ let prompts = &*(data as *const RefCell<Prompts>);
+ let raw = jsc_value_to_string(value);
+ let Some(json) = from_cstr(raw) else { return };
+ g_free(raw as *mut _);
+ if let Some(editable) = crate::vi::parse_focus(&json) {
+ prompts.borrow_mut().vi_focus = Some(editable);
+ }
+}
+
+/// A [`WebKitHost::vi_eval`] script finished: queue what it returned.
+unsafe extern "C" fn on_vi_eval(source: *mut GObject, res: *mut GAsyncResult, data: gpointer) {
+ let ctx = Box::from_raw(data as *mut (Rc<RefCell<Prompts>>, u32));
+ let mut err: *mut GError = std::ptr::null_mut();
+ let v = webkit_web_view_evaluate_javascript_finish(source as *mut WebKitWebView, res, &mut err);
+ let mut text = String::new();
+ if !v.is_null() {
+ if jsc_value_is_string(v) != 0 {
+ let raw = jsc_value_to_string(v);
+ text = from_cstr(raw).unwrap_or_default();
+ g_free(raw as *mut _);
+ }
+ g_object_unref(v as *mut _);
+ }
+ if !err.is_null() {
+ g_error_free(err);
+ }
+ ctx.0.borrow_mut().vi_results.push_back((ctx.1, text));
+}
+
+unsafe extern "C" fn on_found_text(_fc: *mut WebKitFindController, count: guint, data: gpointer) {
+ let prompts = &*(data as *const RefCell<Prompts>);
+ prompts.borrow_mut().find_result = Some(count);
+}
+
+unsafe extern "C" fn on_failed_to_find(_fc: *mut WebKitFindController, data: gpointer) {
+ let prompts = &*(data as *const RefCell<Prompts>);
+ prompts.borrow_mut().find_result = Some(0);
+}
+
/// A message from the account watcher. Anything that does not parse as one of
/// its events is dropped without comment — this is a channel the chrome acts
/// on, so it accepts only what it recognizes.