web browser (Servo)
git clone https://git.lucas.co/cce-browser.git
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;