git.lucas.co / cce-browser
web browser (Servo)
git clone https://git.lucas.co/cce-browser.git

commit9825f57c4c837d84360113643e3d22245d3dc290
parent3e7581b67a e5221f74dd
authorClaude <noreply@anthropic.com>
date2026-10-06 00:49
Merge master, and pin cce-ui to its merged input-method support

cce-ui#2 is merged (e8298ae); the pin moves from that PR's head to the
merge commit.

master's vi mode brought a fourth line field, the command line. It joins
the input-method routing: LineField::ViCmd, the keyboard's while the
command line is open (after a dialog, before the bookmarks search, as
master's focused_edit had it), and it draws what the field shows through
FieldMarks — the composition underlined at the caret — and reports its
caret. bm_search_has_keyboard stays keyboard_field's answer, which now
covers the command line too.

cargo check --all-targets (wpe, against WPE 2.52 headers): ok, no new
warnings in src/main.rs. Tests: 71 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WjL3pejMNY95NHv9BcmXaZ

 CLAUDE.md                | 104 +++++-
 Cargo.toml               |   2 +-
 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              | 839 ++++++++++++++++++++++++++++++++++++++++++++++-
 src/settings.rs          |   5 +
 src/vi.rs                | 741 +++++++++++++++++++++++++++++++++++++++++
 src/webview.rs           |  22 ++
 src/wpe/host.rs          | 213 ++++++++++++
 20 files changed, 1960 insertions(+), 31 deletions(-)

diff --cc CLAUDE.md
index eceb599,17cc52f..b93707a
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@@ -498,36 -504,6 +504,37 @@@ Wheel events pass **winit-signed deltas
  event: the engine hit-tests the wheel, gives the page its `preventDefault` chance, and
  applies the inverted delta itself.
  
 +### An input method composes into the chrome's fields
 +
- The URL bar, the bookmarks search and a dialog's fields are each a cce-ui
- `LineEdit`, and an input method (fcitx5, IBus) composes into whichever has the
- keyboard (`keyboard_field`: a dialog's focused field, else the open bookmarks
- search, else a focused URL bar — `focused_edit` is the same answer). Three
++The URL bar, the bookmarks search, the vi command line and a dialog's fields
++are each a cce-ui `LineEdit`, and an input method (fcitx5, IBus) composes into
++whichever has the keyboard (`keyboard_field`: a dialog's focused field, else
++the open vi command line, else the open bookmarks search, else a focused URL
++bar — `focused_edit` is the same answer). Three
 +things make that work, all against cce-ui's `ime` model:
 +
 +- **Before each frame `sync_ime` hands the composition over.** The field with
 +  the keyboard takes it up (`LineEdit::sync_ime`); the one that just lost the
 +  keyboard drops what it showed (`drop_composition`, which cancels it in the
 +  input method too). A field that went away with the keyboard — a dialog
 +  answered, the menu closed — cannot drop anything, so a composition still up
 +  at the handover is cancelled there: the new field has not taken it yet.
 +- **A field draws `display()`, never its text**: the composition spliced in at
 +  the caret (bullets in a password field), underlined, with the caret where
 +  the input method has its cursor. `FieldMarks::of` measures all of it on the
 +  run `paint_field` draws, and a press is hit-tested on the same string and
 +  carried back with `text_index`. The composition is never in `text`, so
 +  nothing that reads a field — navigation, the bookmark filter, the dialog's
 +  answer — sees it until it is committed, when it arrives as typed keys.
 +- **The field with the keyboard reports its caret as it paints**
 +  (`ime::report_caret`): that is where the candidate window opens, and what
 +  tells the shell text is wanted at all. A folded bar paints no URL field, so
 +  it asks for nothing.
 +
 +A page's own fields get none of this: with the page focused no chrome field
 +reports a caret, so the toolkit leaves text input off and the page gets
 +plain keys. `a_composition_is_drawn_at_the_caret_and_never_held` is the test.
 +
  ### The wheel eases; the trackpad does not
  
  A notch used to move the page `LINE_PX` in one step, which is the browser
diff --cc Cargo.toml
index 132228b,7ac120b..d7e9430
--- a/Cargo.toml
+++ b/Cargo.toml
@@@ -19,7 -19,7 +19,7 @@@ wpe = ["dep:rustix"
  servo = ["dep:servo", "dep:dpi", "dep:euclid", "dep:rustls", "dep:http"]
  
  [dependencies]
- cce-ui = { git = "https://github.com/lsgalante/cce-ui.git", rev = "49bee9238b85b7322e071d0dee9f46cd450a2dd9" }
 -cce-ui = { git = "https://github.com/lsgalante/cce-ui.git", rev = "9afd4d8c634296572feaddc96ae7c7edd0582ce4" }
++cce-ui = { git = "https://github.com/lsgalante/cce-ui.git", rev = "e8298ae8a661907e33bf82589b5ecf949505440f" }
  calloop = "0.13.0"
  wayland-client = { version = "0.31", features = ["system"] }
  servo = { version = "0.4", optional = true }
diff --cc src/main.rs
index 1626afd,4799e6c..ccc9be2
--- a/src/main.rs
+++ b/src/main.rs
@@@ -188,97 -210,6 +210,99 @@@ const SEL_BG: [f32; 4] = [0.24, 0.38, 0
  const ACCENT: [f32; 4] = [0.35, 0.55, 0.85, 1.0];
  const TEXT: [u8; 3] = [220, 220, 225];
  const TEXT_DIM: [u8; 3] = [120, 122, 128];
 +/// A line field's caret, and the underline under an input method's
 +/// composition.
 +const CARET: [f32; 4] = [0.85, 0.87, 0.92, 1.0];
 +
 +/// One of the chrome's line fields. The one with the keyboard
 +/// (`BrowserApp::keyboard_field`) is the one keys edit and an input method
 +/// composes into.
 +#[derive(Clone, Copy, PartialEq, Eq, Debug)]
 +enum LineField {
 +    Url,
 +    BmSearch,
++    /// The vi command line (`:` or `/`), while it is open.
++    ViCmd,
 +    /// The open dialog's field at this index.
 +    #[cfg(feature = "wpe")]
 +    Dialog(usize),
 +}
 +
 +/// What a line field draws, and where on it its caret, selection and an
 +/// input method's composition fall — x offsets from the text's origin, read
 +/// off the same shaped run the text is drawn as.
 +struct FieldMarks {
 +    /// `LineEdit::display`: the text with a composition at the caret,
 +    /// bullets for a password.
 +    shown: String,
 +    caret: f32,
 +    selection: Option<(f32, f32)>,
 +    composition: Option<(f32, f32)>,
 +}
 +
 +impl FieldMarks {
 +    /// Measure `edit` as it will be drawn. Every offset the field holds is
 +    /// into its text, so each goes through `display_index` onto `shown`.
 +    fn of(fs: &mut cce_ui::cosmic_text::FontSystem, edit: &cce_ui::widget::LineEdit) -> Self {
 +        let shown = edit.display();
 +        let mut x = |byte: usize| x_of_boundary_in(fs, &shown, byte);
 +        let caret = x(edit.display_index(edit.cursor));
 +        let selection = edit
 +            .selection
 +            .filter(|&(a, b)| a < b)
 +            .map(|(a, b)| (x(edit.display_index(a)), x(edit.display_index(b))));
 +        let composition = edit.composition_range().map(|(a, b)| (x(a), x(b)));
 +        Self { shown, caret, selection, composition }
 +    }
 +}
 +
 +/// Draw a line field's text in `f` with its marks: the selection under the
 +/// text, `placeholder` dimmed in place of an empty field, the composition
 +/// underlined, and the caret when `caret` is set. Returns the caret's rect,
 +/// which the field with the keyboard reports to the input method.
 +///
 +/// style: deliberate — caret and selection stand 4px inside the rim: the
 +/// glyph box's inset, not a gap.
 +fn paint_field(pc: &mut PaintCtx, f: Rect, marks: &FieldMarks, placeholder: &str, caret: bool) -> Rect {
 +    let ty = cce_ui::layout::align_text_y(f.y, f.height, URL_FONT, 0.0);
 +    let x0 = f.x + text_pad();
 +    let caret_rect = Rect { x: x0 + marks.caret, y: f.y + 4.0, width: 1.0, height: f.height - 8.0 };
 +    pc.clip(f, |pc| {
 +        if let Some((a, b)) = marks.selection {
 +            pc.quad(Rect { x: x0 + a, y: f.y + 4.0, width: b - a, height: f.height - 8.0 }, SEL_BG);
 +        }
 +        if marks.shown.is_empty() && !placeholder.is_empty() {
 +            pc.text(placeholder.to_string(), x0, ty, URL_FONT, TEXT_DIM);
 +        } else {
 +            pc.text(marks.shown.clone(), x0, ty, URL_FONT, TEXT);
 +        }
 +        if let Some((a, b)) = marks.composition {
 +            pc.quad(Rect { x: x0 + a, y: f.y + f.height - 5.0, width: b - a, height: 1.0 }, CARET);
 +        }
 +        if caret {
 +            pc.quad(caret_rect, CARET);
 +        }
 +    });
 +    caret_rect
 +}
 +
 +/// Tell the input method where the caret is: where its candidates open, and
 +/// that text is wanted at all. Only the field with the keyboard calls it,
 +/// as it paints.
 +fn report_caret(r: Rect) {
 +    cce_ui::ime::report_caret(r.x, r.y, r.width, r.height);
 +}
 +
 +/// X offset (text-origin relative) of byte `byte` of `text`, on the shaped
 +/// run a field draws (`URL_FONT`, font=None, matching `pc.text`).
 +fn x_of_boundary_in(fs: &mut cce_ui::cosmic_text::FontSystem, text: &str, byte: usize) -> f32 {
 +    cce_ui::engine::shaped_cluster_offsets(fs, text, URL_FONT, None)
 +        .iter()
 +        .rev()
 +        .find(|&&(b, _)| b <= byte)
 +        .map(|&(_, x)| x)
 +        .unwrap_or(0.0)
 +}
  
  /// A page-blocking prompt drawn over the content.
  ///
@@@ -3086,55 -3146,18 +3186,60 @@@ impl BrowserApp 
      /// while one is up (it blocks everything else), else the URL bar while
      /// it is focused. Undo and redo act on this.
      fn focused_edit(&mut self) -> Option<&mut cce_ui::widget::LineEdit> {
 +        let field = self.keyboard_field()?;
 +        self.line_field(field)
 +    }
 +
 +    /// Which line field has the keyboard: a dialog's focused field while
-     /// one is up, else the bookmarks menu's search while it is open, else
-     /// the URL bar while it is focused.
++    /// one is up, else the vi command line while it is open, else the
++    /// bookmarks menu's search while it is open, else the URL bar while it
++    /// is focused.
 +    fn keyboard_field(&self) -> Option<LineField> {
          #[cfg(feature = "wpe")]
 -        if let Some(m) = self.modal.as_mut() {
 -            let i = m.focused;
 -            return m.fields.get_mut(i).map(|(_, e)| e);
 +        if let Some(m) = self.modal.as_ref() {
 +            return (m.focused < m.fields.len()).then_some(LineField::Dialog(m.focused));
          }
+         if self.settings.vi_mode && self.vi_mode == vi::Mode::Command {
 -            return Some(&mut self.vi_cmd);
++            return Some(LineField::ViCmd);
+         }
 -        if let Some(m) = self.bm_menu.as_mut() {
 -            return Some(&mut m.query);
 +        if self.bm_menu.is_some() {
 +            return Some(LineField::BmSearch);
 +        }
 +        self.url_focused.then_some(LineField::Url)
 +    }
 +
 +    /// The editor behind `field`, while it exists.
 +    fn line_field(&mut self, field: LineField) -> Option<&mut cce_ui::widget::LineEdit> {
 +        match field {
 +            LineField::Url => Some(&mut self.url),
 +            LineField::BmSearch => self.bm_menu.as_mut().map(|m| &mut m.query),
++            LineField::ViCmd => Some(&mut self.vi_cmd),
 +            #[cfg(feature = "wpe")]
 +            LineField::Dialog(i) => self.modal.as_mut()?.fields.get_mut(i).map(|(_, e)| e),
 +        }
 +    }
 +
 +    /// Before a frame is drawn: the field with the keyboard takes up the
 +    /// input method's composition, and one that has just lost the keyboard
 +    /// drops what it was showing. A field that is gone with it — a dialog
 +    /// answered, the bookmarks menu closed — cannot, so a composition still
 +    /// up once the old field has let go is cancelled here: it was that
 +    /// field's, since the new one has not taken anything yet.
 +    fn sync_ime(&mut self) {
 +        let now = self.keyboard_field();
 +        if now != self.ime_field {
 +            let before = self.ime_field;
 +            if let Some(edit) = before.and_then(|f| self.line_field(f)) {
 +                edit.drop_composition();
 +            }
 +            if cce_ui::ime::preedit().is_some() {
 +                cce_ui::ime::request_reset();
 +            }
 +            self.ime_field = now;
 +        }
 +        if let Some(edit) = now.and_then(|f| self.line_field(f)) {
 +            edit.sync_ime();
          }
 -        self.url_focused.then_some(&mut self.url)
      }
  
      /// Whether `focused_edit` is the bookmarks menu's search field — an undo
@@@ -3161,6 -3192,634 +3267,631 @@@
      }
  }
  
+ /// 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);
++            // What the field shows: an input method's composition at the
++            // caret, underlined (`FieldMarks`, as every chrome field).
++            let marks = FieldMarks::of(&mut self.font_system, &self.vi_cmd);
+             let tx = x + measure_text_width(&sigil, sans, URL_FONT) + 3.0;
++            let caret_rect = Rect { x: tx + marks.caret, y: r.y + 5.0, width: 1.0, height: r.height - 10.0 };
+             pc.clip(r, |pc| {
+                 pc.text(sigil, x, ty, URL_FONT, [150, 190, 240]);
 -                if let Some((x0, x1)) = sel {
++                if let Some((x0, x1)) = marks.selection {
+                     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],
 -                );
++                pc.text(marks.shown.clone(), tx, ty, URL_FONT, TEXT);
++                if let Some((x0, x1)) = marks.composition {
++                    pc.quad(Rect { x: tx + x0, y: r.y + r.height - 6.0, width: x1 - x0, height: 1.0 }, CARET);
++                }
++                pc.quad(caret_rect, [0.85, 0.87, 0.92, 1.0]);
+             });
++            report_caret(caret_rect);
+             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;