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

src/main.rs (237.5K)

   1 //! cce-browser — a web browser on the embedded Servo engine.
   2 //!
   3 //! Servo renders pages into a CPU (software) rendering context; each
   4 //! finished frame is read back and uploaded to cce-ui's image registry,
   5 //! then drawn as a single quad under the chrome: the DE's circular corner
   6 //! control (`cce_ui::widget::plate_dock`), which here toggles the utility
   7 //! bar (tabs, the favorites strip, back / forward / reload, URL field) that
   8 //! unfolds from under it. Input over the page area is translated into Servo input events; the
   9 //! URL bar is a small hand-rolled line editor.
  10 
  11 mod accounts;
  12 mod downloads;
  13 mod instance;
  14 mod pages;
  15 mod raindrop;
  16 mod session;
  17 mod settings;
  18 /// Vi-style modal keys, after qutebrowser (`browser.vi-mode`).
  19 mod vi;
  20 /// The retired Servo backend; compiled only under `--features servo`.
  21 #[cfg(feature = "servo")]
  22 mod webview;
  23 /// The in-progress WPE WebKit backend (see WPE-PORT.md). Compiled only under
  24 /// `--features wpe`; the shipping browser is still Servo.
  25 #[cfg(feature = "wpe")]
  26 mod wpe;
  27 
  28 use url::Url;
  29 
  30 use cce_ui::engine::{Application, EngineState, LogicalPosition, LogicalSize, WindowSettings};
  31 use cce_ui::scene::layout::Rect;
  32 use cce_ui::scene::paint::{DisplayList, PaintCtx};
  33 use cce_ui::widget::display::measure_text_width;
  34 use cce_ui::widget::plate_dock;
  35 use cce_ui::widget::{ElementState, Key, KeyEvent, MouseButton, MouseScrollDelta, NamedKey};
  36 
  37 #[cfg(all(not(feature = "wpe"), feature = "servo"))]
  38 use webview::ServoHost as Host;
  39 #[cfg(feature = "wpe")]
  40 use wpe::WebKitHost as Host;
  41 #[cfg(not(any(feature = "wpe", feature = "servo")))]
  42 compile_error!(
  43     "cce-browser needs an engine: build with the default `wpe` feature \
  44      (pacman -S wpewebkit), or --no-default-features --features servo"
  45 );
  46 
  47 /// Clipboard action, named by neither engine. Each backend maps it to its
  48 /// own vocabulary — Servo needs an `EditingActionEvent`, WebKit a named
  49 /// editing command — so the chrome never learns either.
  50 #[derive(Debug, Clone, Copy)]
  51 pub enum EditingCommand {
  52     Copy,
  53     Cut,
  54     Paste,
  55 }
  56 
  57 // Spacing is the DE's ladder (cce-ui `layout.rs`), never a number of this
  58 // app's own. Five readings of it cover the whole chrome:
  59 
  60 /// The bar's inset from the window edge: it stands on the root plate.
  61 fn bar_margin() -> f32 {
  62     cce_ui::layout::root_plate_inset()
  63 }
  64 
  65 /// Inset from a plate's rim to its content — the bar's, and every popover's
  66 /// (bookmarks, accounts, the context menu, a modal).
  67 fn plate_pad() -> f32 {
  68     cce_ui::layout::plate_padding()
  69 }
  70 
  71 /// Gap between items next to each other in a row of the bar (tabs, pills,
  72 /// buttons), and between the bar and a menu it drops: siblings on the root
  73 /// plate.
  74 fn item_gap() -> f32 {
  75     cce_ui::layout::root_plate_gap()
  76 }
  77 
  78 /// Gap between siblings inside a plate: the bar's rows, a modal's fields
  79 /// and its button pair.
  80 fn inner_gap() -> f32 {
  81     cce_ui::layout::plate_gap()
  82 }
  83 
  84 /// Inset from a control's rim to its label — a pill, a field, a menu row —
  85 /// the DE's own button padding.
  86 fn text_pad() -> f32 {
  87     cce_ui::layout::button_padding()
  88 }
  89 
  90 // Control heights are the toolkit's too, one per kind of control:
  91 
  92 /// Every button, tab and favorites pill. The glyph buttons are square at it.
  93 fn btn_h() -> f32 {
  94     cce_ui::layout::button_height()
  95 }
  96 
  97 /// Every line field: the URL field, a dialog's fields, the bookmarks search.
  98 fn field_h() -> f32 {
  99     cce_ui::layout::textbox_height()
 100 }
 101 
 102 /// The controls row holds both kinds, each centred in it.
 103 fn controls_h() -> f32 {
 104     btn_h().max(field_h())
 105 }
 106 
 107 /// Two rows — tab strip on top, nav controls + URL field below — with the
 108 /// favorites strip between them whenever there is one to show. The bar
 109 /// does not carry an empty row: no favorites, no strip, two-row bar.
 110 fn bar_h(favorites: bool) -> f32 {
 111     let favs = if favorites { btn_h() + inner_gap() } else { 0.0 };
 112     plate_pad() + btn_h() + inner_gap() + favs + controls_h() + plate_pad()
 113 }
 114 const BAR_RADIUS: f32 = 10.0;
 115 /// Seconds for the bar to unfold from the corner control (and back).
 116 const CHROME_ANIM_S: f32 = 0.18;
 117 /// How long the bar stays out after a tab opens in the background — long
 118 /// enough to see the new tab land in the strip — before folding itself.
 119 const CHROME_PEEK: std::time::Duration = std::time::Duration::from_millis(1500);
 120 /// How long a press into the page has to reach WebKit and move its focus
 121 /// before an open page field is announced again (`defer_page_press`). Well
 122 /// inside the compositor's 800 ms touch window, which the announcement has
 123 /// to land in to raise the on-screen keyboard.
 124 #[cfg(feature = "wpe")]
 125 const PAGE_PRESS_SETTLE: std::time::Duration = std::time::Duration::from_millis(150);
 126 
 127 /// One frame of the bar's unfold: `t` moves `dt` worth toward `target`
 128 /// (0 folded, 1 open), or straight onto it when the DE's animations switch
 129 /// (`cce_ui::motion::enabled`) is off — "snap, never freeze", as every
 130 /// cce-ui widget does, so the bar still opens and shuts, just without the
 131 /// glide.
 132 fn chrome_step(t: f32, target: f32, dt: f32, animate: bool) -> f32 {
 133     if !animate {
 134         return target;
 135     }
 136     let step = dt / CHROME_ANIM_S;
 137     if target > t {
 138         (t + step).min(target)
 139     } else {
 140         (t - step).max(target)
 141     }
 142 }
 143 /// Radius of the corner control, drawn and hit. The DE's dot
 144 /// (`plate_dock::CORNER_R`) is sized for a pane's corner; the browser's is
 145 /// the whole chrome while folded, so it is drawn bigger — a circular
 146 /// plate rather than a pane's dot, easier to see and to hit.
 147 const DOT_R: f32 = 1.75 * plate_dock::CORNER_R;
 148 /// Centre inset from the bar's corner, on both axes: the DE's margin
 149 /// between the dot and the plate edge, kept as the dot grew.
 150 const DOT_INSET: f32 = plate_dock::CORNER_INSET + (DOT_R - plate_dock::CORNER_R);
 151 /// Width reserved at the right end of the row the corner control sits on
 152 /// (the tab row for a top bar, the controls row for a bottom one), so the
 153 /// new-tab plus or the bookmark star clears the dot in the bar's corner.
 154 const DOT_COL: f32 = 2.0 * DOT_INSET;
 155 
 156 /// The reservation a row makes for the corner control: `DOT_COL` on the
 157 /// row in the bar's anchored corner, nothing on the other.
 158 fn dot_col(position: settings::BarPosition, tabs_row: bool) -> f32 {
 159     let dot_on_tabs = matches!(position, settings::BarPosition::Top);
 160     if dot_on_tabs == tabs_row { DOT_COL } else { 0.0 }
 161 }
 162 const TAB_MIN_W: f32 = 56.0;
 163 const TAB_MAX_W: f32 = 200.0;
 164 /// Tabs at least this wide get a close (`x` glyph) region on their right edge.
 165 const TAB_CLOSE_MIN_W: f32 = 72.0;
 166 const TAB_CLOSE_W: f32 = 18.0;
 167 /// The favorites strip: a row of pills, each one page. Pills take their
 168 /// label's width up to `FAV_MAX_W`, and the strip simply stops at the bar's
 169 /// edge — favorites are a handful by design, not a scrolling list.
 170 const FAV_MAX_W: f32 = 150.0;
 171 const FAV_FONT: f32 = 12.0;
 172 /// The bookmarks menu: a plate of rows dropped from the controls row's
 173 /// bookmarks button — the bar's own way to visit and manage what the star saves.
 174 const BM_W: f32 = 320.0;
 175 const BM_ROW_H: f32 = 24.0;
 176 /// Height of the rule between the menu's three sections.
 177 const BM_SEP_H: f32 = 9.0;
 178 /// The remove hit region at a bookmark row's right end.
 179 const BM_RM_W: f32 = 24.0;
 180 const BM_FONT: f32 = 13.0;
 181 /// The account list: suggestions from cce-secrets, dropped at the login
 182 /// field they are for rather than at the bar, because that is where the
 183 /// person is looking.
 184 const AC_W: f32 = 300.0;
 185 const AC_ROW_H: f32 = 34.0;
 186 /// Rows before the list scrolls with the selection.
 187 const AC_MAX_ROWS: usize = 6;
 188 const AC_FONT: f32 = 13.0;
 189 const AC_SUB_FONT: f32 = 11.0;
 190 /// Utility-bar fill; the negative alpha marks the plate as blur-behind.
 191 /// The blurred page is the base and this color tints it at |alpha|
 192 /// opacity — keep |alpha| low so the frosted content shows through.
 193 const BAR_FILL: [f32; 4] = [0.11, 0.12, 0.13, -0.28];
 194 const URL_FONT: f32 = 14.0;
 195 /// Pixels per wheel notch when the DE reports discrete line deltas.
 196 const LINE_PX: f64 = 76.0;
 197 /// A vi `j`/`k` line, in wheel notches.
 198 const VI_LINE: f32 = 0.5;
 199 /// How soon after a click a field taking focus counts as clicked into — what
 200 /// separates the person entering a field from a page focusing one itself.
 201 const VI_CLICK_WINDOW: std::time::Duration = std::time::Duration::from_millis(1000);
 202 /// How long a vi status message stays up.
 203 const VI_MSG_TIME: std::time::Duration = std::time::Duration::from_secs(3);
 204 /// The vi status line: mode, pending keys, messages, the `:` line.
 205 const VI_LINE_H: f32 = 28.0;
 206 const VI_FONT: f32 = 13.0;
 207 /// Hint labels over the page: qutebrowser's yellow, because a label has to
 208 /// read over any page at all, and the chrome's dark plates vanish into a
 209 /// dark one.
 210 const HINT_H: f32 = 19.0;
 211 const HINT_FONT: f32 = 13.0;
 212 const HINT_BG: [f32; 4] = [0.98, 0.84, 0.30, 1.0];
 213 const HINT_RIM: [f32; 4] = [0.45, 0.34, 0.04, 1.0];
 214 const HINT_TEXT: [u8; 3] = [20, 18, 10];
 215 /// The part of a label already typed.
 216 const HINT_TYPED: [u8; 3] = [140, 112, 30];
 217 
 218 
 219 const PAGE_BG: [f32; 4] = [0.10, 0.10, 0.11, 1.0];
 220 const FIELD_BG: [f32; 4] = [0.09, 0.09, 0.10, 0.40];
 221 const BTN_BG: [f32; 4] = [0.20, 0.21, 0.23, 0.40];
 222 const TAB_BG: [f32; 4] = [0.15, 0.16, 0.18, 0.30];
 223 const TAB_ACTIVE_BG: [f32; 4] = [0.32, 0.34, 0.38, 0.55];
 224 const RIM: [f32; 4] = [0.22, 0.23, 0.25, 1.0];
 225 const RIM_FOCUS: [f32; 4] = [0.33, 0.48, 0.72, 1.0];
 226 /// URL-bar selection highlight; the text is drawn over it.
 227 const SEL_BG: [f32; 4] = [0.24, 0.38, 0.60, 0.95];
 228 const ACCENT: [f32; 4] = [0.35, 0.55, 0.85, 1.0];
 229 const TEXT: [u8; 3] = [220, 220, 225];
 230 const TEXT_DIM: [u8; 3] = [120, 122, 128];
 231 /// A line field's caret, and the underline under an input method's
 232 /// composition.
 233 const CARET: [f32; 4] = [0.85, 0.87, 0.92, 1.0];
 234 
 235 /// One of the chrome's line fields. The one with the keyboard
 236 /// (`BrowserApp::keyboard_field`) is the one keys edit and an input method
 237 /// composes into.
 238 #[derive(Clone, Copy, PartialEq, Eq, Debug)]
 239 enum LineField {
 240     Url,
 241     BmSearch,
 242     /// The vi command line (`:` or `/`), while it is open.
 243     ViCmd,
 244     /// The open dialog's field at this index.
 245     #[cfg(feature = "wpe")]
 246     Dialog(usize),
 247 }
 248 
 249 /// The accessibility node the app numbers a line field by (`AppNodes::id`): the address
 250 /// bar 1, the bookmarks search 2, the vi command line 3, a dialog's fields from 16.
 251 fn a11y_node(field: LineField) -> u64 {
 252     match field {
 253         LineField::Url => 1,
 254         LineField::BmSearch => 2,
 255         LineField::ViCmd => 3,
 256         #[cfg(feature = "wpe")]
 257         LineField::Dialog(i) => 16 + i as u64,
 258     }
 259 }
 260 
 261 /// The open dialog's own node, which holds its fields.
 262 #[cfg(feature = "wpe")]
 263 const A11Y_DIALOG: u64 = 8;
 264 
 265 /// The line field an accessibility node number names ([`a11y_node`]'s inverse).
 266 fn a11y_field(n: u64) -> Option<LineField> {
 267     match n {
 268         1 => Some(LineField::Url),
 269         2 => Some(LineField::BmSearch),
 270         3 => Some(LineField::ViCmd),
 271         #[cfg(feature = "wpe")]
 272         16.. => Some(LineField::Dialog((n - 16) as usize)),
 273         _ => None,
 274     }
 275 }
 276 
 277 /// What a line field draws, and where on it its caret, selection and an
 278 /// input method's composition fall — x offsets from the text's origin, read
 279 /// off the same shaped run the text is drawn as.
 280 struct FieldMarks {
 281     /// `LineEdit::display`: the text with a composition at the caret,
 282     /// bullets for a password.
 283     shown: String,
 284     caret: f32,
 285     selection: Option<(f32, f32)>,
 286     composition: Option<(f32, f32)>,
 287 }
 288 
 289 impl FieldMarks {
 290     /// Measure `edit` as it will be drawn. Every offset the field holds is
 291     /// into its text, so each goes through `display_index` onto `shown`.
 292     fn of(fs: &mut cce_ui::cosmic_text::FontSystem, edit: &cce_ui::widget::LineEdit) -> Self {
 293         let shown = edit.display();
 294         let mut x = |byte: usize| x_of_boundary_in(fs, &shown, byte);
 295         let caret = x(edit.display_index(edit.cursor));
 296         let selection = edit
 297             .selection
 298             .filter(|&(a, b)| a < b)
 299             .map(|(a, b)| (x(edit.display_index(a)), x(edit.display_index(b))));
 300         let composition = edit.composition_range().map(|(a, b)| (x(a), x(b)));
 301         Self { shown, caret, selection, composition }
 302     }
 303 }
 304 
 305 /// Draw a line field's text in `f` with its marks: the selection under the
 306 /// text, `placeholder` dimmed in place of an empty field, the composition
 307 /// underlined, and the caret when `caret` is set. Returns the caret's rect,
 308 /// which the field with the keyboard reports to the input method.
 309 ///
 310 /// style: deliberate — caret and selection stand 4px inside the rim: the
 311 /// glyph box's inset, not a gap.
 312 fn paint_field(pc: &mut PaintCtx, f: Rect, marks: &FieldMarks, placeholder: &str, caret: bool) -> Rect {
 313     let ty = cce_ui::layout::align_text_y(f.y, f.height, URL_FONT, 0.0);
 314     let x0 = f.x + text_pad();
 315     let caret_rect = Rect { x: x0 + marks.caret, y: f.y + 4.0, width: 1.0, height: f.height - 8.0 };
 316     pc.clip(f, |pc| {
 317         if let Some((a, b)) = marks.selection {
 318             pc.quad(Rect { x: x0 + a, y: f.y + 4.0, width: b - a, height: f.height - 8.0 }, SEL_BG);
 319         }
 320         if marks.shown.is_empty() && !placeholder.is_empty() {
 321             pc.text(placeholder.to_string(), x0, ty, URL_FONT, TEXT_DIM);
 322         } else {
 323             pc.text(marks.shown.clone(), x0, ty, URL_FONT, TEXT);
 324         }
 325         if let Some((a, b)) = marks.composition {
 326             pc.quad(Rect { x: x0 + a, y: f.y + f.height - 5.0, width: b - a, height: 1.0 }, CARET);
 327         }
 328         if caret {
 329             pc.quad(caret_rect, CARET);
 330         }
 331     });
 332     caret_rect
 333 }
 334 
 335 /// Tell the input method where the caret is: where its candidates open, and
 336 /// that text is wanted at all. Only the field with the keyboard calls it,
 337 /// as it paints.
 338 fn report_caret(r: Rect) {
 339     cce_ui::ime::report_caret(r.x, r.y, r.width, r.height);
 340 }
 341 
 342 /// X offset (text-origin relative) of byte `byte` of `text`, on the shaped
 343 /// run a field draws (`URL_FONT`, font=None, matching `pc.text`).
 344 fn x_of_boundary_in(fs: &mut cce_ui::cosmic_text::FontSystem, text: &str, byte: usize) -> f32 {
 345     cce_ui::engine::shaped_cluster_offsets(fs, text, URL_FONT, None)
 346         .iter()
 347         .rev()
 348         .find(|&&(b, _)| b <= byte)
 349         .map(|&(_, x)| x)
 350         .unwrap_or(0.0)
 351 }
 352 
 353 /// A page-blocking prompt drawn over the content.
 354 ///
 355 /// Modal on purpose: the page is genuinely blocked inside WebKit until it is
 356 /// answered, so letting the chrome carry on as if nothing were pending would
 357 /// misrepresent what the engine is doing.
 358 #[cfg(feature = "wpe")]
 359 struct Modal {
 360     title: String,
 361     message: String,
 362     /// Editable fields, in tab order. Empty for a bare alert or confirm.
 363     fields: Vec<(&'static str, cce_ui::widget::LineEdit)>,
 364     focused: usize,
 365     has_cancel: bool,
 366     kind: ModalKind,
 367 }
 368 
 369 #[cfg(feature = "wpe")]
 370 enum ModalKind {
 371     /// `alert` / `confirm` / `prompt`.
 372     Script,
 373     /// An HTTP auth challenge.
 374     Auth,
 375     /// The active tab's WebProcess stopped answering. Unlike the other two
 376     /// the page is not waiting on this — it is stuck — so nothing in WebKit
 377     /// is held open; the answer is to kill the process or leave it be.
 378     Unresponsive,
 379 }
 380 
 381 #[cfg(feature = "wpe")]
 382 const MODAL_W: f32 = 420.0;
 383 #[cfg(feature = "wpe")]
 384 const MODAL_BTN_W: f32 = 84.0;
 385 
 386 #[cfg(feature = "wpe")]
 387 impl Modal {
 388     fn height(&self) -> f32 {
 389         plate_pad() * 2.0
 390             + 20.0
 391             + 22.0
 392             + self.fields.len() as f32 * (field_h() + inner_gap())
 393             + inner_gap()
 394             + btn_h()
 395     }
 396 
 397     /// Centred, and clamped so it stays on screen on a small window.
 398     fn rect(&self, win: (f32, f32)) -> Rect {
 399         let w = MODAL_W.min(win.0 - 2.0 * bar_margin()).max(240.0);
 400         let h = self.height();
 401         Rect {
 402             x: ((win.0 - w) / 2.0).max(0.0),
 403             y: ((win.1 - h) / 2.0).max(0.0),
 404             width: w,
 405             height: h,
 406         }
 407     }
 408 
 409     fn field_rect(&self, r: &Rect, i: usize) -> Rect {
 410         Rect {
 411             x: r.x + plate_pad(),
 412             y: r.y + plate_pad() + 42.0 + i as f32 * (field_h() + inner_gap()),
 413             width: r.width - plate_pad() * 2.0,
 414             height: field_h(),
 415         }
 416     }
 417 
 418     /// The (ok, cancel) button labels.
 419     fn labels(&self) -> (&'static str, &'static str) {
 420         match self.kind {
 421             ModalKind::Unresponsive => ("Stop page", "Wait"),
 422             _ => ("OK", "Cancel"),
 423         }
 424     }
 425 
 426     /// (ok, cancel) — cancel is `None` for a bare alert.
 427     fn button_rects(&self, r: &Rect) -> (Rect, Option<Rect>) {
 428         let y = r.y + r.height - plate_pad() - btn_h();
 429         let ok = Rect {
 430             x: r.x + r.width - plate_pad() - MODAL_BTN_W,
 431             y,
 432             width: MODAL_BTN_W,
 433             height: btn_h(),
 434         };
 435         let cancel = self.has_cancel.then(|| Rect {
 436             x: ok.x - MODAL_BTN_W - inner_gap(),
 437             ..ok
 438         });
 439         (ok, cancel)
 440     }
 441 }
 442 
 443 /// The right-click menu, drawn by the chrome at the pointer.
 444 ///
 445 /// Not modal: the page is not blocked (unlike a script dialog), so this only
 446 /// intercepts input for as long as it is open, and any click outside closes
 447 /// it and is otherwise swallowed.
 448 #[cfg(feature = "wpe")]
 449 struct CtxMenu {
 450     items: Vec<CtxItem>,
 451     /// Top-left corner, already clamped to the window.
 452     pos: (f32, f32),
 453 }
 454 
 455 #[cfg(feature = "wpe")]
 456 struct CtxItem {
 457     label: String,
 458     action: CtxAction,
 459     enabled: bool,
 460 }
 461 
 462 #[cfg(feature = "wpe")]
 463 enum CtxAction {
 464     Back,
 465     Forward,
 466     Reload,
 467     /// Copy the page's current selection (through the engine, so it lands on
 468     /// the system clipboard via the clipboard bridge).
 469     CopySelection,
 470     Paste,
 471     OpenInTab(String),
 472     /// Put this text on the clipboard directly (link/image addresses).
 473     CopyText(String),
 474     /// Fetch through WebKit's download pipeline.
 475     Download(String),
 476     OpenExternal,
 477     /// Add the page to the favorites strip, or take it out.
 478     ToggleFavorite,
 479 }
 480 
 481 #[cfg(feature = "wpe")]
 482 const CTX_ROW_H: f32 = 24.0;
 483 #[cfg(feature = "wpe")]
 484 const CTX_W: f32 = 200.0;
 485 
 486 #[cfg(feature = "wpe")]
 487 impl CtxMenu {
 488     fn rect(&self) -> Rect {
 489         Rect {
 490             x: self.pos.0,
 491             y: self.pos.1,
 492             width: CTX_W,
 493             height: plate_pad() * 2.0 + self.items.len() as f32 * CTX_ROW_H,
 494         }
 495     }
 496 
 497     fn row_rect(&self, i: usize) -> Rect {
 498         // style: deliberate — a 2px hairline keeps the row highlight off the
 499         // plate's roll; the rung padding is the vertical one.
 500         Rect {
 501             x: self.pos.0 + 2.0,
 502             y: self.pos.1 + plate_pad() + i as f32 * CTX_ROW_H,
 503             width: CTX_W - 4.0,
 504             height: CTX_ROW_H,
 505         }
 506     }
 507 
 508     fn item_at(&self, x: f32, y: f32) -> Option<usize> {
 509         (0..self.items.len()).find(|&i| self.row_rect(i).contains(x, y))
 510     }
 511 }
 512 
 513 /// A page `<select>`'s list, drawn by the chrome at the select.
 514 ///
 515 /// WPE has no popup of its own — a select nobody answers never opens — so
 516 /// the engine hands the options over (`take_option_menu`) and waits for a
 517 /// pick or a close. Like the right-click menu it owns the pointer and the
 518 /// keyboard while it is up, and a click off it closes it and goes no
 519 /// further, which is also how clicking the select again folds it.
 520 #[cfg(feature = "wpe")]
 521 struct OptMenu {
 522     items: Vec<wpe::OptionItem>,
 523     /// The select's box, in the chrome's logical pixels.
 524     anchor: Rect,
 525     /// The select's width, or the widest label's when that is wider.
 526     width: f32,
 527     /// The highlighted row. The pointer and the arrow keys move the same
 528     /// one, as in a native list; it starts on the select's current value.
 529     highlight: Option<usize>,
 530     /// First visible row, when the list is longer than the room it has.
 531     scroll: usize,
 532     /// Wheel travel not yet worth a whole row. A trackpad sends pixels a
 533     /// few at a time, and rounding each event alone never moves at all.
 534     wheel_rest: f64,
 535     /// Type-to-find: what has been typed, and when the last key came.
 536     typed: String,
 537     typed_at: std::time::Instant,
 538 }
 539 
 540 #[cfg(feature = "wpe")]
 541 const OPT_ROW_H: f32 = 24.0;
 542 #[cfg(feature = "wpe")]
 543 const OPT_FONT: f32 = 13.0;
 544 /// The gutter the current value's dot sits in; an optgroup's options are
 545 /// indented by it again.
 546 #[cfg(feature = "wpe")]
 547 const OPT_INDENT: f32 = 12.0;
 548 /// Type-to-find starts over after this long without a key.
 549 #[cfg(feature = "wpe")]
 550 const OPT_TYPE_RESET: std::time::Duration = std::time::Duration::from_millis(1000);
 551 
 552 /// The select list's plate and rows, from `opt_layout`: the one geometry
 553 /// draw and hit-test read.
 554 #[cfg(feature = "wpe")]
 555 struct OptLayout {
 556     plate: Rect,
 557     /// `(rect, index into items)` for each row the plate shows.
 558     rows: Vec<(Rect, usize)>,
 559     /// How many rows fit; more than this and the list scrolls.
 560     cap: usize,
 561 }
 562 
 563 #[cfg(feature = "wpe")]
 564 impl OptMenu {
 565     /// A row that can be picked: an enabled option, not a group heading.
 566     fn pickable(&self, i: usize) -> bool {
 567         self.items.get(i).is_some_and(|it| it.enabled && !it.group_label)
 568     }
 569 
 570     /// The pickable row `steps` pickable rows away from the highlight,
 571     /// stopping at the ends rather than wrapping, as a native list does.
 572     fn step(&mut self, steps: isize) {
 573         let mut at = self.highlight;
 574         for _ in 0..steps.unsigned_abs() {
 575             let next = if steps > 0 {
 576                 let from = at.map_or(0, |h| h + 1);
 577                 (from..self.items.len()).find(|&i| self.pickable(i))
 578             } else {
 579                 let to = at.unwrap_or(self.items.len());
 580                 (0..to).rev().find(|&i| self.pickable(i))
 581             };
 582             match next {
 583                 Some(i) => at = Some(i),
 584                 None => break,
 585             }
 586         }
 587         self.highlight = at;
 588     }
 589 
 590     /// Type-to-find. Letters typed in quick succession are one prefix
 591     /// ("1", "0" finds "10"); the same letter again cycles through the rows
 592     /// starting with it.
 593     fn find_typed(&mut self, text: &str) {
 594         let now = std::time::Instant::now();
 595         if now.duration_since(self.typed_at) > OPT_TYPE_RESET {
 596             self.typed.clear();
 597         }
 598         self.typed_at = now;
 599         self.typed.push_str(&text.to_lowercase());
 600         let first = self.typed.chars().next().unwrap_or(' ');
 601         let cycling = self.typed.chars().all(|c| c == first);
 602         let (prefix, from) = if cycling {
 603             (first.to_string(), self.highlight.map_or(0, |h| h + 1))
 604         } else {
 605             (self.typed.clone(), self.highlight.unwrap_or(0))
 606         };
 607         let n = self.items.len();
 608         if let Some(i) = (0..n).map(|k| (from + k) % n).find(|&i| {
 609             self.pickable(i)
 610                 && self.items[i].label.trim_start().to_lowercase().starts_with(&prefix)
 611         }) {
 612             self.highlight = Some(i);
 613         }
 614     }
 615 
 616     /// Scroll just far enough that the highlighted row is in view.
 617     fn reveal(&mut self, cap: usize) {
 618         let Some(h) = self.highlight else { return };
 619         if h < self.scroll {
 620             self.scroll = h;
 621         } else if h >= self.scroll + cap {
 622             self.scroll = h + 1 - cap;
 623         }
 624     }
 625 }
 626 
 627 /// The bookmarks menu: the bar's list of saved pages, open under (or over)
 628 /// the bookmarks button in the controls row.
 629 ///
 630 /// It holds a **snapshot** of the store rather than reading it per frame:
 631 /// the list a pointer is travelling down must not reorder underneath it,
 632 /// and the two edits it offers (bookmark this page, remove a row) re-read
 633 /// explicitly. Unlike the right-click menu this is not gated on an engine
 634 /// backend — bookmarks are app state, so the menu works on either.
 635 struct BmMenu {
 636     /// Every bookmark, as the store held them when the menu opened (or was
 637     /// last refreshed through it).
 638     all: Vec<pages::Link>,
 639     /// The ones the search lets through, in store order — what the rows
 640     /// show and what an `Entry` index points into.
 641     items: Vec<pages::Link>,
 642     /// The search field at the top of the plate. It has the keyboard while
 643     /// the menu is open.
 644     query: cce_ui::widget::LineEdit,
 645     /// First listed bookmark, when there are more than the plate can show.
 646     scroll: usize,
 647     hover: Option<BmHit>,
 648     /// The keyboard's row, an index into `items`: Up/Down move it and Enter
 649     /// visits it. Highlighted like a hovered row, as the account list does.
 650     selected: usize,
 651 }
 652 
 653 impl BmMenu {
 654     fn new(all: Vec<pages::Link>) -> Self {
 655         let mut m = BmMenu {
 656             all,
 657             items: Vec::new(),
 658             query: cce_ui::widget::LineEdit::default(),
 659             scroll: 0,
 660             hover: None,
 661             selected: 0,
 662         };
 663         m.filter();
 664         m
 665     }
 666 
 667     /// Re-derive `items` from `all` and the query: every word typed must
 668     /// appear in the title or the address, ignoring case.
 669     fn filter(&mut self) {
 670         let words: Vec<String> =
 671             self.query.text.split_whitespace().map(str::to_lowercase).collect();
 672         self.items = self
 673             .all
 674             .iter()
 675             .filter(|l| {
 676                 let hay = format!("{}\n{}", l.label, l.url).to_lowercase();
 677                 words.iter().all(|w| hay.contains(w.as_str()))
 678             })
 679             .cloned()
 680             .collect();
 681         self.selected = self.selected.min(self.items.len().saturating_sub(1));
 682     }
 683 
 684     /// Move the keyboard selection by `delta` rows, wrapping at the ends as
 685     /// the account list does, and scroll so it stays among the `visible`
 686     /// rows the plate shows.
 687     fn step(&mut self, delta: isize, visible: usize) {
 688         if self.items.is_empty() {
 689             return;
 690         }
 691         let n = self.items.len() as isize;
 692         self.selected = (((self.selected as isize + delta) % n + n) % n) as usize;
 693         let visible = visible.max(1);
 694         if self.selected < self.scroll {
 695             self.scroll = self.selected;
 696         } else if self.selected >= self.scroll + visible {
 697             self.scroll = self.selected + 1 - visible;
 698         }
 699     }
 700 }
 701 
 702 /// What a pointer position falls on inside the menu.
 703 #[derive(Debug, Clone, Copy, PartialEq)]
 704 enum BmHit {
 705     /// The search field.
 706     Search,
 707     /// The add/remove row for the page in the active tab.
 708     Toggle,
 709     /// A bookmark row: its index, and whether the pointer is on the remove
 710     /// region at the row's right end rather than on the row itself.
 711     Entry(usize, bool),
 712     /// Hands the whole collection to `cce://bookmarks`.
 713     Manage,
 714 }
 715 
 716 /// The account list: what cce-secrets can offer the login field that is
 717 /// focused right now.
 718 ///
 719 /// It belongs to a *field*, not to the bar — it opens when one takes focus,
 720 /// follows it when the page scrolls, and goes when focus does. Only accounts
 721 /// matching the site the field sends to are in it — plus, for a sign-in frame
 722 /// from another site, the page's own, marked as such — and no password is
 723 /// fetched to build it: a pick is what asks the keyring for one.
 724 #[cfg(feature = "wpe")]
 725 struct AcMenu {
 726     /// Matches for this field, before filtering: the form's site's first,
 727     /// then — for a sign-in frame from another site — the page's.
 728     all: Vec<accounts::Account>,
 729     /// What survives what has been typed into the username field.
 730     shown: Vec<accounts::Account>,
 731     /// Keyboard selection, an index into `shown`.
 732     selected: usize,
 733     /// First visible row, when `shown` is longer than the list can show.
 734     scroll: usize,
 735     /// The field, in its own frame's viewport coordinates. The top frame's
 736     /// are the chrome's; a child frame's are moved by `frame_offsets`.
 737     field: Rect,
 738     /// The reporting document's token: where the fill goes, and whose
 739     /// offset places the list.
 740     frame: String,
 741     top: bool,
 742     /// The host of the frame the field is in — where the password would go.
 743     /// Differs from `host` only for a sign-in frame from another site.
 744     form_host: String,
 745     /// The tab's host when this list was built. A fetched password is
 746     /// checked against it before it is filled: the keyring answers
 747     /// asynchronously, and by then the tab could be somewhere else entirely.
 748     host: String,
 749     /// The page is not on a secure origin — worth saying before a password
 750     /// goes into it.
 751     insecure: bool,
 752     /// Pointer-hovered row.
 753     hover: Option<usize>,
 754 }
 755 
 756 /// A sign-in the keyring does not know yet, offered for saving.
 757 ///
 758 /// Not modal and not tied to the page: the sign-in it came from usually
 759 /// navigates away at once, and the offer has to outlive that. It waits in the
 760 /// corner for an answer — Save, Never for this site, or Not now — and holds
 761 /// the typed password only until then.
 762 #[cfg(feature = "wpe")]
 763 struct SaveOffer {
 764     /// The frame's origin, stored as the entry's `URL`: the place the
 765     /// credential was typed into, so it is offered there next time.
 766     origin: String,
 767     /// Host of `origin`, and what "Never" remembers.
 768     form_host: String,
 769     /// The tab's host, which titles the entry: the site the person was
 770     /// signing in to, even when the form was a frame from somewhere else.
 771     page_host: String,
 772     username: String,
 773     password: accounts::Secret,
 774     stage: SaveStage,
 775 }
 776 
 777 #[cfg(feature = "wpe")]
 778 #[derive(Clone, PartialEq)]
 779 enum SaveStage {
 780     /// The account index is still being read; whether this is new is not
 781     /// known yet, so nothing is shown.
 782     Checking,
 783     Asking,
 784     Saving,
 785     Failed(String),
 786 }
 787 
 788 #[cfg(feature = "wpe")]
 789 const SAVE_W: f32 = 340.0;
 790 #[cfg(feature = "wpe")]
 791 const SAVE_BTN_W: f32 = 92.0;
 792 
 793 /// The save offer's plate and buttons, from `save_layout`: the one geometry
 794 /// draw and hit-test read.
 795 #[cfg(feature = "wpe")]
 796 struct SaveLayout {
 797     plate: Rect,
 798     /// Save, Never, Not now — or only the last, as Close, after a failure.
 799     buttons: Vec<(Rect, SaveButton)>,
 800 }
 801 
 802 #[cfg(feature = "wpe")]
 803 #[derive(Clone, Copy, PartialEq)]
 804 enum SaveButton {
 805     Save,
 806     Never,
 807     Dismiss,
 808 }
 809 
 810 #[cfg(feature = "wpe")]
 811 impl AcMenu {
 812     /// Narrow the list to what has been typed. Matching is on the username
 813     /// and the entry's title, case-insensitively and anywhere in either —
 814     /// people type the middle of an address as readily as its start.
 815     fn refilter(&mut self, typed: &str) {
 816         let needle = typed.trim().to_lowercase();
 817         self.shown = self
 818             .all
 819             .iter()
 820             .filter(|a| {
 821                 needle.is_empty()
 822                     || a.username.to_lowercase().contains(&needle)
 823                     || a.label.to_lowercase().contains(&needle)
 824             })
 825             .cloned()
 826             .collect();
 827         self.selected = self.selected.min(self.shown.len().saturating_sub(1));
 828         self.scroll = self.scroll.min(self.shown.len().saturating_sub(1));
 829         self.keep_selected_visible();
 830     }
 831 
 832     fn first_row(&self) -> usize {
 833         self.scroll
 834             .min(self.shown.len().saturating_sub(self.shown.len().min(AC_MAX_ROWS)))
 835     }
 836 
 837     /// Move the keyboard selection, scrolling the window to follow it.
 838     fn step(&mut self, delta: isize) {
 839         if self.shown.is_empty() {
 840             return;
 841         }
 842         let n = self.shown.len() as isize;
 843         self.selected = (((self.selected as isize + delta) % n + n) % n) as usize;
 844         self.keep_selected_visible();
 845     }
 846 
 847     fn keep_selected_visible(&mut self) {
 848         let rows = self.shown.len().min(AC_MAX_ROWS);
 849         if rows == 0 {
 850             return;
 851         }
 852         if self.selected < self.scroll {
 853             self.scroll = self.selected;
 854         } else if self.selected >= self.scroll + rows {
 855             self.scroll = self.selected + 1 - rows;
 856         }
 857     }
 858 }
 859 
 860 /// Every rect the menu draws and hit-tests, derived once — the same
 861 /// one-geometry rule the bar's own helpers follow.
 862 struct BmLayout {
 863     plate: Rect,
 864     /// The search field, the plate's first row.
 865     search: Rect,
 866     toggle: Rect,
 867     /// `(rect, index into items)` for each row the plate can show.
 868     rows: Vec<(Rect, usize)>,
 869     /// The "nothing saved yet" row, in place of the list.
 870     empty: Option<Rect>,
 871     manage: Rect,
 872     /// How many bookmarks fit; more than this and the list scrolls.
 873     cap: usize,
 874 }
 875 
 876 /// What a vi script evaluation in flight was for.
 877 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 878 enum ViAsk {
 879     Hints,
 880     FocusInput,
 881     ClickCheck,
 882 }
 883 
 884 #[derive(Debug, Clone)]
 885 pub enum Message {
 886     /// The accounts worker answered a load: the index, or why it failed.
 887     Accounts(Result<Vec<accounts::Account>, String>),
 888     /// One entry's password arrived for the account at this object path.
 889     /// The payload prints as `Secret(…)`; see `accounts::Secret`.
 890     Credential(String, accounts::Secret),
 891     /// A save to the keyring finished: the new entry's title, or why not.
 892     Saved(Result<String, String>),
 893     /// Servo requested an event-loop spin (waker or delegate signal).
 894     Spin,
 895     /// Last tab closed: exit the app.
 896     Quit,
 897     /// A later launch forwarded its argument here (see `instance.rs`):
 898     /// `Some` is a URL or file path to open in a new tab, `None` a bare
 899     /// launch that becomes a blank tab.
 900     OpenExternal(Option<String>),
 901 }
 902 
 903 struct BrowserApp {
 904     host: Host,
 905     /// Whether a renderer has been handed over yet — the first one is the
 906     /// process's own, any later one is a replacement after a reconnect. See
 907     /// `renderer_init`.
 908     seen_renderer: bool,
 909     /// Loaded from the app config; re-read when the window regains focus.
 910     settings: settings::Settings,
 911     win: (f32, f32),
 912     scale: f64,
 913     pointer: (f32, f32),
 914     /// Buttons whose press was handed to the page and whose release it is
 915     /// therefore still owed. The chrome opens menus on a press — a
 916     /// right-click opens the context menu — and every menu branch below
 917     /// swallows the clicks that follow, so without this the engine never
 918     /// sees the button come back up.
 919     page_buttons: Vec<MouseButton>,
 920     /// URL bar contents; mirrors the page URL unless the bar is focused.
 921     /// Text, caret and selection all live in the shared editor — the same
 922     /// one the dialog fields use.
 923     url: cce_ui::widget::LineEdit,
 924     url_focused: bool,
 925     /// The press that is dragging in the URL field is the one that entered
 926     /// it. Entering selects the whole URL — unless the press is dragged, in
 927     /// which case it selects what the drag covers; so the select-all waits
 928     /// for a release that no motion came before.
 929     url_entry_press: bool,
 930     /// Shift, from its own key events (pointer events carry no modifiers):
 931     /// what makes a press in the URL field a shift+click.
 932     shift_held: bool,
 933     /// The circle menu: the DE's corner control toggles the utility bar,
 934     /// which unfolds from under it. `chrome_t` is the unfold progress
 935     /// (0 = closed, 1 = bar), animated in `tick` toward whichever state
 936     /// `chrome_open` names.
 937     chrome_open: bool,
 938     chrome_t: f32,
 939     /// Set while the bar is out on its own — unfolded to show a tab that
 940     /// opened in the background — and when it folds again. Anything the
 941     /// person does with the bar makes it theirs (`None`), and then it stays.
 942     chrome_peek: Option<std::time::Instant>,
 943     /// When a press into an open page field is announced again
 944     /// (`defer_page_press`).
 945     #[cfg(feature = "wpe")]
 946     page_press_settle: Option<std::time::Instant>,
 947     /// Where the pointer was when the peek began. Still there is not a
 948     /// hover: a link middle-clicked near the top of the page sits under
 949     /// the bar it unfolds, and must not hold it out.
 950     peek_pointer: (f32, f32),
 951     /// Pointer over the corner control — its hover emphasis is a repaint.
 952     dot_hover: bool,
 953     loading: bool,
 954     /// Page title; drives the toplevel title (the engine re-applies
 955     /// `settings().title` whenever it changes).
 956     title: Option<String>,
 957     /// The page-blocking dialog or auth challenge currently on screen, if
 958     /// any. Only the WPE backend raises these — Servo has no delegate hooks
 959     /// for them, which is why they were listed as "not implemented".
 960     #[cfg(feature = "wpe")]
 961     modal: Option<Modal>,
 962     /// Open right-click menu, if any.
 963     #[cfg(feature = "wpe")]
 964     ctx_menu: Option<CtxMenu>,
 965     #[cfg(feature = "wpe")]
 966     opt_menu: Option<OptMenu>,
 967     /// Kept so the WPE backend's calloop sources can fire `Spin`; Servo
 968     /// wakes the loop itself through its `EventLoopWaker`.
 969     #[cfg(feature = "wpe")]
 970     sender: calloop::channel::Sender<Message>,
 971     /// Whether calloop watches the GLib fd (`register_sources`); without it,
 972     /// pumps come on the heartbeat alone.
 973     #[cfg(feature = "wpe")]
 974     glib_fd_watched: bool,
 975     /// When `update` last pumped GLib, which a heartbeat is measured from.
 976     #[cfg(feature = "wpe")]
 977     last_pump: std::time::Instant,
 978     /// A `Spin` sent from `tick` and not yet pumped: one is enough.
 979     #[cfg(feature = "wpe")]
 980     spin_queued: bool,
 981     /// App-side bundled-fonts `FontSystem` (the same set the toolkit renders
 982     /// with) for URL-bar caret/click metrics via `shaped_cluster_offsets` —
 983     /// `measure_text_width`'s inked-extent numbers drift off the drawn glyphs.
 984     font_system: cce_ui::cosmic_text::FontSystem,
 985     /// The line field that had the keyboard when the last frame was built,
 986     /// so the frame it loses it can drop a composition it was showing.
 987     ime_field: Option<LineField>,
 988     /// The open-tab set, persisted across restarts (see `session.rs`).
 989     session: session::Session,
 990     /// The favorites store, shared with the host (and so with the
 991     /// `cce://favorites` page, which edits it); `favs` is the strip as last
 992     /// read from it, refreshed with the rest of the page state.
 993     favorites: std::sync::Arc<pages::Favorites>,
 994     favs: Vec<pages::Link>,
 995     /// The bookmarks store, shared with the host (and so with the
 996     /// `cce://bookmarks` page); the menu below lists and edits it.
 997     bookmarks: std::sync::Arc<pages::Bookmarks>,
 998     /// The open bookmarks menu, if any.
 999     bm_menu: Option<BmMenu>,
1000     /// Wheel easing for the page, through the DE's shared scroll model.
1001     ///
1002     /// The browser does not own the page's offset — WebKit does — so this
1003     /// runs as a *virtual* one: notches move its target, `tick` walks the
1004     /// eased position, and what the engine receives each frame is the
1005     /// difference since the last one. It is rebased to zero whenever the
1006     /// glide settles, so nothing accumulates across a session.
1007     scroll: cce_ui::widget::scroll_motion::ScrollMotion,
1008     /// The virtual offset already handed to the engine.
1009     scroll_sent: (f32, f32),
1010     /// Accounts from cce-secrets, and the worker that reads them.
1011     accounts: accounts::Accounts,
1012     /// Hosts the person said never to offer saving on.
1013     never_save: accounts::NeverSave,
1014     /// The `browser.raindrop` setting, shared live with the sync worker,
1015     /// which is started the first time it is on (RAINDROP-SYNC.md).
1016     raindrop_on: std::sync::Arc<std::sync::atomic::AtomicBool>,
1017     raindrop_started: bool,
1018     /// A new sign-in waiting for Save / Never / Not now.
1019     #[cfg(feature = "wpe")]
1020     save_offer: Option<SaveOffer>,
1021     /// Where each child frame's viewport sits in the top frame's, by the
1022     /// frame's token, as the watchers relay it. Cleared on navigation.
1023     #[cfg(feature = "wpe")]
1024     frame_offsets: std::collections::HashMap<String, (f32, f32)>,
1025     /// The last login field reported, from any frame — replayed when the
1026     /// account index finishes loading, since a field focused before then got
1027     /// no list and will not report itself again.
1028     #[cfg(feature = "wpe")]
1029     last_field: Option<wpe::FormEvent>,
1030     /// The open account list, if a login field is focused and something in
1031     /// the keyring matches the page.
1032     #[cfg(feature = "wpe")]
1033     ac_menu: Option<AcMenu>,
1034     /// The active tab's URL as of the last page-state sync, for spotting an
1035     /// actual navigation. The engine's dirty flag is not that: it also fires
1036     /// for a title, a loading transition, a favicon — and treating those as
1037     /// navigations closed the account list in the same pump that opened it,
1038     /// and would cut every wheel glide short the moment the page it was
1039     /// scrolling said anything about itself.
1040     nav_url: Option<String>,
1041     /// Hovered pill in the favorites strip — a repaint, like the dot.
1042     fav_hover: Option<usize>,
1043     /// Where an eased scroll the keyboard started is aimed: the middle of
1044     /// the page, not wherever the pointer happens to rest. A real wheel
1045     /// event takes it back to the pointer.
1046     scroll_origin: Option<(f32, f32)>,
1047     /// Submitting the URL bar opens a new tab instead of loading here — vi's
1048     /// `O` / `gO`.
1049     url_new_tab: bool,
1050     /// Recently closed tabs' addresses, newest last, for `u` / `:undo`.
1051     closed_tabs: Vec<Url>,
1052     /// Vi mode (`browser.vi-mode`, `src/vi.rs`). Everything below is idle
1053     /// while the setting is off.
1054     vi_mode: vi::Mode,
1055     /// Count and keys typed toward a normal-mode binding.
1056     vi_keys: vi::Keys,
1057     /// Labels over the page in hint mode; empty while the page is still
1058     /// being asked for them.
1059     vi_hints: Vec<vi::Hint>,
1060     vi_hint_kind: vi::HintKind,
1061     vi_hint_typed: String,
1062     /// The script evaluation whose answer is awaited, by tag; an answer
1063     /// under any other tag is stale.
1064     vi_pending: Option<(u32, ViAsk)>,
1065     vi_tag: u32,
1066     /// The `:` / `/` / `?` line.
1067     vi_cmd: cce_ui::widget::LineEdit,
1068     vi_prompt: vi::Prompt,
1069     /// Lines submitted, `(is a search, text)`, oldest first; Up/Down walk
1070     /// the ones of the open prompt's kind.
1071     vi_history: Vec<(bool, String)>,
1072     vi_history_at: Option<usize>,
1073     /// A status message and when it goes.
1074     vi_msg: Option<(String, std::time::Instant)>,
1075     /// The last page click (or followed hint) — see `VI_CLICK_WINDOW`.
1076     vi_click: Option<std::time::Instant>,
1077     /// Keys whose press vi took, so their release is not handed to the page
1078     /// unpaired — even when the press changed the mode (`i`).
1079     vi_swallowed: Vec<String>,
1080     /// The last search, for `n` / `N` and an empty `/`; and whether its
1081     /// highlights are up.
1082     vi_search: Option<String>,
1083     vi_searching: bool,
1084 }
1085 
1086 
1087 /// The floating utility bar, overlaid on the page content. Anchored to the
1088 /// top or bottom window edge per the config; the page is full-bleed either
1089 /// way, so nothing but the chrome geometry depends on this. Every other
1090 /// bar-relative rect below is derived from this one — never from
1091 /// `bar_margin()` directly, or it would stay pinned to the top.
1092 fn bar_rect(win: (f32, f32), position: settings::BarPosition, favorites: bool) -> Rect {
1093     let h = bar_h(favorites);
1094     let y = match position {
1095         settings::BarPosition::Top => bar_margin(),
1096         settings::BarPosition::Bottom => (win.1 - bar_margin() - h).max(bar_margin()),
1097     };
1098     Rect {
1099         x: bar_margin(),
1100         y,
1101         width: (win.0 - 2.0 * bar_margin()).max(120.0),
1102         height: h,
1103     }
1104 }
1105 
1106 /// Y of the tab-strip row.
1107 fn tabs_y(bar: &Rect) -> f32 {
1108     bar.y + plate_pad()
1109 }
1110 
1111 /// Y of the favorites strip — under the tabs, where it only exists when
1112 /// the bar was sized for it.
1113 fn favs_y(bar: &Rect) -> f32 {
1114     bar.y + plate_pad() + btn_h() + inner_gap()
1115 }
1116 
1117 /// Y of the nav-controls row: the bar's bottom row, whether or not the
1118 /// favorites strip sits above it, so it is measured from the bottom edge.
1119 fn controls_y(bar: &Rect) -> f32 {
1120     bar.y + bar.height - plate_pad() - controls_h()
1121 }
1122 
1123 /// The favorites strip's pills, one rect per favorite that fits, in strip
1124 /// order (index into the strip = index into the result). Widths follow the
1125 /// labels, which is why this takes the font: draw and hit-test both read it
1126 /// with the same font and get the same rects.
1127 fn fav_rects(bar: &Rect, favs: &[pages::Link], sans: &str) -> Vec<Rect> {
1128     let mut rects = Vec::with_capacity(favs.len());
1129     let right = bar.x + bar.width - plate_pad();
1130     let mut x = bar.x + plate_pad();
1131     for f in favs {
1132         let w = (measure_text_width(&f.label, sans, FAV_FONT) + 2.0 * text_pad())
1133             .min(FAV_MAX_W)
1134             .max(btn_h());
1135         if x + w > right {
1136             break;
1137         }
1138         rects.push(Rect { x, y: favs_y(bar), width: w, height: btn_h() });
1139         x += w + item_gap();
1140     }
1141     rects
1142 }
1143 
1144 fn plus_rect(bar: &Rect, position: settings::BarPosition) -> Rect {
1145     Rect {
1146         x: bar.x + bar.width - plate_pad() - dot_col(position, true) - btn_h(),
1147         y: tabs_y(bar),
1148         width: btn_h(),
1149         height: btn_h(),
1150     }
1151 }
1152 
1153 fn tab_rect(bar: &Rect, position: settings::BarPosition, count: usize, i: usize) -> Rect {
1154     let avail = bar.width
1155         - 2.0 * plate_pad()
1156         - dot_col(position, true)
1157         - btn_h()
1158         - item_gap()
1159         - (count.max(1) - 1) as f32 * item_gap();
1160     let w = (avail / count.max(1) as f32).clamp(TAB_MIN_W, TAB_MAX_W);
1161     Rect {
1162         x: bar.x + plate_pad() + i as f32 * (w + item_gap()),
1163         y: tabs_y(bar),
1164         width: w,
1165         height: btn_h(),
1166     }
1167 }
1168 
1169 /// The close hit region on a tab pill, when the pill is wide enough.
1170 fn tab_close_rect(pill: &Rect) -> Option<Rect> {
1171     (pill.width >= TAB_CLOSE_MIN_W).then(|| Rect {
1172         x: pill.x + pill.width - TAB_CLOSE_W,
1173         y: pill.y,
1174         width: TAB_CLOSE_W,
1175         height: pill.height,
1176     })
1177 }
1178 
1179 fn btn_rect(bar: &Rect, i: usize) -> Rect {
1180     Rect {
1181         x: bar.x + plate_pad() + i as f32 * (btn_h() + item_gap()),
1182         y: controls_y(bar) + (controls_h() - btn_h()) / 2.0,
1183         width: btn_h(),
1184         height: btn_h(),
1185     }
1186 }
1187 
1188 /// The bookmark star, at the right end of the controls row — clear of the
1189 /// corner control when that row holds it.
1190 fn star_rect(bar: &Rect, position: settings::BarPosition) -> Rect {
1191     Rect {
1192         x: bar.x + bar.width - plate_pad() - dot_col(position, false) - btn_h(),
1193         y: controls_y(bar) + (controls_h() - btn_h()) / 2.0,
1194         width: btn_h(),
1195         height: btn_h(),
1196     }
1197 }
1198 
1199 /// The bookmarks menu button, immediately left of the star: the star is
1200 /// this page's bookmark, this is all of them.
1201 fn bm_btn_rect(bar: &Rect, position: settings::BarPosition) -> Rect {
1202     let star = star_rect(bar, position);
1203     Rect { x: star.x - item_gap() - btn_h(), ..star }
1204 }
1205 
1206 fn url_rect(bar: &Rect, position: settings::BarPosition) -> Rect {
1207     let x = bar.x + plate_pad() + 3.0 * (btn_h() + item_gap());
1208     let right = bm_btn_rect(bar, position).x - item_gap();
1209     let y = controls_y(bar) + (controls_h() - field_h()) / 2.0;
1210     Rect { x, y, width: (right - x).max(60.0), height: field_h() }
1211 }
1212 
1213 /// Whether a password filled into this page would leave it in the clear.
1214 ///
1215 /// Loopback is not: nothing crosses a network. Everything else that is not
1216 /// https is, including a `file:` page, which has no origin to speak of.
1217 #[cfg(feature = "wpe")]
1218 fn insecure_origin(origin: &str, host: &str) -> bool {
1219     let secure_scheme = origin.split(':').next() == Some("https");
1220     let loopback = matches!(host, "localhost" | "127.0.0.1" | "::1")
1221         || host.ends_with(".localhost");
1222     !secure_scheme && !loopback
1223 }
1224 
1225 /// Turn URL-bar input into something loadable: a real URL as-is, a bare
1226 /// host gets https://, anything else becomes a search.
1227 fn parse_url_input(input: &str, search_prefix: &str) -> Option<Url> {
1228     let s = input.trim();
1229     if s.is_empty() {
1230         return None;
1231     }
1232     if s.eq_ignore_ascii_case("about:history") {
1233         return Url::parse("cce://history").ok();
1234     }
1235     if s.eq_ignore_ascii_case("about:bookmarks") {
1236         return Url::parse("cce://bookmarks").ok();
1237     }
1238     if s.eq_ignore_ascii_case("about:favorites") {
1239         return Url::parse("cce://favorites").ok();
1240     }
1241     if s.eq_ignore_ascii_case("about:downloads") {
1242         return Url::parse("cce://downloads").ok();
1243     }
1244     if s.eq_ignore_ascii_case("about:cookies") {
1245         return Url::parse("cce://cookies").ok();
1246     }
1247     if let Ok(u) = Url::parse(s) {
1248         if matches!(u.scheme(), "http" | "https" | "file" | "data" | "about" | "cce") {
1249             return Some(u);
1250         }
1251     }
1252     if !s.contains(' ') && s.contains('.') {
1253         if let Ok(u) = Url::parse(&format!("https://{s}")) {
1254             return Some(u);
1255         }
1256     }
1257     let q: String = url::form_urlencoded::byte_serialize(s.as_bytes()).collect();
1258     Url::parse(&format!("{search_prefix}{q}")).ok()
1259 }
1260 
1261 /// Turn the startup argument into something loadable.
1262 ///
1263 /// This is deliberately not [`parse_url_input`]: that one is the URL *bar*,
1264 /// where a dotted word is meant to become a domain guess. Argv is different —
1265 /// the desktop entry claims `text/html`, and the XDG spec lets a launcher pass
1266 /// a local file for `%u` "either as a file: URL or as a file path". A plain
1267 /// path takes the domain-guess branch and turns `/home/me/page.html` into
1268 /// `https:///home/me/page.html`, so an existing path is resolved to a file:
1269 /// URL first and only a non-path falls through to the bar's parsing.
1270 fn parse_startup_arg(arg: &str, search_prefix: &str) -> Option<Url> {
1271     let path = std::path::Path::new(arg);
1272     if path.exists() {
1273         // Relative paths need the cwd joined on before file: URL conversion.
1274         if let Ok(abs) = std::fs::canonicalize(path) {
1275             if let Ok(u) = Url::from_file_path(&abs) {
1276                 return Some(u);
1277             }
1278         }
1279     }
1280     parse_url_input(arg, search_prefix)
1281 }
1282 
1283 
1284 
1285 
1286 
1287 impl BrowserApp {
1288     /// The utility bar's rect for the current window size and configured
1289     /// edge — the single source every chrome hit-test and draw reads.
1290     fn bar(&self) -> Rect {
1291         bar_rect(self.win, self.settings.bar_position, !self.favs.is_empty())
1292     }
1293 
1294     /// The favorites strip's pills for the current bar.
1295     fn fav_rects(&self, bar: &Rect) -> Vec<Rect> {
1296         let (sans, ..) = cce_ui::layout::read_preferred_fonts();
1297         fav_rects(bar, &self.favs, &sans)
1298     }
1299 
1300     /// Re-read the strip from the store. Called with the rest of the page
1301     /// state, which is also when the `cce://favorites` page's edits — made
1302     /// on the way into a navigation — become visible.
1303     fn refresh_favorites(&mut self) {
1304         let favs = self.favorites.snapshot();
1305         if favs != self.favs {
1306             self.favs = favs;
1307             self.fav_hover = None;
1308         }
1309     }
1310 
1311     /// Add or remove the active page from the favorites strip.
1312     fn toggle_favorite(&mut self) {
1313         self.host.toggle_favorite();
1314         self.refresh_favorites();
1315     }
1316 
1317     /// Drop any glide in flight and rebase the virtual offset.
1318     ///
1319     /// Called wherever the deltas would land somewhere they were not aimed:
1320     /// another tab, another page.
1321     fn stop_scroll(&mut self) {
1322         self.scroll.x.jump_to(0.0);
1323         self.scroll.y.jump_to(0.0);
1324         self.scroll_sent = (0.0, 0.0);
1325         self.scroll_origin = None;
1326     }
1327 
1328     /// Hand the engine what the glide moved since the last frame.
1329     ///
1330     /// Deltas, not an offset: WebKit keeps the real scroll position (and
1331     /// clamps it at the ends of the page), so the model here only has to say
1332     /// how far to move. Sign flips back on the way out — the shared model
1333     /// counts an offset that grows as content moves up, the engine takes the
1334     /// winit convention the rest of this file passes it.
1335     fn advance_scroll(&mut self, dt: f32) -> bool {
1336         use cce_ui::widget::scroll_motion::Bounds;
1337         if !self.scroll.is_animating() {
1338             // Settled: rebase, so a long session never walks the accumulator
1339             // out into the far reaches of f32.
1340             if self.scroll_sent != (0.0, 0.0) {
1341                 self.stop_scroll();
1342             }
1343             return false;
1344         }
1345         self.scroll.tick(dt, Bounds::UNBOUNDED, Bounds::UNBOUNDED);
1346         let (x, y) = (self.scroll.x.pos(), self.scroll.y.pos());
1347         let (dx, dy) = (x - self.scroll_sent.0, y - self.scroll_sent.1);
1348         self.scroll_sent = (x, y);
1349         if dx == 0.0 && dy == 0.0 {
1350             return true;
1351         }
1352         // Say which gesture these belong to rather than inheriting whatever
1353         // the last real event set: a glide is a wheel, and a stale FingerEnd
1354         // would tell the engine every frame that a gesture had just ended.
1355         cce_ui::widget::scroll_motion::set_scroll_phase(cce_ui::widget::ScrollPhase::Wheel);
1356         let s = self.scale;
1357         let (px, py) = self.scroll_origin.unwrap_or(self.pointer);
1358         self.host.wheel(-(dx as f64) * s, -(dy as f64) * s, px * s as f32, py * s as f32);
1359         true
1360     }
1361 
1362     /// The tab's host, for matching accounts and for checking that a field
1363     /// event came from the page the chrome thinks is on screen.
1364     fn page_host(&self) -> Option<String> {
1365         self.host.url().and_then(|u| u.host_str().map(str::to_string))
1366     }
1367 
1368     /// The accounts a field earns, best first.
1369     ///
1370     /// A field is offered the accounts of the site it sends to: `form_host`,
1371     /// the frame it lives in. When that frame is from another site than the
1372     /// page — iCloud's sign-in is `idmsa.apple.com` inside `icloud.com` — the
1373     /// page's own accounts are offered too, after the frame's and marked with
1374     /// after the frame's, because the entry people have is usually for the
1375     /// site they typed, and the form's host is an implementation detail of it.
1376     /// That is a real widening: it hands the page's password to an embedded
1377     /// site if picked. It is never automatic, the list says which site the
1378     /// form is from (`ac_notes`), and an ad frame with a password field is
1379     /// not what sits inside the sites this is for.
1380     #[cfg(feature = "wpe")]
1381     fn offered(&self, form_host: &str, page_host: &str) -> Vec<accounts::Account> {
1382         let mut all = self.accounts.matching(form_host);
1383         if form_host != page_host {
1384             for a in self.accounts.matching(page_host) {
1385                 if !all.iter().any(|b| b.path == a.path) {
1386                     all.push(a);
1387                 }
1388             }
1389         }
1390         all
1391     }
1392 
1393     /// The lines under the account list, each with its colour: whose form
1394     /// this is, when it is not the page's own, and whether the password
1395     /// would cross the network in the clear.
1396     #[cfg(feature = "wpe")]
1397     fn ac_notes(menu: &AcMenu) -> Vec<(String, [u8; 3])> {
1398         let mut notes = Vec::new();
1399         if menu.form_host != menu.host {
1400             notes.push((format!("sign-in form from {}", menu.form_host), TEXT_DIM));
1401         }
1402         if menu.insecure {
1403             notes.push((
1404                 "insecure page — this password would be sent unencrypted".to_string(),
1405                 [212, 155, 155],
1406             ));
1407         }
1408         notes
1409     }
1410 
1411     /// A login-field event from a watcher. Open, move, refill or close the
1412     /// account list; place child frames; take a sign-in for saving.
1413     ///
1414     /// Which frame spoke is believable — the watchers run in a world the page
1415     /// cannot reach, and report their own `location.origin` — so the guard
1416     /// is: a top frame must be on the tab's own host, and a child frame is
1417     /// matched against its own.
1418     #[cfg(feature = "wpe")]
1419     fn on_form_event(&mut self, event: wpe::FormEvent) -> bool {
1420         use wpe::FormEvent;
1421         if !self.settings.accounts {
1422             return false;
1423         }
1424         match event {
1425             FormEvent::Blur { frame } => {
1426                 // Only the field's own frame can close its list: focus moving
1427                 // from one frame to another blurs one and focuses the other,
1428                 // and the two reports can arrive in either order.
1429                 let was = self.ac_menu.as_ref().is_some_and(|m| m.frame == frame);
1430                 if was {
1431                     self.ac_menu = None;
1432                 }
1433                 if self.last_field.as_ref().is_some_and(
1434                     |e| matches!(e, FormEvent::Field { frame: f, .. } if *f == frame),
1435                 ) {
1436                     self.last_field = None;
1437                 }
1438                 was
1439             }
1440             FormEvent::Frame { frame, offset } => {
1441                 // A page could invent frames; a bound keeps that from growing.
1442                 if self.frame_offsets.len() >= 32 && !self.frame_offsets.contains_key(&frame) {
1443                     self.frame_offsets.clear();
1444                 }
1445                 let changed = self.frame_offsets.insert(frame.clone(), offset) != Some(offset);
1446                 changed && self.ac_menu.as_ref().is_some_and(|m| m.frame == frame)
1447             }
1448             FormEvent::Submit { origin, top, username, password, .. } => {
1449                 self.on_submit(origin, top, username, password.into_inner().into());
1450                 self.save_offer
1451                     .as_ref()
1452                     .is_some_and(|o| o.stage != SaveStage::Checking)
1453             }
1454             FormEvent::Field { ref origin, ref frame, top, password, rect, ref value, moved } => {
1455                 log::debug!(
1456                     "login field: password={password} moved={moved} top={top} origin={origin} \
1457                      page={:?} rect={rect:?}",
1458                     self.page_host()
1459                 );
1460                 let Some(host) = self.page_host() else {
1461                     self.ac_menu = None;
1462                     return false;
1463                 };
1464                 let Some(form_host) = url::Url::parse(origin)
1465                     .ok()
1466                     .and_then(|u| u.host_str().map(str::to_string))
1467                 else {
1468                     self.ac_menu = None;
1469                     return false;
1470                 };
1471                 if top && form_host != host {
1472                     self.ac_menu = None;
1473                     return false;
1474                 }
1475                 let (frame, filter, insecure) = (
1476                     frame.clone(),
1477                     // A password field filters by nothing; a username field
1478                     // by what is in it.
1479                     if password { String::new() } else { value.clone() },
1480                     insecure_origin(origin, &form_host),
1481                 );
1482                 self.last_field = Some(event);
1483                 // The index is read the first time a login field appears —
1484                 // never at launch, so a browser that sees no login form never
1485                 // opens the keyring.
1486                 self.accounts.ensure_loaded();
1487                 let field = self.field_rect(rect);
1488                 let all = self.offered(&form_host, &host);
1489                 log::debug!("{} accounts match {form_host} (page {host})", all.len());
1490                 match self.ac_menu.as_mut() {
1491                     Some(menu) if moved && menu.frame == frame => {
1492                         menu.field = field;
1493                         menu.all = all;
1494                         menu.host = host.clone();
1495                         menu.form_host = form_host;
1496                         menu.insecure = insecure;
1497                         menu.refilter(&filter);
1498                     }
1499                     _ => {
1500                         let mut menu = AcMenu {
1501                             all,
1502                             shown: Vec::new(),
1503                             selected: 0,
1504                             scroll: 0,
1505                             field,
1506                             frame,
1507                             top,
1508                             form_host,
1509                             host: host.clone(),
1510                             insecure,
1511                             hover: None,
1512                         };
1513                         menu.refilter(&filter);
1514                         self.ac_menu = Some(menu);
1515                     }
1516                 }
1517                 // An empty list is no list: nothing matched, or the index is
1518                 // still loading and the next `Accounts` message will reopen.
1519                 if self.ac_menu.as_ref().is_some_and(|m| m.shown.is_empty()) {
1520                     self.ac_menu = None;
1521                 }
1522                 true
1523             }
1524         }
1525     }
1526 
1527     /// A sign-in went out. Hold it as an offer to save, unless it is already
1528     /// in the keyring, or the site is on the never list.
1529     ///
1530     /// Whether it is new can only be answered once the index is read, so the
1531     /// offer starts as `Checking` and `resolve_save` decides — now, or when
1532     /// the index arrives.
1533     #[cfg(feature = "wpe")]
1534     fn on_submit(&mut self, origin: String, top: bool, username: String, password: accounts::Secret) {
1535         let Some(page_host) = self.page_host() else { return };
1536         let Some(form_host) = url::Url::parse(&origin)
1537             .ok()
1538             .and_then(|u| u.host_str().map(str::to_string))
1539         else {
1540             return;
1541         };
1542         // Same guard as a field: a top frame must be the tab's own.
1543         if top && form_host != page_host {
1544             return;
1545         }
1546         if self.never_save.contains(&form_host) {
1547             return;
1548         }
1549         // The fill path's own submit — or the same sign-in reported twice —
1550         // is not a second offer.
1551         if self.save_offer.as_ref().is_some_and(|o| {
1552             o.form_host == form_host && o.username == username && o.password == password
1553         }) {
1554             return;
1555         }
1556         self.save_offer = Some(SaveOffer {
1557             origin,
1558             form_host,
1559             page_host,
1560             username,
1561             password,
1562             stage: SaveStage::Checking,
1563         });
1564         self.accounts.ensure_loaded();
1565         self.resolve_save();
1566     }
1567 
1568     /// Decide a `Checking` offer, once the index can answer: drop it when an
1569     /// entry for that site already has this username, show it otherwise.
1570     ///
1571     /// "That site" is everything the field would have been offered, borrowed
1572     /// entries included — a sign-in filled from the page's own entry into a
1573     /// frame from another site is that entry, not a new login.
1574     #[cfg(feature = "wpe")]
1575     fn resolve_save(&mut self) {
1576         let Some(offer) = self.save_offer.as_ref() else { return };
1577         if offer.stage != SaveStage::Checking || !self.accounts.is_ready() {
1578             return;
1579         }
1580         if let Some(e) = self.accounts.error.as_ref() {
1581             // Nothing could be saved either; saying so on every sign-in
1582             // would be noise. The load failure is already logged.
1583             log::info!("not offering to save a login: the keyring is unavailable ({e})");
1584             self.save_offer = None;
1585             return;
1586         }
1587         let known = self.offered(&offer.form_host, &offer.page_host);
1588         if accounts::Accounts::knows(&known, &offer.username) {
1589             self.save_offer = None;
1590         } else if let Some(o) = self.save_offer.as_mut() {
1591             o.stage = SaveStage::Asking;
1592         }
1593     }
1594 
1595     /// Where the save offer sits: in the corner the bar hangs from, under
1596     /// the dot for a top bar and over it for a bottom one — or past the bar
1597     /// itself while that is out — so it never covers the controls it sits by.
1598     #[cfg(feature = "wpe")]
1599     fn save_layout(&self) -> Option<SaveLayout> {
1600         let offer = self.save_offer.as_ref()?;
1601         if offer.stage == SaveStage::Checking {
1602             return None;
1603         }
1604         let width = SAVE_W.min(self.win.0 - 2.0 * bar_margin()).max(220.0);
1605         let height = plate_pad() * 2.0 + 20.0 + 18.0 + 18.0 + inner_gap() + btn_h();
1606         let (cx, cy) = self.dot_center();
1607         let edge = cx + DOT_R;
1608         let x = (edge - width).clamp(0.0, (self.win.0 - width).max(0.0));
1609         let gap = item_gap();
1610         let y = match self.settings.bar_position {
1611             settings::BarPosition::Top => {
1612                 let below = if self.chrome_t > 0.0 {
1613                     let (bar, _) = self.chrome_plate();
1614                     bar.y + bar.height
1615                 } else {
1616                     cy + DOT_R
1617                 };
1618                 below + gap
1619             }
1620             settings::BarPosition::Bottom => {
1621                 let above = if self.chrome_t > 0.0 {
1622                     self.chrome_plate().0.y
1623                 } else {
1624                     cy - DOT_R
1625                 };
1626                 (above - gap - height).max(0.0)
1627             }
1628         };
1629         let plate = Rect { x, y, width, height };
1630         let by = plate.y + plate.height - plate_pad() - btn_h();
1631         let at = |k: f32| Rect {
1632             x: plate.x + plate.width - plate_pad() - (k + 1.0) * SAVE_BTN_W - k * inner_gap(),
1633             y: by,
1634             width: SAVE_BTN_W,
1635             height: btn_h(),
1636         };
1637         let buttons = match offer.stage {
1638             SaveStage::Failed(_) => vec![(at(0.0), SaveButton::Dismiss)],
1639             SaveStage::Saving => Vec::new(),
1640             _ => vec![
1641                 (at(0.0), SaveButton::Save),
1642                 (at(1.0), SaveButton::Dismiss),
1643                 (at(2.0), SaveButton::Never),
1644             ],
1645         };
1646         Some(SaveLayout { plate, buttons })
1647     }
1648 
1649     /// Act on a press at a point over the save offer. Returns whether the
1650     /// offer took the press — anywhere on its plate does.
1651     #[cfg(feature = "wpe")]
1652     fn save_click(&mut self, x: f32, y: f32) -> bool {
1653         let Some(layout) = self.save_layout() else { return false };
1654         if !layout.plate.contains(x, y) {
1655             return false;
1656         }
1657         let Some((_, button)) = layout.buttons.iter().find(|(r, _)| r.contains(x, y)) else {
1658             return true;
1659         };
1660         match button {
1661             SaveButton::Save => {
1662                 if let Some(offer) = self.save_offer.as_mut() {
1663                     offer.stage = SaveStage::Saving;
1664                     let label = offer
1665                         .page_host
1666                         .strip_prefix("www.")
1667                         .unwrap_or(&offer.page_host)
1668                         .to_string();
1669                     let login = accounts::NewLogin {
1670                         label,
1671                         username: offer.username.clone(),
1672                         url: offer.origin.clone(),
1673                         password: offer.password.clone(),
1674                     };
1675                     self.accounts.save(login);
1676                 }
1677             }
1678             SaveButton::Never => {
1679                 if let Some(offer) = self.save_offer.take() {
1680                     self.never_save.add(&offer.form_host);
1681                 }
1682             }
1683             SaveButton::Dismiss => self.save_offer = None,
1684         }
1685         true
1686     }
1687 
1688     /// A viewport rect from the page, in the chrome's coordinates.
1689     ///
1690     /// These are the same space, and that is worth stating rather than
1691     /// rediscovering: `resize` gives WPE the **logical** size and sets the
1692     /// scale separately (`logical_size`), so a CSS pixel in the page is a
1693     /// logical pixel in the chrome at any output scale. Verified at scale 2.
1694     #[cfg(feature = "wpe")]
1695     fn field_rect(&self, rect: (f32, f32, f32, f32)) -> Rect {
1696         Rect { x: rect.0, y: rect.1, width: rect.2, height: rect.3 }
1697     }
1698 
1699     /// Hand a picked account's credential to the page and close the list.
1700     ///
1701     /// The keyring answers on its own schedule — an unlock prompt can put
1702     /// seconds between the pick and this — so everything is checked again
1703     /// here: the list is still open, it still holds the account that was
1704     /// picked, and the tab is still on the host it was opened for. If any of
1705     /// that has changed the credential is dropped on the floor rather than
1706     /// typed into whatever page is there now.
1707     #[cfg(feature = "wpe")]
1708     fn fill_account(&mut self, path: &str, secret: &accounts::Secret) {
1709         let same_page = self
1710             .ac_menu
1711             .as_ref()
1712             .zip(self.page_host())
1713             .is_some_and(|(menu, host)| menu.host == host);
1714         let target = self
1715             .ac_menu
1716             .as_ref()
1717             .filter(|_| same_page)
1718             .and_then(|m| m.shown.iter().find(|a| a.path == path).map(|a| (a, &m.frame)))
1719             .map(|(a, frame)| (a.username.clone(), frame.clone()));
1720         // The fill goes to the ask of the document the list was opened for,
1721         // and only that one; if it has gone, so does the credential.
1722         let filled = target.is_some_and(|(username, frame)| {
1723             self.host.fill_credentials(&frame, &username, secret.expose())
1724         });
1725         if !filled {
1726             log::warn!("dropped a credential: the page moved on before it arrived");
1727         }
1728         self.ac_menu = None;
1729     }
1730 
1731     /// Ask for the password behind the selected row.
1732     #[cfg(feature = "wpe")]
1733     fn pick_account(&mut self, index: usize) {
1734         let Some(account) = self.ac_menu.as_ref().and_then(|m| m.shown.get(index)) else {
1735             return;
1736         };
1737         // The secret is fetched now, for this one entry, and arrives as
1738         // `Message::Credential`. Nothing is held in the menu.
1739         self.accounts.fetch(&account.path);
1740     }
1741 
1742     /// The account list's plate and rows, or `None` when it is closed. Draw
1743     /// and hit-test read this, as everywhere else in this chrome.
1744     #[cfg(feature = "wpe")]
1745     fn ac_layout(&self) -> Option<(Rect, Vec<Rect>)> {
1746         let menu = self.ac_menu.as_ref()?;
1747         if menu.shown.is_empty() {
1748             return None;
1749         }
1750         // A child frame's field is placed by the frame's offset, which the
1751         // watchers relay separately; until it has arrived there is nowhere
1752         // honest to draw the list, so it waits.
1753         let (dx, dy) = if menu.top { (0.0, 0.0) } else { *self.frame_offsets.get(&menu.frame)? };
1754         let anchor = Rect { x: menu.field.x + dx, y: menu.field.y + dy, ..menu.field };
1755         let rows = menu.shown.len().min(AC_MAX_ROWS);
1756         let height =
1757             2.0 * plate_pad() + rows as f32 * AC_ROW_H + Self::ac_notes(menu).len() as f32 * 18.0;
1758         let width = AC_W.min(self.win.0 - 2.0 * bar_margin()).max(180.0);
1759         let x = anchor.x.clamp(0.0, (self.win.0 - width).max(0.0));
1760         // Under the field, or above it when there is no room below — the
1761         // list must never cover the field it is filling.
1762         // style: deliberate — 2px off the field, so the list reads as
1763         // attached to it; the field is page content, not a plate sibling.
1764         let below = anchor.y + anchor.height + 2.0;
1765         let y = if below + height <= self.win.1 - bar_margin() {
1766             below
1767         } else {
1768             (anchor.y - 2.0 - height).max(0.0)
1769         };
1770         let plate = Rect { x, y, width, height };
1771         // Row *positions*; which account each shows is `first_row() + k`.
1772         // style: deliberate — the 2px hairline keeps a row's highlight off
1773         // the plate's roll.
1774         let rects = (0..rows)
1775             .map(|k| Rect {
1776                 x: plate.x + 2.0,
1777                 y: plate.y + plate_pad() + (k as f32) * AC_ROW_H,
1778                 width: plate.width - 4.0,
1779                 height: AC_ROW_H,
1780             })
1781             .collect();
1782         Some((plate, rects))
1783     }
1784 
1785     /// The account row at a pointer position, if any.
1786     #[cfg(feature = "wpe")]
1787     fn ac_hit(&self, x: f32, y: f32) -> Option<usize> {
1788         let (_, rows) = self.ac_layout()?;
1789         let first = self.ac_menu.as_ref()?.first_row();
1790         rows.iter().position(|r| r.contains(x, y)).map(|k| first + k)
1791     }
1792 
1793     /// Whether the active page is one that can be saved at all: an internal
1794     /// page or a blank tab cannot.
1795     fn saveable(&self) -> bool {
1796         self.host
1797             .url()
1798             .is_some_and(|u| !matches!(u.scheme(), "cce" | "about"))
1799     }
1800 
1801     /// Drop the bookmarks menu from its button, taking a snapshot of the
1802     /// store. Focus leaves the URL bar with it: a field behind an open menu
1803     /// must not keep eating keystrokes, the same reason folding drops it.
1804     fn open_bm_menu(&mut self) {
1805         if self.url_focused {
1806             self.url_focused = false;
1807             self.url.selection = None;
1808             self.sync_page_state();
1809         }
1810         self.bm_menu = Some(BmMenu::new(self.bookmarks.snapshot()));
1811     }
1812 
1813     fn close_bm_menu(&mut self) {
1814         self.bm_menu = None;
1815     }
1816 
1817     /// Re-read the store into the open menu after an edit made through it,
1818     /// keeping the scroll inside the new range.
1819     fn refresh_bm_menu(&mut self) {
1820         let all = self.bookmarks.snapshot();
1821         if let Some(m) = self.bm_menu.as_mut() {
1822             m.all = all;
1823             m.filter();
1824             m.hover = None;
1825         }
1826         self.clamp_bm_scroll();
1827     }
1828 
1829     /// Keep the menu's scroll inside the range its (filtered) list has.
1830     fn clamp_bm_scroll(&mut self) {
1831         let cap = self.bm_layout().map_or(usize::MAX, |l| l.cap);
1832         if let Some(m) = self.bm_menu.as_mut() {
1833             m.scroll = m.scroll.min(m.items.len().saturating_sub(cap));
1834         }
1835     }
1836 
1837     /// The search field's keys, while the menu is open. Escape never gets
1838     /// here (it closes the menu first); Enter visits the first match, as a
1839     /// click on it would; anything that changes the query re-filters the
1840     /// list and takes it back to the top.
1841     fn edit_bm_search(&mut self, event: &KeyEvent) {
1842         // Up/Down walk the matches; the field has no use for them.
1843         let delta = match event.logical_key {
1844             Key::Named(NamedKey::ArrowDown) => Some(1),
1845             Key::Named(NamedKey::ArrowUp) => Some(-1),
1846             _ => None,
1847         };
1848         if let Some(delta) = delta {
1849             let visible = self.bm_layout().map_or(1, |l| l.rows.len());
1850             if let Some(m) = self.bm_menu.as_mut() {
1851                 m.step(delta, visible);
1852             }
1853             return;
1854         }
1855         let Some(m) = self.bm_menu.as_mut() else { return };
1856         let before = m.query.text.clone();
1857         match m.query.handle_key(event) {
1858             cce_ui::widget::EditOutcome::Submit => {
1859                 if !m.items.is_empty() {
1860                     let i = m.selected;
1861                     self.bm_open(i, false);
1862                 }
1863                 return;
1864             }
1865             cce_ui::widget::EditOutcome::Cancel => {
1866                 self.close_bm_menu();
1867                 return;
1868             }
1869             _ => {}
1870         }
1871         if m.query.text != before {
1872             self.bm_query_changed();
1873         }
1874     }
1875 
1876     /// The query's text changed (a key, a paste, an undo): re-filter and
1877     /// start the list from its top again.
1878     fn bm_query_changed(&mut self) {
1879         if let Some(m) = self.bm_menu.as_mut() {
1880             m.filter();
1881             m.scroll = 0;
1882             m.selected = 0;
1883             m.hover = None;
1884         }
1885     }
1886 
1887     /// Byte of the search query under pointer x `x`.
1888     fn bm_query_index_at(&mut self, x: f32) -> Option<usize> {
1889         let field = self.bm_layout()?.search;
1890         let shown = self.bm_menu.as_ref()?.query.display();
1891         let at = self.boundary_at_x(&shown, x - field.x - text_pad());
1892         Some(self.bm_menu.as_ref()?.query.text_index(at))
1893     }
1894 
1895     /// The menu's geometry for the current window, bar edge and item count:
1896     /// `None` when it is closed. Draw and hit-test both read this.
1897     ///
1898     /// It hangs off the button that opens it — below the bar on a top bar,
1899     /// above it on a bottom one — right-aligned with that button and
1900     /// clamped on screen, and it never grows past the space it has: the
1901     /// list is capped to what fits and scrolls instead.
1902     fn bm_layout(&self) -> Option<BmLayout> {
1903         let menu = self.bm_menu.as_ref()?;
1904         let bar = self.bar();
1905         let btn = bm_btn_rect(&bar, self.settings.bar_position);
1906         let width = BM_W.min(self.win.0 - 2.0 * bar_margin()).max(160.0);
1907         let x = (btn.x + btn.width - width)
1908             .clamp(bar_margin(), (self.win.0 - bar_margin() - width).max(bar_margin()));
1909         // The furniture the list is fitted around: the search field and its
1910         // gap, the toggle row, two rules and the manage row.
1911         let fixed = 2.0 * plate_pad() + field_h() + inner_gap() + 2.0 * BM_ROW_H + 2.0 * BM_SEP_H;
1912         let avail = match self.settings.bar_position {
1913             settings::BarPosition::Top => self.win.1 - (bar.y + bar.height + item_gap()) - bar_margin(),
1914             settings::BarPosition::Bottom => bar.y - item_gap() - bar_margin(),
1915         };
1916         let cap = (((avail - fixed) / BM_ROW_H).floor().max(1.0)) as usize;
1917         // The plate is sized for the whole collection, not for what the
1918         // search lets through, so it keeps its size while a query narrows
1919         // the list: hanging above a bottom bar, a plate that shrank would
1920         // slide its search field out from under the pointer mid-typing.
1921         // An empty list still keeps its one "nothing here" row.
1922         let slots = menu.all.len().clamp(1, cap);
1923         let height = fixed + slots as f32 * BM_ROW_H;
1924         let y = match self.settings.bar_position {
1925             settings::BarPosition::Top => bar.y + bar.height + item_gap(),
1926             settings::BarPosition::Bottom => bar.y - item_gap() - height,
1927         };
1928         let plate = Rect { x, y, width, height };
1929         // style: deliberate — the 2px hairline keeps a row's highlight off
1930         // the plate's roll.
1931         let row = |dy: f32| Rect {
1932             x: x + 2.0,
1933             y: y + plate_pad() + dy,
1934             width: width - 4.0,
1935             height: BM_ROW_H,
1936         };
1937         let toggle_y = field_h() + inner_gap();
1938         let list_y = toggle_y + BM_ROW_H + BM_SEP_H;
1939         let shown = menu.items.len().min(slots);
1940         let first = menu.scroll.min(menu.items.len().saturating_sub(shown));
1941         let rows = (0..shown)
1942             .map(|k| (row(list_y + k as f32 * BM_ROW_H), first + k))
1943             .collect();
1944         // The field sits in its row at the plate's text inset, so its text
1945         // lines up with the rows' labels below it.
1946         let search = Rect {
1947             x: x + plate_pad(),
1948             y: y + plate_pad(),
1949             width: width - 2.0 * plate_pad(),
1950             height: field_h(),
1951         };
1952         Some(BmLayout {
1953             plate,
1954             search,
1955             toggle: row(toggle_y),
1956             rows,
1957             empty: menu.items.is_empty().then(|| row(list_y)),
1958             manage: row(list_y + slots as f32 * BM_ROW_H + BM_SEP_H),
1959             cap,
1960         })
1961     }
1962 
1963     /// What the pointer is on inside the menu — `None` for its padding and
1964     /// rules as much as for the world outside it, so the caller checks the
1965     /// plate itself before deciding a click was "outside".
1966     fn bm_hit(&self, x: f32, y: f32) -> Option<BmHit> {
1967         let l = self.bm_layout()?;
1968         if l.search.contains(x, y) {
1969             return Some(BmHit::Search);
1970         }
1971         if l.toggle.contains(x, y) {
1972             return Some(BmHit::Toggle);
1973         }
1974         if l.manage.contains(x, y) {
1975             return Some(BmHit::Manage);
1976         }
1977         l.rows.iter().find(|(r, _)| r.contains(x, y)).map(|(r, i)| {
1978             BmHit::Entry(*i, x >= r.x + r.width - BM_RM_W)
1979         })
1980     }
1981 
1982     /// Visit a bookmark from the menu: in the active tab, which is a pick —
1983     /// menu and bar fold away to show the page — or in a new tab, which
1984     /// leaves the menu up so several can be opened in a row.
1985     fn bm_open(&mut self, index: usize, new_tab: bool) {
1986         let Some(url) = self
1987             .bm_menu
1988             .as_ref()
1989             .and_then(|m| m.items.get(index))
1990             .and_then(|i| Url::parse(&i.url).ok())
1991         else {
1992             return;
1993         };
1994         if new_tab {
1995             self.open_background_tab(url);
1996         } else {
1997             self.host.load(url);
1998             self.loading = true;
1999             self.close_bm_menu();
2000             self.close_chrome();
2001             self.sync_page_state();
2002         }
2003     }
2004 
2005     /// Drop one bookmark from inside the menu. The list stays open —
2006     /// pruning is the one thing done several times in a row.
2007     fn bm_remove(&mut self, index: usize) {
2008         let Some(url) = self
2009             .bm_menu
2010             .as_ref()
2011             .and_then(|m| m.items.get(index))
2012             .map(|i| i.url.clone())
2013         else {
2014             return;
2015         };
2016         self.bookmarks.remove(&url);
2017         self.refresh_bm_menu();
2018     }
2019 
2020     /// Wheel over the list: one bookmark per notch, clamped to the range
2021     /// the plate cannot show.
2022     fn bm_scroll(&mut self, dy: f64) {
2023         let Some(l) = self.bm_layout() else { return };
2024         let max = self
2025             .bm_menu
2026             .as_ref()
2027             .map_or(0, |m| m.items.len().saturating_sub(l.cap));
2028         if let Some(m) = self.bm_menu.as_mut() {
2029             // Positive dy is up, cce-ui's winit convention.
2030             let step: isize = if dy > 0.0 { -1 } else { 1 };
2031             m.scroll = (m.scroll as isize + step).clamp(0, max as isize) as usize;
2032         }
2033     }
2034 
2035     /// Act on a click inside the menu.
2036     fn bm_click(&mut self, button: MouseButton, hit: Option<BmHit>) {
2037         match (button, hit) {
2038             (MouseButton::Left, Some(BmHit::Toggle)) => {
2039                 if self.saveable() || self.host.active_bookmarked() {
2040                     self.host.toggle_bookmark();
2041                     self.refresh_bm_menu();
2042                 }
2043             }
2044             (MouseButton::Left, Some(BmHit::Entry(i, true))) => self.bm_remove(i),
2045             (MouseButton::Left, Some(BmHit::Entry(i, false))) => self.bm_open(i, false),
2046             (MouseButton::Middle, Some(BmHit::Entry(i, _))) => self.bm_open(i, true),
2047             (MouseButton::Left, Some(BmHit::Manage)) => {
2048                 self.close_bm_menu();
2049                 self.close_chrome();
2050                 self.open_internal_page("cce://bookmarks");
2051             }
2052             _ => {}
2053         }
2054     }
2055 
2056     /// Centre of the corner control: the bar's corner nearest the window
2057     /// corner it is anchored to — top-right for a top bar, bottom-right for
2058     /// a bottom one — at the DE's inset. A circle menu is the corner of the
2059     /// thing it expands into, so it sits where the bar's corner will be and
2060     /// stays there when the bar is out.
2061     fn dot_center(&self) -> (f32, f32) {
2062         let bar = self.bar();
2063         let inset = DOT_INSET;
2064         let cy = match self.settings.bar_position {
2065             settings::BarPosition::Top => bar.y + inset,
2066             settings::BarPosition::Bottom => bar.y + bar.height - inset,
2067         };
2068         (bar.x + bar.width - inset, cy)
2069     }
2070 
2071     fn dot_hit(&self, x: f32, y: f32) -> bool {
2072         let (cx, cy) = self.dot_center();
2073         let (dx, dy) = (x - cx, y - cy);
2074         dx * dx + dy * dy <= DOT_R * DOT_R
2075     }
2076 
2077     /// Unfold progress with easing applied — what the plate is drawn from.
2078     fn chrome_ease(&self) -> f32 {
2079         let t = self.chrome_t.clamp(0.0, 1.0);
2080         t * t * (3.0 - 2.0 * t)
2081     }
2082 
2083     /// The bar plate as currently drawn — the full bar, or the shape it is
2084     /// unfolding through — and its corner radius. It grows out of the dot
2085     /// itself: the seed is the dot's own disc, so the control expands as an
2086     /// object into the bar and, open, is the bar's corner.
2087     fn chrome_plate(&self) -> (Rect, f32) {
2088         let e = self.chrome_ease();
2089         let (cx, cy) = self.dot_center();
2090         let seed = DOT_R;
2091         let bar = self.bar();
2092         let lerp = |a: f32, b: f32| a + (b - a) * e;
2093         let plate = Rect {
2094             x: lerp(cx - seed, bar.x),
2095             y: lerp(cy - seed, bar.y),
2096             width: lerp(2.0 * seed, bar.width),
2097             height: lerp(2.0 * seed, bar.height),
2098         };
2099         (plate, lerp(seed, BAR_RADIUS))
2100     }
2101 
2102     /// Whether a pointer position is over the chrome: the corner control
2103     /// always, the plate while any of it is showing.
2104     fn chrome_hit(&self, x: f32, y: f32) -> bool {
2105         self.dot_hit(x, y) || (self.chrome_t > 0.0 && self.chrome_plate().0.contains(x, y))
2106     }
2107 
2108     fn open_chrome(&mut self) {
2109         self.chrome_open = true;
2110         self.chrome_peek = None;
2111     }
2112 
2113     /// Unfold the bar for a moment, to show a tab that just opened behind
2114     /// the page. A bar the person already has open is left alone; a peek
2115     /// already under way runs on from now.
2116     fn peek_chrome(&mut self) {
2117         if self.chrome_open && self.chrome_peek.is_none() {
2118             return;
2119         }
2120         self.chrome_open = true;
2121         self.peek_pointer = self.pointer;
2122         self.arm_peek();
2123     }
2124 
2125     /// Set the peek's deadline, and a wake for it: an idle page produces
2126     /// nothing that would turn the loop when it passes.
2127     fn arm_peek(&mut self) {
2128         self.chrome_peek = Some(std::time::Instant::now() + CHROME_PEEK);
2129         let tx = self.sender.clone();
2130         std::thread::spawn(move || {
2131             std::thread::sleep(CHROME_PEEK);
2132             let _ = tx.send(Message::Spin);
2133         });
2134     }
2135 
2136     /// Fold a peek whose time is up. A pointer brought onto the bar holds
2137     /// it out a while longer. Returns whether the bar folded.
2138     fn settle_peek(&mut self) -> bool {
2139         let Some(at) = self.chrome_peek else { return false };
2140         if std::time::Instant::now() < at {
2141             return false;
2142         }
2143         if self.pointer != self.peek_pointer && self.chrome_hit(self.pointer.0, self.pointer.1) {
2144             self.arm_peek();
2145             return false;
2146         }
2147         self.close_chrome();
2148         true
2149     }
2150 
2151     /// Open `url` in a tab behind the page — a middle-click's tab — and
2152     /// show the bar for a moment so the new tab is seen to arrive.
2153     fn open_background_tab(&mut self, url: Url) {
2154         self.host.open_background_tab(url);
2155         self.sync_page_state();
2156         self.persist_session();
2157         self.peek_chrome();
2158     }
2159 
2160     /// Fold the bar back into the orb; drops URL-bar focus with it, since
2161     /// a field that is not on screen must not keep eating keystrokes.
2162     fn close_chrome(&mut self) {
2163         self.chrome_open = false;
2164         self.chrome_peek = None;
2165         self.url_new_tab = false;
2166         // The menu hangs off a bar that is going away.
2167         self.bm_menu = None;
2168         if self.url_focused {
2169             self.url_focused = false;
2170             self.url.selection = None;
2171             self.sync_page_state();
2172         }
2173     }
2174 
2175     /// The page fills the whole window; the utility bar floats above it.
2176     fn content_px(&self) -> (u32, u32) {
2177         (
2178             (self.win.0 as f64 * self.scale) as u32,
2179             (self.win.1 as f64 * self.scale) as u32,
2180         )
2181     }
2182 
2183     /// Pull delegate-observed page state into the chrome.
2184     fn sync_page_state(&mut self) {
2185         self.refresh_favorites();
2186         self.loading = self.host.loading();
2187         self.title = self.host.title().filter(|t| !t.is_empty());
2188         if !self.url_focused {
2189             if let Some(u) = self.host.url() {
2190                 let s = u.to_string();
2191                 self.url = cce_ui::widget::LineEdit::with_text(
2192                     if s == "about:blank" { String::new() } else { s },
2193                 );
2194             }
2195         }
2196     }
2197 
2198     /// Adopt whatever the engine is blocked on. Returns whether the chrome
2199     /// needs redrawing.
2200     #[cfg(feature = "wpe")]
2201     fn sync_modal(&mut self) -> bool {
2202         if let Some(m) = &self.modal {
2203             // A hang that cleared on its own takes its question with it.
2204             if matches!(m.kind, ModalKind::Unresponsive) && !self.host.active_unresponsive() {
2205                 self.modal = None;
2206                 return true;
2207             }
2208             return false;
2209         }
2210         if let Some(d) = self.host.pending_dialog() {
2211             let mut fields = Vec::new();
2212             if let Some(default) = d.prompt_default.clone() {
2213                 let mut e = cce_ui::widget::LineEdit::with_text(default);
2214                 e.select_all();
2215                 fields.push(("", e));
2216             }
2217             self.modal = Some(Modal {
2218                 title: "This page says".to_string(),
2219                 message: d.message,
2220                 fields,
2221                 focused: 0,
2222                 has_cancel: d.has_cancel,
2223                 kind: ModalKind::Script,
2224             });
2225             return true;
2226         }
2227         if let Some(a) = self.host.pending_auth() {
2228             let where_ = if a.realm.is_empty() {
2229                 a.host.clone()
2230             } else {
2231                 format!("{} — {}", a.host, a.realm)
2232             };
2233             self.modal = Some(Modal {
2234                 title: if a.retry {
2235                     "Sign in failed — try again".to_string()
2236                 } else {
2237                     "Sign in".to_string()
2238                 },
2239                 message: where_,
2240                 fields: vec![
2241                     ("Username", cce_ui::widget::LineEdit::default()),
2242                     ("Password", cce_ui::widget::LineEdit::masked()),
2243                 ],
2244                 focused: 0,
2245                 has_cancel: true,
2246                 kind: ModalKind::Auth,
2247             });
2248             return true;
2249         }
2250         if self.host.active_unresponsive() {
2251             let site = self
2252                 .host
2253                 .url()
2254                 .map(|u| u.host_str().map_or_else(|| u.to_string(), str::to_string))
2255                 .unwrap_or_default();
2256             self.modal = Some(Modal {
2257                 title: "This page isn't responding".to_string(),
2258                 message: site,
2259                 fields: Vec::new(),
2260                 focused: 0,
2261                 has_cancel: true,
2262                 kind: ModalKind::Unresponsive,
2263             });
2264             return true;
2265         }
2266         false
2267     }
2268 
2269     /// Answer the engine and dismiss. `ok` false is cancel.
2270     #[cfg(feature = "wpe")]
2271     fn close_modal(&mut self, ok: bool) {
2272         let Some(m) = self.modal.take() else { return };
2273         match m.kind {
2274             ModalKind::Script => {
2275                 let text = m.fields.first().map(|(_, e)| e.text.clone());
2276                 self.host.respond_dialog(ok, text.as_deref());
2277             }
2278             ModalKind::Unresponsive => {
2279                 if ok {
2280                     self.host.stop_unresponsive();
2281                 } else {
2282                     self.host.wait_unresponsive();
2283                 }
2284             }
2285             ModalKind::Auth => {
2286                 if ok {
2287                     let user = m.fields[0].1.text.clone();
2288                     let password = m.fields[1].1.text.clone();
2289                     self.host.respond_auth(Some((&user, &password)));
2290                 } else {
2291                     self.host.respond_auth(None);
2292                 }
2293             }
2294         }
2295     }
2296 
2297     /// Put a select's list up at the select, highlighting its current value.
2298     #[cfg(feature = "wpe")]
2299     fn open_opt_menu(&mut self, info: wpe::OptionMenuInfo) {
2300         let (sans, ..) = cce_ui::layout::read_preferred_fonts();
2301         let widest = info
2302             .items
2303             .iter()
2304             .map(|it| {
2305                 let indent = if it.group_child { OPT_INDENT } else { 0.0 };
2306                 measure_text_width(&it.label, &sans, OPT_FONT) + indent
2307             })
2308             .fold(0.0, f32::max);
2309         let (x, y, width, height) = info.anchor;
2310         // Room for the dot's gutter, the text padding, the row inset and a
2311         // scroll thumb on top of the widest label.
2312         let menu_w = (widest + OPT_INDENT + 2.0 * text_pad() + 12.0).max(width).max(80.0);
2313         let mut menu = OptMenu {
2314             highlight: info.items.iter().position(|it| it.selected),
2315             items: info.items,
2316             anchor: Rect { x, y, width, height },
2317             width: menu_w,
2318             scroll: 0,
2319             wheel_rest: 0.0,
2320             typed: String::new(),
2321             typed_at: std::time::Instant::now(),
2322         };
2323         if menu.highlight.is_some_and(|h| !menu.pickable(h)) {
2324             menu.highlight = None;
2325         }
2326         self.opt_menu = Some(menu);
2327         self.opt_reveal();
2328     }
2329 
2330     /// Take the select's list down without a pick.
2331     #[cfg(feature = "wpe")]
2332     fn close_opt_menu(&mut self) {
2333         if self.opt_menu.take().is_some() {
2334             self.host.close_option_menu();
2335         }
2336     }
2337 
2338     /// Pick row `index`, if it can be picked. A heading or a disabled
2339     /// option does nothing and leaves the list up.
2340     #[cfg(feature = "wpe")]
2341     fn pick_opt(&mut self, index: usize) {
2342         if self.opt_menu.as_ref().is_some_and(|m| m.pickable(index)) {
2343             self.opt_menu = None;
2344             self.host.pick_option(index);
2345         }
2346     }
2347 
2348     /// Keep the highlighted row in view.
2349     #[cfg(feature = "wpe")]
2350     fn opt_reveal(&mut self) {
2351         let Some(cap) = self.opt_layout().map(|l| l.cap) else { return };
2352         if let Some(m) = self.opt_menu.as_mut() {
2353             m.reveal(cap);
2354         }
2355     }
2356 
2357     /// The select list's plate and rows, or `None` when it is closed.
2358     ///
2359     /// Under the select, or over it when there is more room above — never
2360     /// covering the select itself, and never past the window: a list longer
2361     /// than the room scrolls.
2362     #[cfg(feature = "wpe")]
2363     fn opt_layout(&self) -> Option<OptLayout> {
2364         let m = self.opt_menu.as_ref()?;
2365         let n = m.items.len();
2366         if n == 0 {
2367             return None;
2368         }
2369         let margin = bar_margin();
2370         // style: deliberate — 2px off the select, so the list reads as
2371         // attached to it; the select is page content, not a plate sibling.
2372         let below = self.win.1 - margin - (m.anchor.y + m.anchor.height + 2.0);
2373         let above = m.anchor.y - 2.0 - margin;
2374         let need = 2.0 * plate_pad() + n as f32 * OPT_ROW_H;
2375         let down = need <= below || below >= above;
2376         let room = if down { below } else { above };
2377         let cap = (((room - 2.0 * plate_pad()) / OPT_ROW_H).floor().max(1.0) as usize).min(n);
2378         let height = 2.0 * plate_pad() + cap as f32 * OPT_ROW_H;
2379         let y = if down {
2380             m.anchor.y + m.anchor.height + 2.0
2381         } else {
2382             m.anchor.y - 2.0 - height
2383         }
2384         .max(0.0);
2385         let width = m.width.min(self.win.0 - 2.0 * margin);
2386         let x = m.anchor.x.clamp(0.0, (self.win.0 - width).max(0.0));
2387         let plate = Rect { x, y, width, height };
2388         let first = m.scroll.min(n - cap);
2389         // style: deliberate — the 2px hairline keeps a row's highlight off
2390         // the plate's roll.
2391         let rows = (0..cap)
2392             .map(|k| {
2393                 let r = Rect {
2394                     x: plate.x + 2.0,
2395                     y: plate.y + plate_pad() + k as f32 * OPT_ROW_H,
2396                     width: plate.width - 4.0,
2397                     height: OPT_ROW_H,
2398                 };
2399                 (r, first + k)
2400             })
2401             .collect();
2402         Some(OptLayout { plate, rows, cap })
2403     }
2404 
2405     /// The select list's row at a pointer position, if any.
2406     #[cfg(feature = "wpe")]
2407     fn opt_hit(&self, x: f32, y: f32) -> Option<usize> {
2408         let l = self.opt_layout()?;
2409         l.rows.iter().find(|(r, _)| r.contains(x, y)).map(|(_, i)| *i)
2410     }
2411 
2412     /// Scroll the select list by `rows`, positive down.
2413     #[cfg(feature = "wpe")]
2414     fn opt_scroll(&mut self, rows: isize) {
2415         let Some(cap) = self.opt_layout().map(|l| l.cap) else { return };
2416         if let Some(m) = self.opt_menu.as_mut() {
2417             let max = m.items.len().saturating_sub(cap) as isize;
2418             m.scroll = (m.scroll.min(max as usize) as isize + rows).clamp(0, max) as usize;
2419         }
2420     }
2421 
2422     /// Build the right-click menu from what the hit test found, placed at
2423     /// the pointer and clamped to the window.
2424     #[cfg(feature = "wpe")]
2425     fn open_ctx_menu(&mut self, info: wpe::ContextMenuInfo) {
2426         let mut items = Vec::new();
2427         let item = |label: &str, action: CtxAction, enabled: bool| CtxItem {
2428             label: label.to_string(),
2429             action,
2430             enabled,
2431         };
2432         if let Some((uri, _label)) = info.link {
2433             items.push(item("Open Link in New Tab", CtxAction::OpenInTab(uri.clone()), true));
2434             items.push(item("Copy Link", CtxAction::CopyText(uri.clone()), true));
2435             items.push(item("Download Link", CtxAction::Download(uri), true));
2436         }
2437         if let Some(uri) = info.image_uri {
2438             items.push(item("Copy Image Address", CtxAction::CopyText(uri.clone()), true));
2439             items.push(item("Download Image", CtxAction::Download(uri), true));
2440         }
2441         if info.is_selection {
2442             items.push(item("Copy", CtxAction::CopySelection, true));
2443         }
2444         if info.is_editable {
2445             items.push(item("Paste", CtxAction::Paste, true));
2446         }
2447         items.push(item("Back", CtxAction::Back, self.host.can_go_back()));
2448         items.push(item("Forward", CtxAction::Forward, self.host.can_go_forward()));
2449         items.push(item("Reload", CtxAction::Reload, true));
2450         let favorited = self.host.active_favorited();
2451         items.push(item(
2452             if favorited { "Remove from Favorites" } else { "Add to Favorites" },
2453             CtxAction::ToggleFavorite,
2454             favorited || self.saveable(),
2455         ));
2456         items.push(item("Open in Other Browser", CtxAction::OpenExternal, true));
2457 
2458         let h = plate_pad() * 2.0 + items.len() as f32 * CTX_ROW_H;
2459         let pos = (
2460             self.pointer.0.min(self.win.0 - CTX_W - bar_margin()).max(0.0),
2461             self.pointer.1.min(self.win.1 - h - bar_margin()).max(0.0),
2462         );
2463         self.ctx_menu = Some(CtxMenu { items, pos });
2464     }
2465 
2466     #[cfg(feature = "wpe")]
2467     fn dispatch_ctx_action(&mut self, index: usize) {
2468         let Some(menu) = self.ctx_menu.take() else { return };
2469         let Some(it) = menu.items.get(index) else { return };
2470         if !it.enabled {
2471             return;
2472         }
2473         match &it.action {
2474             CtxAction::Back => self.host.back(),
2475             CtxAction::Forward => self.host.forward(),
2476             CtxAction::Reload => self.host.reload(),
2477             CtxAction::CopySelection => self.host.editing_action_cmd(EditingCommand::Copy),
2478             CtxAction::Paste => self.host.editing_action_cmd(EditingCommand::Paste),
2479             CtxAction::OpenInTab(uri) => {
2480                 if let Ok(url) = Url::parse(uri) {
2481                     self.open_background_tab(url);
2482                 }
2483             }
2484             CtxAction::CopyText(text) => {
2485                 cce_ui::widget::clipboard::copy_to_clipboard(text);
2486             }
2487             CtxAction::Download(uri) => self.host.download_uri(uri),
2488             CtxAction::OpenExternal => self.open_external(),
2489             CtxAction::ToggleFavorite => self.toggle_favorite(),
2490         }
2491     }
2492 
2493     /// Hand a page-area press to the engine, remembering it so the matching
2494     /// release can always follow.
2495     ///
2496     /// Releases do not come through here: `drain_page_release` has already
2497     /// sent them from the top of `handle_mouse_input`, before any of the
2498     /// branches that swallow a click.
2499     fn page_press(&mut self, button: MouseButton, pressed: bool, pos: LogicalPosition) {
2500         if !pressed {
2501             return;
2502         }
2503         if !self.page_buttons.contains(&button) {
2504             self.page_buttons.push(button);
2505         }
2506         #[cfg(feature = "wpe")]
2507         self.defer_page_press();
2508         let s = self.scale as f32;
2509         self.host.mouse_button_ui(button, true, pos.x * s, pos.y * s);
2510     }
2511 
2512     /// A press into the page while one of its text fields is open. The
2513     /// toolkit announces an open field again after every press
2514     /// (`ime::note_press`), which is what lets a tap on a field that already
2515     /// has focus raise the on-screen keyboard — but here it would announce
2516     /// the field before WebKit has even seen the press, so a tap that is
2517     /// about to take focus away (a link, the page around the field, a page
2518     /// that autofocused its search box) would flash the board up. The
2519     /// announcement is taken back and made once the page has had
2520     /// `PAGE_PRESS_SETTLE` to answer, and only if the field is still open.
2521     /// A tap that moves the caret is announced without waiting, as a moved
2522     /// caret.
2523     #[cfg(feature = "wpe")]
2524     fn defer_page_press(&mut self) {
2525         if self.host.page_text_field().is_none() || !cce_ui::ime::take_press() {
2526             return;
2527         }
2528         self.page_press_settle = Some(std::time::Instant::now() + PAGE_PRESS_SETTLE);
2529         let tx = self.sender.clone();
2530         std::thread::spawn(move || {
2531             std::thread::sleep(PAGE_PRESS_SETTLE);
2532             let _ = tx.send(Message::Spin);
2533         });
2534     }
2535 
2536     /// Make the announcement `defer_page_press` held back, once its time is
2537     /// up. Returns whether a frame is needed to make it.
2538     #[cfg(feature = "wpe")]
2539     fn settle_page_press(&mut self) -> bool {
2540         let Some(at) = self.page_press_settle else { return false };
2541         if std::time::Instant::now() < at {
2542             return false;
2543         }
2544         self.page_press_settle = None;
2545         self.host.page_text_field().is_some() && cce_ui::ime::note_press()
2546     }
2547 
2548     /// Give the page the release it is owed, wherever the pointer ended up
2549     /// and whatever the chrome is about to do with this click.
2550     ///
2551     /// A release only means anything to whoever received the press, and the
2552     /// two are not routed alike: the press goes to the page, then the chrome
2553     /// may put a menu up — the right-click menu does exactly that, from the
2554     /// press — and every menu branch below swallows the clicks that arrive
2555     /// while it is open. A release swallowed there leaves WebKit holding the
2556     /// button down for good, which is how right-click quietly stops working
2557     /// until the app is restarted. A release nobody is owed (the one that
2558     /// dismissed the menu, say) is dropped rather than reaching the page
2559     /// unpaired.
2560     fn drain_page_release(&mut self, button: MouseButton, pos: LogicalPosition) {
2561         let Some(i) = self.page_buttons.iter().position(|b| *b == button) else {
2562             return;
2563         };
2564         self.page_buttons.remove(i);
2565         let s = self.scale as f32;
2566         self.host.mouse_button_ui(button, false, pos.x * s, pos.y * s);
2567     }
2568 
2569     fn navigate(&mut self) {
2570         if let Some(url) = parse_url_input(&self.url.text, &self.settings.search_prefix) {
2571             if std::mem::take(&mut self.url_new_tab) {
2572                 self.host.open_tab(url);
2573                 self.persist_session();
2574             } else {
2575                 self.host.load(url);
2576             }
2577             self.url_focused = false;
2578             self.url.selection = None;
2579             self.loading = true;
2580             // Submitting is the menu's "pick": it folds away to show the page.
2581             self.chrome_open = false;
2582         }
2583     }
2584 
2585     /// Pick up settings edits (system-interface, cce-data-editor) when the
2586     /// window regains focus. Returns whether anything changed.
2587     fn reload_settings(&mut self) -> bool {
2588         let new = settings::load();
2589         if new == self.settings {
2590             return false;
2591         }
2592         downloads::set_download_dir(new.download_dir.clone());
2593         #[cfg(feature = "wpe")]
2594         if new.accounts != self.settings.accounts {
2595             self.host.set_accounts_enabled(new.accounts);
2596             if !new.accounts {
2597                 self.ac_menu = None;
2598             }
2599         }
2600         if new.vi_mode != self.settings.vi_mode {
2601             self.host.set_vi_enabled(new.vi_mode);
2602             self.vi_set_mode(vi::Mode::Normal);
2603             self.vi_msg = None;
2604             if self.vi_searching {
2605                 self.host.find_finish();
2606                 self.vi_searching = false;
2607             }
2608         }
2609         self.raindrop_on.store(new.raindrop, std::sync::atomic::Ordering::SeqCst);
2610         if new.raindrop && !self.raindrop_started {
2611             raindrop::sync::spawn(self.host.bookmarks(), self.raindrop_on.clone());
2612             self.raindrop_started = true;
2613         }
2614         self.host.set_history_enabled(new.history);
2615         self.host.set_color_scheme_dark(new.color_scheme.is_dark());
2616         self.host.set_force_dark(new.color_scheme.forces_dark());
2617         self.settings = new;
2618         true
2619     }
2620 
2621     /// Hand the current page to another browser — the escape hatch for the
2622     /// places Servo cannot follow, like a Cloudflare challenge that never
2623     /// completes.
2624     ///
2625     /// Prefers the configured command; otherwise asks XDG. The guard matters:
2626     /// cce-browser's own desktop entry claims http/https, so once it is the
2627     /// default handler, `xdg-open` would hand the page straight back to us.
2628     fn open_external(&mut self) {
2629         let Some(url) = self
2630             .host
2631             .url()
2632             .map(|u| u.to_string())
2633             .or_else(|| parse_url_input(&self.url.text, &self.settings.search_prefix).map(|u| u.to_string()))
2634         else {
2635             return;
2636         };
2637         let configured = self.settings.external_browser.clone();
2638         std::thread::spawn(move || {
2639             let command = match configured {
2640                 Some(c) => c,
2641                 None => {
2642                     let default = std::process::Command::new("xdg-mime")
2643                         .args(["query", "default", "x-scheme-handler/https"])
2644                         .output()
2645                         .ok()
2646                         .and_then(|o| String::from_utf8(o.stdout).ok())
2647                         .unwrap_or_default();
2648                     if default.trim_start().starts_with("cce-browser") {
2649                         log::warn!(
2650                             "cce-browser is the default https handler; set browser.external-browser                              to another command or this would just reopen here"
2651                         );
2652                         return;
2653                     }
2654                     "xdg-open".to_string()
2655                 }
2656             };
2657             let mut parts = command.split_whitespace();
2658             let Some(program) = parts.next() else { return };
2659             let args: Vec<&str> = parts.collect();
2660             match std::process::Command::new(program).args(args).arg(&url).spawn() {
2661                 Ok(_) => log::info!("handed {url} to {program}"),
2662                 Err(e) => log::warn!("could not run {program}: {e}"),
2663             }
2664         });
2665     }
2666 
2667     /// Write the open-tab set to the session store (a no-op when nothing
2668     /// changed). Blank tabs are not worth resurrecting, so they are skipped —
2669     /// which also means a browser left on nothing but "New Tab" starts fresh.
2670     fn persist_session(&mut self) {
2671         let active = self.host.active_index();
2672         let tabs: Vec<(String, bool)> = (0..self.host.tab_count())
2673             .filter_map(|i| {
2674                 let url = self.host.tab(i)?.url.as_ref()?.to_string();
2675                 (url != "about:blank").then_some((url, i == active))
2676             })
2677             .collect();
2678         self.session.save(&tabs);
2679     }
2680 
2681     /// New blank tab with the URL bar focused for typing.
2682     fn new_tab(&mut self) {
2683         let url = Url::parse("about:blank").expect("about:blank");
2684         self.host.open_tab(url);
2685         self.url = cce_ui::widget::LineEdit::default();
2686         self.url_focused = true;
2687         self.url_new_tab = false;
2688         // The focused field has to be on screen, so a new tab unfolds the
2689         // menu even when it was opened by chord.
2690         self.open_chrome();
2691         self.sync_page_state();
2692         self.persist_session();
2693     }
2694 
2695     /// Close a tab; returns `Message::Quit` when it was the last one.
2696     fn close_tab(&mut self, index: usize) -> Option<Message> {
2697         // The host closes the list's engine side with the tab.
2698         #[cfg(feature = "wpe")]
2699         {
2700             self.opt_menu = None;
2701         }
2702         if let Some(url) = self.host.tab(index).and_then(|t| t.url.clone()) {
2703             if url.as_str() != "about:blank" {
2704                 self.closed_tabs.push(url);
2705                 if self.closed_tabs.len() > 20 {
2706                     self.closed_tabs.remove(0);
2707                 }
2708             }
2709         }
2710         if index == self.host.active_index() {
2711             self.vi_page_changed();
2712         }
2713         if !self.host.close_tab(index) {
2714             // Deliberately emptied: save the empty set so the next launch
2715             // starts on the homepage instead of restoring what was closed.
2716             self.persist_session();
2717             return Some(Message::Quit);
2718         }
2719         self.url_focused = false;
2720         self.sync_page_state();
2721         self.persist_session();
2722         None
2723     }
2724 
2725     fn switch_tab(&mut self, index: usize) {
2726         // A glide aimed at this page must not land on the next one.
2727         self.stop_scroll();
2728         if index != self.host.active_index() {
2729             self.vi_page_changed();
2730         }
2731         // The other tab has its own fields, and may have none.
2732         #[cfg(feature = "wpe")]
2733         {
2734             self.ac_menu = None;
2735             self.last_field = None;
2736             self.host.clear_form_events();
2737             // The host closes the list's engine side as it switches.
2738             self.opt_menu = None;
2739         }
2740         self.host.activate(index);
2741         self.url_focused = false;
2742         self.sync_page_state();
2743         self.persist_session();
2744     }
2745 
2746     /// Show an internal page: reuse a tab already on it (reloading, so
2747     /// live pages like downloads refresh), otherwise open a new one.
2748     fn open_internal_page(&mut self, page: &str) {
2749         let Ok(url) = Url::parse(page) else { return };
2750         for i in 0..self.host.tab_count() {
2751             let on_page = self
2752                 .host
2753                 .tab(i)
2754                 .and_then(|t| t.url.as_ref().map(|u| u.as_str().starts_with(page)))
2755                 .unwrap_or(false);
2756             if on_page {
2757                 self.switch_tab(i);
2758                 self.host.reload();
2759                 return;
2760             }
2761         }
2762         self.host.open_tab(url);
2763         self.url_focused = false;
2764         self.sync_page_state();
2765         self.persist_session();
2766     }
2767 
2768     /// Widest prefix of `text` fitting `avail`, with a "…"-style tail cut.
2769     fn fit_text(text: &str, sans: &str, size: f32, avail: f32) -> String {
2770         if measure_text_width(text, sans, size) <= avail {
2771             return text.to_string();
2772         }
2773         let mut end = text.len();
2774         while end > 0 {
2775             end = cce_ui::widget::line_edit::prev_boundary(text, end);
2776             let cut = format!("{}...", &text[..end]);
2777             if measure_text_width(&cut, sans, size) <= avail {
2778                 return cut;
2779             }
2780         }
2781         String::new()
2782     }
2783 
2784     /// A cce-icons glyph `size` px square, centred in `r` and tinted
2785     /// `color` as text is — the one way this chrome draws a symbol, never a
2786     /// character standing in for one. Should the icon set be missing,
2787     /// `fallback` (a plain word, never a symbol) is drawn small in its
2788     /// place, cut to the rect.
2789     fn glyph(pc: &mut PaintCtx, sans: &str, name: &str, fallback: &str, r: Rect, size: f32, color: [u8; 3]) {
2790         let g = Rect {
2791             x: r.x + (r.width - size) / 2.0,
2792             y: r.y + (r.height - size) / 2.0,
2793             width: size,
2794             height: size,
2795         };
2796         let c = color.map(|v| v as f32 / 255.0);
2797         if pc.icon(name, g, [c[0], c[1], c[2], 1.0]) {
2798             return;
2799         }
2800         const FALLBACK_FONT: f32 = 9.0;
2801         let word = Self::fit_text(fallback, sans, FALLBACK_FONT, r.width - 2.0);
2802         let w = measure_text_width(&word, sans, FALLBACK_FONT);
2803         pc.text(
2804             word,
2805             r.x + (r.width - w) / 2.0,
2806             cce_ui::layout::align_text_y(r.y, r.height, FALLBACK_FONT, 0.0),
2807             FALLBACK_FONT,
2808             color,
2809         );
2810     }
2811 
2812     /// Draw the page-blocking prompt, if one is up. Same primitives as the
2813     /// utility bar — there are no cce-ui widgets in this app — with a scrim
2814     /// over the page so it reads as blocked, which it genuinely is.
2815     #[cfg(feature = "wpe")]
2816     fn paint_modal(&mut self, pc: &mut PaintCtx, sans: &str) {
2817         let Some(m) = self.modal.as_ref() else { return };
2818         let r = m.rect(self.win);
2819 
2820         pc.quad(
2821             Rect { x: 0.0, y: 0.0, width: self.win.0, height: self.win.1 },
2822             [0.0, 0.0, 0.0, 0.45],
2823         );
2824         let radii = (BAR_RADIUS, BAR_RADIUS, BAR_RADIUS, BAR_RADIUS);
2825         pc.plate(r, radii, &cce_ui::scene::Material::opaque([0.13, 0.14, 0.16, 1.0]), cce_ui::layout::bevel_width().min(4.0));
2826 
2827         pc.text(
2828             m.title.clone(),
2829             r.x + plate_pad(),
2830             r.y + plate_pad(),
2831             14.0,
2832             TEXT,
2833         );
2834         pc.text(
2835             Self::fit_text(&m.message, sans, 13.0, r.width - plate_pad() * 2.0),
2836             r.x + plate_pad(),
2837             r.y + plate_pad() + 22.0,
2838             13.0,
2839             TEXT_DIM,
2840         );
2841 
2842         // What each field draws (bullets for a password) and where its marks
2843         // fall; only the focused field shows a selection.
2844         let marks: Vec<FieldMarks> = m
2845             .fields
2846             .iter()
2847             .enumerate()
2848             .map(|(i, (_, edit))| {
2849                 let mut marks = FieldMarks::of(&mut self.font_system, edit);
2850                 if i != m.focused {
2851                     marks.selection = None;
2852                 }
2853                 marks
2854             })
2855             .collect();
2856 
2857         for (i, ((label, _), marks)) in m.fields.iter().zip(&marks).enumerate() {
2858             let f = m.field_rect(&r, i);
2859             let focused = i == m.focused;
2860             pc.rounded_rect(
2861                 Rect { x: f.x - 1.0, y: f.y - 1.0, width: f.width + 2.0, height: f.height + 2.0 },
2862                 7.0,
2863                 (true, true, true, true),
2864                 if focused { RIM_FOCUS } else { RIM },
2865             );
2866             pc.rounded_rect(f, 6.0, (true, true, true, true), FIELD_BG);
2867             // `display()` masks a password field; the text itself never
2868             // reaches the paint list. The dialog blocks everything else, so
2869             // its focused field is the one with the keyboard.
2870             let caret = paint_field(pc, f, marks, label, focused);
2871             if focused {
2872                 report_caret(caret);
2873             }
2874         }
2875 
2876         let (ok, cancel) = m.button_rects(&r);
2877         let (ok_label, cancel_label) = m.labels();
2878         for (rect, label, accent) in [(Some(ok), ok_label, true), (cancel, cancel_label, false)]
2879             .into_iter()
2880             .filter_map(|(rc, l, a)| rc.map(|rc| (rc, l, a)))
2881         {
2882             pc.rounded_rect(
2883                 rect,
2884                 6.0,
2885                 (true, true, true, true),
2886                 if accent { ACCENT } else { BTN_BG },
2887             );
2888             let w = measure_text_width(label, sans, 13.0);
2889             pc.text(
2890                 label,
2891                 rect.x + (rect.width - w) / 2.0,
2892                 cce_ui::layout::align_text_y(rect.y, rect.height, 13.0, 0.0),
2893                 13.0,
2894                 TEXT,
2895             );
2896         }
2897     }
2898 
2899     /// Draw the account list at the login field it belongs to.
2900     ///
2901     /// Two lines per row: the username that will be filled, and the entry's
2902     /// own title under it, because a keyring holds several accounts on one
2903     /// site and the title is how they were told apart when they were saved.
2904     #[cfg(feature = "wpe")]
2905     fn paint_ac_menu(&mut self, pc: &mut PaintCtx, sans: &str) {
2906         let Some((plate, rows)) = self.ac_layout() else { return };
2907         let Some(menu) = self.ac_menu.as_ref() else { return };
2908         let first = menu.first_row();
2909         pc.plate(
2910             plate,
2911             (8.0, 8.0, 8.0, 8.0),
2912             &cce_ui::scene::Material::opaque([0.13, 0.14, 0.16, 1.0]),
2913             cce_ui::layout::bevel_width().min(3.0),
2914         );
2915         for (k, r) in rows.iter().enumerate() {
2916             let Some(account) = menu.shown.get(first + k) else { continue };
2917             let picked = first + k == menu.selected || menu.hover == Some(first + k);
2918             if picked {
2919                 pc.rounded_rect(*r, 5.0, (true, true, true, true), TAB_ACTIVE_BG);
2920             }
2921             let width = r.width - 2.0 * text_pad();
2922             let user = if account.username.is_empty() {
2923                 account.label.clone()
2924             } else {
2925                 account.username.clone()
2926             };
2927             pc.text(
2928                 Self::fit_text(&user, sans, AC_FONT, width),
2929                 r.x + text_pad(),
2930                 r.y + 5.0,
2931                 AC_FONT,
2932                 TEXT,
2933             );
2934             // The second line names where the entry came from: its title, and
2935             // the site it is stored against when that is not the title.
2936             let mut sub = account.label.clone();
2937             if let Some(host) = account.host() {
2938                 if !sub.to_lowercase().contains(&host) {
2939                     sub = if sub.is_empty() { host } else { format!("{sub} — {host}") };
2940                 }
2941             }
2942             pc.text(
2943                 Self::fit_text(&sub, sans, AC_SUB_FONT, width),
2944                 r.x + text_pad(),
2945                 r.y + 5.0 + AC_FONT + 3.0,
2946                 AC_SUB_FONT,
2947                 TEXT_DIM,
2948             );
2949         }
2950         // Say it plainly when the form is another site's, and when the page
2951         // is not https: the password is about to go somewhere the person may
2952         // not have expected, and only they can decide that is fine.
2953         let notes = Self::ac_notes(menu);
2954         let first_note = plate.y + plate.height - plate_pad() - notes.len() as f32 * 18.0;
2955         for (k, (note, color)) in notes.into_iter().enumerate() {
2956             pc.text(
2957                 Self::fit_text(&note, sans, AC_SUB_FONT, plate.width - 2.0 * text_pad()),
2958                 plate.x + text_pad(),
2959                 first_note + k as f32 * 18.0 + 3.0,
2960                 AC_SUB_FONT,
2961                 color,
2962             );
2963         }
2964     }
2965 
2966     /// Draw the save offer: what would be saved, and the three answers.
2967     /// The password itself is never drawn.
2968     #[cfg(feature = "wpe")]
2969     fn paint_save_offer(&mut self, pc: &mut PaintCtx, sans: &str) {
2970         let Some(l) = self.save_layout() else { return };
2971         let Some(offer) = self.save_offer.as_ref() else { return };
2972         pc.plate(
2973             l.plate,
2974             (8.0, 8.0, 8.0, 8.0),
2975             &cce_ui::scene::Material::opaque([0.13, 0.14, 0.16, 1.0]),
2976             cce_ui::layout::bevel_width().min(3.0),
2977         );
2978         let x = l.plate.x + plate_pad();
2979         let w = l.plate.width - 2.0 * plate_pad();
2980         let y = l.plate.y + plate_pad();
2981         let (title, line, sub, sub_color) = match &offer.stage {
2982             SaveStage::Failed(why) => {
2983                 ("Password not saved".to_string(), why.clone(), String::new(), TEXT_DIM)
2984             }
2985             stage => {
2986                 let title = if *stage == SaveStage::Saving {
2987                     "Saving to the keyring…"
2988                 } else {
2989                     "Save this password?"
2990                 };
2991                 let who = if offer.username.is_empty() {
2992                     "(no username)".to_string()
2993                 } else {
2994                     offer.username.clone()
2995                 };
2996                 let site = if offer.form_host == offer.page_host {
2997                     offer.form_host.clone()
2998                 } else {
2999                     format!("{} — form from {}", offer.page_host, offer.form_host)
3000                 };
3001                 (title.to_string(), who, site, TEXT_DIM)
3002             }
3003         };
3004         pc.text(title, x, y, 14.0, TEXT);
3005         pc.text(Self::fit_text(&line, sans, 13.0, w), x, y + 22.0, 13.0, TEXT);
3006         pc.text(Self::fit_text(&sub, sans, 12.0, w), x, y + 40.0, 12.0, sub_color);
3007         for (rect, button) in &l.buttons {
3008             let (label, accent) = match (button, &offer.stage) {
3009                 (SaveButton::Save, _) => ("Save", true),
3010                 (SaveButton::Never, _) => ("Never", false),
3011                 (SaveButton::Dismiss, SaveStage::Failed(_)) => ("Close", false),
3012                 (SaveButton::Dismiss, _) => ("Not now", false),
3013             };
3014             pc.rounded_rect(*rect, 6.0, (true, true, true, true), if accent { ACCENT } else { BTN_BG });
3015             let tw = measure_text_width(label, sans, 13.0);
3016             pc.text(
3017                 label,
3018                 rect.x + (rect.width - tw) / 2.0,
3019                 cce_ui::layout::align_text_y(rect.y, rect.height, 13.0, 0.0),
3020                 13.0,
3021                 TEXT,
3022             );
3023         }
3024     }
3025 
3026     /// Draw the bookmarks menu: the same plate-and-rows vocabulary as the
3027     /// right-click menu, in three sections — what to do with this page, the
3028     /// pages already saved, and the way out to the full collection.
3029     fn paint_bm_menu(&mut self, pc: &mut PaintCtx, sans: &str) {
3030         let Some(l) = self.bm_layout() else { return };
3031         // The field's marks are measured here, before painting borrows
3032         // anything, on the same shaped run it draws.
3033         let (total, items, hover, scroll, searching, marks, selected) = match self.bm_menu.as_ref() {
3034             Some(m) => (
3035                 m.all.len(),
3036                 m.items.clone(),
3037                 m.hover,
3038                 m.scroll,
3039                 !m.query.text.is_empty(),
3040                 FieldMarks::of(&mut self.font_system, &m.query),
3041                 m.selected,
3042             ),
3043             None => return,
3044         };
3045         let has_keyboard = self.bm_search_has_keyboard();
3046         pc.plate(
3047             l.plate,
3048             (8.0, 8.0, 8.0, 8.0),
3049             &cce_ui::scene::Material::opaque([0.13, 0.14, 0.16, 1.0]),
3050             cce_ui::layout::bevel_width().min(3.0),
3051         );
3052 
3053         // The search field. It has the keyboard whenever the menu is open,
3054         // so it always wears the focused rim and shows its caret.
3055         let f = l.search;
3056         pc.rounded_rect(
3057             Rect { x: f.x - 1.0, y: f.y - 1.0, width: f.width + 2.0, height: f.height + 2.0 },
3058             7.0,
3059             (true, true, true, true),
3060             RIM_FOCUS,
3061         );
3062         pc.rounded_rect(f, 6.0, (true, true, true, true), FIELD_BG);
3063         let caret = paint_field(pc, f, &marks, "Search bookmarks", true);
3064         if has_keyboard {
3065             report_caret(caret);
3066         }
3067 
3068         let text_at = |pc: &mut PaintCtx, r: &Rect, s: String, color: [u8; 3]| {
3069             pc.text(
3070                 s,
3071                 r.x + text_pad(),
3072                 cce_ui::layout::align_text_y(r.y, r.height, BM_FONT, 0.0),
3073                 BM_FONT,
3074                 color,
3075             );
3076         };
3077         let highlight = |pc: &mut PaintCtx, r: &Rect| {
3078             pc.rounded_rect(*r, 5.0, (true, true, true, true), TAB_ACTIVE_BG);
3079         };
3080 
3081         // What this page can do: the toggle names the state in words, where
3082         // the star only lights up.
3083         let saved = self.host.active_bookmarked();
3084         let can_save = saved || self.saveable();
3085         if hover == Some(BmHit::Toggle) && can_save {
3086             highlight(pc, &l.toggle);
3087         }
3088         let label = if saved { "Remove Bookmark" } else { "Bookmark This Page" };
3089         text_at(
3090             pc,
3091             &l.toggle,
3092             Self::fit_text(label, sans, BM_FONT, l.toggle.width - 2.0 * text_pad()),
3093             if can_save { TEXT } else { TEXT_DIM },
3094         );
3095 
3096         // The saved pages themselves, newest first.
3097         for (r, i) in &l.rows {
3098             let Some(item) = items.get(*i) else { continue };
3099             let hovered = matches!(hover, Some(BmHit::Entry(h, _)) if h == *i);
3100             if hovered || *i == selected {
3101                 highlight(pc, r);
3102             }
3103             text_at(
3104                 pc,
3105                 r,
3106                 Self::fit_text(
3107                     &item.label,
3108                     sans,
3109                     BM_FONT,
3110                     r.width - 2.0 * text_pad() - BM_RM_W,
3111                 ),
3112                 // Full brightness whether hovered or not: dim means
3113                 // *unavailable* everywhere else in this chrome, and every
3114                 // saved page is available. Hover is the highlight's job.
3115                 TEXT,
3116             );
3117             // The remove glyph shows on the hovered row only — always-on x's
3118             // down a whole list read as clutter, and as a hazard.
3119             if hovered {
3120                 let on_rm = hover == Some(BmHit::Entry(*i, true));
3121                 let rm = Rect { x: r.x + r.width - BM_RM_W, width: BM_RM_W, ..*r };
3122                 Self::glyph(
3123                     pc,
3124                     sans,
3125                     "x",
3126                     "Remove",
3127                     rm,
3128                     10.0,
3129                     if on_rm { [212, 155, 155] } else { TEXT_DIM },
3130                 );
3131             }
3132         }
3133         if let Some(r) = l.empty {
3134             let what = if searching { "No matches" } else { "No bookmarks yet" };
3135             text_at(pc, &r, what.to_string(), TEXT_DIM);
3136         }
3137 
3138         // Scroll position, when the list is longer than the plate.
3139         if items.len() > l.rows.len() && !l.rows.is_empty() {
3140             let first = l.rows[0].0;
3141             let track = Rect {
3142                 x: l.plate.x + l.plate.width - 5.0,
3143                 y: first.y,
3144                 width: 2.0,
3145                 height: l.rows.len() as f32 * BM_ROW_H,
3146             };
3147             let frac = l.rows.len() as f32 / items.len() as f32;
3148             let offset = scroll as f32 / items.len() as f32;
3149             pc.rounded_rect(
3150                 Rect {
3151                     y: track.y + offset * track.height,
3152                     height: (frac * track.height).max(12.0),
3153                     ..track
3154                 },
3155                 1.0,
3156                 (true, true, true, true),
3157                 RIM,
3158             );
3159         }
3160 
3161         // The rules between the three sections.
3162         for y in [l.toggle.y + BM_ROW_H + BM_SEP_H / 2.0, l.manage.y - BM_SEP_H / 2.0] {
3163             pc.quad(
3164                 Rect {
3165                     x: l.plate.x + text_pad(),
3166                     y,
3167                     width: l.plate.width - 2.0 * text_pad(),
3168                     height: 1.0,
3169                 },
3170                 RIM,
3171             );
3172         }
3173 
3174         if hover == Some(BmHit::Manage) {
3175             highlight(pc, &l.manage);
3176         }
3177         text_at(
3178             pc,
3179             &l.manage,
3180             // The whole collection: this row hands all of it to the page.
3181             format!("Manage Bookmarks ({total})"),
3182             TEXT,
3183         );
3184     }
3185 
3186     /// Draw a select's list: its options, the current value marked with a
3187     /// check glyph, optgroup headings dim and their options indented under them.
3188     #[cfg(feature = "wpe")]
3189     fn paint_opt_menu(&mut self, pc: &mut PaintCtx, sans: &str) {
3190         let Some(l) = self.opt_layout() else { return };
3191         let Some(m) = self.opt_menu.as_ref() else { return };
3192         pc.plate(
3193             l.plate,
3194             (8.0, 8.0, 8.0, 8.0),
3195             &cce_ui::scene::Material::opaque([0.13, 0.14, 0.16, 1.0]),
3196             cce_ui::layout::bevel_width().min(3.0),
3197         );
3198         let n = m.items.len();
3199         let scrolls = n > l.cap;
3200         for (r, i) in &l.rows {
3201             let Some(it) = m.items.get(*i) else { continue };
3202             if m.highlight == Some(*i) && m.pickable(*i) {
3203                 pc.rounded_rect(*r, 5.0, (true, true, true, true), TAB_ACTIVE_BG);
3204             }
3205             let gutter = r.x + text_pad();
3206             if it.selected {
3207                 let mark = Rect { x: gutter - 2.0, y: r.y, width: OPT_INDENT, height: r.height };
3208                 Self::glyph(pc, sans, "check", "", mark, 10.0, TEXT);
3209             }
3210             let x = gutter + OPT_INDENT + if it.group_child { OPT_INDENT } else { 0.0 };
3211             let avail = r.x + r.width - x - text_pad() - if scrolls { 6.0 } else { 0.0 };
3212             let color = if m.pickable(*i) { TEXT } else { TEXT_DIM };
3213             pc.text(
3214                 Self::fit_text(&it.label, sans, OPT_FONT, avail),
3215                 x,
3216                 cce_ui::layout::align_text_y(r.y, r.height, OPT_FONT, 0.0),
3217                 OPT_FONT,
3218                 color,
3219             );
3220         }
3221         // A long list says where in it the view is.
3222         if scrolls {
3223             let track = l.plate.height - 2.0 * plate_pad();
3224             let thumb = (track * l.cap as f32 / n as f32).max(12.0);
3225             let first = l.rows.first().map_or(0, |(_, i)| *i);
3226             let at = (track - thumb) * first as f32 / (n - l.cap) as f32;
3227             pc.rounded_rect(
3228                 Rect {
3229                     x: l.plate.x + l.plate.width - 7.0,
3230                     y: l.plate.y + plate_pad() + at,
3231                     width: 3.0,
3232                     height: thumb,
3233                 },
3234                 1.5,
3235                 (true, true, true, true),
3236                 [0.6, 0.62, 0.66, 0.5],
3237             );
3238         }
3239     }
3240 
3241     /// Draw the right-click menu: a small plate at the pointer, rows with a
3242     /// hover highlight, disabled rows dimmed. Same primitives as everything
3243     /// else in this chrome.
3244     #[cfg(feature = "wpe")]
3245     fn paint_ctx_menu(&mut self, pc: &mut PaintCtx, sans: &str) {
3246         let Some(menu) = self.ctx_menu.as_ref() else { return };
3247         let r = menu.rect();
3248         pc.plate(
3249             r,
3250             (8.0, 8.0, 8.0, 8.0),
3251             &cce_ui::scene::Material::opaque([0.13, 0.14, 0.16, 1.0]),
3252             cce_ui::layout::bevel_width().min(3.0),
3253         );
3254         let hovered = menu.item_at(self.pointer.0, self.pointer.1);
3255         for (i, it) in menu.items.iter().enumerate() {
3256             let row = menu.row_rect(i);
3257             if hovered == Some(i) && it.enabled {
3258                 pc.rounded_rect(row, 5.0, (true, true, true, true), TAB_ACTIVE_BG);
3259             }
3260             let color = if it.enabled { TEXT } else { TEXT_DIM };
3261             pc.text(
3262                 Self::fit_text(&it.label, sans, 13.0, row.width - 2.0 * text_pad()),
3263                 row.x + text_pad(),
3264                 cce_ui::layout::align_text_y(row.y, row.height, 13.0, 0.0),
3265                 13.0,
3266                 color,
3267             );
3268         }
3269     }
3270 
3271     /// The byte of the URL under pointer x `click_x`. The bar draws
3272     /// `display()` — with a composition in it while one is up — so the hit
3273     /// lands in that and `text_index` carries it back to the text.
3274     fn cursor_from_click(&mut self, click_x: f32, field: &Rect) -> usize {
3275         let shown = self.url.display();
3276         let at = self.boundary_at_x(&shown, click_x - field.x - text_pad());
3277         self.url.text_index(at)
3278     }
3279 
3280     /// The char boundary of `text` nearest `rel_x` (text-origin relative),
3281     /// off the same shaped run a field draws (`URL_FONT`, font=None,
3282     /// matching `pc.text`). The URL bar and the dialog fields both read it,
3283     /// so a click lands where the glyphs are.
3284     fn boundary_at_x(&mut self, text: &str, rel_x: f32) -> usize {
3285         let offsets =
3286             cce_ui::engine::shaped_cluster_offsets(&mut self.font_system, text, URL_FONT, None);
3287         offsets
3288             .iter()
3289             .min_by(|a, b| (a.1 - rel_x).abs().total_cmp(&(b.1 - rel_x).abs()))
3290             .map(|&(b, _)| b)
3291             .unwrap_or(text.len())
3292     }
3293 
3294     /// The byte of dialog field `i`'s text under pointer x `x`. The field
3295     /// draws `display()` — bullets, for a password — so the hit lands in
3296     /// that and `text_index` carries it back to the text.
3297     #[cfg(feature = "wpe")]
3298     fn modal_index_at(&mut self, i: usize, x: f32) -> Option<usize> {
3299         let m = self.modal.as_ref()?;
3300         let f = m.field_rect(&m.rect(self.win), i);
3301         let shown = m.fields.get(i)?.1.display();
3302         let at = self.boundary_at_x(&shown, x - f.x - text_pad());
3303         Some(self.modal.as_ref()?.fields[i].1.text_index(at))
3304     }
3305 
3306     /// Select the whole URL, caret at the end — what entering the bar does,
3307     /// whether from a click, Ctrl+L or Ctrl+A. No-op on an empty field.
3308     fn select_all_url(&mut self) {
3309         self.url.select_all();
3310     }
3311 
3312     /// The line field that has the keyboard, if any: a dialog's focused field
3313     /// while one is up (it blocks everything else), else the URL bar while
3314     /// it is focused. Undo and redo act on this.
3315     fn focused_edit(&mut self) -> Option<&mut cce_ui::widget::LineEdit> {
3316         let field = self.keyboard_field()?;
3317         self.line_field(field)
3318     }
3319 
3320     /// Which line field has the keyboard: a dialog's focused field while
3321     /// one is up, else the vi command line while it is open, else the
3322     /// bookmarks menu's search while it is open, else the URL bar while it
3323     /// is focused.
3324     fn keyboard_field(&self) -> Option<LineField> {
3325         #[cfg(feature = "wpe")]
3326         if let Some(m) = self.modal.as_ref() {
3327             return (m.focused < m.fields.len()).then_some(LineField::Dialog(m.focused));
3328         }
3329         if self.settings.vi_mode && self.vi_mode == vi::Mode::Command {
3330             return Some(LineField::ViCmd);
3331         }
3332         if self.bm_menu.is_some() {
3333             return Some(LineField::BmSearch);
3334         }
3335         self.url_focused.then_some(LineField::Url)
3336     }
3337 
3338     /// The editor behind `field`, while it exists.
3339     fn line_field(&mut self, field: LineField) -> Option<&mut cce_ui::widget::LineEdit> {
3340         match field {
3341             LineField::Url => Some(&mut self.url),
3342             LineField::BmSearch => self.bm_menu.as_mut().map(|m| &mut m.query),
3343             LineField::ViCmd => Some(&mut self.vi_cmd),
3344             #[cfg(feature = "wpe")]
3345             LineField::Dialog(i) => self.modal.as_mut()?.fields.get_mut(i).map(|(_, e)| e),
3346         }
3347     }
3348 
3349     /// Before a frame is drawn: the field with the keyboard takes up the
3350     /// input method's composition, and one that has just lost the keyboard
3351     /// drops what it was showing. A field that is gone with it — a dialog
3352     /// answered, the bookmarks menu closed — cannot, so a composition still
3353     /// up once the old field has let go is cancelled here: it was that
3354     /// field's, since the new one has not taken anything yet.
3355     fn sync_ime(&mut self) {
3356         let now = self.keyboard_field();
3357         if now != self.ime_field {
3358             let before = self.ime_field;
3359             if let Some(edit) = before.and_then(|f| self.line_field(f)) {
3360                 edit.drop_composition();
3361             }
3362             if cce_ui::ime::preedit().is_some() {
3363                 cce_ui::ime::request_reset();
3364             }
3365             self.ime_field = now;
3366         }
3367         if let Some(edit) = now.and_then(|f| self.line_field(f)) {
3368             edit.sync_ime();
3369         }
3370     }
3371 
3372     /// Whether `focused_edit` is the bookmarks menu's search field — an undo
3373     /// there changes the query, so the list has to follow.
3374     fn bm_search_has_keyboard(&self) -> bool {
3375         self.keyboard_field() == Some(LineField::BmSearch)
3376     }
3377 
3378 
3379 
3380     /// URL-bar keys. Editing is the shared [`cce_ui::widget::LineEdit`]; only what
3381     /// makes this bar a *URL* bar — Enter navigates, Escape returns focus to
3382     /// the page — is decided here.
3383     fn edit_url(&mut self, event: &KeyEvent) {
3384         match self.url.handle_key(event) {
3385             cce_ui::widget::EditOutcome::Submit => self.navigate(),
3386             cce_ui::widget::EditOutcome::Cancel => {
3387                 self.url_focused = false;
3388                 self.url_new_tab = false;
3389                 self.url.selection = None;
3390                 self.sync_page_state();
3391             }
3392             cce_ui::widget::EditOutcome::Edited | cce_ui::widget::EditOutcome::Ignored => {}
3393         }
3394     }
3395 }
3396 
3397 /// A key's identity across its press and release: the logical key, case
3398 /// folded, since Shift can be let go between the two (`G` comes up as `g`).
3399 fn key_id(event: &KeyEvent) -> String {
3400     format!("{:?}", event.logical_key).to_lowercase()
3401 }
3402 
3403 // ---- vi mode (`src/vi.rs`) ----
3404 impl BrowserApp {
3405     /// Change mode, dropping whatever the old one had up.
3406     fn vi_set_mode(&mut self, mode: vi::Mode) {
3407         if mode != vi::Mode::Hint {
3408             self.vi_hints.clear();
3409             self.vi_hint_typed.clear();
3410             if matches!(self.vi_pending, Some((_, ViAsk::Hints))) {
3411                 self.vi_pending = None;
3412             }
3413         }
3414         self.vi_keys.clear();
3415         self.vi_mode = mode;
3416     }
3417 
3418     /// The page went away under vi: a navigation, or another tab shown.
3419     /// Insert mode and hints belonged to it; so did any search highlight.
3420     fn vi_page_changed(&mut self) {
3421         if matches!(self.vi_mode, vi::Mode::Insert | vi::Mode::Hint) {
3422             self.vi_set_mode(vi::Mode::Normal);
3423         }
3424         self.vi_pending = None;
3425         self.vi_searching = false;
3426         // A click that navigated must not let the next page's autofocus
3427         // count as clicked into.
3428         self.vi_click = None;
3429     }
3430 
3431     /// Show a message on the status line for a while. The wake is needed for
3432     /// the same reason as the peek's: an idle page turns no loop.
3433     fn vi_say(&mut self, text: impl Into<String>) {
3434         self.vi_msg = Some((text.into(), std::time::Instant::now() + VI_MSG_TIME));
3435         let tx = self.sender.clone();
3436         std::thread::spawn(move || {
3437             std::thread::sleep(VI_MSG_TIME);
3438             let _ = tx.send(Message::Spin);
3439         });
3440     }
3441 
3442     /// Ask the page something; the answer arrives in `vi_drain`.
3443     fn vi_ask(&mut self, ask: ViAsk, script: &str) {
3444         self.vi_tag = self.vi_tag.wrapping_add(1).max(1);
3445         self.vi_pending = Some((self.vi_tag, ask));
3446         self.host.vi_eval(script, self.vi_tag);
3447     }
3448 
3449     /// Take in what the page side said since the last pump: script answers,
3450     /// focus moves, a search's outcome, and a message's time running out.
3451     /// Returns whether anything shows differently.
3452     fn vi_drain(&mut self) -> bool {
3453         let mut changed = false;
3454         while let Some((tag, text)) = self.host.take_vi_result() {
3455             let Some((want, ask)) = self.vi_pending else { continue };
3456             if tag != want {
3457                 continue;
3458             }
3459             self.vi_pending = None;
3460             changed = true;
3461             match ask {
3462                 ViAsk::Hints if self.vi_mode == vi::Mode::Hint => {
3463                     self.vi_hints = vi::parse_hints(&text);
3464                     if self.vi_hints.is_empty() {
3465                         self.vi_set_mode(vi::Mode::Normal);
3466                         self.vi_say("No elements found");
3467                     }
3468                 }
3469                 ViAsk::Hints => {}
3470                 ViAsk::FocusInput if text == "yes" => self.vi_set_mode(vi::Mode::Insert),
3471                 ViAsk::FocusInput => self.vi_say("No text field found"),
3472                 ViAsk::ClickCheck => {
3473                     if text == "yes" && self.vi_mode == vi::Mode::Normal {
3474                         self.vi_set_mode(vi::Mode::Insert);
3475                     }
3476                 }
3477             }
3478         }
3479         if let Some(editable) = self.host.take_vi_focus() {
3480             if self.settings.vi_mode {
3481                 let clicked = self.vi_click.is_some_and(|t| t.elapsed() < VI_CLICK_WINDOW);
3482                 match (self.vi_mode, editable) {
3483                     (vi::Mode::Normal, true) if clicked => self.vi_set_mode(vi::Mode::Insert),
3484                     (vi::Mode::Insert, false) => self.vi_set_mode(vi::Mode::Normal),
3485                     _ => {}
3486                 }
3487                 changed = true;
3488             }
3489         }
3490         if self.host.take_find_result() == Some(0) {
3491             let text = self.vi_search.clone().unwrap_or_default();
3492             self.vi_say(format!("Text not found: {text}"));
3493             changed = true;
3494         }
3495         if self.vi_msg.as_ref().is_some_and(|(_, at)| std::time::Instant::now() >= *at) {
3496             self.vi_msg = None;
3497             changed = true;
3498         }
3499         changed
3500     }
3501 
3502     /// Mark a press as taken, so its release is too.
3503     fn vi_swallow(&mut self, event: &KeyEvent) {
3504         if self.vi_swallowed.len() >= 16 {
3505             self.vi_swallowed.remove(0);
3506         }
3507         self.vi_swallowed.push(key_id(event));
3508     }
3509 
3510     /// The vi stage of `handle_key_input`. `Some` when vi took the key —
3511     /// carrying what the key did, which can be quitting — `None` to let it
3512     /// go on down the funnel.
3513     fn vi_key(&mut self, event: &KeyEvent, needs_rebuild: &mut bool) -> Option<Option<Message>> {
3514         use vi::Mode;
3515         if !self.settings.vi_mode {
3516             return None;
3517         }
3518         let owns_keyboard = matches!(self.vi_mode, Mode::Hint | Mode::Command);
3519         if event.state != ElementState::Pressed {
3520             let id = key_id(event);
3521             if let Some(i) = self.vi_swallowed.iter().position(|k| *k == id) {
3522                 self.vi_swallowed.remove(i);
3523                 return Some(None);
3524             }
3525             return owns_keyboard.then_some(None);
3526         }
3527         // A modifier on its own means nothing to vi.
3528         if matches!(
3529             event.logical_key,
3530             Key::Named(NamedKey::Shift | NamedKey::Control | NamedKey::Alt | NamedKey::Super)
3531         ) {
3532             return owns_keyboard.then_some(None);
3533         }
3534         match self.vi_mode {
3535             Mode::Command => {
3536                 self.vi_swallow(event);
3537                 *needs_rebuild = true;
3538                 return Some(self.vi_cmd_key(event));
3539             }
3540             Mode::Hint => {
3541                 self.vi_swallow(event);
3542                 *needs_rebuild = true;
3543                 self.vi_hint_key(event);
3544                 return Some(None);
3545             }
3546             _ => {}
3547         }
3548         // A field of the chrome's own has the keyboard; vi stays out of it.
3549         if self.url_focused || self.bm_menu.is_some() {
3550             return None;
3551         }
3552         let escape = event.logical_key == Key::Named(NamedKey::Escape);
3553         match self.vi_mode {
3554             Mode::Passthrough => {
3555                 if escape && event.shift {
3556                     self.vi_swallow(event);
3557                     self.vi_set_mode(Mode::Normal);
3558                     *needs_rebuild = true;
3559                 } else {
3560                     self.host.key_ui(event);
3561                 }
3562                 return Some(None);
3563             }
3564             Mode::Insert => {
3565                 if !escape {
3566                     return None;
3567                 }
3568                 self.vi_swallow(event);
3569                 self.vi_set_mode(Mode::Normal);
3570                 *needs_rebuild = true;
3571                 return Some(None);
3572             }
3573             _ => {}
3574         }
3575 
3576         // Normal mode. Escape clears what vi has going — pending keys, a
3577         // message, a search's highlights — and only with none of that does
3578         // it go on, to fold the bar or reach the page.
3579         if escape {
3580             let mut used = !self.vi_keys.is_empty() || self.vi_msg.take().is_some();
3581             self.vi_keys.clear();
3582             if self.vi_searching {
3583                 self.host.find_finish();
3584                 self.vi_searching = false;
3585                 used = true;
3586             }
3587             if !used {
3588                 return None;
3589             }
3590             self.vi_swallow(event);
3591             *needs_rebuild = true;
3592             return Some(None);
3593         }
3594         *needs_rebuild = true;
3595         // Named keys are never commands: they end a sequence and go on.
3596         let Some(token) = vi::token(event) else {
3597             self.vi_keys.clear();
3598             return None;
3599         };
3600         // A chord vi has no binding for is the chrome's, or the page's.
3601         if (event.ctrl || event.alt) && !self.vi_keys.takes(&token) {
3602             self.vi_keys.clear();
3603             return None;
3604         }
3605         self.vi_swallow(event);
3606         self.vi_msg = None;
3607         match self.vi_keys.feed(&token) {
3608             vi::Fed::Run(action, count) => Some(self.vi_run(action, count)),
3609             vi::Fed::Pending | vi::Fed::Unbound => Some(None),
3610         }
3611     }
3612 
3613     /// Do a normal-mode command.
3614     fn vi_run(&mut self, action: vi::Action, count: Option<u32>) -> Option<Message> {
3615         use vi::Action as A;
3616         let n = count.unwrap_or(1).max(1) as usize;
3617         let tabs = self.host.tab_count();
3618         let cur = self.host.active_index();
3619         match action {
3620             A::ScrollLines(dx, dy) => self.vi_scroll_lines(dx * n as f32, dy * n as f32),
3621             A::ScrollPage(f) => {
3622                 let js = vi::scroll_js(Some(f * n as f32), None, cce_ui::motion::enabled());
3623                 self.host.vi_eval(&js, 0);
3624             }
3625             A::Top | A::Bottom => {
3626                 let end = if action == A::Top { 0.0 } else { 1.0 };
3627                 let to = count.map_or(end, |c| c.min(100) as f32 / 100.0);
3628                 self.host.vi_eval(&vi::scroll_js(None, Some(to), false), 0);
3629             }
3630             A::Back => self.host.back(),
3631             A::Forward => self.host.forward(),
3632             A::Reload => self.host.reload(),
3633             A::TabNext if tabs > 1 => self.switch_tab((cur + n) % tabs),
3634             A::TabPrev if tabs > 1 => self.switch_tab((cur + tabs - n % tabs) % tabs),
3635             A::TabGoto => match count {
3636                 Some(c) => return self.vi_run(A::TabFocus(c as usize), None),
3637                 None => return self.vi_run(A::TabNext, None),
3638             },
3639             A::TabFocus(i) if (1..=tabs).contains(&i) => self.switch_tab(i - 1),
3640             A::TabFocus(i) => self.vi_say(format!("There is no tab {i}")),
3641             A::TabFirst => self.switch_tab(0),
3642             A::TabLast => self.switch_tab(tabs.saturating_sub(1)),
3643             A::TabNext | A::TabPrev => {}
3644             A::TabClose => return self.close_tab(cur),
3645             A::TabOnly => {
3646                 for i in (0..tabs).rev().filter(|&i| i != cur) {
3647                     self.close_tab(i);
3648                 }
3649             }
3650             A::UndoClose => match self.closed_tabs.pop() {
3651                 Some(url) => {
3652                     self.host.open_tab(url);
3653                     self.sync_page_state();
3654                     self.persist_session();
3655                 }
3656                 None => self.vi_say("No closed tabs"),
3657             },
3658             A::Open { tab, edit } => {
3659                 self.open_chrome();
3660                 let text = if edit {
3661                     self.host.url().map(|u| u.to_string()).unwrap_or_default()
3662                 } else {
3663                     String::new()
3664                 };
3665                 self.url = cce_ui::widget::LineEdit::with_text(text);
3666                 self.url_focused = true;
3667                 self.url_new_tab = tab;
3668             }
3669             A::Hint(kind) => {
3670                 self.vi_set_mode(vi::Mode::Hint);
3671                 self.vi_hint_kind = kind;
3672                 self.vi_ask(ViAsk::Hints, &vi::hints_js(kind != vi::HintKind::Follow));
3673             }
3674             A::YankUrl => match self.host.url() {
3675                 Some(u) => {
3676                     cce_ui::widget::clipboard::copy_to_clipboard(u.as_str());
3677                     self.vi_say(format!("Yanked {u}"));
3678                 }
3679                 None => self.vi_say("No address to yank"),
3680             },
3681             A::YankTitle => match self.title.clone().filter(|t| !t.is_empty()) {
3682                 Some(t) => {
3683                     cce_ui::widget::clipboard::copy_to_clipboard(&t);
3684                     self.vi_say(format!("Yanked {t}"));
3685                 }
3686                 None => self.vi_say("No title to yank"),
3687             },
3688             A::Paste { tab } => {
3689                 let url = cce_ui::widget::clipboard::read_from_clipboard()
3690                     .map(|t| t.trim().to_string())
3691                     .filter(|t| !t.is_empty())
3692                     .and_then(|t| parse_url_input(&t, &self.settings.search_prefix));
3693                 match url {
3694                     Some(url) if tab => {
3695                         self.host.open_tab(url);
3696                         self.sync_page_state();
3697                         self.persist_session();
3698                     }
3699                     Some(url) => {
3700                         self.host.load(url);
3701                         self.loading = true;
3702                     }
3703                     None => self.vi_say("Nothing to open in the clipboard"),
3704                 }
3705             }
3706             A::Insert => self.vi_set_mode(vi::Mode::Insert),
3707             A::FocusInput => self.vi_ask(ViAsk::FocusInput, &vi::focus_input_js()),
3708             A::Passthrough => self.vi_set_mode(vi::Mode::Passthrough),
3709             A::Prompt(p) => {
3710                 self.vi_set_mode(vi::Mode::Command);
3711                 self.vi_prompt = p;
3712                 self.vi_cmd = cce_ui::widget::LineEdit::default();
3713                 self.vi_history_at = None;
3714             }
3715             A::SearchNext | A::SearchPrev => {
3716                 if self.vi_searching {
3717                     for _ in 0..n {
3718                         if action == A::SearchNext {
3719                             self.host.find_next();
3720                         } else {
3721                             self.host.find_prev();
3722                         }
3723                     }
3724                 } else if let Some(text) = self.vi_search.clone() {
3725                     // A search from before a navigation: start it again here.
3726                     self.host.find(&text, action == A::SearchPrev);
3727                     self.vi_searching = true;
3728                 } else {
3729                     self.vi_say("No previous search");
3730                 }
3731             }
3732             A::Bookmark => {
3733                 self.host.toggle_bookmark();
3734                 let on = self.host.active_bookmarked();
3735                 self.vi_say(if on { "Bookmarked" } else { "Bookmark removed" });
3736             }
3737             A::Page(page) => self.open_internal_page(page),
3738             A::Up | A::Root => {
3739                 let to = self.host.url().and_then(|u| {
3740                     if action == A::Up { vi::url_up(&u) } else { vi::url_root(&u) }
3741                 });
3742                 if let Some(url) = to {
3743                     self.host.load(url);
3744                     self.loading = true;
3745                 }
3746             }
3747         }
3748         None
3749     }
3750 
3751     /// `j`/`k`/`h`/`l`: a wheel's worth of lines through the same eased
3752     /// model a notch takes, aimed at the middle of the page.
3753     fn vi_scroll_lines(&mut self, dx: f32, dy: f32) {
3754         let center = (self.win.0 / 2.0, self.win.1 / 2.0);
3755         // Lines down are a negative wheel delta (winit: positive = up).
3756         let (lx, ly) = (-dx * VI_LINE, -dy * VI_LINE);
3757         if cce_ui::widget::scroll_motion::scroll_settings().smooth {
3758             use cce_ui::widget::scroll_motion::Bounds;
3759             self.scroll_origin = Some(center);
3760             self.scroll.apply(
3761                 &MouseScrollDelta::LineDelta(lx, ly),
3762                 (LINE_PX as f32, LINE_PX as f32),
3763                 Bounds::UNBOUNDED,
3764                 Bounds::UNBOUNDED,
3765             );
3766         } else {
3767             cce_ui::widget::scroll_motion::set_scroll_phase(cce_ui::widget::ScrollPhase::Wheel);
3768             let s = self.scale;
3769             self.host.wheel(
3770                 lx as f64 * LINE_PX * s,
3771                 ly as f64 * LINE_PX * s,
3772                 center.0 * s as f32,
3773                 center.1 * s as f32,
3774             );
3775         }
3776     }
3777 
3778     /// Keys while the labels are up: letters narrow them down, a whole label
3779     /// picks, Backspace takes a letter back, Escape gives up.
3780     fn vi_hint_key(&mut self, event: &KeyEvent) {
3781         match &event.logical_key {
3782             Key::Named(NamedKey::Escape) => self.vi_set_mode(vi::Mode::Normal),
3783             Key::Named(NamedKey::Backspace) => {
3784                 self.vi_hint_typed.pop();
3785             }
3786             Key::Character(c) if !event.ctrl && !event.alt => {
3787                 let next = format!("{}{}", self.vi_hint_typed, c.to_lowercase());
3788                 // A letter no label continues with is ignored, not an error.
3789                 if self.vi_hints.iter().any(|h| h.label.starts_with(&next)) {
3790                     let hit = self.vi_hints.iter().find(|h| h.label == next).cloned();
3791                     self.vi_hint_typed = next;
3792                     if let Some(h) = hit {
3793                         self.vi_follow(h);
3794                     }
3795                 }
3796             }
3797             _ => {}
3798         }
3799     }
3800 
3801     fn vi_follow(&mut self, hint: vi::Hint) {
3802         let kind = self.vi_hint_kind;
3803         self.vi_set_mode(vi::Mode::Normal);
3804         match kind {
3805             // A real click, at the element: the page sees a person's click
3806             // (popups allowed, focus moved, cross-origin frames and all), and
3807             // a field picked this way is clicked into.
3808             vi::HintKind::Follow => {
3809                 self.vi_click = Some(std::time::Instant::now());
3810                 self.click_page(MouseButton::Left, hint.at);
3811                 self.vi_ask(ViAsk::ClickCheck, &vi::active_editable_js());
3812             }
3813             // A middle-click: the link becomes a background tab by the same
3814             // route a pointer's middle-click takes.
3815             vi::HintKind::Background => self.click_page(MouseButton::Middle, hint.at),
3816             vi::HintKind::Yank => match hint.href {
3817                 Some(href) => {
3818                     cce_ui::widget::clipboard::copy_to_clipboard(&href);
3819                     self.vi_say(format!("Yanked {href}"));
3820                 }
3821                 None => self.vi_say("That has no link to yank"),
3822             },
3823         }
3824     }
3825 
3826     /// Press and release `button` on the page at `at` (logical pixels).
3827     fn click_page(&mut self, button: MouseButton, at: (f32, f32)) {
3828         let s = self.scale as f32;
3829         let (x, y) = (at.0 * s, at.1 * s);
3830         self.host.mouse_move(x, y);
3831         self.host.mouse_button_ui(button, true, x, y);
3832         self.host.mouse_button_ui(button, false, x, y);
3833     }
3834 
3835     /// Keys for the `:` / `/` / `?` line. Editing is the shared `LineEdit`;
3836     /// Up/Down walk earlier lines of the same kind, and Backspace on an empty
3837     /// line leaves it, as in vim.
3838     fn vi_cmd_key(&mut self, event: &KeyEvent) -> Option<Message> {
3839         let search = self.vi_prompt != vi::Prompt::Command;
3840         match event.logical_key {
3841             Key::Named(NamedKey::ArrowUp | NamedKey::ArrowDown) => {
3842                 let up = event.logical_key == Key::Named(NamedKey::ArrowUp);
3843                 let past: Vec<String> = self
3844                     .vi_history
3845                     .iter()
3846                     .filter(|(s, _)| *s == search)
3847                     .map(|(_, t)| t.clone())
3848                     .collect();
3849                 if past.is_empty() {
3850                     return None;
3851                 }
3852                 let at = match (self.vi_history_at, up) {
3853                     (None, true) => Some(past.len() - 1),
3854                     (None, false) => None,
3855                     (Some(i), true) => Some(i.saturating_sub(1)),
3856                     (Some(i), false) => (i + 1 < past.len()).then_some(i + 1),
3857                 };
3858                 self.vi_history_at = at;
3859                 self.vi_cmd = cce_ui::widget::LineEdit::with_text(at.map_or(String::new(), |i| past[i].clone()));
3860                 return None;
3861             }
3862             Key::Named(NamedKey::Backspace) if self.vi_cmd.text.is_empty() => {
3863                 self.vi_set_mode(vi::Mode::Normal);
3864                 return None;
3865             }
3866             _ => {}
3867         }
3868         match self.vi_cmd.handle_key(event) {
3869             cce_ui::widget::EditOutcome::Submit => {
3870                 let line = self.vi_cmd.text.clone();
3871                 self.vi_set_mode(vi::Mode::Normal);
3872                 if !line.trim().is_empty() {
3873                     self.vi_history.retain(|(s, t)| !(*s == search && *t == line));
3874                     self.vi_history.push((search, line.clone()));
3875                     if self.vi_history.len() > 100 {
3876                         self.vi_history.remove(0);
3877                     }
3878                 }
3879                 self.vi_submit(&line)
3880             }
3881             cce_ui::widget::EditOutcome::Cancel => {
3882                 self.vi_set_mode(vi::Mode::Normal);
3883                 None
3884             }
3885             _ => None,
3886         }
3887     }
3888 
3889     fn vi_submit(&mut self, line: &str) -> Option<Message> {
3890         match self.vi_prompt {
3891             vi::Prompt::Search | vi::Prompt::SearchBack => {
3892                 // An empty search repeats the last one, as in vim.
3893                 let text = Some(line.to_string()).filter(|l| !l.is_empty()).or_else(|| self.vi_search.clone());
3894                 if let Some(text) = text {
3895                     self.host.find(&text, self.vi_prompt == vi::Prompt::SearchBack);
3896                     self.vi_search = Some(text);
3897                     self.vi_searching = true;
3898                 }
3899                 None
3900             }
3901             vi::Prompt::Command => match vi::parse_command(line) {
3902                 Ok(vi::Cmd::Run(action)) => self.vi_run(action, None),
3903                 Ok(vi::Cmd::Open { target, tab, background }) => {
3904                     match parse_url_input(&target, &self.settings.search_prefix) {
3905                         Some(url) if background => self.open_background_tab(url),
3906                         Some(url) if tab => {
3907                             self.host.open_tab(url);
3908                             self.sync_page_state();
3909                             self.persist_session();
3910                         }
3911                         Some(url) => {
3912                             self.host.load(url);
3913                             self.loading = true;
3914                         }
3915                         None => self.vi_say(format!("Cannot open {target}")),
3916                     }
3917                     None
3918                 }
3919                 // Like closing the window: the tabs are kept for next time.
3920                 Ok(vi::Cmd::Quit) => Some(Message::Quit),
3921                 Ok(vi::Cmd::Empty) => None,
3922                 Err(why) => {
3923                     self.vi_say(why);
3924                     None
3925                 }
3926             },
3927         }
3928     }
3929 
3930     /// Where the status line goes: the bottom-left corner, clear of the
3931     /// dot (which is on the right), lifted over a bottom bar while it shows.
3932     fn vi_line_rect(&self, width: f32) -> Rect {
3933         let mut y = self.win.1 - bar_margin() - VI_LINE_H;
3934         if self.settings.bar_position == settings::BarPosition::Bottom && self.chrome_ease() > 0.0 {
3935             y = y.min(self.chrome_plate().0.y - item_gap() - VI_LINE_H);
3936         }
3937         let width = width.min(self.win.0 - 2.0 * bar_margin()).max(0.0);
3938         Rect { x: bar_margin(), y, width, height: VI_LINE_H }
3939     }
3940 
3941     /// Hint labels over the page, and the status line: the mode, pending
3942     /// keys, a message, or the `:` line being typed. Normal mode with
3943     /// nothing to say draws nothing — the chrome stays a dot.
3944     fn paint_vi(&mut self, pc: &mut PaintCtx, sans: &str) {
3945         if !self.settings.vi_mode {
3946             return;
3947         }
3948         if self.vi_mode == vi::Mode::Hint {
3949             let typed = self.vi_hint_typed.clone();
3950             let typed_w = measure_text_width(&typed, sans, HINT_FONT);
3951             for h in self.vi_hints.iter().filter(|h| h.label.starts_with(&typed)) {
3952                 let w = measure_text_width(&h.label, sans, HINT_FONT) + 8.0;
3953                 let x = h.rect.0.clamp(0.0, (self.win.0 - w).max(0.0));
3954                 let y = h.rect.1.clamp(0.0, (self.win.1 - HINT_H).max(0.0));
3955                 pc.rounded_rect(
3956                     Rect { x: x - 1.0, y: y - 1.0, width: w + 2.0, height: HINT_H + 2.0 },
3957                     5.0,
3958                     (true, true, true, true),
3959                     HINT_RIM,
3960                 );
3961                 pc.rounded_rect(Rect { x, y, width: w, height: HINT_H }, 4.0, (true, true, true, true), HINT_BG);
3962                 let ty = cce_ui::layout::align_text_y(y, HINT_H, HINT_FONT, 0.0);
3963                 if !typed.is_empty() {
3964                     pc.text(typed.clone(), x + 4.0, ty, HINT_FONT, HINT_TYPED);
3965                 }
3966                 pc.text(h.label[typed.len()..].to_string(), x + 4.0 + typed_w, ty, HINT_FONT, HINT_TEXT);
3967             }
3968         }
3969 
3970         let material = cce_ui::scene::Material::opaque([0.13, 0.14, 0.16, 1.0]);
3971         let bevel = cce_ui::layout::bevel_width().min(3.0);
3972         if self.vi_mode == vi::Mode::Command {
3973             let r = self.vi_line_rect((self.win.0 * 0.5).clamp(320.0, 720.0));
3974             pc.plate(r, (8.0, 8.0, 8.0, 8.0), &material, bevel);
3975             let sigil = self.vi_prompt.sigil().to_string();
3976             let x = r.x + text_pad();
3977             let ty = cce_ui::layout::align_text_y(r.y, r.height, URL_FONT, 0.0);
3978             // What the field shows: an input method's composition at the
3979             // caret, underlined (`FieldMarks`, as every chrome field).
3980             let marks = FieldMarks::of(&mut self.font_system, &self.vi_cmd);
3981             let tx = x + measure_text_width(&sigil, sans, URL_FONT) + 3.0;
3982             let caret_rect = Rect { x: tx + marks.caret, y: r.y + 5.0, width: 1.0, height: r.height - 10.0 };
3983             pc.clip(r, |pc| {
3984                 pc.text(sigil, x, ty, URL_FONT, [150, 190, 240]);
3985                 if let Some((x0, x1)) = marks.selection {
3986                     pc.quad(Rect { x: tx + x0, y: r.y + 5.0, width: x1 - x0, height: r.height - 10.0 }, SEL_BG);
3987                 }
3988                 pc.text(marks.shown.clone(), tx, ty, URL_FONT, TEXT);
3989                 if let Some((x0, x1)) = marks.composition {
3990                     pc.quad(Rect { x: tx + x0, y: r.y + r.height - 6.0, width: x1 - x0, height: 1.0 }, CARET);
3991                 }
3992                 pc.quad(caret_rect, [0.85, 0.87, 0.92, 1.0]);
3993             });
3994             report_caret(caret_rect);
3995             return;
3996         }
3997 
3998         let (text, color) = if let Some((msg, _)) = &self.vi_msg {
3999             (msg.clone(), TEXT)
4000         } else {
4001             let keys = self.vi_keys.shown();
4002             match self.vi_mode.label() {
4003                 Some(label) => (label.to_string(), [150, 190, 240]),
4004                 None if !keys.is_empty() => (keys, TEXT),
4005                 None => return,
4006             }
4007         };
4008         let avail = self.win.0 - 2.0 * bar_margin() - 2.0 * text_pad();
4009         let text = Self::fit_text(&text, sans, VI_FONT, avail);
4010         let r = self.vi_line_rect(measure_text_width(&text, sans, VI_FONT) + 2.0 * text_pad());
4011         pc.plate(r, (8.0, 8.0, 8.0, 8.0), &material, bevel);
4012         pc.text(
4013             text,
4014             r.x + text_pad(),
4015             cce_ui::layout::align_text_y(r.y, r.height, VI_FONT, 0.0),
4016             VI_FONT,
4017             color,
4018         );
4019     }
4020 }
4021 
4022 impl BrowserApp {
4023     /// When GLib next needs a pump nothing else will cause: its own timeout
4024     /// as of the last pump, or a heartbeat while one is needed (no fd
4025     /// watched, or the deadlock watch running). `None`: sleep until an
4026     /// event — an idle static page used to be pumped four times a second
4027     /// regardless (a fixed timer, 100 ms when GLib asked for no timeout and
4028     /// never more than 250 ms, re-armed from the timeout of the pump BEFORE
4029     /// the one it triggered).
4030     #[cfg(feature = "wpe")]
4031     fn next_glib_pump(&self) -> Option<std::time::Instant> {
4032         const HEARTBEAT: std::time::Duration = std::time::Duration::from_millis(250);
4033         let heartbeat = (!self.glib_fd_watched || self.host.wants_heartbeat()).then(|| self.last_pump + HEARTBEAT);
4034         match (self.host.glib_deadline(), heartbeat) {
4035             (Some(a), Some(b)) => Some(a.min(b)),
4036             (a, b) => a.or(b),
4037         }
4038     }
4039 }
4040 
4041 impl Application for BrowserApp {
4042     type Message = Message;
4043 
4044     fn create(sender: cce_ui::engine::AppSender<Self::Message>) -> Self {
4045         // The app keeps calloop's sender; `AppSender` converts into it.
4046         let sender: calloop::channel::Sender<Self::Message> = sender.into();
4047         // Serve the instance socket claimed in main(), if this launch won it.
4048         instance::spawn_listener(sender.clone());
4049         let settings = settings::load();
4050         downloads::set_download_dir(settings.download_dir.clone());
4051         // Optional CLI arg: the start URL (same parsing as the URL bar).
4052         let arg = std::env::args()
4053             .nth(1)
4054             .and_then(|arg| parse_startup_arg(&arg, &settings.search_prefix));
4055         // The previous run's tabs. When there are some, they come back in
4056         // order and an argv URL opens as an extra tab on top of them —
4057         // otherwise the argument (or the configured homepage) is the one
4058         // starting tab, as before session restore existed.
4059         let mut session = session::Session::new();
4060         let (saved, saved_active) = session.load();
4061         let restored = !saved.is_empty();
4062         let mut queue = saved;
4063         if queue.is_empty() {
4064             queue.push(
4065                 arg.clone()
4066                     .or_else(|| parse_url_input(&settings.homepage, &settings.search_prefix))
4067                     .unwrap_or_else(|| {
4068                         Url::parse(settings::DEFAULT_HOMEPAGE).expect("home url")
4069                     }),
4070             );
4071         }
4072         let first = queue.remove(0);
4073 
4074         #[cfg(all(not(feature = "wpe"), feature = "servo"))]
4075         let mut host =
4076             Host::new(sender.clone(), first, (1200, 800), settings.color_scheme.forces_dark());
4077         #[cfg(feature = "wpe")]
4078         let mut host = {
4079             let _ = &sender; // WPE wakes through register_sources, not a waker
4080             Host::new(first, (1200, 800))
4081         };
4082         for url in queue {
4083             host.open_tab(url);
4084         }
4085         if restored {
4086             host.activate(saved_active.min(host.tab_count() - 1));
4087             if let Some(url) = arg {
4088                 host.open_tab(url);
4089             }
4090         }
4091         // The bar mirrors whichever tab ended up active.
4092         let url_text = host
4093             .url()
4094             .map(|u| u.to_string())
4095             .filter(|s| s != "about:blank")
4096             .unwrap_or_default();
4097         host.set_history_enabled(settings.history);
4098         host.set_color_scheme_dark(settings.color_scheme.is_dark());
4099         #[cfg(feature = "wpe")]
4100         host.set_force_dark(settings.color_scheme.forces_dark());
4101         #[cfg(feature = "wpe")]
4102         host.set_accounts_enabled(settings.accounts);
4103         host.set_vi_enabled(settings.vi_mode);
4104         let accounts = accounts::Accounts::spawn(sender.clone());
4105         let raindrop_on = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(settings.raindrop));
4106         let raindrop_started = settings.raindrop;
4107         if raindrop_started {
4108             raindrop::sync::spawn(host.bookmarks(), raindrop_on.clone());
4109         }
4110         let favorites = host.favorites();
4111         let favs = favorites.snapshot();
4112         let bookmarks = host.bookmarks();
4113         Self {
4114             host,
4115             seen_renderer: false,
4116             settings,
4117             win: (1200.0, 800.0),
4118             scale: 1.0,
4119             pointer: (0.0, 0.0),
4120             page_buttons: Vec::new(),
4121             url: cce_ui::widget::LineEdit::with_text(url_text),
4122             url_focused: false,
4123             url_entry_press: false,
4124             shift_held: false,
4125             chrome_open: false,
4126             chrome_t: 0.0,
4127             chrome_peek: None,
4128             #[cfg(feature = "wpe")]
4129             page_press_settle: None,
4130             peek_pointer: (0.0, 0.0),
4131             dot_hover: false,
4132             loading: true,
4133             title: None,
4134             #[cfg(feature = "wpe")]
4135             modal: None,
4136             #[cfg(feature = "wpe")]
4137             ctx_menu: None,
4138             #[cfg(feature = "wpe")]
4139             opt_menu: None,
4140             #[cfg(feature = "wpe")]
4141             sender,
4142             font_system: cce_ui::create_font_system(),
4143             ime_field: None,
4144             session,
4145             favorites,
4146             favs,
4147             fav_hover: None,
4148             bookmarks,
4149             bm_menu: None,
4150             scroll: cce_ui::widget::scroll_motion::ScrollMotion::new(),
4151             scroll_sent: (0.0, 0.0),
4152             accounts,
4153             never_save: accounts::NeverSave::load(),
4154             raindrop_on,
4155             raindrop_started,
4156             #[cfg(feature = "wpe")]
4157             save_offer: None,
4158             #[cfg(feature = "wpe")]
4159             frame_offsets: std::collections::HashMap::new(),
4160             #[cfg(feature = "wpe")]
4161             last_field: None,
4162             #[cfg(feature = "wpe")]
4163             ac_menu: None,
4164             nav_url: None,
4165             scroll_origin: None,
4166             url_new_tab: false,
4167             closed_tabs: Vec::new(),
4168             vi_mode: vi::Mode::Normal,
4169             vi_keys: vi::Keys::default(),
4170             vi_hints: Vec::new(),
4171             vi_hint_kind: vi::HintKind::Follow,
4172             vi_hint_typed: String::new(),
4173             vi_pending: None,
4174             vi_tag: 0,
4175             vi_cmd: cce_ui::widget::LineEdit::default(),
4176             vi_prompt: vi::Prompt::Command,
4177             vi_history: Vec::new(),
4178             vi_history_at: None,
4179             vi_msg: None,
4180             vi_click: None,
4181             vi_swallowed: Vec::new(),
4182             vi_search: None,
4183             vi_searching: false,
4184             #[cfg(feature = "wpe")]
4185             glib_fd_watched: false,
4186             #[cfg(feature = "wpe")]
4187             last_pump: std::time::Instant::now(),
4188             #[cfg(feature = "wpe")]
4189             spin_queued: false,
4190         }
4191     }
4192 
4193     /// Wake on GLib activity rather than polling for it.
4194     ///
4195     /// Servo pushed `Message::Spin` into calloop from its own threads; WPE
4196     /// runs a GLib main context, so we register the epoll fd carrying its
4197     /// pollfd set; it fires `Spin`, which lands in `update` and calls `pump`
4198     /// — the same path the Servo waker used. GLib's own timeouts, which no
4199     /// fd reports, are kept by the runner's idle sleep instead
4200     /// (`idle_poll_interval` / `tick`, from [`Self::next_glib_pump`]).
4201     #[cfg(feature = "wpe")]
4202     fn register_sources(&mut self, handle: &calloop::LoopHandle<'_, EngineState<Self>>) {
4203         use calloop::{generic::Generic, Interest, Mode, PostAction};
4204 
4205         if let Some(fd) = self.host.poll_fd_owned() {
4206             let tx = self.sender.clone();
4207             // Level-triggered: `pump` drains the epoll, so an un-consumed
4208             // socket re-arms rather than being missed.
4209             let source = Generic::new(fd, Interest::READ, Mode::Level);
4210             match handle.insert_source(source, move |_, _, _| {
4211                 let _ = tx.send(Message::Spin);
4212                 Ok(PostAction::Continue)
4213             }) {
4214                 Ok(_) => self.glib_fd_watched = true,
4215                 Err(e) => log::warn!("could not watch the GLib fd ({e}); pumping on the heartbeat alone"),
4216             }
4217         }
4218         // The first pump, which sets GLib's first deadline.
4219         self.spin_queued = true;
4220         let _ = self.sender.send(Message::Spin);
4221     }
4222 
4223 
4224     fn settings(&self) -> WindowSettings {
4225         WindowSettings {
4226             title: self.title.clone().unwrap_or_else(|| "Browser".to_string()),
4227             app_id: "cce-browser".to_string(),
4228             width: 1200,
4229             height: 800,
4230             fullscreen: false,
4231             min_size: Some((480, 320)),
4232         }
4233     }
4234 
4235     fn update(&mut self, msg: Self::Message, needs_rebuild: &mut bool, exit: &mut bool) {
4236         match msg {
4237             Message::Spin => {
4238                 #[cfg(feature = "wpe")]
4239                 {
4240                     self.spin_queued = false;
4241                     self.last_pump = std::time::Instant::now();
4242                 }
4243                 let (new_frame, dirty) = self.host.pump();
4244                 // A page field opening or closing has to reach the frame
4245                 // that claims it, even when the page repaints nothing.
4246                 #[cfg(feature = "wpe")]
4247                 if self.host.take_page_text_field_changed() {
4248                     *needs_rebuild = true;
4249                 }
4250                 #[cfg(feature = "wpe")]
4251                 if self.settle_page_press() {
4252                     *needs_rebuild = true;
4253                 }
4254                 #[cfg(feature = "wpe")]
4255                 if self.sync_modal() {
4256                     *needs_rebuild = true;
4257                 }
4258                 #[cfg(feature = "wpe")]
4259                 if let Some(info) = self.host.take_context_menu() {
4260                     self.open_ctx_menu(info);
4261                     *needs_rebuild = true;
4262                 }
4263                 #[cfg(feature = "wpe")]
4264                 if let Some(info) = self.host.take_option_menu() {
4265                     self.open_opt_menu(info);
4266                     *needs_rebuild = true;
4267                 }
4268                 // The page closed the list itself: the select went away, or
4269                 // the page navigated.
4270                 #[cfg(feature = "wpe")]
4271                 if self.opt_menu.is_some() && !self.host.option_menu_open() {
4272                     self.opt_menu = None;
4273                     *needs_rebuild = true;
4274                 }
4275                 #[cfg(feature = "wpe")]
4276                 for url in self.host.take_background_opens() {
4277                     self.open_background_tab(url);
4278                     *needs_rebuild = true;
4279                 }
4280                 if self.settle_peek() {
4281                     *needs_rebuild = true;
4282                 }
4283                 if self.host.take_download_started() {
4284                     self.open_internal_page("cce://downloads");
4285                 }
4286                 if dirty {
4287                     // A navigation retires whatever field was focused and
4288                     // whatever glide was in flight. Only a real one: the URL
4289                     // changing, not the dirty flag, which also fires while the
4290                     // page that owns them is still settling.
4291                     let now = self.host.url().map(|u| u.to_string());
4292                     if now != self.nav_url {
4293                         self.nav_url = now;
4294                         self.stop_scroll();
4295                         // A load, not a pushState: a search field rewriting
4296                         // the address on every keystroke must not lose the
4297                         // keyboard to it.
4298                         if self.host.loading() {
4299                             self.vi_page_changed();
4300                         }
4301                         #[cfg(feature = "wpe")]
4302                         {
4303                             self.ac_menu = None;
4304                             self.frame_offsets.clear();
4305                             self.last_field = None;
4306                         }
4307                     }
4308                     self.sync_page_state();
4309                     // Navigation reaches the tab set through these signals,
4310                     // so this is where an address change gets persisted.
4311                     self.persist_session();
4312                 }
4313                 if self.vi_drain() {
4314                     *needs_rebuild = true;
4315                 }
4316                 // Drained after the navigation check, so a field reported in
4317                 // the same pump that finished the load is not thrown away
4318                 // with the page it arrived on.
4319                 #[cfg(feature = "wpe")]
4320                 while let Some(event) = self.host.take_form_event() {
4321                     if self.on_form_event(event) {
4322                         *needs_rebuild = true;
4323                     }
4324                 }
4325                 if new_frame || dirty {
4326                     *needs_rebuild = true;
4327                 }
4328             }
4329             Message::Accounts(result) => {
4330                 match &result {
4331                     Ok(list) => log::info!("accounts: {} entries from the keyring", list.len()),
4332                     Err(e) => log::warn!("accounts unavailable: {e}"),
4333                 }
4334                 self.accounts.loaded(result);
4335                 // A field may have been focused while the index was still
4336                 // being read; this is when its list can finally open. The
4337                 // chrome replays its own copy of that report — the frame it
4338                 // came from may be one it cannot run script in.
4339                 #[cfg(feature = "wpe")]
4340                 {
4341                     if let Some(wpe::FormEvent::Field {
4342                         origin, frame, top, password, rect, value, ..
4343                     }) = self.last_field.take()
4344                     {
4345                         let replay = wpe::FormEvent::Field {
4346                             origin, frame, top, password, rect, value, moved: false,
4347                         };
4348                         self.on_form_event(replay);
4349                     }
4350                     // And a sign-in may be waiting to learn whether it is new.
4351                     self.resolve_save();
4352                     *needs_rebuild = true;
4353                 }
4354             }
4355             Message::Saved(result) => {
4356                 #[cfg(feature = "wpe")]
4357                 {
4358                     match result {
4359                         Ok(label) => {
4360                             log::info!("accounts: saved a login as \"{label}\"");
4361                             self.save_offer = None;
4362                             // The next login field reads the index again, new
4363                             // entry and all.
4364                             self.accounts.invalidate();
4365                         }
4366                         Err(why) => {
4367                             log::warn!("accounts: {why}");
4368                             if let Some(o) = self.save_offer.as_mut() {
4369                                 o.stage = SaveStage::Failed(why);
4370                             }
4371                         }
4372                     }
4373                     *needs_rebuild = true;
4374                 }
4375                 #[cfg(not(feature = "wpe"))]
4376                 {
4377                     let _ = result;
4378                 }
4379             }
4380             Message::Credential(path, secret) => {
4381                 #[cfg(feature = "wpe")]
4382                 {
4383                     self.fill_account(&path, &secret);
4384                     *needs_rebuild = true;
4385                 }
4386                 #[cfg(not(feature = "wpe"))]
4387                 {
4388                     let _ = (path, secret);
4389                 }
4390             }
4391             Message::Quit => *exit = true,
4392             Message::OpenExternal(arg) => {
4393                 match arg {
4394                     Some(arg) => {
4395                         // Same parsing as the launch argument, and for the
4396                         // same reason: this *is* one, relayed.
4397                         if let Some(url) = parse_startup_arg(&arg, &self.settings.search_prefix) {
4398                             self.host.open_tab(url);
4399                             self.url_focused = false;
4400                             self.sync_page_state();
4401                             self.persist_session();
4402                         }
4403                     }
4404                     None => self.new_tab(),
4405                 }
4406                 // Bring the window to the user: focus + camera pan + raise
4407                 // over the control socket. A fresh launch used to get this
4408                 // from the compositor for free; without it the tab opens in
4409                 // a window parked somewhere off-camera and the click looks
4410                 // like it did nothing. (xdg-activation is not the route: the
4411                 // compositor deliberately answers it with an attention
4412                 // notification, not focus.)
4413                 std::thread::spawn(|| {
4414                     let _ = cce_ui::ipc::focus_window("cce-browser");
4415                 });
4416                 *needs_rebuild = true;
4417             }
4418         }
4419     }
4420 
4421     /// The DE's undo chord (`input.kdl`, Ctrl+Z by default), offered here by
4422     /// the runner before it becomes a key: it undoes typing in whichever
4423     /// field has the keyboard — a dialog's focused field, else the URL bar.
4424     /// With neither, or nothing to undo, false lets the chord go on as a key,
4425     /// which is how Ctrl+Z still reaches a web page's own editor.
4426     fn undo(&mut self, needs_rebuild: &mut bool) -> bool {
4427         let done = self.focused_edit().is_some_and(|e| e.undo());
4428         if done && self.bm_search_has_keyboard() {
4429             self.bm_query_changed();
4430         }
4431         *needs_rebuild |= done;
4432         done
4433     }
4434 
4435     /// Redo — see [`undo`](Self::undo).
4436     fn redo(&mut self, needs_rebuild: &mut bool) -> bool {
4437         let done = self.focused_edit().is_some_and(|e| e.redo());
4438         if done && self.bm_search_has_keyboard() {
4439             self.bm_query_changed();
4440         }
4441         *needs_rebuild |= done;
4442         done
4443     }
4444 
4445     /// The line fields, for a screen reader: the address bar, and while they are up the
4446     /// bookmarks search, the vi command line and a dialog with its fields. Each is a text
4447     /// field a reader reads by character and line and sets (`accessibility_action`).
4448     fn accessibility(&mut self, nodes: &mut cce_ui::a11y::AppNodes) {
4449         use cce_ui::a11y::AppNodes;
4450         let has = self.keyboard_field();
4451         let focus = |field| (has == Some(field)).then(|| a11y_node(field));
4452         let mut focused = None;
4453 
4454         let bar = self.chrome_open.then(|| url_rect(&self.bar(), self.settings.bar_position));
4455         let mut t = self.url.a11y_text(has == Some(LineField::Url));
4456         t.placeholder = Some("Search or enter address".into());
4457         let url = nodes.text_field(a11y_node(LineField::Url), "Address", &t, bar);
4458         nodes.push_top(AppNodes::id(a11y_node(LineField::Url)), url);
4459         focused = focused.or(focus(LineField::Url));
4460 
4461         if let Some(m) = self.bm_menu.as_ref() {
4462             let mut t = m.query.a11y_text(has == Some(LineField::BmSearch));
4463             t.placeholder = Some("Search bookmarks".into());
4464             let rect = self.bm_layout().map(|l| l.search);
4465             let search = nodes.text_field(a11y_node(LineField::BmSearch), "Search bookmarks", &t, rect);
4466             nodes.push_top(AppNodes::id(a11y_node(LineField::BmSearch)), search);
4467             focused = focused.or(focus(LineField::BmSearch));
4468         }
4469         if self.settings.vi_mode && self.vi_mode == vi::Mode::Command {
4470             let label = if self.vi_prompt == vi::Prompt::Command { "Command" } else { "Find in page" };
4471             let line = nodes.text_field(a11y_node(LineField::ViCmd), label, &self.vi_cmd.a11y_text(has == Some(LineField::ViCmd)), None);
4472             nodes.push_top(AppNodes::id(a11y_node(LineField::ViCmd)), line);
4473             focused = focused.or(focus(LineField::ViCmd));
4474         }
4475         #[cfg(feature = "wpe")]
4476         if let Some(m) = self.modal.as_ref() {
4477             // The dialog, modal, its fields inside it: a reader keeps to it, as the keyboard does.
4478             let rect = m.rect(self.win);
4479             let mut dialog = cce_ui::accesskit::Node::new(cce_ui::accesskit::Role::Dialog);
4480             dialog.set_modal();
4481             if !m.title.is_empty() {
4482                 dialog.set_label(m.title.as_str());
4483             }
4484             if !m.message.is_empty() {
4485                 dialog.set_description(m.message.as_str());
4486             }
4487             let mut children = Vec::new();
4488             for (i, (label, edit)) in m.fields.iter().enumerate() {
4489                 let field = LineField::Dialog(i);
4490                 let mut t = edit.a11y_text(has == Some(field));
4491                 t.placeholder = Some((*label).to_string());
4492                 let node = nodes.text_field(a11y_node(field), label, &t, Some(m.field_rect(&rect, i)));
4493                 nodes.push(AppNodes::id(a11y_node(field)), node);
4494                 children.push(AppNodes::id(a11y_node(field)));
4495                 focused = focused.or(focus(field));
4496             }
4497             dialog.set_children(children);
4498             nodes.push_top(AppNodes::id(A11Y_DIALOG), dialog);
4499         }
4500         if let Some(n) = focused {
4501             nodes.set_focus(AppNodes::id(n));
4502         }
4503     }
4504 
4505     /// A reader's request on a line field: its keyboard, or a new text — set as typing it
4506     /// would be, the bookmarks list following its search. The address bar takes the
4507     /// keyboard to be set (unfolding the bar), since out of it the bar shows the page's
4508     /// address; Enter then goes where it says, as after typing.
4509     fn accessibility_action(&mut self, n: u64, action: cce_ui::a11y::AppAction) -> bool {
4510         use cce_ui::a11y::AppAction;
4511         let Some(field) = a11y_field(n) else { return false };
4512         let focus = |app: &mut Self| match field {
4513             LineField::Url => {
4514                 if !app.url_focused {
4515                     app.open_chrome();
4516                     app.url_focused = true;
4517                     app.url_new_tab = false;
4518                     app.select_all_url();
4519                 }
4520             }
4521             #[cfg(feature = "wpe")]
4522             LineField::Dialog(i) => {
4523                 if let Some(m) = app.modal.as_mut() {
4524                     m.focused = i;
4525                 }
4526             }
4527             // The keyboard is already the open menu's or the command line's.
4528             LineField::BmSearch | LineField::ViCmd => {}
4529         };
4530         match action {
4531             AppAction::Focus => {
4532                 focus(self);
4533                 true
4534             }
4535             AppAction::SetText(text) => {
4536                 focus(self);
4537                 let Some(edit) = self.line_field(field) else { return false };
4538                 let changed = edit.a11y_set_text(&text);
4539                 if changed && field == LineField::BmSearch {
4540                     self.bm_query_changed();
4541                 }
4542                 changed
4543             }
4544             _ => false,
4545         }
4546     }
4547 
4548     fn tick(&mut self, dt: f32, needs_rebuild: &mut bool) {
4549         // GLib's deadline (or the heartbeat) has come: pump. Through the
4550         // sender, so the pump runs in `update` like every other.
4551         #[cfg(feature = "wpe")]
4552         if !self.spin_queued && self.next_glib_pump().is_some_and(|t| t <= std::time::Instant::now()) {
4553             self.spin_queued = true;
4554             let _ = self.sender.send(Message::Spin);
4555         }
4556         // The page's wheel glide, one frame's worth. It feeds the engine, so
4557         // the frame it produces is what actually redraws; asking for a
4558         // rebuild here is what keeps the loop turning until it lands.
4559         if self.advance_scroll(dt) {
4560             *needs_rebuild = true;
4561         }
4562         let target = if self.chrome_open { 1.0 } else { 0.0 };
4563         if self.chrome_t != target {
4564             self.chrome_t = chrome_step(self.chrome_t, target, dt, cce_ui::motion::enabled());
4565             // Keeps the runner's warm loop alive until the morph lands.
4566             *needs_rebuild = true;
4567         }
4568     }
4569 
4570     /// Sleep no longer than GLib's next deadline (`next_glib_pump`); the
4571     /// `tick` that wakes then sends the pump.
4572     #[cfg(feature = "wpe")]
4573     fn idle_poll_interval(&self) -> Option<std::time::Duration> {
4574         self.next_glib_pump()
4575             .map(|t| t.saturating_duration_since(std::time::Instant::now()).max(std::time::Duration::from_millis(1)))
4576     }
4577 
4578     fn handle_focus_change(&mut self, focused: bool, needs_rebuild: &mut bool) {
4579         // The page's own focus: without it WebKit paints no text caret.
4580         self.host.focus(focused);
4581         // Keys and buttons released while another window had focus never
4582         // reach us; forget them rather than act on a stale Shift or drag.
4583         if !focused {
4584             self.shift_held = false;
4585             self.vi_swallowed.clear();
4586             // A select's list is a transient of the page, and goes with focus
4587             // as a native one does.
4588             #[cfg(feature = "wpe")]
4589             self.close_opt_menu();
4590             if self.url.dragging() {
4591                 self.url.release();
4592                 self.url_entry_press = false;
4593             }
4594             #[cfg(feature = "wpe")]
4595             if let Some(m) = self.modal.as_mut() {
4596                 for (_, edit) in m.fields.iter_mut() {
4597                     edit.release();
4598                 }
4599             }
4600             if let Some(m) = self.bm_menu.as_mut() {
4601                 m.query.release();
4602             }
4603         }
4604         // A settings change can move the bar to the other edge, so a reload
4605         // that changed anything has to redraw the chrome.
4606         if focused && self.reload_settings() {
4607             *needs_rebuild = true;
4608         }
4609     }
4610 
4611     fn handle_resize(&mut self, width: f32, height: f32, scale: f64) {
4612         // The select the list hangs from is about to move.
4613         #[cfg(feature = "wpe")]
4614         if self.win != (width, height) {
4615             self.close_opt_menu();
4616         }
4617         if self.win != (width, height) && self.vi_mode == vi::Mode::Hint {
4618             self.vi_set_mode(vi::Mode::Normal);
4619         }
4620         self.win = (width, height);
4621         self.scale = scale;
4622         let (w, h) = self.content_px();
4623         self.host.resize(w, h, scale as f32);
4624     }
4625 
4626     /// Re-paint the page when the renderer is replaced.
4627     ///
4628     /// The tab images are **renderer** ids, and a renderer does not outlive
4629     /// its session — `window_runner` rebuilds it around the same
4630     /// `Application` after a lost Wayland transport, and a draw for an
4631     /// unknown id is skipped rather than reported. See
4632     /// `Host::renderer_replaced` for why dropping the ids is only half of it.
4633     ///
4634     /// Not on the first renderer: no page has rendered yet, and remapping the
4635     /// view before the first frame would only make the engine repeat work.
4636     fn renderer_init(&mut self, _renderer: &mut cce_ui::vk::VkRenderer) {
4637         if std::mem::replace(&mut self.seen_renderer, true) {
4638             log::info!("[browser] renderer replaced; re-painting the page");
4639             self.host.renderer_replaced();
4640         }
4641     }
4642 
4643     fn handle_pointer_move(&mut self, pos: LogicalPosition, _needs_rebuild: &mut bool) {
4644         self.pointer = (pos.x, pos.y);
4645         // A drag that began in the URL field owns the pointer until the
4646         // release, wherever it goes: past either end of the field it selects
4647         // to that end, and the page never sees the motion.
4648         #[cfg(feature = "wpe")]
4649         if let Some(i) = self
4650             .modal
4651             .as_ref()
4652             .and_then(|m| m.fields.iter().position(|(_, e)| e.dragging()))
4653         {
4654             if let Some(at) = self.modal_index_at(i, pos.x) {
4655                 if let Some(m) = self.modal.as_mut() {
4656                     if m.fields[i].1.drag_to(at) {
4657                         *_needs_rebuild = true;
4658                     }
4659                 }
4660             }
4661             return;
4662         }
4663         if self.url.dragging() {
4664             let field = url_rect(&self.bar(), self.settings.bar_position);
4665             let at = self.cursor_from_click(pos.x, &field);
4666             if self.url.drag_to(at) {
4667                 // Moved: the press is a drag, not the click that selects all.
4668                 self.url_entry_press = false;
4669                 *_needs_rebuild = true;
4670             }
4671             return;
4672         }
4673         // A select's list moves its highlight onto a row the pointer could
4674         // pick; off the rows it stays where it was, as a native list's does.
4675         // The page under it sees no moves.
4676         #[cfg(feature = "wpe")]
4677         if self.opt_menu.is_some() {
4678             let over = self
4679                 .opt_hit(pos.x, pos.y)
4680                 .filter(|&i| self.opt_menu.as_ref().is_some_and(|m| m.pickable(i)));
4681             if let (Some(i), Some(m)) = (over, self.opt_menu.as_mut()) {
4682                 if m.highlight != Some(i) {
4683                     m.highlight = Some(i);
4684                     *_needs_rebuild = true;
4685                 }
4686             }
4687             return;
4688         }
4689         #[cfg(feature = "wpe")]
4690         if self.ctx_menu.is_some() {
4691             // Hover highlight tracks the pointer; the page underneath does
4692             // not see moves while the menu is up.
4693             *_needs_rebuild = true;
4694             return;
4695         }
4696         // The account list tracks hover the same way, and shields the page
4697         // under it.
4698         #[cfg(feature = "wpe")]
4699         if self.ac_menu.is_some() {
4700             let over = self.ac_hit(pos.x, pos.y);
4701             if self.ac_menu.as_ref().is_some_and(|m| m.hover != over) {
4702                 if let Some(m) = self.ac_menu.as_mut() {
4703                     m.hover = over;
4704                 }
4705                 *_needs_rebuild = true;
4706             }
4707             if over.is_some() {
4708                 return;
4709             }
4710         }
4711 
4712         // An open bookmarks menu tracks hover, and the page under it sees
4713         // no moves at all.
4714         if self.bm_menu.is_some() {
4715             if self.bm_menu.as_ref().is_some_and(|m| m.query.dragging()) {
4716                 if let Some(at) = self.bm_query_index_at(pos.x) {
4717                     if self.bm_menu.as_mut().is_some_and(|m| m.query.drag_to(at)) {
4718                         *_needs_rebuild = true;
4719                     }
4720                 }
4721                 return;
4722             }
4723             let h = self.bm_hit(pos.x, pos.y);
4724             if self.bm_menu.as_ref().is_some_and(|m| m.hover != h) {
4725                 if let Some(m) = self.bm_menu.as_mut() {
4726                     m.hover = h;
4727                 }
4728                 *_needs_rebuild = true;
4729             }
4730             return;
4731         }
4732 
4733         let over_dot = self.dot_hit(pos.x, pos.y);
4734         if over_dot != self.dot_hover {
4735             self.dot_hover = over_dot;
4736             *_needs_rebuild = true;
4737         }
4738         let over_fav = if self.chrome_open && !self.favs.is_empty() {
4739             let bar = self.bar();
4740             self.fav_rects(&bar).iter().position(|r| r.contains(pos.x, pos.y))
4741         } else {
4742             None
4743         };
4744         if over_fav != self.fav_hover {
4745             self.fav_hover = over_fav;
4746             *_needs_rebuild = true;
4747         }
4748         if !self.chrome_hit(pos.x, pos.y) {
4749             let s = self.scale as f32;
4750             self.host.mouse_move(pos.x * s, pos.y * s);
4751         }
4752     }
4753 
4754     fn handle_mouse_input(
4755         &mut self,
4756         button: MouseButton,
4757         state: ElementState,
4758         pos: LogicalPosition,
4759         needs_rebuild: &mut bool,
4760     ) -> Option<Self::Message> {
4761         let pressed = state == ElementState::Pressed;
4762 
4763         // A click ends hinting (the labels no longer match what is under
4764         // them once anything moves) and the `:` line, as a click off any
4765         // other field drops it.
4766         if pressed && matches!(self.vi_mode, vi::Mode::Hint | vi::Mode::Command) {
4767             self.vi_set_mode(vi::Mode::Normal);
4768             *needs_rebuild = true;
4769         }
4770 
4771         // Before any of the branches that swallow a click: a button the page
4772         // is holding gets its release no matter where it was let go, or the
4773         // engine goes on believing it is still down.
4774         if !pressed {
4775             self.drain_page_release(button, pos);
4776         }
4777 
4778         // The end of a drag in the URL field, wherever the pointer is now.
4779         if !pressed && button == MouseButton::Left && self.url.dragging() {
4780             self.url.release();
4781             if std::mem::take(&mut self.url_entry_press) {
4782                 self.select_all_url();
4783             }
4784             *needs_rebuild = true;
4785             return None;
4786         }
4787 
4788         #[cfg(feature = "wpe")]
4789         if self.modal.is_some() {
4790             // The end of a drag in a dialog field, wherever the pointer is.
4791             if !pressed && button == MouseButton::Left {
4792                 if let Some(m) = self.modal.as_mut() {
4793                     for (_, edit) in m.fields.iter_mut() {
4794                         edit.release();
4795                     }
4796                 }
4797                 return None;
4798             }
4799             if !pressed || button != MouseButton::Left {
4800                 return None;
4801             }
4802             *needs_rebuild = true;
4803             let (hit_ok, hit_cancel, field) = {
4804                 let m = self.modal.as_ref().unwrap();
4805                 let r = m.rect(self.win);
4806                 let (ok, cancel) = m.button_rects(&r);
4807                 (
4808                     ok.contains(pos.x, pos.y),
4809                     cancel.is_some_and(|c| c.contains(pos.x, pos.y)),
4810                     (0..m.fields.len()).find(|&i| m.field_rect(&r, i).contains(pos.x, pos.y)),
4811                 )
4812             };
4813             if hit_ok {
4814                 self.close_modal(true);
4815             } else if hit_cancel {
4816                 self.close_modal(false);
4817             } else if let Some(i) = field {
4818                 // A press places the caret and starts a drag; Shift extends
4819                 // the selection — in the field that already had focus.
4820                 let at = self.modal_index_at(i, pos.x);
4821                 let shift = self.shift_held;
4822                 if let (Some(at), Some(m)) = (at, self.modal.as_mut()) {
4823                     let extend = shift && m.focused == i;
4824                     m.focused = i;
4825                     m.fields[i].1.press(at, extend);
4826                 }
4827             }
4828             // Anything else is swallowed: the page must not receive clicks
4829             // while it is blocked waiting on this.
4830             return None;
4831         }
4832 
4833         // An open select list owns the next click: on a row it picks, off the
4834         // plate it closes — clicking the select again included — and either
4835         // way the click goes no further.
4836         #[cfg(feature = "wpe")]
4837         if let Some(l) = self.opt_layout() {
4838             if pressed {
4839                 *needs_rebuild = true;
4840                 match self.opt_hit(pos.x, pos.y) {
4841                     Some(i) if button == MouseButton::Left => self.pick_opt(i),
4842                     _ if !l.plate.contains(pos.x, pos.y) => self.close_opt_menu(),
4843                     _ => {}
4844                 }
4845             }
4846             return None;
4847         }
4848 
4849         // An open context menu owns the next click: on an item it dispatches,
4850         // anywhere else it just closes — either way the click goes no further.
4851         #[cfg(feature = "wpe")]
4852         if let Some(menu) = self.ctx_menu.as_ref() {
4853             if pressed {
4854                 *needs_rebuild = true;
4855                 match (button, menu.item_at(pos.x, pos.y)) {
4856                     (MouseButton::Left, Some(i)) => self.dispatch_ctx_action(i),
4857                     _ => self.ctx_menu = None,
4858                 }
4859             }
4860             return None;
4861         }
4862 
4863         // The save offer takes any press on its plate, buttons or not; the
4864         // page under it never sees one. Presses elsewhere leave it waiting.
4865         #[cfg(feature = "wpe")]
4866         if self.save_layout().is_some_and(|l| l.plate.contains(pos.x, pos.y)) {
4867             if pressed && button == MouseButton::Left {
4868                 self.save_click(pos.x, pos.y);
4869             }
4870             *needs_rebuild = true;
4871             return None;
4872         }
4873 
4874         // A click on an account row picks it. A click anywhere else closes
4875         // the list and goes on to the page as usual — unlike the chrome's own
4876         // menus, this one sits over the page's own controls, and swallowing
4877         // the click that dismisses it would eat a button press.
4878         #[cfg(feature = "wpe")]
4879         if self.ac_menu.is_some() {
4880             if let Some(index) = self.ac_hit(pos.x, pos.y) {
4881                 if pressed && button == MouseButton::Left {
4882                     self.pick_account(index);
4883                 }
4884                 *needs_rebuild = true;
4885                 return None;
4886             }
4887             if pressed {
4888                 self.ac_menu = None;
4889                 *needs_rebuild = true;
4890             }
4891         }
4892 
4893         // The bookmarks menu owns the next click while it is open: a row
4894         // acts, a click off the plate closes it, and either way the click
4895         // goes no further — the rule the right-click menu already follows.
4896         if self.bm_menu.is_some() {
4897             if !pressed {
4898                 // The end of a drag in the search field, wherever it is.
4899                 if button == MouseButton::Left {
4900                     if let Some(m) = self.bm_menu.as_mut() {
4901                         m.query.release();
4902                     }
4903                 }
4904                 return None;
4905             }
4906             *needs_rebuild = true;
4907             let target = self.bm_hit(pos.x, pos.y);
4908             // The search field: a press places the caret and starts a drag,
4909             // Shift extends — the URL bar's rules.
4910             if target == Some(BmHit::Search) {
4911                 if button == MouseButton::Left {
4912                     let at = self.bm_query_index_at(pos.x);
4913                     let shift = self.shift_held;
4914                     if let (Some(at), Some(m)) = (at, self.bm_menu.as_mut()) {
4915                         m.query.press(at, shift);
4916                     }
4917                 }
4918                 return None;
4919             }
4920             let inside = self.bm_layout().is_some_and(|l| l.plate.contains(pos.x, pos.y));
4921             if target.is_some() {
4922                 self.bm_click(button, target);
4923             } else if !inside {
4924                 self.close_bm_menu();
4925             }
4926             return None;
4927         }
4928 
4929         let bar = self.bar();
4930         let pos_edge = self.settings.bar_position;
4931         if self.chrome_hit(pos.x, pos.y) {
4932             if !pressed || !matches!(button, MouseButton::Left | MouseButton::Middle) {
4933                 return None;
4934             }
4935             *needs_rebuild = true;
4936             // Using a bar that was only peeking makes it the person's.
4937             self.chrome_peek = None;
4938             // The corner control toggles the bar, open or closed.
4939             if self.dot_hit(pos.x, pos.y) {
4940                 if button == MouseButton::Left {
4941                     if self.chrome_open {
4942                         self.close_chrome();
4943                     } else {
4944                         self.open_chrome();
4945                     }
4946                 }
4947                 return None;
4948             }
4949             // Still folding shut: nothing under the plate is live.
4950             if !self.chrome_open {
4951                 return None;
4952             }
4953             // Tab strip: activate / close (x region or middle click) / new tab.
4954             let count = self.host.tab_count();
4955             for i in 0..count {
4956                 let pill = tab_rect(&bar, pos_edge, count, i);
4957                 if !pill.contains(pos.x, pos.y) {
4958                     continue;
4959                 }
4960                 let on_close =
4961                     tab_close_rect(&pill).is_some_and(|r| r.contains(pos.x, pos.y));
4962                 if button == MouseButton::Middle || on_close {
4963                     return self.close_tab(i);
4964                 }
4965                 self.switch_tab(i);
4966                 // Picking a tab is a menu choice: the bar folds away. Closing
4967                 // one is not — several may go in a row.
4968                 self.close_chrome();
4969                 return None;
4970             }
4971             // Favorites strip: a pill is a menu pick — load it here and fold
4972             // — or, middle-clicked, a new tab, with the bar left out so
4973             // several can be opened in a row.
4974             if let Some(i) = self.fav_rects(&bar).iter().position(|r| r.contains(pos.x, pos.y)) {
4975                 let Ok(url) = Url::parse(&self.favs[i].url) else { return None };
4976                 if button == MouseButton::Middle {
4977                     self.open_background_tab(url);
4978                 } else {
4979                     self.host.load(url);
4980                     self.loading = true;
4981                     self.close_chrome();
4982                     self.sync_page_state();
4983                 }
4984                 return None;
4985             }
4986             if button != MouseButton::Left {
4987                 return None;
4988             }
4989             if plus_rect(&bar, pos_edge).contains(pos.x, pos.y) {
4990                 self.new_tab();
4991             } else if btn_rect(&bar, 0).contains(pos.x, pos.y) {
4992                 self.host.back();
4993             } else if btn_rect(&bar, 1).contains(pos.x, pos.y) {
4994                 self.host.forward();
4995             } else if btn_rect(&bar, 2).contains(pos.x, pos.y) {
4996                 self.host.reload();
4997             } else if star_rect(&bar, pos_edge).contains(pos.x, pos.y) {
4998                 self.host.toggle_bookmark();
4999             } else if bm_btn_rect(&bar, pos_edge).contains(pos.x, pos.y) {
5000                 self.open_bm_menu();
5001             } else {
5002                 let field = url_rect(&bar, pos_edge);
5003                 if field.contains(pos.x, pos.y) {
5004                     let at = self.cursor_from_click(pos.x, &field);
5005                     // Entering the bar selects the whole URL, so typing
5006                     // replaces it instead of appending to it — on the
5007                     // release, if this press is not dragged (see
5008                     // `url_entry_press`). Inside the bar a press places the
5009                     // caret, Shift extends to it, and a drag selects.
5010                     self.url_entry_press = !self.url_focused;
5011                     let extend = self.url_focused && self.shift_held;
5012                     if !self.url_focused {
5013                         self.url_new_tab = false;
5014                     }
5015                     self.url_focused = true;
5016                     self.url.press(at, extend);
5017                 } else {
5018                     self.url_focused = false;
5019                     self.url.selection = None;
5020                 }
5021             }
5022             return None;
5023         }
5024 
5025         // Page area: a click folds the menu (and URL-bar focus with it),
5026         // then goes to the page. Not a peek, which folds on its own: a run
5027         // of middle-clicked links keeps it out rather than flapping it.
5028         if self.chrome_open && pressed && self.chrome_peek.is_none() {
5029             self.close_chrome();
5030             *needs_rebuild = true;
5031         }
5032         match button {
5033             MouseButton::Back if pressed => self.host.back(),
5034             MouseButton::Forward if pressed => self.host.forward(),
5035             _ => self.page_press(button, pressed, pos),
5036         }
5037         if button == MouseButton::Left && self.settings.vi_mode {
5038             if pressed {
5039                 self.vi_click = Some(std::time::Instant::now());
5040             } else if self.vi_mode == vi::Mode::Normal {
5041                 // Clicking a field that already had focus moves no focus,
5042                 // so the watcher has nothing to say; ask the page instead.
5043                 self.vi_ask(ViAsk::ClickCheck, &vi::active_editable_js());
5044             }
5045         }
5046         None
5047     }
5048 
5049     fn handle_mouse_wheel(&mut self, delta: &MouseScrollDelta, pos: LogicalPosition, needs_rebuild: &mut bool) {
5050         self.scroll_origin = None;
5051         // The labels would stay put while the page moved under them.
5052         if self.vi_mode == vi::Mode::Hint {
5053             self.vi_set_mode(vi::Mode::Normal);
5054             *needs_rebuild = true;
5055         }
5056         // A select's list takes the wheel: over it, it scrolls the list;
5057         // anywhere else it is swallowed, so the select stays under it.
5058         #[cfg(feature = "wpe")]
5059         if let Some(l) = self.opt_layout() {
5060             if l.plate.contains(pos.x, pos.y) {
5061                 // Positive is up, cce-ui's winit convention; three rows a
5062                 // notch, a row per row's height of finger travel.
5063                 let travel = match delta {
5064                     MouseScrollDelta::LineDelta(_, y) => -*y as f64 * 3.0,
5065                     MouseScrollDelta::PixelDelta(p) => -p.y / OPT_ROW_H as f64,
5066                 };
5067                 let Some(m) = self.opt_menu.as_mut() else { return };
5068                 m.wheel_rest += travel;
5069                 let rows = m.wheel_rest.trunc();
5070                 m.wheel_rest -= rows;
5071                 self.opt_scroll(rows as isize);
5072                 // The rows moved under a pointer that did not.
5073                 let over = self.opt_hit(pos.x, pos.y);
5074                 if let (Some(i), Some(m)) = (over, self.opt_menu.as_mut()) {
5075                     if m.pickable(i) {
5076                         m.highlight = Some(i);
5077                     }
5078                 }
5079                 *needs_rebuild = true;
5080             }
5081             return;
5082         }
5083         // The account list moves with its field, so the page keeps the
5084         // wheel — but not under the plate itself.
5085         #[cfg(feature = "wpe")]
5086         if self
5087             .ac_layout()
5088             .is_some_and(|(plate, _)| plate.contains(pos.x, pos.y))
5089         {
5090             return;
5091         }
5092 
5093         // An open menu takes the wheel: over its plate it scrolls the list,
5094         // anywhere else it is swallowed rather than scrolling the page
5095         // behind it.
5096         if self.bm_menu.is_some() {
5097             if self.bm_layout().is_some_and(|l| l.plate.contains(pos.x, pos.y)) {
5098                 let dy = match delta {
5099                     MouseScrollDelta::LineDelta(_, y) => *y as f64,
5100                     MouseScrollDelta::PixelDelta(p) => p.y,
5101                 };
5102                 self.bm_scroll(dy);
5103                 *needs_rebuild = true;
5104             }
5105             return;
5106         }
5107         if self.chrome_hit(pos.x, pos.y) {
5108             return;
5109         }
5110         // A wheel notch eases instead of jumping, through the same model
5111         // every other cce app scrolls by (`smooth_scroll` / `scroll_ease` in
5112         // input.kdl, this app's domain then `cce-ui`'s). Without it a notch
5113         // moved the page LINE_PX in one step, which is the browser feeling
5114         // unlike the rest of the desktop.
5115         //
5116         // Only a *notch* takes this path. A trackpad's pixel deltas already
5117         // follow the finger, and the engine runs its own kinetic scrolling off
5118         // the gesture phases this passes it — two coast models fighting over
5119         // one page would be worse than either.
5120         let discrete = matches!(delta, MouseScrollDelta::LineDelta(..));
5121         let phase = cce_ui::widget::scroll_motion::current_scroll_phase();
5122         if discrete
5123             && phase == cce_ui::widget::ScrollPhase::Wheel
5124             && cce_ui::widget::scroll_motion::scroll_settings().smooth
5125         {
5126             use cce_ui::widget::scroll_motion::Bounds;
5127             // Unbounded: the page's real limits are WebKit's business, and it
5128             // clamps. Notches arriving mid-glide accumulate into one movement
5129             // rather than a staircase.
5130             self.scroll.apply(
5131                 delta,
5132                 (LINE_PX as f32, LINE_PX as f32),
5133                 Bounds::UNBOUNDED,
5134                 Bounds::UNBOUNDED,
5135             );
5136             // Keeps the runner's loop warm until the glide lands, the same
5137             // way the chrome's unfold does.
5138             *needs_rebuild = true;
5139             return;
5140         }
5141 
5142         // WheelDelta keeps cce-ui's winit sign convention (positive = scroll
5143         // up); the engine inverts it into the scroll offset internally, after
5144         // the page has had its preventDefault chance.
5145         let (dx, dy) = match delta {
5146             MouseScrollDelta::LineDelta(x, y) => (*x as f64 * LINE_PX, *y as f64 * LINE_PX),
5147             MouseScrollDelta::PixelDelta(p) => (p.x, p.y),
5148         };
5149         let s = self.scale;
5150         self.host.wheel(dx * s, dy * s, pos.x * s as f32, pos.y * s as f32);
5151     }
5152 
5153     fn handle_key_input(&mut self, event: &KeyEvent, needs_rebuild: &mut bool) -> Option<Self::Message> {
5154         // Noted, never consumed: Shift still goes wherever keys go.
5155         if matches!(event.logical_key, Key::Named(NamedKey::Shift)) {
5156             self.shift_held = event.state == ElementState::Pressed;
5157         }
5158         // An open select list has the keyboard: arrows and paging move the
5159         // highlight, Enter (or Space, before any type-to-find) picks it,
5160         // Escape and Tab close, and letters find. Nothing reaches the page or
5161         // the chrome's chords while it is up.
5162         #[cfg(feature = "wpe")]
5163         if self.opt_menu.is_some() && event.state == ElementState::Pressed {
5164             *needs_rebuild = true;
5165             let cap = self.opt_layout().map_or(1, |l| l.cap) as isize;
5166             let typing = self.opt_menu.as_ref().is_some_and(|m| {
5167                 !m.typed.is_empty() && m.typed_at.elapsed() <= OPT_TYPE_RESET
5168             });
5169             let Some(m) = self.opt_menu.as_mut() else { return None };
5170             match &event.logical_key {
5171                 Key::Named(NamedKey::ArrowDown) => m.step(1),
5172                 Key::Named(NamedKey::ArrowUp) => m.step(-1),
5173                 Key::Named(NamedKey::PageDown) => m.step(cap - 1),
5174                 Key::Named(NamedKey::PageUp) => m.step(1 - cap),
5175                 Key::Named(NamedKey::End) => m.step(isize::MAX),
5176                 Key::Named(NamedKey::Home) => m.step(isize::MIN + 1),
5177                 Key::Named(NamedKey::Escape | NamedKey::Tab) => {
5178                     self.close_opt_menu();
5179                     return None;
5180                 }
5181                 Key::Named(NamedKey::Space) if !typing => {
5182                     if let Some(i) = m.highlight {
5183                         self.pick_opt(i);
5184                     }
5185                     return None;
5186                 }
5187                 Key::Named(NamedKey::Enter) => {
5188                     match m.highlight {
5189                         Some(i) => self.pick_opt(i),
5190                         None => self.close_opt_menu(),
5191                     }
5192                     return None;
5193                 }
5194                 _ if !event.ctrl && !event.alt => {
5195                     if let Some(text) = event.text.as_deref().filter(|t| !t.is_empty()) {
5196                         m.find_typed(text);
5197                     }
5198                 }
5199                 _ => {}
5200             }
5201             self.opt_reveal();
5202             return None;
5203         }
5204         #[cfg(feature = "wpe")]
5205         if self.ctx_menu.is_some() && event.state == ElementState::Pressed {
5206             // Any key dismisses; Escape is just the one people will mean.
5207             self.ctx_menu = None;
5208             *needs_rebuild = true;
5209             return None;
5210         }
5211 
5212         // A modal is exactly that: the page is blocked inside WebKit, so the
5213         // chrome's own chords must not fire behind it either.
5214         #[cfg(feature = "wpe")]
5215         if self.modal.is_some() {
5216             *needs_rebuild = true;
5217             if event.state == ElementState::Pressed
5218                 && event.logical_key == Key::Named(NamedKey::Tab)
5219             {
5220                 if let Some(m) = self.modal.as_mut() {
5221                     if !m.fields.is_empty() {
5222                         let n = m.fields.len();
5223                         m.focused = if event.shift {
5224                             (m.focused + n - 1) % n
5225                         } else {
5226                             (m.focused + 1) % n
5227                         };
5228                     }
5229                 }
5230                 return None;
5231             }
5232             let outcome = match self.modal.as_mut() {
5233                 Some(m) if !m.fields.is_empty() => {
5234                     let i = m.focused;
5235                     m.fields[i].1.handle_key(event)
5236                 }
5237                 // No field: Enter accepts, Escape cancels, nothing else acts.
5238                 // Except over a hang, where accepting kills the page: that
5239                 // question can pop up mid-typing, and an Enter meant for the
5240                 // page must not throw away what was typed into it.
5241                 Some(m) => match (&event.logical_key, event.state) {
5242                     (Key::Named(NamedKey::Enter), ElementState::Pressed)
5243                         if !matches!(m.kind, ModalKind::Unresponsive) =>
5244                     {
5245                         cce_ui::widget::EditOutcome::Submit
5246                     }
5247                     (Key::Named(NamedKey::Escape), ElementState::Pressed) => {
5248                         cce_ui::widget::EditOutcome::Cancel
5249                     }
5250                     _ => cce_ui::widget::EditOutcome::Ignored,
5251                 },
5252                 None => cce_ui::widget::EditOutcome::Ignored,
5253             };
5254             match outcome {
5255                 cce_ui::widget::EditOutcome::Submit => self.close_modal(true),
5256                 cce_ui::widget::EditOutcome::Cancel => self.close_modal(false),
5257                 _ => {}
5258             }
5259             return None;
5260         }
5261 
5262         // An open account list takes the keys that drive it, and passes on
5263         // everything else — the person is typing into the page's own field,
5264         // and that typing is what filters the list.
5265         #[cfg(feature = "wpe")]
5266         if self.ac_layout().is_some() && event.state == ElementState::Pressed && !self.url_focused {
5267             match &event.logical_key {
5268                 Key::Named(NamedKey::ArrowDown) => {
5269                     if let Some(m) = self.ac_menu.as_mut() {
5270                         m.step(1);
5271                     }
5272                     *needs_rebuild = true;
5273                     return None;
5274                 }
5275                 Key::Named(NamedKey::ArrowUp) => {
5276                     if let Some(m) = self.ac_menu.as_mut() {
5277                         m.step(-1);
5278                     }
5279                     *needs_rebuild = true;
5280                     return None;
5281                 }
5282                 Key::Named(NamedKey::Enter) => {
5283                     let selected = self.ac_menu.as_ref().map(|m| m.selected);
5284                     if let Some(i) = selected {
5285                         self.pick_account(i);
5286                     }
5287                     *needs_rebuild = true;
5288                     return None;
5289                 }
5290                 Key::Named(NamedKey::Escape) => {
5291                     self.ac_menu = None;
5292                     *needs_rebuild = true;
5293                     return None;
5294                 }
5295                 _ => {}
5296             }
5297         }
5298 
5299         // An open bookmarks menu owns Escape, ahead of the URL bar and the
5300         // page both.
5301         if self.bm_menu.is_some()
5302             && event.state == ElementState::Pressed
5303             && event.logical_key == Key::Named(NamedKey::Escape)
5304         {
5305             self.close_bm_menu();
5306             *needs_rebuild = true;
5307             return None;
5308         }
5309 
5310         // Vi mode, ahead of the chrome's chords: normal mode's Ctrl bindings
5311         // (Ctrl+D/U/F/B scroll, Ctrl+V is passthrough) win over them, and
5312         // passthrough hands the page even those.
5313         if let Some(out) = self.vi_key(event, needs_rebuild) {
5314             return out;
5315         }
5316 
5317         // Tab shortcuts work regardless of URL-bar focus.
5318         if event.state == ElementState::Pressed && event.ctrl {
5319             let count = self.host.tab_count();
5320             match &event.logical_key {
5321                 Key::Character(c) if c == "t" => {
5322                     self.new_tab();
5323                     *needs_rebuild = true;
5324                     return None;
5325                 }
5326                 Key::Character(c) if c == "w" => {
5327                     *needs_rebuild = true;
5328                     return self.close_tab(self.host.active_index());
5329                 }
5330                 // Ctrl+Shift+Delete opens the cookie page rather than
5331                 // clearing outright; the page asks first.
5332                 Key::Named(NamedKey::Delete) if event.shift => {
5333                     self.open_internal_page("cce://cookies");
5334                     *needs_rebuild = true;
5335                     return None;
5336                 }
5337                 // The favorites pair sits a Shift above the bookmarks pair:
5338                 // Ctrl+Shift+D toggles the page in the strip, Ctrl+Shift+B
5339                 // opens the page that manages it.
5340                 Key::Character(c) if event.shift && c.eq_ignore_ascii_case("d") => {
5341                     self.toggle_favorite();
5342                     *needs_rebuild = true;
5343                     return None;
5344                 }
5345                 Key::Character(c) if event.shift && c.eq_ignore_ascii_case("b") => {
5346                     self.open_internal_page("cce://favorites");
5347                     *needs_rebuild = true;
5348                     return None;
5349                 }
5350                 Key::Character(c) if c == "h" || c == "b" || c == "j" => {
5351                     let page = match c.as_str() {
5352                         "h" => "cce://history",
5353                         "b" => "cce://bookmarks",
5354                         _ => "cce://downloads",
5355                     };
5356                     self.open_internal_page(page);
5357                     *needs_rebuild = true;
5358                     return None;
5359                 }
5360                 Key::Character(c) if c == "d" => {
5361                     self.host.toggle_bookmark();
5362                     *needs_rebuild = true;
5363                     return None;
5364                 }
5365                 // Ctrl+Shift+O: open the current page in another browser.
5366                 Key::Character(c) if event.shift && c.eq_ignore_ascii_case("o") => {
5367                     self.open_external();
5368                     return None;
5369                 }
5370                 Key::Named(NamedKey::Tab) if count > 1 => {
5371                     let cur = self.host.active_index();
5372                     let next = if event.shift { (cur + count - 1) % count } else { (cur + 1) % count };
5373                     self.switch_tab(next);
5374                     *needs_rebuild = true;
5375                     return None;
5376                 }
5377                 _ => {}
5378             }
5379         }
5380 
5381         // An open bookmarks menu's search field has the keyboard, the way a
5382         // focused URL bar does (opening the menu drops the bar's focus).
5383         if self.bm_menu.is_some() {
5384             if event.state == ElementState::Pressed {
5385                 self.edit_bm_search(event);
5386                 *needs_rebuild = true;
5387             }
5388             return None;
5389         }
5390 
5391         if self.url_focused {
5392             if event.state == ElementState::Pressed {
5393                 self.edit_url(event);
5394                 *needs_rebuild = true;
5395             }
5396             return None;
5397         }
5398 
5399         if event.state == ElementState::Pressed {
5400             if event.ctrl {
5401                 if let Key::Character(c) = &event.logical_key {
5402                     match c.as_str() {
5403                         // Page clipboard: Servo needs the chord as an
5404                         // editing action, not as the raw keystroke.
5405                         "c" | "x" | "v" => {
5406                             self.host.editing_action_cmd(match c.as_str() {
5407                                 "c" => EditingCommand::Copy,
5408                                 "x" => EditingCommand::Cut,
5409                                 _ => EditingCommand::Paste,
5410                             });
5411                             return None;
5412                         }
5413                         "l" => {
5414                             self.open_chrome();
5415                             self.url_new_tab = false;
5416                             self.url_focused = true;
5417                             self.select_all_url();
5418                             *needs_rebuild = true;
5419                             return None;
5420                         }
5421                         "r" => {
5422                             self.host.reload();
5423                             return None;
5424                         }
5425                         _ => {}
5426                     }
5427                 }
5428             }
5429             if event.logical_key == Key::Named(NamedKey::F5) {
5430                 self.host.reload();
5431                 return None;
5432             }
5433             // An open menu owns Escape; closed, the page keeps it.
5434             if self.chrome_open && event.logical_key == Key::Named(NamedKey::Escape) {
5435                 self.close_chrome();
5436                 *needs_rebuild = true;
5437                 return None;
5438             }
5439         }
5440 
5441         self.host.key_ui(event);
5442         None
5443     }
5444 
5445     fn display_list(&mut self, size: LogicalSize, _scale: f64) -> Option<DisplayList> {
5446         // Whatever the engine last handed over is about to be on screen. That
5447         // is what lets the next one be read: until a frame is drawn, reading
5448         // another would be copying over a picture nobody saw. One already
5449         // waiting has to be fetched: its page is held up until it is read,
5450         // and a held-up page makes no noise that would turn the loop.
5451         #[cfg(feature = "wpe")]
5452         if self.host.frame_drawn() {
5453             let _ = self.sender.send(Message::Spin);
5454         }
5455         self.win = (size.width, size.height);
5456         self.sync_ime();
5457         let mut pc = PaintCtx::new();
5458         let w = size.width;
5459 
5460         let bar = self.bar();
5461         let pos_edge = self.settings.bar_position;
5462 
5463         // The standard root plate (cce-ui PlateSpec::window); the page is full-bleed content drawn on it.
5464         pc.root_plate(w, size.height);
5465         // Page: full-bleed under the floating bar.
5466         let content = Rect { x: 0.0, y: 0.0, width: w, height: size.height };
5467         if let Some((id, ..)) = self.host.image() {
5468             pc.image(id, content, 1.0);
5469         } else {
5470             // Just clear of the bar, whichever edge it is on.
5471             let y = match self.settings.bar_position {
5472                 settings::BarPosition::Top => bar.y + bar.height + item_gap(),
5473                 settings::BarPosition::Bottom => bar_margin(),
5474             };
5475             pc.text("Loading...", bar_margin(), y, 13.0, TEXT_DIM);
5476         }
5477         // A text field in the page has focus: claim its caret, so a tap on it
5478         // raises the on-screen keyboard. First, so a chrome field drawn over
5479         // the page (URL bar, dialog, vi line) claims last and wins.
5480         #[cfg(feature = "wpe")]
5481         if let Some((x, y, w, h)) = self.host.page_text_field() {
5482             cce_ui::text_input::claim(x, y, w, h);
5483         }
5484 
5485         // The bar plate — or the shape it is unfolding through. Nothing but
5486         // the corner control shows while closed. Blur-behind, frosting the
5487         // page under it; the corner exponent eases from circular at the
5488         // dot-sized seed to the DE's own once it is the bar.
5489         let e = self.chrome_ease();
5490         let (plate, radius) = self.chrome_plate();
5491         let (sans, ..) = cce_ui::layout::read_preferred_fonts();
5492         if e > 0.0 {
5493             let shape = 2.0 + (cce_ui::layout::corner_shape() - 2.0) * e;
5494             pc.plate_shaped(
5495                 plate,
5496                 (radius, radius, radius, radius),
5497                 &cce_ui::scene::Material::from_fill(BAR_FILL),
5498                 cce_ui::layout::bevel_width().min(4.0),
5499                 Some(shape),
5500             );
5501         }
5502 
5503         // Open: the bar's contents, laid out at their final positions and
5504         // clipped to the plate, so they are revealed as it unfolds.
5505         if e > 0.0 {
5506             pc.clip_rounded(plate, radius, |pc| {
5507                 if self.loading {
5508                     pc.quad(
5509                         Rect { x: bar.x, y: bar.y + bar.height - 2.0, width: bar.width, height: 2.0 },
5510                         ACCENT,
5511                     );
5512                 }
5513 
5514                 // Tab strip.
5515             let count = self.host.tab_count();
5516             let active = self.host.active_index();
5517             for i in 0..count {
5518                 let pill = tab_rect(&bar, pos_edge, count, i);
5519                 let is_active = i == active;
5520                 pc.rounded_rect(
5521                     pill,
5522                     7.0,
5523                     (true, true, true, true),
5524                     if is_active { TAB_ACTIVE_BG } else { TAB_BG },
5525                 );
5526                 let tab = self.host.tab(i);
5527                 let title = tab
5528                     .and_then(|t| t.title.clone().filter(|s| !s.is_empty()))
5529                     .or_else(|| tab.and_then(|t| t.url.clone()).map(|u| u.to_string()))
5530                     .filter(|s| s != "about:blank")
5531                     .unwrap_or_else(|| "New Tab".to_string());
5532                 let close = tab_close_rect(&pill);
5533                 let text_avail = pill.width - 2.0 * text_pad() - close.map_or(0.0, |_| TAB_CLOSE_W - 4.0);
5534                 let label = Self::fit_text(&title, &sans, 12.0, text_avail);
5535                 let color = if is_active { TEXT } else { TEXT_DIM };
5536                 pc.text(
5537                     label,
5538                     pill.x + text_pad(),
5539                     cce_ui::layout::align_text_y(pill.y, pill.height, 12.0, 0.0),
5540                     12.0,
5541                     color,
5542                 );
5543                 if tab.is_some_and(|t| t.loading) {
5544                     pc.quad(
5545                         Rect { x: pill.x, y: pill.y + pill.height - 2.0, width: pill.width, height: 2.0 },
5546                         ACCENT,
5547                     );
5548                 }
5549                 if let Some(cr) = close {
5550                     let glyph_box = Rect { x: cr.x - 2.0, ..cr };
5551                     Self::glyph(pc, &sans, "x", "Close", glyph_box, 9.0, TEXT_DIM);
5552                 }
5553             }
5554             let plus = plus_rect(&bar, pos_edge);
5555             pc.rounded_rect(plus, 7.0, (true, true, true, true), BTN_BG);
5556             Self::glyph(pc, &sans, "plus", "New", plus, 12.0, TEXT);
5557 
5558             // Favorites strip: label pills, the hovered one lifted like an
5559             // active tab. Labels are cut to the pill, never the other way.
5560             let fav_rects = self.fav_rects(&bar);
5561             for (i, r) in fav_rects.iter().enumerate() {
5562                 let hovered = self.fav_hover == Some(i);
5563                 pc.rounded_rect(
5564                     *r,
5565                     7.0,
5566                     (true, true, true, true),
5567                     if hovered { TAB_ACTIVE_BG } else { TAB_BG },
5568                 );
5569                 let label =
5570                     Self::fit_text(&self.favs[i].label, &sans, FAV_FONT, r.width - 2.0 * text_pad());
5571                 pc.text(
5572                     label,
5573                     r.x + text_pad(),
5574                     cce_ui::layout::align_text_y(r.y, r.height, FAV_FONT, 0.0),
5575                     FAV_FONT,
5576                     if hovered { TEXT } else { TEXT_DIM },
5577                 );
5578             }
5579 
5580             // (glyph, fallback word) for Back, Forward and Reload.
5581             let faces = [("arrow-left", "Back"), ("arrow-right", "Forward"), ("refresh", "Reload")];
5582             let enabled = [self.host.can_go_back(), self.host.can_go_forward(), true];
5583             for (i, (name, word)) in faces.iter().enumerate() {
5584                 let r = btn_rect(&bar, i);
5585                 pc.rounded_rect(r, 6.0, (true, true, true, true), BTN_BG);
5586                 let color = if enabled[i] { TEXT } else { TEXT_DIM };
5587                 Self::glyph(pc, &sans, name, word, r, 14.0, color);
5588             }
5589 
5590             // Bookmark star: accent-lit when the page is bookmarked.
5591             let star = star_rect(&bar, pos_edge);
5592             pc.rounded_rect(star, 6.0, (true, true, true, true), BTN_BG);
5593             let starred = self.host.active_bookmarked();
5594             let star_color: [u8; 3] = if starred { [150, 190, 240] } else { TEXT_DIM };
5595             Self::glyph(pc, &sans, "star", "Save", star, 15.0, star_color);
5596 
5597             // Bookmarks menu button: all the saved pages, where the star
5598             // beside it is only this one. Lit while its menu is open.
5599             let bmb = bm_btn_rect(&bar, pos_edge);
5600             pc.rounded_rect(bmb, 6.0, (true, true, true, true), BTN_BG);
5601             let bm_color = if self.bm_menu.is_some() { [150, 190, 240] } else { TEXT };
5602             Self::glyph(pc, &sans, "bookmarks", "Saved", bmb, 14.0, bm_color);
5603 
5604             // URL field: rim + recess, brighter rim when focused.
5605             let f = url_rect(&bar, pos_edge);
5606             let rim = if self.url_focused { RIM_FOCUS } else { RIM };
5607             pc.rounded_rect(
5608                 Rect { x: f.x - 1.0, y: f.y - 1.0, width: f.width + 2.0, height: f.height + 2.0 },
5609                 7.0,
5610                 (true, true, true, true),
5611                 rim,
5612             );
5613             pc.rounded_rect(f, 6.0, (true, true, true, true), FIELD_BG);
5614             let marks = FieldMarks::of(&mut self.font_system, &self.url);
5615             let caret = paint_field(pc, f, &marks, "", self.url_focused);
5616             // Drawn here only while the bar is out, so a folded bar asks
5617             // for no text.
5618             if self.keyboard_field() == Some(LineField::Url) {
5619                 report_caret(caret);
5620             }
5621 
5622             });
5623         }
5624 
5625         // The corner control, over the bar: a circular plate of the bar's
5626         // own material — the seed the bar unfolds from, so folded it reads
5627         // as the bar in miniature, and open it is a plate on the bar's
5628         // corner. Emphasized while hovered or while the bar is out.
5629         let (cx, cy) = self.dot_center();
5630         let r = if self.dot_hover || self.chrome_open { DOT_R * 1.15 } else { DOT_R };
5631         pc.plate_shaped(
5632             Rect { x: cx - r, y: cy - r, width: 2.0 * r, height: 2.0 * r },
5633             (r, r, r, r),
5634             &cce_ui::scene::Material::from_fill(BAR_FILL),
5635             cce_ui::layout::bevel_width().min(3.0),
5636             Some(2.0),
5637         );
5638 
5639         self.paint_vi(&mut pc, &sans);
5640         self.paint_bm_menu(&mut pc, &sans);
5641         #[cfg(feature = "wpe")]
5642         self.paint_ac_menu(&mut pc, &sans);
5643         #[cfg(feature = "wpe")]
5644         self.paint_save_offer(&mut pc, &sans);
5645         #[cfg(feature = "wpe")]
5646         self.paint_opt_menu(&mut pc, &sans);
5647         #[cfg(feature = "wpe")]
5648         self.paint_ctx_menu(&mut pc, &sans);
5649         #[cfg(feature = "wpe")]
5650         self.paint_modal(&mut pc, &sans);
5651 
5652         Some(pc.finish())
5653     }
5654 
5655     fn display_list_text(&self) -> bool {
5656         true
5657     }
5658 
5659     fn clear_color(&self) -> [f32; 4] {
5660         PAGE_BG
5661     }
5662 }
5663 
5664 /// Turn off Intel's CCS compression for this process and everything it
5665 /// spawns.
5666 ///
5667 /// Mesa's iris driver can deadlock two threads against each other through
5668 /// the aux map CCS needs: one adds a mapping and wants the buffer manager's
5669 /// lock, the other reuses a cached buffer under that lock and wants the aux
5670 /// map's. WebKit's two GPU painting threads hit exactly that and froze a
5671 /// page for good (2026-10-05; CLAUDE.md, "A dead or hung page"). Without CCS
5672 /// the aux-map code never runs. The cost is uncompressed surfaces — more
5673 /// memory bandwidth on the iGPU.
5674 ///
5675 /// Set first, before anything has started a thread or opened a GPU device:
5676 /// WebKit's page processes inherit it through their sandbox, and the chrome's
5677 /// own Vulkan device reads it too. `CCE_BROWSER_CCS=1` keeps compression, for
5678 /// measuring what this costs.
5679 fn disable_intel_ccs() {
5680     if std::env::var_os("CCE_BROWSER_CCS").is_some_and(|v| v == "1") {
5681         return;
5682     }
5683     let current = std::env::var("INTEL_DEBUG").unwrap_or_default();
5684     if current.split(',').any(|f| f.trim() == "noccs") {
5685         return;
5686     }
5687     let value = if current.is_empty() { "noccs".to_string() } else { format!("{current},noccs") };
5688     std::env::set_var("INTEL_DEBUG", value);
5689 }
5690 
5691 fn main() {
5692     disable_intel_ccs();
5693     env_logger::init();
5694     // A read-only look at what a Raindrop sync would do (RAINDROP-SYNC.md).
5695     // Ahead of the instance hand-off: it is a tool, not a launch, and must
5696     // work while the browser is running.
5697     if std::env::args().nth(1).as_deref() == Some("--raindrop-plan") {
5698         match raindrop::dry_run() {
5699             Ok(report) => print!("{report}"),
5700             Err(e) => {
5701                 eprintln!("raindrop: {e}");
5702                 std::process::exit(1);
5703             }
5704         }
5705         return;
5706     }
5707     // Hand the launch to a running instance before any engine work: an
5708     // external open (`xdg-open` → `cce-browser %u`) becomes a tab there,
5709     // and this process never touches Wayland or the shared profile dir.
5710     if instance::forward_or_claim(std::env::args().nth(1).as_deref()) {
5711         return;
5712     }
5713     cce_ui::engine::run::<BrowserApp>();
5714     instance::cleanup();
5715 }
5716 
5717 #[cfg(test)]
5718 mod tests {
5719     use super::*;
5720 
5721     const SEARCH: &str = "https://duckduckgo.com/?q=";
5722 
5723     #[test]
5724     fn a_composition_is_drawn_at_the_caret_and_never_held() {
5725         use cce_ui::ime::{set_preedit, Preedit};
5726         use cce_ui::widget::LineEdit;
5727         let mut fs = cce_ui::create_font_system_with_system_fonts();
5728         let mut x = |s: &str, b: usize| x_of_boundary_in(&mut fs, s, b);
5729         let (ax, axyzb_1, axyzb_2, axyzb_4) = (x("ab", 1), x("axyzb", 1), x("axyzb", 2), x("axyzb", 4));
5730         assert!(ax > 0.0 && axyzb_2 > axyzb_1 && axyzb_4 > axyzb_2, "the run has widths to measure");
5731 
5732         let mut fs = cce_ui::create_font_system_with_system_fonts();
5733         let mut url = LineEdit::with_text("ab");
5734         url.cursor = 1;
5735         let before = FieldMarks::of(&mut fs, &url);
5736         assert_eq!((before.shown.as_str(), before.caret, before.composition), ("ab", ax, None));
5737 
5738         // "xyz" composing between a and b, the input method's cursor after x.
5739         set_preedit(Some(Preedit::new("xyz", Some((1, 1)))));
5740         assert!(url.sync_ime());
5741         let marks = FieldMarks::of(&mut fs, &url);
5742         assert_eq!(marks.shown, "axyzb", "drawn at the caret");
5743         assert_eq!(url.text, "ab", "never in what the bar holds");
5744         assert_eq!(marks.composition, Some((axyzb_1, axyzb_4)), "underlined where it is drawn");
5745         assert_eq!(marks.caret, axyzb_2, "the caret where the input method has it");
5746 
5747         // A press in it drops it, and lands on the text as held.
5748         let at = url.text_index(2);
5749         url.press(at, false);
5750         url.release();
5751         assert!(!url.composing() && cce_ui::ime::preedit().is_none());
5752         assert_eq!(FieldMarks::of(&mut fs, &url).shown, "ab");
5753 
5754         // A password field's composition is bullets too.
5755         let mut password = LineEdit::masked();
5756         set_preedit(Some(Preedit::new("pw", None)));
5757         password.sync_ime();
5758         let marks = FieldMarks::of(&mut fs, &password);
5759         assert_eq!(marks.shown, "\u{2022}\u{2022}");
5760         assert!(password.text.is_empty());
5761         set_preedit(None);
5762     }
5763 
5764     #[test]
5765     fn the_bookmarks_search_keeps_entries_matching_every_word() {
5766         let link = |label: &str, url: &str| pages::Link { label: label.into(), url: url.into() };
5767         let mut m = BmMenu::new(vec![
5768             link("Rust Book", "https://doc.rust-lang.org/book/"),
5769             link("Example Domain", "https://example.com/"),
5770             link("rustup", "https://rustup.rs/"),
5771         ]);
5772         assert_eq!(m.items.len(), 3, "an empty query lets everything through");
5773         m.query.text = "RUST".into();
5774         m.filter();
5775         let labels: Vec<_> = m.items.iter().map(|l| l.label.as_str()).collect();
5776         assert_eq!(labels, ["Rust Book", "rustup"], "case-insensitive, store order kept");
5777         m.query.text = "rust book".into();
5778         m.filter();
5779         assert_eq!(m.items.len(), 1, "every word must match");
5780         m.query.text = "example.com".into();
5781         m.filter();
5782         assert_eq!(m.items[0].label, "Example Domain", "the address counts too");
5783         m.query.text = "nothing-like-this".into();
5784         m.filter();
5785         assert!(m.items.is_empty());
5786         assert_eq!(m.all.len(), 3, "filtering never drops the snapshot");
5787     }
5788 
5789     #[test]
5790     fn the_bookmarks_selection_wraps_and_stays_in_view() {
5791         let link = |n: usize| pages::Link { label: format!("page {n}"), url: format!("https://e.com/{n}") };
5792         let mut m = BmMenu::new((0..5).map(link).collect());
5793         assert_eq!(m.selected, 0);
5794         m.step(1, 3);
5795         m.step(1, 3);
5796         assert_eq!((m.selected, m.scroll), (2, 0), "still among the three shown");
5797         m.step(1, 3);
5798         assert_eq!((m.selected, m.scroll), (3, 1), "scrolled to keep it in view");
5799         m.step(1, 3);
5800         m.step(1, 3);
5801         assert_eq!((m.selected, m.scroll), (0, 0), "wrapped past the end, back to the top");
5802         m.step(-1, 3);
5803         assert_eq!((m.selected, m.scroll), (4, 2), "and back past the start, to the bottom");
5804         // A narrower query keeps the selection on a match.
5805         m.query.text = "page 1".into();
5806         m.filter();
5807         assert_eq!((m.items.len(), m.selected), (1, 0));
5808         // Nothing to select: stepping does nothing.
5809         m.query.text = "none".into();
5810         m.filter();
5811         m.step(1, 3);
5812         assert_eq!(m.selected, 0);
5813     }
5814 
5815     #[test]
5816     fn the_bar_glides_with_animations_on_and_snaps_with_them_off() {
5817         let frame = 1.0 / 60.0;
5818         // On: a frame moves it part of the way, and it lands exactly.
5819         let t = chrome_step(0.0, 1.0, frame, true);
5820         assert!(t > 0.0 && t < 1.0, "{t}");
5821         assert_eq!(chrome_step(0.95, 1.0, frame, true), 1.0);
5822         assert_eq!(chrome_step(0.05, 0.0, frame, true), 0.0);
5823         // The whole unfold takes CHROME_ANIM_S, whichever way it goes.
5824         let frames = (CHROME_ANIM_S / frame).ceil() as usize;
5825         let open = (0..frames).fold(0.0, |t, _| chrome_step(t, 1.0, frame, true));
5826         let shut = (0..frames).fold(1.0, |t, _| chrome_step(t, 0.0, frame, true));
5827         assert_eq!((open, shut), (1.0, 0.0));
5828         // Off: one frame lands it, from anywhere, either way — snap, not freeze.
5829         for from in [0.0, 0.3, 1.0] {
5830             assert_eq!(chrome_step(from, 1.0, frame, false), 1.0);
5831             assert_eq!(chrome_step(from, 0.0, frame, false), 0.0);
5832         }
5833     }
5834 
5835     #[test]
5836     fn startup_arg_resolves_an_existing_path_to_a_file_url() {
5837         // Scoped to this process, like every other scratch directory in the
5838         // crate: /tmp is one namespace shared by every user of the machine.
5839         let dir = std::env::temp_dir()
5840             .join(format!("cce-browser-argv-test-{}", std::process::id()));
5841         std::fs::create_dir_all(&dir).unwrap();
5842         let page = dir.join("page.html");
5843         std::fs::write(&page, "<html></html>").unwrap();
5844 
5845         let u = parse_startup_arg(page.to_str().unwrap(), SEARCH).unwrap();
5846         assert_eq!(u.scheme(), "file");
5847         assert!(u.path().ends_with("page.html"), "got {u}");
5848 
5849         // The bar parser is what this guards against: a dotted, space-free
5850         // path takes its bare-host branch and becomes a bogus https URL.
5851         let bar = parse_url_input(page.to_str().unwrap(), SEARCH).unwrap();
5852         assert_eq!(bar.scheme(), "https");
5853 
5854         // Sole user of this directory, so it can go whole.
5855         let _ = std::fs::remove_dir_all(&dir);
5856     }
5857 
5858     #[cfg(feature = "wpe")]
5859     #[test]
5860     fn only_loopback_escapes_the_insecure_warning() {
5861         assert!(!insecure_origin("https://example.com", "example.com"));
5862         assert!(insecure_origin("http://example.com", "example.com"));
5863         assert!(!insecure_origin("http://localhost:8731", "localhost"));
5864         assert!(!insecure_origin("http://127.0.0.1:8080", "127.0.0.1"));
5865         assert!(!insecure_origin("http://dev.localhost", "dev.localhost"));
5866         // A file: page has no transport to secure, and no origin worth the
5867         // name; say so rather than stay quiet.
5868         assert!(insecure_origin("null", ""));
5869     }
5870 
5871     #[test]
5872     fn startup_arg_still_takes_urls_and_searches() {
5873         let u = parse_startup_arg("https://example.com/x", SEARCH).unwrap();
5874         assert_eq!(u.as_str(), "https://example.com/x");
5875 
5876         // A bare host that is not a path still guesses https.
5877         assert_eq!(parse_startup_arg("example.com", SEARCH).unwrap().scheme(), "https");
5878 
5879         // A non-existent path is not a file: it falls through to the bar rules.
5880         let missing = parse_startup_arg("/nonexistent/nope.html", SEARCH).unwrap();
5881         assert_ne!(missing.scheme(), "file");
5882     }
5883 }