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

src/main.rs (123.2K)

   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 lineedit;
  15 mod pages;
  16 mod session;
  17 mod settings;
  18 /// The retired Servo backend; compiled only under `--features servo`.
  19 #[cfg(feature = "servo")]
  20 mod webview;
  21 /// The in-progress WPE WebKit backend (see WPE-PORT.md). Compiled only under
  22 /// `--features wpe`; the shipping browser is still Servo.
  23 #[cfg(feature = "wpe")]
  24 mod wpe;
  25 
  26 use url::Url;
  27 use wayland_client::QueueHandle;
  28 
  29 use cce_ui::engine::{Application, EngineState, LogicalPosition, LogicalSize, WindowSettings};
  30 use cce_ui::scene::layout::Rect;
  31 use cce_ui::scene::paint::{DisplayList, PaintCtx};
  32 use cce_ui::widget::display::measure_text_width;
  33 use cce_ui::widget::plate_dock;
  34 use cce_ui::widget::{ElementState, Key, KeyEvent, MouseButton, MouseScrollDelta, NamedKey};
  35 
  36 #[cfg(all(not(feature = "wpe"), feature = "servo"))]
  37 use webview::ServoHost as Host;
  38 #[cfg(feature = "wpe")]
  39 use wpe::WebKitHost as Host;
  40 #[cfg(not(any(feature = "wpe", feature = "servo")))]
  41 compile_error!(
  42     "cce-browser needs an engine: build with the default `wpe` feature \
  43      (pacman -S wpewebkit), or --no-default-features --features servo"
  44 );
  45 
  46 /// Clipboard action, named by neither engine. Each backend maps it to its
  47 /// own vocabulary — Servo needs an `EditingActionEvent`, WebKit a named
  48 /// editing command — so the chrome never learns either.
  49 #[derive(Debug, Clone, Copy)]
  50 pub enum EditingCommand {
  51     Copy,
  52     Cut,
  53     Paste,
  54 }
  55 
  56 // Spacing is the DE's ladder (cce-ui `layout.rs`), never a number of this
  57 // app's own. Five readings of it cover the whole chrome:
  58 
  59 /// The bar's inset from the window edge: it stands on the root plate.
  60 fn bar_margin() -> f32 {
  61     cce_ui::layout::root_plate_inset()
  62 }
  63 
  64 /// Inset from a plate's rim to its content — the bar's, and every popover's
  65 /// (bookmarks, accounts, the context menu, a modal).
  66 fn plate_pad() -> f32 {
  67     cce_ui::layout::plate_padding()
  68 }
  69 
  70 /// Gap between items next to each other in a row of the bar (tabs, pills,
  71 /// buttons), and between the bar and a menu it drops: siblings on the root
  72 /// plate.
  73 fn item_gap() -> f32 {
  74     cce_ui::layout::root_plate_gap()
  75 }
  76 
  77 /// Gap between siblings inside a plate: the bar's rows, a modal's fields
  78 /// and its button pair.
  79 fn inner_gap() -> f32 {
  80     cce_ui::layout::plate_gap()
  81 }
  82 
  83 /// Inset from a control's rim to its label — a pill, a field, a menu row —
  84 /// the DE's own button padding.
  85 fn text_pad() -> f32 {
  86     cce_ui::layout::button_padding()
  87 }
  88 /// Two rows — tab strip on top, nav controls + URL field below — with the
  89 /// favorites strip between them whenever there is one to show. The bar
  90 /// does not carry an empty row: no favorites, no strip, two-row bar.
  91 fn bar_h(favorites: bool) -> f32 {
  92     let favs = if favorites { FAV_H + inner_gap() } else { 0.0 };
  93     plate_pad() + TAB_H + inner_gap() + favs + BTN_H + plate_pad()
  94 }
  95 const BAR_RADIUS: f32 = 10.0;
  96 /// Seconds for the bar to unfold from the corner control (and back).
  97 const CHROME_ANIM_S: f32 = 0.18;
  98 /// Width reserved at the right end of the row the corner control sits on
  99 /// (the tab row for a top bar, the controls row for a bottom one), so the
 100 /// "+" or the bookmark star clears the dot in the bar's corner.
 101 const DOT_COL: f32 = 2.0 * plate_dock::CORNER_INSET;
 102 
 103 /// The reservation a row makes for the corner control: `DOT_COL` on the
 104 /// row in the bar's anchored corner, nothing on the other.
 105 fn dot_col(position: settings::BarPosition, tabs_row: bool) -> f32 {
 106     let dot_on_tabs = matches!(position, settings::BarPosition::Top);
 107     if dot_on_tabs == tabs_row { DOT_COL } else { 0.0 }
 108 }
 109 const TAB_H: f32 = 24.0;
 110 const TAB_MIN_W: f32 = 56.0;
 111 const TAB_MAX_W: f32 = 200.0;
 112 /// Tabs at least this wide get a close "x" region on their right edge.
 113 const TAB_CLOSE_MIN_W: f32 = 72.0;
 114 const TAB_CLOSE_W: f32 = 18.0;
 115 const PLUS_W: f32 = 26.0;
 116 /// The favorites strip: a row of pills, each one page. Pills take their
 117 /// label's width up to `FAV_MAX_W`, and the strip simply stops at the bar's
 118 /// edge — favorites are a handful by design, not a scrolling list.
 119 const FAV_H: f32 = 22.0;
 120 const FAV_MAX_W: f32 = 150.0;
 121 const FAV_FONT: f32 = 12.0;
 122 /// The bookmarks menu: a plate of rows dropped from the controls row's "B"
 123 /// button — the bar's own way to visit and manage what the star saves.
 124 const BM_W: f32 = 320.0;
 125 const BM_ROW_H: f32 = 24.0;
 126 /// Height of the rule between the menu's three sections.
 127 const BM_SEP_H: f32 = 9.0;
 128 /// The remove hit region at a bookmark row's right end.
 129 const BM_RM_W: f32 = 24.0;
 130 const BM_FONT: f32 = 13.0;
 131 /// The account list: suggestions from cce-secrets, dropped at the login
 132 /// field they are for rather than at the bar, because that is where the
 133 /// person is looking.
 134 const AC_W: f32 = 300.0;
 135 const AC_ROW_H: f32 = 34.0;
 136 /// Rows before the list scrolls with the selection.
 137 const AC_MAX_ROWS: usize = 6;
 138 const AC_FONT: f32 = 13.0;
 139 const AC_SUB_FONT: f32 = 11.0;
 140 /// Utility-bar fill; the negative alpha marks the plate as blur-behind.
 141 /// The blurred page is the base and this color tints it at |alpha|
 142 /// opacity — keep |alpha| low so the frosted content shows through.
 143 const BAR_FILL: [f32; 4] = [0.11, 0.12, 0.13, -0.28];
 144 const BTN_W: f32 = 30.0;
 145 const BTN_H: f32 = 26.0;
 146 const URL_FONT: f32 = 14.0;
 147 /// Pixels per wheel notch when the DE reports discrete line deltas.
 148 const LINE_PX: f64 = 76.0;
 149 
 150 
 151 const PAGE_BG: [f32; 4] = [0.10, 0.10, 0.11, 1.0];
 152 const FIELD_BG: [f32; 4] = [0.09, 0.09, 0.10, 0.40];
 153 const BTN_BG: [f32; 4] = [0.20, 0.21, 0.23, 0.40];
 154 const TAB_BG: [f32; 4] = [0.15, 0.16, 0.18, 0.30];
 155 const TAB_ACTIVE_BG: [f32; 4] = [0.32, 0.34, 0.38, 0.55];
 156 const RIM: [f32; 4] = [0.22, 0.23, 0.25, 1.0];
 157 const RIM_FOCUS: [f32; 4] = [0.33, 0.48, 0.72, 1.0];
 158 /// URL-bar selection highlight; the text is drawn over it.
 159 const SEL_BG: [f32; 4] = [0.24, 0.38, 0.60, 0.95];
 160 const ACCENT: [f32; 4] = [0.35, 0.55, 0.85, 1.0];
 161 const TEXT: [u8; 3] = [220, 220, 225];
 162 const TEXT_DIM: [u8; 3] = [120, 122, 128];
 163 
 164 /// A page-blocking prompt drawn over the content.
 165 ///
 166 /// Modal on purpose: the page is genuinely blocked inside WebKit until it is
 167 /// answered, so letting the chrome carry on as if nothing were pending would
 168 /// misrepresent what the engine is doing.
 169 #[cfg(feature = "wpe")]
 170 struct Modal {
 171     title: String,
 172     message: String,
 173     /// Editable fields, in tab order. Empty for a bare alert or confirm.
 174     fields: Vec<(&'static str, lineedit::LineEdit)>,
 175     focused: usize,
 176     has_cancel: bool,
 177     kind: ModalKind,
 178 }
 179 
 180 #[cfg(feature = "wpe")]
 181 enum ModalKind {
 182     /// `alert` / `confirm` / `prompt`.
 183     Script,
 184     /// An HTTP auth challenge.
 185     Auth,
 186 }
 187 
 188 #[cfg(feature = "wpe")]
 189 const MODAL_W: f32 = 420.0;
 190 #[cfg(feature = "wpe")]
 191 const MODAL_FIELD_H: f32 = 26.0;
 192 #[cfg(feature = "wpe")]
 193 const MODAL_BTN_W: f32 = 84.0;
 194 
 195 #[cfg(feature = "wpe")]
 196 impl Modal {
 197     fn height(&self) -> f32 {
 198         plate_pad() * 2.0
 199             + 20.0
 200             + 22.0
 201             + self.fields.len() as f32 * (MODAL_FIELD_H + inner_gap())
 202             + inner_gap()
 203             + BTN_H
 204     }
 205 
 206     /// Centred, and clamped so it stays on screen on a small window.
 207     fn rect(&self, win: (f32, f32)) -> Rect {
 208         let w = MODAL_W.min(win.0 - 2.0 * bar_margin()).max(240.0);
 209         let h = self.height();
 210         Rect {
 211             x: ((win.0 - w) / 2.0).max(0.0),
 212             y: ((win.1 - h) / 2.0).max(0.0),
 213             width: w,
 214             height: h,
 215         }
 216     }
 217 
 218     fn field_rect(&self, r: &Rect, i: usize) -> Rect {
 219         Rect {
 220             x: r.x + plate_pad(),
 221             y: r.y + plate_pad() + 42.0 + i as f32 * (MODAL_FIELD_H + inner_gap()),
 222             width: r.width - plate_pad() * 2.0,
 223             height: MODAL_FIELD_H,
 224         }
 225     }
 226 
 227     /// (ok, cancel) — cancel is `None` for a bare alert.
 228     fn button_rects(&self, r: &Rect) -> (Rect, Option<Rect>) {
 229         let y = r.y + r.height - plate_pad() - BTN_H;
 230         let ok = Rect {
 231             x: r.x + r.width - plate_pad() - MODAL_BTN_W,
 232             y,
 233             width: MODAL_BTN_W,
 234             height: BTN_H,
 235         };
 236         let cancel = self.has_cancel.then(|| Rect {
 237             x: ok.x - MODAL_BTN_W - inner_gap(),
 238             ..ok
 239         });
 240         (ok, cancel)
 241     }
 242 }
 243 
 244 /// The right-click menu, drawn by the chrome at the pointer.
 245 ///
 246 /// Not modal: the page is not blocked (unlike a script dialog), so this only
 247 /// intercepts input for as long as it is open, and any click outside closes
 248 /// it and is otherwise swallowed.
 249 #[cfg(feature = "wpe")]
 250 struct CtxMenu {
 251     items: Vec<CtxItem>,
 252     /// Top-left corner, already clamped to the window.
 253     pos: (f32, f32),
 254 }
 255 
 256 #[cfg(feature = "wpe")]
 257 struct CtxItem {
 258     label: String,
 259     action: CtxAction,
 260     enabled: bool,
 261 }
 262 
 263 #[cfg(feature = "wpe")]
 264 enum CtxAction {
 265     Back,
 266     Forward,
 267     Reload,
 268     /// Copy the page's current selection (through the engine, so it lands on
 269     /// the system clipboard via the clipboard bridge).
 270     CopySelection,
 271     Paste,
 272     OpenInTab(String),
 273     /// Put this text on the clipboard directly (link/image addresses).
 274     CopyText(String),
 275     /// Fetch through WebKit's download pipeline.
 276     Download(String),
 277     OpenExternal,
 278     /// Add the page to the favorites strip, or take it out.
 279     ToggleFavorite,
 280 }
 281 
 282 #[cfg(feature = "wpe")]
 283 const CTX_ROW_H: f32 = 24.0;
 284 #[cfg(feature = "wpe")]
 285 const CTX_W: f32 = 200.0;
 286 
 287 #[cfg(feature = "wpe")]
 288 impl CtxMenu {
 289     fn rect(&self) -> Rect {
 290         Rect {
 291             x: self.pos.0,
 292             y: self.pos.1,
 293             width: CTX_W,
 294             height: plate_pad() * 2.0 + self.items.len() as f32 * CTX_ROW_H,
 295         }
 296     }
 297 
 298     fn row_rect(&self, i: usize) -> Rect {
 299         // style: deliberate — a 2px hairline keeps the row highlight off the
 300         // plate's roll; the rung padding is the vertical one.
 301         Rect {
 302             x: self.pos.0 + 2.0,
 303             y: self.pos.1 + plate_pad() + i as f32 * CTX_ROW_H,
 304             width: CTX_W - 4.0,
 305             height: CTX_ROW_H,
 306         }
 307     }
 308 
 309     fn item_at(&self, x: f32, y: f32) -> Option<usize> {
 310         (0..self.items.len()).find(|&i| hit(&self.row_rect(i), x, y))
 311     }
 312 }
 313 
 314 /// The bookmarks menu: the bar's list of saved pages, open under (or over)
 315 /// the "B" button in the controls row.
 316 ///
 317 /// It holds a **snapshot** of the store rather than reading it per frame:
 318 /// the list a pointer is travelling down must not reorder underneath it,
 319 /// and the two edits it offers (bookmark this page, remove a row) re-read
 320 /// explicitly. Unlike the right-click menu this is not gated on an engine
 321 /// backend — bookmarks are app state, so the menu works on either.
 322 struct BmMenu {
 323     items: Vec<pages::Link>,
 324     /// First listed bookmark, when there are more than the plate can show.
 325     scroll: usize,
 326     hover: Option<BmHit>,
 327 }
 328 
 329 /// What a pointer position falls on inside the menu.
 330 #[derive(Debug, Clone, Copy, PartialEq)]
 331 enum BmHit {
 332     /// The add/remove row for the page in the active tab.
 333     Toggle,
 334     /// A bookmark row: its index, and whether the pointer is on the remove
 335     /// region at the row's right end rather than on the row itself.
 336     Entry(usize, bool),
 337     /// Hands the whole collection to `cce://bookmarks`.
 338     Manage,
 339 }
 340 
 341 /// The account list: what cce-secrets can offer the login field that is
 342 /// focused right now.
 343 ///
 344 /// It belongs to a *field*, not to the bar — it opens when one takes focus,
 345 /// follows it when the page scrolls, and goes when focus does. Only the
 346 /// accounts matching the tab's own host are ever in it, and no password is
 347 /// fetched to build it: a pick is what asks the keyring for one.
 348 #[cfg(feature = "wpe")]
 349 struct AcMenu {
 350     /// Matches for this host, before filtering.
 351     all: Vec<accounts::Account>,
 352     /// What survives what has been typed into the username field.
 353     shown: Vec<accounts::Account>,
 354     /// Keyboard selection, an index into `shown`.
 355     selected: usize,
 356     /// First visible row, when `shown` is longer than the list can show.
 357     scroll: usize,
 358     /// The field, in the chrome's own coordinates.
 359     anchor: Rect,
 360     /// The host this list was built for. A fetched password is checked
 361     /// against it before it is filled: the keyring answers asynchronously,
 362     /// and by then the tab could be somewhere else entirely.
 363     host: String,
 364     /// The page is not on a secure origin — worth saying before a password
 365     /// goes into it.
 366     insecure: bool,
 367     /// Pointer-hovered row.
 368     hover: Option<usize>,
 369 }
 370 
 371 #[cfg(feature = "wpe")]
 372 impl AcMenu {
 373     /// Narrow the list to what has been typed. Matching is on the username
 374     /// and the entry's title, case-insensitively and anywhere in either —
 375     /// people type the middle of an address as readily as its start.
 376     fn refilter(&mut self, typed: &str) {
 377         let needle = typed.trim().to_lowercase();
 378         self.shown = self
 379             .all
 380             .iter()
 381             .filter(|a| {
 382                 needle.is_empty()
 383                     || a.username.to_lowercase().contains(&needle)
 384                     || a.label.to_lowercase().contains(&needle)
 385             })
 386             .cloned()
 387             .collect();
 388         self.selected = self.selected.min(self.shown.len().saturating_sub(1));
 389         self.scroll = self.scroll.min(self.shown.len().saturating_sub(1));
 390         self.keep_selected_visible();
 391     }
 392 
 393     fn first_row(&self) -> usize {
 394         self.scroll
 395             .min(self.shown.len().saturating_sub(self.shown.len().min(AC_MAX_ROWS)))
 396     }
 397 
 398     /// Move the keyboard selection, scrolling the window to follow it.
 399     fn step(&mut self, delta: isize) {
 400         if self.shown.is_empty() {
 401             return;
 402         }
 403         let n = self.shown.len() as isize;
 404         self.selected = (((self.selected as isize + delta) % n + n) % n) as usize;
 405         self.keep_selected_visible();
 406     }
 407 
 408     fn keep_selected_visible(&mut self) {
 409         let rows = self.shown.len().min(AC_MAX_ROWS);
 410         if rows == 0 {
 411             return;
 412         }
 413         if self.selected < self.scroll {
 414             self.scroll = self.selected;
 415         } else if self.selected >= self.scroll + rows {
 416             self.scroll = self.selected + 1 - rows;
 417         }
 418     }
 419 }
 420 
 421 /// Every rect the menu draws and hit-tests, derived once — the same
 422 /// one-geometry rule the bar's own helpers follow.
 423 struct BmLayout {
 424     plate: Rect,
 425     toggle: Rect,
 426     /// `(rect, index into items)` for each row the plate can show.
 427     rows: Vec<(Rect, usize)>,
 428     /// The "nothing saved yet" row, in place of the list.
 429     empty: Option<Rect>,
 430     manage: Rect,
 431     /// How many bookmarks fit; more than this and the list scrolls.
 432     cap: usize,
 433 }
 434 
 435 #[derive(Debug, Clone)]
 436 pub enum Message {
 437     /// The accounts worker answered a load: the index, or why it failed.
 438     Accounts(Result<Vec<accounts::Account>, String>),
 439     /// One entry's password arrived for the account at this object path.
 440     /// The payload prints as `Secret(…)`; see `accounts::Secret`.
 441     Credential(String, accounts::Secret),
 442     /// Servo requested an event-loop spin (waker or delegate signal).
 443     Spin,
 444     /// Last tab closed: exit the app.
 445     Quit,
 446     /// A later launch forwarded its argument here (see `instance.rs`):
 447     /// `Some` is a URL or file path to open in a new tab, `None` a bare
 448     /// launch that becomes a blank tab.
 449     OpenExternal(Option<String>),
 450 }
 451 
 452 struct BrowserApp {
 453     host: Host,
 454     /// Whether a renderer has been handed over yet — the first one is the
 455     /// process's own, any later one is a replacement after a reconnect. See
 456     /// `renderer_init`.
 457     seen_renderer: bool,
 458     /// Loaded from the app config; re-read when the window regains focus.
 459     settings: settings::Settings,
 460     win: (f32, f32),
 461     scale: f64,
 462     pointer: (f32, f32),
 463     /// Buttons whose press was handed to the page and whose release it is
 464     /// therefore still owed. The chrome opens menus on a press — a
 465     /// right-click opens the context menu — and every menu branch below
 466     /// swallows the clicks that follow, so without this the engine never
 467     /// sees the button come back up.
 468     page_buttons: Vec<MouseButton>,
 469     /// URL bar contents; mirrors the page URL unless the bar is focused.
 470     /// Text, caret and selection all live in the shared editor — the same
 471     /// one the dialog fields use.
 472     url: lineedit::LineEdit,
 473     url_focused: bool,
 474     /// The circle menu: the DE's corner control toggles the utility bar,
 475     /// which unfolds from under it. `chrome_t` is the unfold progress
 476     /// (0 = closed, 1 = bar), animated in `tick` toward whichever state
 477     /// `chrome_open` names.
 478     chrome_open: bool,
 479     chrome_t: f32,
 480     /// Pointer over the corner control — its hover emphasis is a repaint.
 481     dot_hover: bool,
 482     loading: bool,
 483     /// Page title; drives the toplevel title (the engine re-applies
 484     /// `settings().title` whenever it changes).
 485     title: Option<String>,
 486     /// The page-blocking dialog or auth challenge currently on screen, if
 487     /// any. Only the WPE backend raises these — Servo has no delegate hooks
 488     /// for them, which is why they were listed as "not implemented".
 489     #[cfg(feature = "wpe")]
 490     modal: Option<Modal>,
 491     /// Open right-click menu, if any.
 492     #[cfg(feature = "wpe")]
 493     ctx_menu: Option<CtxMenu>,
 494     /// Kept so the WPE backend's calloop sources can fire `Spin`; Servo
 495     /// wakes the loop itself through its `EventLoopWaker`.
 496     #[cfg(feature = "wpe")]
 497     sender: calloop::channel::Sender<Message>,
 498     /// App-side bundled-fonts `FontSystem` (the same set the toolkit renders
 499     /// with) for URL-bar caret/click metrics via `shaped_cluster_offsets` —
 500     /// `measure_text_width`'s inked-extent numbers drift off the drawn glyphs.
 501     font_system: cce_ui::cosmic_text::FontSystem,
 502     /// The open-tab set, persisted across restarts (see `session.rs`).
 503     session: session::Session,
 504     /// The favorites store, shared with the host (and so with the
 505     /// `cce://favorites` page, which edits it); `favs` is the strip as last
 506     /// read from it, refreshed with the rest of the page state.
 507     favorites: std::sync::Arc<pages::Favorites>,
 508     favs: Vec<pages::Link>,
 509     /// The bookmarks store, shared with the host (and so with the
 510     /// `cce://bookmarks` page); the menu below lists and edits it.
 511     bookmarks: std::sync::Arc<pages::Bookmarks>,
 512     /// The open bookmarks menu, if any.
 513     bm_menu: Option<BmMenu>,
 514     /// Wheel easing for the page, through the DE's shared scroll model.
 515     ///
 516     /// The browser does not own the page's offset — WebKit does — so this
 517     /// runs as a *virtual* one: notches move its target, `tick` walks the
 518     /// eased position, and what the engine receives each frame is the
 519     /// difference since the last one. It is rebased to zero whenever the
 520     /// glide settles, so nothing accumulates across a session.
 521     scroll: cce_ui::widget::scroll_motion::ScrollMotion,
 522     /// The virtual offset already handed to the engine.
 523     scroll_sent: (f32, f32),
 524     /// Accounts from cce-secrets, and the worker that reads them.
 525     accounts: accounts::Accounts,
 526     /// The open account list, if a login field is focused and something in
 527     /// the keyring matches the page.
 528     #[cfg(feature = "wpe")]
 529     ac_menu: Option<AcMenu>,
 530     /// The active tab's URL as of the last page-state sync, for spotting an
 531     /// actual navigation. The engine's dirty flag is not that: it also fires
 532     /// for a title, a loading transition, a favicon — and treating those as
 533     /// navigations closed the account list in the same pump that opened it,
 534     /// and would cut every wheel glide short the moment the page it was
 535     /// scrolling said anything about itself.
 536     nav_url: Option<String>,
 537     /// Hovered pill in the favorites strip — a repaint, like the dot.
 538     fav_hover: Option<usize>,
 539 }
 540 
 541 fn hit(r: &Rect, x: f32, y: f32) -> bool {
 542     x >= r.x && x < r.x + r.width && y >= r.y && y < r.y + r.height
 543 }
 544 
 545 /// The floating utility bar, overlaid on the page content. Anchored to the
 546 /// top or bottom window edge per the config; the page is full-bleed either
 547 /// way, so nothing but the chrome geometry depends on this. Every other
 548 /// bar-relative rect below is derived from this one — never from
 549 /// `bar_margin()` directly, or it would stay pinned to the top.
 550 fn bar_rect(win: (f32, f32), position: settings::BarPosition, favorites: bool) -> Rect {
 551     let h = bar_h(favorites);
 552     let y = match position {
 553         settings::BarPosition::Top => bar_margin(),
 554         settings::BarPosition::Bottom => (win.1 - bar_margin() - h).max(bar_margin()),
 555     };
 556     Rect {
 557         x: bar_margin(),
 558         y,
 559         width: (win.0 - 2.0 * bar_margin()).max(120.0),
 560         height: h,
 561     }
 562 }
 563 
 564 /// Y of the tab-strip row.
 565 fn tabs_y(bar: &Rect) -> f32 {
 566     bar.y + plate_pad()
 567 }
 568 
 569 /// Y of the favorites strip — under the tabs, where it only exists when
 570 /// the bar was sized for it.
 571 fn favs_y(bar: &Rect) -> f32 {
 572     bar.y + plate_pad() + TAB_H + inner_gap()
 573 }
 574 
 575 /// Y of the nav-controls row: the bar's bottom row, whether or not the
 576 /// favorites strip sits above it, so it is measured from the bottom edge.
 577 fn controls_y(bar: &Rect) -> f32 {
 578     bar.y + bar.height - plate_pad() - BTN_H
 579 }
 580 
 581 /// The favorites strip's pills, one rect per favorite that fits, in strip
 582 /// order (index into the strip = index into the result). Widths follow the
 583 /// labels, which is why this takes the font: draw and hit-test both read it
 584 /// with the same font and get the same rects.
 585 fn fav_rects(bar: &Rect, favs: &[pages::Link], sans: &str) -> Vec<Rect> {
 586     let mut rects = Vec::with_capacity(favs.len());
 587     let right = bar.x + bar.width - plate_pad();
 588     let mut x = bar.x + plate_pad();
 589     for f in favs {
 590         let w = (measure_text_width(&f.label, sans, FAV_FONT) + 2.0 * text_pad())
 591             .min(FAV_MAX_W)
 592             .max(FAV_H);
 593         if x + w > right {
 594             break;
 595         }
 596         rects.push(Rect { x, y: favs_y(bar), width: w, height: FAV_H });
 597         x += w + item_gap();
 598     }
 599     rects
 600 }
 601 
 602 fn plus_rect(bar: &Rect, position: settings::BarPosition) -> Rect {
 603     Rect {
 604         x: bar.x + bar.width - plate_pad() - dot_col(position, true) - PLUS_W,
 605         y: tabs_y(bar),
 606         width: PLUS_W,
 607         height: TAB_H,
 608     }
 609 }
 610 
 611 fn tab_rect(bar: &Rect, position: settings::BarPosition, count: usize, i: usize) -> Rect {
 612     let avail = bar.width
 613         - 2.0 * plate_pad()
 614         - dot_col(position, true)
 615         - PLUS_W
 616         - item_gap()
 617         - (count.max(1) - 1) as f32 * item_gap();
 618     let w = (avail / count.max(1) as f32).clamp(TAB_MIN_W, TAB_MAX_W);
 619     Rect {
 620         x: bar.x + plate_pad() + i as f32 * (w + item_gap()),
 621         y: tabs_y(bar),
 622         width: w,
 623         height: TAB_H,
 624     }
 625 }
 626 
 627 /// The close "x" hit region on a tab pill, when the pill is wide enough.
 628 fn tab_close_rect(pill: &Rect) -> Option<Rect> {
 629     (pill.width >= TAB_CLOSE_MIN_W).then(|| Rect {
 630         x: pill.x + pill.width - TAB_CLOSE_W,
 631         y: pill.y,
 632         width: TAB_CLOSE_W,
 633         height: pill.height,
 634     })
 635 }
 636 
 637 fn btn_rect(bar: &Rect, i: usize) -> Rect {
 638     Rect {
 639         x: bar.x + plate_pad() + i as f32 * (BTN_W + item_gap()),
 640         y: controls_y(bar),
 641         width: BTN_W,
 642         height: BTN_H,
 643     }
 644 }
 645 
 646 /// The bookmark star, at the right end of the controls row — clear of the
 647 /// corner control when that row holds it.
 648 fn star_rect(bar: &Rect, position: settings::BarPosition) -> Rect {
 649     Rect {
 650         x: bar.x + bar.width - plate_pad() - dot_col(position, false) - BTN_W,
 651         y: controls_y(bar),
 652         width: BTN_W,
 653         height: BTN_H,
 654     }
 655 }
 656 
 657 /// The bookmarks menu button, immediately left of the star: the star is
 658 /// this page's bookmark, this is all of them.
 659 fn bm_btn_rect(bar: &Rect, position: settings::BarPosition) -> Rect {
 660     let star = star_rect(bar, position);
 661     Rect { x: star.x - item_gap() - BTN_W, ..star }
 662 }
 663 
 664 fn url_rect(bar: &Rect, position: settings::BarPosition) -> Rect {
 665     let x = bar.x + plate_pad() + 3.0 * (BTN_W + item_gap());
 666     let right = bm_btn_rect(bar, position).x - item_gap();
 667     Rect { x, y: controls_y(bar), width: (right - x).max(60.0), height: BTN_H }
 668 }
 669 
 670 /// Whether a password filled into this page would leave it in the clear.
 671 ///
 672 /// Loopback is not: nothing crosses a network. Everything else that is not
 673 /// https is, including a `file:` page, which has no origin to speak of.
 674 #[cfg(feature = "wpe")]
 675 fn insecure_origin(origin: &str, host: &str) -> bool {
 676     let secure_scheme = origin.split(':').next() == Some("https");
 677     let loopback = matches!(host, "localhost" | "127.0.0.1" | "::1")
 678         || host.ends_with(".localhost");
 679     !secure_scheme && !loopback
 680 }
 681 
 682 /// Turn URL-bar input into something loadable: a real URL as-is, a bare
 683 /// host gets https://, anything else becomes a search.
 684 fn parse_url_input(input: &str, search_prefix: &str) -> Option<Url> {
 685     let s = input.trim();
 686     if s.is_empty() {
 687         return None;
 688     }
 689     if s.eq_ignore_ascii_case("about:history") {
 690         return Url::parse("cce://history").ok();
 691     }
 692     if s.eq_ignore_ascii_case("about:bookmarks") {
 693         return Url::parse("cce://bookmarks").ok();
 694     }
 695     if s.eq_ignore_ascii_case("about:favorites") {
 696         return Url::parse("cce://favorites").ok();
 697     }
 698     if s.eq_ignore_ascii_case("about:downloads") {
 699         return Url::parse("cce://downloads").ok();
 700     }
 701     if s.eq_ignore_ascii_case("about:cookies") {
 702         return Url::parse("cce://cookies").ok();
 703     }
 704     if let Ok(u) = Url::parse(s) {
 705         if matches!(u.scheme(), "http" | "https" | "file" | "data" | "about" | "cce") {
 706             return Some(u);
 707         }
 708     }
 709     if !s.contains(' ') && s.contains('.') {
 710         if let Ok(u) = Url::parse(&format!("https://{s}")) {
 711             return Some(u);
 712         }
 713     }
 714     let q: String = url::form_urlencoded::byte_serialize(s.as_bytes()).collect();
 715     Url::parse(&format!("{search_prefix}{q}")).ok()
 716 }
 717 
 718 /// Turn the startup argument into something loadable.
 719 ///
 720 /// This is deliberately not [`parse_url_input`]: that one is the URL *bar*,
 721 /// where a dotted word is meant to become a domain guess. Argv is different —
 722 /// the desktop entry claims `text/html`, and the XDG spec lets a launcher pass
 723 /// a local file for `%u` "either as a file: URL or as a file path". A plain
 724 /// path takes the domain-guess branch and turns `/home/me/page.html` into
 725 /// `https:///home/me/page.html`, so an existing path is resolved to a file:
 726 /// URL first and only a non-path falls through to the bar's parsing.
 727 fn parse_startup_arg(arg: &str, search_prefix: &str) -> Option<Url> {
 728     let path = std::path::Path::new(arg);
 729     if path.exists() {
 730         // Relative paths need the cwd joined on before file: URL conversion.
 731         if let Ok(abs) = std::fs::canonicalize(path) {
 732             if let Ok(u) = Url::from_file_path(&abs) {
 733                 return Some(u);
 734             }
 735         }
 736     }
 737     parse_url_input(arg, search_prefix)
 738 }
 739 
 740 
 741 
 742 
 743 
 744 impl BrowserApp {
 745     /// The utility bar's rect for the current window size and configured
 746     /// edge — the single source every chrome hit-test and draw reads.
 747     fn bar(&self) -> Rect {
 748         bar_rect(self.win, self.settings.bar_position, !self.favs.is_empty())
 749     }
 750 
 751     /// The favorites strip's pills for the current bar.
 752     fn fav_rects(&self, bar: &Rect) -> Vec<Rect> {
 753         let (sans, ..) = cce_ui::layout::read_preferred_fonts();
 754         fav_rects(bar, &self.favs, &sans)
 755     }
 756 
 757     /// Re-read the strip from the store. Called with the rest of the page
 758     /// state, which is also when the `cce://favorites` page's edits — made
 759     /// on the way into a navigation — become visible.
 760     fn refresh_favorites(&mut self) {
 761         let favs = self.favorites.snapshot();
 762         if favs != self.favs {
 763             self.favs = favs;
 764             self.fav_hover = None;
 765         }
 766     }
 767 
 768     /// Add or remove the active page from the favorites strip.
 769     fn toggle_favorite(&mut self) {
 770         self.host.toggle_favorite();
 771         self.refresh_favorites();
 772     }
 773 
 774     /// Drop any glide in flight and rebase the virtual offset.
 775     ///
 776     /// Called wherever the deltas would land somewhere they were not aimed:
 777     /// another tab, another page.
 778     fn stop_scroll(&mut self) {
 779         self.scroll.x.jump_to(0.0);
 780         self.scroll.y.jump_to(0.0);
 781         self.scroll_sent = (0.0, 0.0);
 782     }
 783 
 784     /// Hand the engine what the glide moved since the last frame.
 785     ///
 786     /// Deltas, not an offset: WebKit keeps the real scroll position (and
 787     /// clamps it at the ends of the page), so the model here only has to say
 788     /// how far to move. Sign flips back on the way out — the shared model
 789     /// counts an offset that grows as content moves up, the engine takes the
 790     /// winit convention the rest of this file passes it.
 791     fn advance_scroll(&mut self, dt: f32) -> bool {
 792         use cce_ui::widget::scroll_motion::Bounds;
 793         if !self.scroll.is_animating() {
 794             // Settled: rebase, so a long session never walks the accumulator
 795             // out into the far reaches of f32.
 796             if self.scroll_sent != (0.0, 0.0) {
 797                 self.stop_scroll();
 798             }
 799             return false;
 800         }
 801         self.scroll.tick(dt, Bounds::UNBOUNDED, Bounds::UNBOUNDED);
 802         let (x, y) = (self.scroll.x.pos(), self.scroll.y.pos());
 803         let (dx, dy) = (x - self.scroll_sent.0, y - self.scroll_sent.1);
 804         self.scroll_sent = (x, y);
 805         if dx == 0.0 && dy == 0.0 {
 806             return true;
 807         }
 808         // Say which gesture these belong to rather than inheriting whatever
 809         // the last real event set: a glide is a wheel, and a stale FingerEnd
 810         // would tell the engine every frame that a gesture had just ended.
 811         cce_ui::widget::scroll_motion::set_scroll_phase(cce_ui::widget::ScrollPhase::Wheel);
 812         let s = self.scale;
 813         self.host.wheel(
 814             -(dx as f64) * s,
 815             -(dy as f64) * s,
 816             self.pointer.0 * s as f32,
 817             self.pointer.1 * s as f32,
 818         );
 819         true
 820     }
 821 
 822     /// The tab's host, for matching accounts and for checking that a field
 823     /// event came from the page the chrome thinks is on screen.
 824     fn page_host(&self) -> Option<String> {
 825         self.host.url().and_then(|u| u.host_str().map(str::to_string))
 826     }
 827 
 828     /// A login field was reported. Open, move or refill the account list.
 829     ///
 830     /// The origin check is the guard: the watcher runs in the top frame, so
 831     /// its origin must be the tab's own. Anything else is dropped rather than
 832     /// offered a credential.
 833     #[cfg(feature = "wpe")]
 834     fn on_form_event(&mut self, event: wpe::FormEvent) -> bool {
 835         use wpe::FormEvent;
 836         if !self.settings.accounts {
 837             return false;
 838         }
 839         match event {
 840             FormEvent::Blur => {
 841                 let was = self.ac_menu.is_some();
 842                 self.ac_menu = None;
 843                 was
 844             }
 845             FormEvent::Field { origin, password, rect, value, moved } => {
 846                 log::debug!(
 847                     "login field: password={password} moved={moved} origin={origin} \
 848                      page={:?} rect={rect:?}",
 849                     self.page_host()
 850                 );
 851                 let Some(host) = self.page_host() else {
 852                     self.ac_menu = None;
 853                     return false;
 854                 };
 855                 let same_origin = url::Url::parse(&origin)
 856                     .ok()
 857                     .and_then(|u| u.host_str().map(|h| h == host))
 858                     .unwrap_or(false);
 859                 if !same_origin {
 860                     self.ac_menu = None;
 861                     return false;
 862                 }
 863                 // The index is read the first time a login field appears —
 864                 // never at launch, so a browser that sees no login form never
 865                 // opens the keyring.
 866                 self.accounts.ensure_loaded();
 867                 let anchor = self.field_rect(rect);
 868                 let all = self.accounts.matching(&host);
 869                 log::debug!("{} accounts match {host}", all.len());
 870                 let insecure = insecure_origin(&origin, &host);
 871                 // A password field filters by nothing; a username field by
 872                 // what is in it.
 873                 let filter = if password { String::new() } else { value };
 874                 match self.ac_menu.as_mut() {
 875                     Some(menu) if moved => {
 876                         menu.anchor = anchor;
 877                         menu.all = all;
 878                         menu.host = host.clone();
 879                         menu.insecure = insecure;
 880                         menu.refilter(&filter);
 881                     }
 882                     _ => {
 883                         let mut menu = AcMenu {
 884                             all,
 885                             shown: Vec::new(),
 886                             selected: 0,
 887                             scroll: 0,
 888                             anchor,
 889                             host: host.clone(),
 890                             insecure,
 891                             hover: None,
 892                         };
 893                         menu.refilter(&filter);
 894                         self.ac_menu = Some(menu);
 895                     }
 896                 }
 897                 // An empty list is no list: nothing matched, or the index is
 898                 // still loading and the next `Accounts` message will reopen.
 899                 if self.ac_menu.as_ref().is_some_and(|m| m.shown.is_empty()) {
 900                     self.ac_menu = None;
 901                 }
 902                 true
 903             }
 904         }
 905     }
 906 
 907     /// A viewport rect from the page, in the chrome's coordinates.
 908     ///
 909     /// These are the same space, and that is worth stating rather than
 910     /// rediscovering: `resize` gives WPE the **logical** size and sets the
 911     /// scale separately (`logical_size`), so a CSS pixel in the page is a
 912     /// logical pixel in the chrome at any output scale. Verified at scale 2.
 913     #[cfg(feature = "wpe")]
 914     fn field_rect(&self, rect: (f32, f32, f32, f32)) -> Rect {
 915         Rect { x: rect.0, y: rect.1, width: rect.2, height: rect.3 }
 916     }
 917 
 918     /// Hand a picked account's credential to the page and close the list.
 919     ///
 920     /// The keyring answers on its own schedule — an unlock prompt can put
 921     /// seconds between the pick and this — so everything is checked again
 922     /// here: the list is still open, it still holds the account that was
 923     /// picked, and the tab is still on the host it was opened for. If any of
 924     /// that has changed the credential is dropped on the floor rather than
 925     /// typed into whatever page is there now.
 926     #[cfg(feature = "wpe")]
 927     fn fill_account(&mut self, path: &str, secret: &accounts::Secret) {
 928         let same_page = self
 929             .ac_menu
 930             .as_ref()
 931             .zip(self.page_host())
 932             .is_some_and(|(menu, host)| menu.host == host);
 933         let username = self
 934             .ac_menu
 935             .as_ref()
 936             .filter(|_| same_page)
 937             .and_then(|m| m.shown.iter().find(|a| a.path == path))
 938             .map(|a| a.username.clone());
 939         match username {
 940             Some(username) => self.host.fill_credentials(&username, secret.expose()),
 941             None => log::warn!("dropped a credential: the page moved on before it arrived"),
 942         }
 943         self.ac_menu = None;
 944     }
 945 
 946     /// Ask for the password behind the selected row.
 947     #[cfg(feature = "wpe")]
 948     fn pick_account(&mut self, index: usize) {
 949         let Some(account) = self.ac_menu.as_ref().and_then(|m| m.shown.get(index)) else {
 950             return;
 951         };
 952         // The secret is fetched now, for this one entry, and arrives as
 953         // `Message::Credential`. Nothing is held in the menu.
 954         self.accounts.fetch(&account.path);
 955     }
 956 
 957     /// The account list's plate and rows, or `None` when it is closed. Draw
 958     /// and hit-test read this, as everywhere else in this chrome.
 959     #[cfg(feature = "wpe")]
 960     fn ac_layout(&self) -> Option<(Rect, Vec<Rect>)> {
 961         let menu = self.ac_menu.as_ref()?;
 962         if menu.shown.is_empty() {
 963             return None;
 964         }
 965         let rows = menu.shown.len().min(AC_MAX_ROWS);
 966         let height = 2.0 * plate_pad() + rows as f32 * AC_ROW_H + if menu.insecure { 18.0 } else { 0.0 };
 967         let width = AC_W.min(self.win.0 - 2.0 * bar_margin()).max(180.0);
 968         let x = menu
 969             .anchor
 970             .x
 971             .clamp(0.0, (self.win.0 - width).max(0.0));
 972         // Under the field, or above it when there is no room below — the
 973         // list must never cover the field it is filling.
 974         // style: deliberate — 2px off the field, so the list reads as
 975         // attached to it; the field is page content, not a plate sibling.
 976         let below = menu.anchor.y + menu.anchor.height + 2.0;
 977         let y = if below + height <= self.win.1 - bar_margin() {
 978             below
 979         } else {
 980             (menu.anchor.y - 2.0 - height).max(0.0)
 981         };
 982         let plate = Rect { x, y, width, height };
 983         // Row *positions*; which account each shows is `first_row() + k`.
 984         // style: deliberate — the 2px hairline keeps a row's highlight off
 985         // the plate's roll.
 986         let rects = (0..rows)
 987             .map(|k| Rect {
 988                 x: plate.x + 2.0,
 989                 y: plate.y + plate_pad() + (k as f32) * AC_ROW_H,
 990                 width: plate.width - 4.0,
 991                 height: AC_ROW_H,
 992             })
 993             .collect();
 994         Some((plate, rects))
 995     }
 996 
 997     /// The account row at a pointer position, if any.
 998     #[cfg(feature = "wpe")]
 999     fn ac_hit(&self, x: f32, y: f32) -> Option<usize> {
1000         let (_, rows) = self.ac_layout()?;
1001         let first = self.ac_menu.as_ref()?.first_row();
1002         rows.iter().position(|r| hit(r, x, y)).map(|k| first + k)
1003     }
1004 
1005     /// Whether the active page is one that can be saved at all: an internal
1006     /// page or a blank tab cannot.
1007     fn saveable(&self) -> bool {
1008         self.host
1009             .url()
1010             .is_some_and(|u| !matches!(u.scheme(), "cce" | "about"))
1011     }
1012 
1013     /// Drop the bookmarks menu from its button, taking a snapshot of the
1014     /// store. Focus leaves the URL bar with it: a field behind an open menu
1015     /// must not keep eating keystrokes, the same reason folding drops it.
1016     fn open_bm_menu(&mut self) {
1017         if self.url_focused {
1018             self.url_focused = false;
1019             self.url.selection = None;
1020             self.sync_page_state();
1021         }
1022         self.bm_menu = Some(BmMenu {
1023             items: self.bookmarks.snapshot(),
1024             scroll: 0,
1025             hover: None,
1026         });
1027     }
1028 
1029     fn close_bm_menu(&mut self) {
1030         self.bm_menu = None;
1031     }
1032 
1033     /// Re-read the store into the open menu after an edit made through it,
1034     /// keeping the scroll inside the new range.
1035     fn refresh_bm_menu(&mut self) {
1036         let items = self.bookmarks.snapshot();
1037         let cap = self.bm_layout().map_or(items.len(), |l| l.cap);
1038         if let Some(m) = self.bm_menu.as_mut() {
1039             m.scroll = m.scroll.min(items.len().saturating_sub(cap));
1040             m.items = items;
1041             m.hover = None;
1042         }
1043     }
1044 
1045     /// The menu's geometry for the current window, bar edge and item count:
1046     /// `None` when it is closed. Draw and hit-test both read this.
1047     ///
1048     /// It hangs off the button that opens it — below the bar on a top bar,
1049     /// above it on a bottom one — right-aligned with that button and
1050     /// clamped on screen, and it never grows past the space it has: the
1051     /// list is capped to what fits and scrolls instead.
1052     fn bm_layout(&self) -> Option<BmLayout> {
1053         let menu = self.bm_menu.as_ref()?;
1054         let bar = self.bar();
1055         let btn = bm_btn_rect(&bar, self.settings.bar_position);
1056         let width = BM_W.min(self.win.0 - 2.0 * bar_margin()).max(160.0);
1057         let x = (btn.x + btn.width - width)
1058             .clamp(bar_margin(), (self.win.0 - bar_margin() - width).max(bar_margin()));
1059         // The furniture the list is fitted around: toggle row, two rules and
1060         // the manage row.
1061         let fixed = 2.0 * plate_pad() + 2.0 * BM_ROW_H + 2.0 * BM_SEP_H;
1062         let avail = match self.settings.bar_position {
1063             settings::BarPosition::Top => self.win.1 - (bar.y + bar.height + item_gap()) - bar_margin(),
1064             settings::BarPosition::Bottom => bar.y - item_gap() - bar_margin(),
1065         };
1066         let cap = (((avail - fixed) / BM_ROW_H).floor().max(1.0)) as usize;
1067         // An empty list still shows its one "nothing here" row.
1068         let shown = menu.items.len().clamp(1, cap);
1069         let height = fixed + shown as f32 * BM_ROW_H;
1070         let y = match self.settings.bar_position {
1071             settings::BarPosition::Top => bar.y + bar.height + item_gap(),
1072             settings::BarPosition::Bottom => bar.y - item_gap() - height,
1073         };
1074         let plate = Rect { x, y, width, height };
1075         // style: deliberate — the 2px hairline keeps a row's highlight off
1076         // the plate's roll.
1077         let row = |dy: f32| Rect {
1078             x: x + 2.0,
1079             y: y + plate_pad() + dy,
1080             width: width - 4.0,
1081             height: BM_ROW_H,
1082         };
1083         let list_y = BM_ROW_H + BM_SEP_H;
1084         let first = menu.scroll.min(menu.items.len().saturating_sub(shown));
1085         let rows = (0..shown.min(menu.items.len()))
1086             .map(|k| (row(list_y + k as f32 * BM_ROW_H), first + k))
1087             .collect();
1088         Some(BmLayout {
1089             plate,
1090             toggle: row(0.0),
1091             rows,
1092             empty: menu.items.is_empty().then(|| row(list_y)),
1093             manage: row(list_y + shown as f32 * BM_ROW_H + BM_SEP_H),
1094             cap,
1095         })
1096     }
1097 
1098     /// What the pointer is on inside the menu — `None` for its padding and
1099     /// rules as much as for the world outside it, so the caller checks the
1100     /// plate itself before deciding a click was "outside".
1101     fn bm_hit(&self, x: f32, y: f32) -> Option<BmHit> {
1102         let l = self.bm_layout()?;
1103         if hit(&l.toggle, x, y) {
1104             return Some(BmHit::Toggle);
1105         }
1106         if hit(&l.manage, x, y) {
1107             return Some(BmHit::Manage);
1108         }
1109         l.rows.iter().find(|(r, _)| hit(r, x, y)).map(|(r, i)| {
1110             BmHit::Entry(*i, x >= r.x + r.width - BM_RM_W)
1111         })
1112     }
1113 
1114     /// Visit a bookmark from the menu: in the active tab, which is a pick —
1115     /// menu and bar fold away to show the page — or in a new tab, which
1116     /// leaves the menu up so several can be opened in a row.
1117     fn bm_open(&mut self, index: usize, new_tab: bool) {
1118         let Some(url) = self
1119             .bm_menu
1120             .as_ref()
1121             .and_then(|m| m.items.get(index))
1122             .and_then(|i| Url::parse(&i.url).ok())
1123         else {
1124             return;
1125         };
1126         if new_tab {
1127             self.host.open_tab(url);
1128             self.url_focused = false;
1129             self.sync_page_state();
1130             self.persist_session();
1131         } else {
1132             self.host.load(url);
1133             self.loading = true;
1134             self.close_bm_menu();
1135             self.close_chrome();
1136             self.sync_page_state();
1137         }
1138     }
1139 
1140     /// Drop one bookmark from inside the menu. The list stays open —
1141     /// pruning is the one thing done several times in a row.
1142     fn bm_remove(&mut self, index: usize) {
1143         let Some(url) = self
1144             .bm_menu
1145             .as_ref()
1146             .and_then(|m| m.items.get(index))
1147             .map(|i| i.url.clone())
1148         else {
1149             return;
1150         };
1151         self.bookmarks.remove(&url);
1152         self.refresh_bm_menu();
1153     }
1154 
1155     /// Wheel over the list: one bookmark per notch, clamped to the range
1156     /// the plate cannot show.
1157     fn bm_scroll(&mut self, dy: f64) {
1158         let Some(l) = self.bm_layout() else { return };
1159         let max = self
1160             .bm_menu
1161             .as_ref()
1162             .map_or(0, |m| m.items.len().saturating_sub(l.cap));
1163         if let Some(m) = self.bm_menu.as_mut() {
1164             // Positive dy is up, cce-ui's winit convention.
1165             let step: isize = if dy > 0.0 { -1 } else { 1 };
1166             m.scroll = (m.scroll as isize + step).clamp(0, max as isize) as usize;
1167         }
1168     }
1169 
1170     /// Act on a click inside the menu.
1171     fn bm_click(&mut self, button: MouseButton, hit: Option<BmHit>) {
1172         match (button, hit) {
1173             (MouseButton::Left, Some(BmHit::Toggle)) => {
1174                 if self.saveable() || self.host.active_bookmarked() {
1175                     self.host.toggle_bookmark();
1176                     self.refresh_bm_menu();
1177                 }
1178             }
1179             (MouseButton::Left, Some(BmHit::Entry(i, true))) => self.bm_remove(i),
1180             (MouseButton::Left, Some(BmHit::Entry(i, false))) => self.bm_open(i, false),
1181             (MouseButton::Middle, Some(BmHit::Entry(i, _))) => self.bm_open(i, true),
1182             (MouseButton::Left, Some(BmHit::Manage)) => {
1183                 self.close_bm_menu();
1184                 self.close_chrome();
1185                 self.open_internal_page("cce://bookmarks");
1186             }
1187             _ => {}
1188         }
1189     }
1190 
1191     /// Centre of the corner control: the bar's corner nearest the window
1192     /// corner it is anchored to — top-right for a top bar, bottom-right for
1193     /// a bottom one — at the DE's inset. A circle menu is the corner of the
1194     /// thing it expands into, so it sits where the bar's corner will be and
1195     /// stays there when the bar is out.
1196     fn dot_center(&self) -> (f32, f32) {
1197         let bar = self.bar();
1198         let inset = plate_dock::CORNER_INSET;
1199         let cy = match self.settings.bar_position {
1200             settings::BarPosition::Top => bar.y + inset,
1201             settings::BarPosition::Bottom => bar.y + bar.height - inset,
1202         };
1203         (bar.x + bar.width - inset, cy)
1204     }
1205 
1206     fn dot_hit(&self, x: f32, y: f32) -> bool {
1207         plate_dock::corner_hit(self.dot_center(), x, y)
1208     }
1209 
1210     /// Unfold progress with easing applied — what the plate is drawn from.
1211     fn chrome_ease(&self) -> f32 {
1212         let t = self.chrome_t.clamp(0.0, 1.0);
1213         t * t * (3.0 - 2.0 * t)
1214     }
1215 
1216     /// The bar plate as currently drawn — the full bar, or the shape it is
1217     /// unfolding through — and its corner radius. It grows out of the dot
1218     /// itself: the seed is the dot's own disc, so the control expands as an
1219     /// object into the bar and, open, is the bar's corner.
1220     fn chrome_plate(&self) -> (Rect, f32) {
1221         let e = self.chrome_ease();
1222         let (cx, cy) = self.dot_center();
1223         let seed = plate_dock::CORNER_R;
1224         let bar = self.bar();
1225         let lerp = |a: f32, b: f32| a + (b - a) * e;
1226         let plate = Rect {
1227             x: lerp(cx - seed, bar.x),
1228             y: lerp(cy - seed, bar.y),
1229             width: lerp(2.0 * seed, bar.width),
1230             height: lerp(2.0 * seed, bar.height),
1231         };
1232         (plate, lerp(seed, BAR_RADIUS))
1233     }
1234 
1235     /// Whether a pointer position is over the chrome: the corner control
1236     /// always, the plate while any of it is showing.
1237     fn chrome_hit(&self, x: f32, y: f32) -> bool {
1238         self.dot_hit(x, y) || (self.chrome_t > 0.0 && hit(&self.chrome_plate().0, x, y))
1239     }
1240 
1241     fn open_chrome(&mut self) {
1242         self.chrome_open = true;
1243     }
1244 
1245     /// Fold the bar back into the orb; drops URL-bar focus with it, since
1246     /// a field that is not on screen must not keep eating keystrokes.
1247     fn close_chrome(&mut self) {
1248         self.chrome_open = false;
1249         // The menu hangs off a bar that is going away.
1250         self.bm_menu = None;
1251         if self.url_focused {
1252             self.url_focused = false;
1253             self.url.selection = None;
1254             self.sync_page_state();
1255         }
1256     }
1257 
1258     /// The page fills the whole window; the utility bar floats above it.
1259     fn content_px(&self) -> (u32, u32) {
1260         (
1261             (self.win.0 as f64 * self.scale) as u32,
1262             (self.win.1 as f64 * self.scale) as u32,
1263         )
1264     }
1265 
1266     /// Pull delegate-observed page state into the chrome.
1267     fn sync_page_state(&mut self) {
1268         self.refresh_favorites();
1269         self.loading = self.host.loading();
1270         self.title = self.host.title().filter(|t| !t.is_empty());
1271         if !self.url_focused {
1272             if let Some(u) = self.host.url() {
1273                 let s = u.to_string();
1274                 self.url = lineedit::LineEdit::with_text(
1275                     if s == "about:blank" { String::new() } else { s },
1276                 );
1277             }
1278         }
1279     }
1280 
1281     /// Adopt whatever the engine is blocked on. Returns whether the chrome
1282     /// needs redrawing.
1283     #[cfg(feature = "wpe")]
1284     fn sync_modal(&mut self) -> bool {
1285         if self.modal.is_some() {
1286             return false;
1287         }
1288         if let Some(d) = self.host.pending_dialog() {
1289             let mut fields = Vec::new();
1290             if let Some(default) = d.prompt_default.clone() {
1291                 let mut e = lineedit::LineEdit::with_text(default);
1292                 e.select_all();
1293                 fields.push(("", e));
1294             }
1295             self.modal = Some(Modal {
1296                 title: "This page says".to_string(),
1297                 message: d.message,
1298                 fields,
1299                 focused: 0,
1300                 has_cancel: d.has_cancel,
1301                 kind: ModalKind::Script,
1302             });
1303             return true;
1304         }
1305         if let Some(a) = self.host.pending_auth() {
1306             let where_ = if a.realm.is_empty() {
1307                 a.host.clone()
1308             } else {
1309                 format!("{} — {}", a.host, a.realm)
1310             };
1311             self.modal = Some(Modal {
1312                 title: if a.retry {
1313                     "Sign in failed — try again".to_string()
1314                 } else {
1315                     "Sign in".to_string()
1316                 },
1317                 message: where_,
1318                 fields: vec![
1319                     ("Username", lineedit::LineEdit::default()),
1320                     ("Password", lineedit::LineEdit::masked()),
1321                 ],
1322                 focused: 0,
1323                 has_cancel: true,
1324                 kind: ModalKind::Auth,
1325             });
1326             return true;
1327         }
1328         false
1329     }
1330 
1331     /// Answer the engine and dismiss. `ok` false is cancel.
1332     #[cfg(feature = "wpe")]
1333     fn close_modal(&mut self, ok: bool) {
1334         let Some(m) = self.modal.take() else { return };
1335         match m.kind {
1336             ModalKind::Script => {
1337                 let text = m.fields.first().map(|(_, e)| e.text.clone());
1338                 self.host.respond_dialog(ok, text.as_deref());
1339             }
1340             ModalKind::Auth => {
1341                 if ok {
1342                     let user = m.fields[0].1.text.clone();
1343                     let password = m.fields[1].1.text.clone();
1344                     self.host.respond_auth(Some((&user, &password)));
1345                 } else {
1346                     self.host.respond_auth(None);
1347                 }
1348             }
1349         }
1350     }
1351 
1352     /// Build the right-click menu from what the hit test found, placed at
1353     /// the pointer and clamped to the window.
1354     #[cfg(feature = "wpe")]
1355     fn open_ctx_menu(&mut self, info: wpe::ContextMenuInfo) {
1356         let mut items = Vec::new();
1357         let item = |label: &str, action: CtxAction, enabled: bool| CtxItem {
1358             label: label.to_string(),
1359             action,
1360             enabled,
1361         };
1362         if let Some((uri, _label)) = info.link {
1363             items.push(item("Open Link in New Tab", CtxAction::OpenInTab(uri.clone()), true));
1364             items.push(item("Copy Link", CtxAction::CopyText(uri.clone()), true));
1365             items.push(item("Download Link", CtxAction::Download(uri), true));
1366         }
1367         if let Some(uri) = info.image_uri {
1368             items.push(item("Copy Image Address", CtxAction::CopyText(uri.clone()), true));
1369             items.push(item("Download Image", CtxAction::Download(uri), true));
1370         }
1371         if info.is_selection {
1372             items.push(item("Copy", CtxAction::CopySelection, true));
1373         }
1374         if info.is_editable {
1375             items.push(item("Paste", CtxAction::Paste, true));
1376         }
1377         items.push(item("Back", CtxAction::Back, self.host.can_go_back()));
1378         items.push(item("Forward", CtxAction::Forward, self.host.can_go_forward()));
1379         items.push(item("Reload", CtxAction::Reload, true));
1380         let favorited = self.host.active_favorited();
1381         items.push(item(
1382             if favorited { "Remove from Favorites" } else { "Add to Favorites" },
1383             CtxAction::ToggleFavorite,
1384             favorited || self.saveable(),
1385         ));
1386         items.push(item("Open in Other Browser", CtxAction::OpenExternal, true));
1387 
1388         let h = plate_pad() * 2.0 + items.len() as f32 * CTX_ROW_H;
1389         let pos = (
1390             self.pointer.0.min(self.win.0 - CTX_W - bar_margin()).max(0.0),
1391             self.pointer.1.min(self.win.1 - h - bar_margin()).max(0.0),
1392         );
1393         self.ctx_menu = Some(CtxMenu { items, pos });
1394     }
1395 
1396     #[cfg(feature = "wpe")]
1397     fn dispatch_ctx_action(&mut self, index: usize) {
1398         let Some(menu) = self.ctx_menu.take() else { return };
1399         let Some(it) = menu.items.get(index) else { return };
1400         if !it.enabled {
1401             return;
1402         }
1403         match &it.action {
1404             CtxAction::Back => self.host.back(),
1405             CtxAction::Forward => self.host.forward(),
1406             CtxAction::Reload => self.host.reload(),
1407             CtxAction::CopySelection => self.host.editing_action_cmd(EditingCommand::Copy),
1408             CtxAction::Paste => self.host.editing_action_cmd(EditingCommand::Paste),
1409             CtxAction::OpenInTab(uri) => {
1410                 if let Ok(url) = Url::parse(uri) {
1411                     self.host.open_tab(url);
1412                     self.sync_page_state();
1413                 }
1414             }
1415             CtxAction::CopyText(text) => {
1416                 cce_ui::widget::clipboard::copy_to_clipboard(text);
1417             }
1418             CtxAction::Download(uri) => self.host.download_uri(uri),
1419             CtxAction::OpenExternal => self.open_external(),
1420             CtxAction::ToggleFavorite => self.toggle_favorite(),
1421         }
1422     }
1423 
1424     /// Hand a page-area press to the engine, remembering it so the matching
1425     /// release can always follow.
1426     ///
1427     /// Releases do not come through here: `drain_page_release` has already
1428     /// sent them from the top of `handle_mouse_input`, before any of the
1429     /// branches that swallow a click.
1430     fn page_press(&mut self, button: MouseButton, pressed: bool, pos: LogicalPosition) {
1431         if !pressed {
1432             return;
1433         }
1434         if !self.page_buttons.contains(&button) {
1435             self.page_buttons.push(button);
1436         }
1437         let s = self.scale as f32;
1438         self.host.mouse_button_ui(button, true, pos.x * s, pos.y * s);
1439     }
1440 
1441     /// Give the page the release it is owed, wherever the pointer ended up
1442     /// and whatever the chrome is about to do with this click.
1443     ///
1444     /// A release only means anything to whoever received the press, and the
1445     /// two are not routed alike: the press goes to the page, then the chrome
1446     /// may put a menu up — the right-click menu does exactly that, from the
1447     /// press — and every menu branch below swallows the clicks that arrive
1448     /// while it is open. A release swallowed there leaves WebKit holding the
1449     /// button down for good, which is how right-click quietly stops working
1450     /// until the app is restarted. A release nobody is owed (the one that
1451     /// dismissed the menu, say) is dropped rather than reaching the page
1452     /// unpaired.
1453     fn drain_page_release(&mut self, button: MouseButton, pos: LogicalPosition) {
1454         let Some(i) = self.page_buttons.iter().position(|b| *b == button) else {
1455             return;
1456         };
1457         self.page_buttons.remove(i);
1458         let s = self.scale as f32;
1459         self.host.mouse_button_ui(button, false, pos.x * s, pos.y * s);
1460     }
1461 
1462     fn navigate(&mut self) {
1463         if let Some(url) = parse_url_input(&self.url.text, &self.settings.search_prefix) {
1464             self.host.load(url);
1465             self.url_focused = false;
1466             self.url.selection = None;
1467             self.loading = true;
1468             // Submitting is the menu's "pick": it folds away to show the page.
1469             self.chrome_open = false;
1470         }
1471     }
1472 
1473     /// Pick up settings edits (system-interface, cce-data-editor) when the
1474     /// window regains focus. Returns whether anything changed.
1475     fn reload_settings(&mut self) -> bool {
1476         let new = settings::load();
1477         if new == self.settings {
1478             return false;
1479         }
1480         downloads::set_download_dir(new.download_dir.clone());
1481         #[cfg(feature = "wpe")]
1482         if new.accounts != self.settings.accounts {
1483             self.host.set_accounts_enabled(new.accounts);
1484             if !new.accounts {
1485                 self.ac_menu = None;
1486             }
1487         }
1488         self.host.set_history_enabled(new.history);
1489         self.host.set_color_scheme_dark(new.color_scheme.is_dark());
1490         self.host.set_force_dark(new.color_scheme.forces_dark());
1491         self.settings = new;
1492         true
1493     }
1494 
1495     /// Hand the current page to another browser — the escape hatch for the
1496     /// places Servo cannot follow, like a Cloudflare challenge that never
1497     /// completes.
1498     ///
1499     /// Prefers the configured command; otherwise asks XDG. The guard matters:
1500     /// cce-browser's own desktop entry claims http/https, so once it is the
1501     /// default handler, `xdg-open` would hand the page straight back to us.
1502     fn open_external(&mut self) {
1503         let Some(url) = self
1504             .host
1505             .url()
1506             .map(|u| u.to_string())
1507             .or_else(|| parse_url_input(&self.url.text, &self.settings.search_prefix).map(|u| u.to_string()))
1508         else {
1509             return;
1510         };
1511         let configured = self.settings.external_browser.clone();
1512         std::thread::spawn(move || {
1513             let command = match configured {
1514                 Some(c) => c,
1515                 None => {
1516                     let default = std::process::Command::new("xdg-mime")
1517                         .args(["query", "default", "x-scheme-handler/https"])
1518                         .output()
1519                         .ok()
1520                         .and_then(|o| String::from_utf8(o.stdout).ok())
1521                         .unwrap_or_default();
1522                     if default.trim_start().starts_with("cce-browser") {
1523                         log::warn!(
1524                             "cce-browser is the default https handler; set browser.external-browser                              to another command or this would just reopen here"
1525                         );
1526                         return;
1527                     }
1528                     "xdg-open".to_string()
1529                 }
1530             };
1531             let mut parts = command.split_whitespace();
1532             let Some(program) = parts.next() else { return };
1533             let args: Vec<&str> = parts.collect();
1534             match std::process::Command::new(program).args(args).arg(&url).spawn() {
1535                 Ok(_) => log::info!("handed {url} to {program}"),
1536                 Err(e) => log::warn!("could not run {program}: {e}"),
1537             }
1538         });
1539     }
1540 
1541     /// Write the open-tab set to the session store (a no-op when nothing
1542     /// changed). Blank tabs are not worth resurrecting, so they are skipped —
1543     /// which also means a browser left on nothing but "New Tab" starts fresh.
1544     fn persist_session(&mut self) {
1545         let active = self.host.active_index();
1546         let tabs: Vec<(String, bool)> = (0..self.host.tab_count())
1547             .filter_map(|i| {
1548                 let url = self.host.tab(i)?.url.as_ref()?.to_string();
1549                 (url != "about:blank").then_some((url, i == active))
1550             })
1551             .collect();
1552         self.session.save(&tabs);
1553     }
1554 
1555     /// New blank tab with the URL bar focused for typing.
1556     fn new_tab(&mut self) {
1557         let url = Url::parse("about:blank").expect("about:blank");
1558         self.host.open_tab(url);
1559         self.url = lineedit::LineEdit::default();
1560         self.url_focused = true;
1561         // The focused field has to be on screen, so a new tab unfolds the
1562         // menu even when it was opened by chord.
1563         self.open_chrome();
1564         self.sync_page_state();
1565         self.persist_session();
1566     }
1567 
1568     /// Close a tab; returns `Message::Quit` when it was the last one.
1569     fn close_tab(&mut self, index: usize) -> Option<Message> {
1570         if !self.host.close_tab(index) {
1571             // Deliberately emptied: save the empty set so the next launch
1572             // starts on the homepage instead of restoring what was closed.
1573             self.persist_session();
1574             return Some(Message::Quit);
1575         }
1576         self.url_focused = false;
1577         self.sync_page_state();
1578         self.persist_session();
1579         None
1580     }
1581 
1582     fn switch_tab(&mut self, index: usize) {
1583         // A glide aimed at this page must not land on the next one.
1584         self.stop_scroll();
1585         // The other tab has its own fields, and may have none.
1586         #[cfg(feature = "wpe")]
1587         {
1588             self.ac_menu = None;
1589             self.host.clear_form_events();
1590         }
1591         self.host.activate(index);
1592         self.url_focused = false;
1593         self.sync_page_state();
1594         self.persist_session();
1595     }
1596 
1597     /// Show an internal page: reuse a tab already on it (reloading, so
1598     /// live pages like downloads refresh), otherwise open a new one.
1599     fn open_internal_page(&mut self, page: &str) {
1600         let Ok(url) = Url::parse(page) else { return };
1601         for i in 0..self.host.tab_count() {
1602             let on_page = self
1603                 .host
1604                 .tab(i)
1605                 .and_then(|t| t.url.as_ref().map(|u| u.as_str().starts_with(page)))
1606                 .unwrap_or(false);
1607             if on_page {
1608                 self.switch_tab(i);
1609                 self.host.reload();
1610                 return;
1611             }
1612         }
1613         self.host.open_tab(url);
1614         self.url_focused = false;
1615         self.sync_page_state();
1616         self.persist_session();
1617     }
1618 
1619     /// Widest prefix of `text` fitting `avail`, with a "…"-style tail cut.
1620     fn fit_text(text: &str, sans: &str, size: f32, avail: f32) -> String {
1621         if measure_text_width(text, sans, size) <= avail {
1622             return text.to_string();
1623         }
1624         let mut end = text.len();
1625         while end > 0 {
1626             end = lineedit::prev_boundary(text, end);
1627             let cut = format!("{}...", &text[..end]);
1628             if measure_text_width(&cut, sans, size) <= avail {
1629                 return cut;
1630             }
1631         }
1632         String::new()
1633     }
1634 
1635     /// Draw the page-blocking prompt, if one is up. Same primitives as the
1636     /// utility bar — there are no cce-ui widgets in this app — with a scrim
1637     /// over the page so it reads as blocked, which it genuinely is.
1638     #[cfg(feature = "wpe")]
1639     fn paint_modal(&mut self, pc: &mut PaintCtx, sans: &str) {
1640         let Some(m) = self.modal.as_ref() else { return };
1641         let r = m.rect(self.win);
1642 
1643         pc.quad(
1644             Rect { x: 0.0, y: 0.0, width: self.win.0, height: self.win.1 },
1645             [0.0, 0.0, 0.0, 0.45],
1646         );
1647         let radii = (BAR_RADIUS, BAR_RADIUS, BAR_RADIUS, BAR_RADIUS);
1648         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));
1649 
1650         pc.text(
1651             m.title.clone(),
1652             r.x + plate_pad(),
1653             r.y + plate_pad(),
1654             14.0,
1655             TEXT,
1656         );
1657         pc.text(
1658             Self::fit_text(&m.message, sans, 13.0, r.width - plate_pad() * 2.0),
1659             r.x + plate_pad(),
1660             r.y + plate_pad() + 22.0,
1661             13.0,
1662             TEXT_DIM,
1663         );
1664 
1665         for (i, (label, edit)) in m.fields.iter().enumerate() {
1666             let f = m.field_rect(&r, i);
1667             let focused = i == m.focused;
1668             pc.rounded_rect(
1669                 Rect { x: f.x - 1.0, y: f.y - 1.0, width: f.width + 2.0, height: f.height + 2.0 },
1670                 7.0,
1671                 (true, true, true, true),
1672                 if focused { RIM_FOCUS } else { RIM },
1673             );
1674             pc.rounded_rect(f, 6.0, (true, true, true, true), FIELD_BG);
1675             let ty = cce_ui::layout::align_text_y(f.y, f.height, URL_FONT, 0.0);
1676             // `display()` masks a password field; the text itself never
1677             // reaches the paint list.
1678             let shown = edit.display();
1679             if shown.is_empty() && !label.is_empty() {
1680                 pc.text(*label, f.x + text_pad(), ty, URL_FONT, TEXT_DIM);
1681             } else {
1682                 pc.text(shown, f.x + text_pad(), ty, URL_FONT, TEXT);
1683             }
1684         }
1685 
1686         let (ok, cancel) = m.button_rects(&r);
1687         for (rect, label, accent) in [(Some(ok), "OK", true), (cancel, "Cancel", false)]
1688             .into_iter()
1689             .filter_map(|(rc, l, a)| rc.map(|rc| (rc, l, a)))
1690         {
1691             pc.rounded_rect(
1692                 rect,
1693                 6.0,
1694                 (true, true, true, true),
1695                 if accent { ACCENT } else { BTN_BG },
1696             );
1697             let w = measure_text_width(label, sans, 13.0);
1698             pc.text(
1699                 label,
1700                 rect.x + (rect.width - w) / 2.0,
1701                 cce_ui::layout::align_text_y(rect.y, rect.height, 13.0, 0.0),
1702                 13.0,
1703                 TEXT,
1704             );
1705         }
1706     }
1707 
1708     /// Draw the account list at the login field it belongs to.
1709     ///
1710     /// Two lines per row: the username that will be filled, and the entry's
1711     /// own title under it, because a keyring holds several accounts on one
1712     /// site and the title is how they were told apart when they were saved.
1713     #[cfg(feature = "wpe")]
1714     fn paint_ac_menu(&mut self, pc: &mut PaintCtx, sans: &str) {
1715         let Some((plate, rows)) = self.ac_layout() else { return };
1716         let Some(menu) = self.ac_menu.as_ref() else { return };
1717         let first = menu.first_row();
1718         pc.plate(
1719             plate,
1720             (8.0, 8.0, 8.0, 8.0),
1721             &cce_ui::scene::Material::opaque([0.13, 0.14, 0.16, 1.0]),
1722             cce_ui::layout::bevel_width().min(3.0),
1723         );
1724         for (k, r) in rows.iter().enumerate() {
1725             let Some(account) = menu.shown.get(first + k) else { continue };
1726             let picked = first + k == menu.selected || menu.hover == Some(first + k);
1727             if picked {
1728                 pc.rounded_rect(*r, 5.0, (true, true, true, true), TAB_ACTIVE_BG);
1729             }
1730             let width = r.width - 2.0 * text_pad();
1731             let user = if account.username.is_empty() {
1732                 account.label.clone()
1733             } else {
1734                 account.username.clone()
1735             };
1736             pc.text(
1737                 Self::fit_text(&user, sans, AC_FONT, width),
1738                 r.x + text_pad(),
1739                 r.y + 5.0,
1740                 AC_FONT,
1741                 TEXT,
1742             );
1743             // The second line names where the entry came from: its title, and
1744             // the site it is stored against when that is not the title.
1745             let mut sub = account.label.clone();
1746             if let Some(host) = account.host() {
1747                 if !sub.to_lowercase().contains(&host) {
1748                     sub = if sub.is_empty() { host } else { format!("{sub} — {host}") };
1749                 }
1750             }
1751             pc.text(
1752                 Self::fit_text(&sub, sans, AC_SUB_FONT, width),
1753                 r.x + text_pad(),
1754                 r.y + 5.0 + AC_FONT + 3.0,
1755                 AC_SUB_FONT,
1756                 TEXT_DIM,
1757             );
1758         }
1759         // Say it plainly when the page is not https: the password is about to
1760         // cross the network in the clear, and only the person can decide that
1761         // is fine.
1762         if menu.insecure {
1763             pc.text(
1764                 "insecure page — this password would be sent unencrypted",
1765                 plate.x + text_pad(),
1766                 plate.y + plate.height - 15.0,
1767                 AC_SUB_FONT,
1768                 [212, 155, 155],
1769             );
1770         }
1771     }
1772 
1773     /// Draw the bookmarks menu: the same plate-and-rows vocabulary as the
1774     /// right-click menu, in three sections — what to do with this page, the
1775     /// pages already saved, and the way out to the full collection.
1776     fn paint_bm_menu(&mut self, pc: &mut PaintCtx, sans: &str) {
1777         let Some(l) = self.bm_layout() else { return };
1778         let (items, hover, scroll) = match self.bm_menu.as_ref() {
1779             Some(m) => (m.items.clone(), m.hover, m.scroll),
1780             None => return,
1781         };
1782         pc.plate(
1783             l.plate,
1784             (8.0, 8.0, 8.0, 8.0),
1785             &cce_ui::scene::Material::opaque([0.13, 0.14, 0.16, 1.0]),
1786             cce_ui::layout::bevel_width().min(3.0),
1787         );
1788 
1789         let text_at = |pc: &mut PaintCtx, r: &Rect, s: String, color: [u8; 3]| {
1790             pc.text(
1791                 s,
1792                 r.x + text_pad(),
1793                 cce_ui::layout::align_text_y(r.y, r.height, BM_FONT, 0.0),
1794                 BM_FONT,
1795                 color,
1796             );
1797         };
1798         let highlight = |pc: &mut PaintCtx, r: &Rect| {
1799             pc.rounded_rect(*r, 5.0, (true, true, true, true), TAB_ACTIVE_BG);
1800         };
1801 
1802         // What this page can do: the toggle names the state in words, where
1803         // the star only lights up.
1804         let saved = self.host.active_bookmarked();
1805         let can_save = saved || self.saveable();
1806         if hover == Some(BmHit::Toggle) && can_save {
1807             highlight(pc, &l.toggle);
1808         }
1809         let label = if saved { "Remove Bookmark" } else { "Bookmark This Page" };
1810         text_at(
1811             pc,
1812             &l.toggle,
1813             Self::fit_text(label, sans, BM_FONT, l.toggle.width - 2.0 * text_pad()),
1814             if can_save { TEXT } else { TEXT_DIM },
1815         );
1816 
1817         // The saved pages themselves, newest first.
1818         for (r, i) in &l.rows {
1819             let Some(item) = items.get(*i) else { continue };
1820             let hovered = matches!(hover, Some(BmHit::Entry(h, _)) if h == *i);
1821             if hovered {
1822                 highlight(pc, r);
1823             }
1824             text_at(
1825                 pc,
1826                 r,
1827                 Self::fit_text(
1828                     &item.label,
1829                     sans,
1830                     BM_FONT,
1831                     r.width - 2.0 * text_pad() - BM_RM_W,
1832                 ),
1833                 // Full brightness whether hovered or not: dim means
1834                 // *unavailable* everywhere else in this chrome, and every
1835                 // saved page is available. Hover is the highlight's job.
1836                 TEXT,
1837             );
1838             // The remove "x" shows on the hovered row only — always-on x's
1839             // down a whole list read as clutter, and as a hazard.
1840             if hovered {
1841                 let on_rm = hover == Some(BmHit::Entry(*i, true));
1842                 let xw = measure_text_width("x", sans, 11.0);
1843                 pc.text(
1844                     "x",
1845                     r.x + r.width - BM_RM_W / 2.0 - xw / 2.0,
1846                     cce_ui::layout::align_text_y(r.y, r.height, 11.0, 0.0),
1847                     11.0,
1848                     if on_rm { [212, 155, 155] } else { TEXT_DIM },
1849                 );
1850             }
1851         }
1852         if let Some(r) = l.empty {
1853             text_at(pc, &r, "No bookmarks yet".to_string(), TEXT_DIM);
1854         }
1855 
1856         // Scroll position, when the list is longer than the plate.
1857         if items.len() > l.rows.len() && !l.rows.is_empty() {
1858             let first = l.rows[0].0;
1859             let track = Rect {
1860                 x: l.plate.x + l.plate.width - 5.0,
1861                 y: first.y,
1862                 width: 2.0,
1863                 height: l.rows.len() as f32 * BM_ROW_H,
1864             };
1865             let frac = l.rows.len() as f32 / items.len() as f32;
1866             let offset = scroll as f32 / items.len() as f32;
1867             pc.rounded_rect(
1868                 Rect {
1869                     y: track.y + offset * track.height,
1870                     height: (frac * track.height).max(12.0),
1871                     ..track
1872                 },
1873                 1.0,
1874                 (true, true, true, true),
1875                 RIM,
1876             );
1877         }
1878 
1879         // The rules between the three sections.
1880         for y in [l.toggle.y + BM_ROW_H + BM_SEP_H / 2.0, l.manage.y - BM_SEP_H / 2.0] {
1881             pc.quad(
1882                 Rect {
1883                     x: l.plate.x + text_pad(),
1884                     y,
1885                     width: l.plate.width - 2.0 * text_pad(),
1886                     height: 1.0,
1887                 },
1888                 RIM,
1889             );
1890         }
1891 
1892         if hover == Some(BmHit::Manage) {
1893             highlight(pc, &l.manage);
1894         }
1895         text_at(
1896             pc,
1897             &l.manage,
1898             format!("Manage Bookmarks ({})", items.len()),
1899             TEXT,
1900         );
1901     }
1902 
1903     /// Draw the right-click menu: a small plate at the pointer, rows with a
1904     /// hover highlight, disabled rows dimmed. Same primitives as everything
1905     /// else in this chrome.
1906     #[cfg(feature = "wpe")]
1907     fn paint_ctx_menu(&mut self, pc: &mut PaintCtx, sans: &str) {
1908         let Some(menu) = self.ctx_menu.as_ref() else { return };
1909         let r = menu.rect();
1910         pc.plate(
1911             r,
1912             (8.0, 8.0, 8.0, 8.0),
1913             &cce_ui::scene::Material::opaque([0.13, 0.14, 0.16, 1.0]),
1914             cce_ui::layout::bevel_width().min(3.0),
1915         );
1916         let hovered = menu.item_at(self.pointer.0, self.pointer.1);
1917         for (i, it) in menu.items.iter().enumerate() {
1918             let row = menu.row_rect(i);
1919             if hovered == Some(i) && it.enabled {
1920                 pc.rounded_rect(row, 5.0, (true, true, true, true), TAB_ACTIVE_BG);
1921             }
1922             let color = if it.enabled { TEXT } else { TEXT_DIM };
1923             pc.text(
1924                 Self::fit_text(&it.label, sans, 13.0, row.width - 2.0 * text_pad()),
1925                 row.x + text_pad(),
1926                 cce_ui::layout::align_text_y(row.y, row.height, 13.0, 0.0),
1927                 13.0,
1928                 color,
1929             );
1930         }
1931     }
1932 
1933     fn cursor_from_click(&mut self, click_x: f32, field: &Rect) -> usize {
1934         let rel = click_x - field.x - text_pad();
1935         // Boundary x offsets from the same shaped buffer the bar draws (font=None,
1936         // matching `pc.text`), then the closest boundary to the click.
1937         let text = self.url.text.clone();
1938         let offsets =
1939             cce_ui::engine::shaped_cluster_offsets(&mut self.font_system, &text, URL_FONT, None);
1940         offsets
1941             .iter()
1942             .min_by(|a, b| (a.1 - rel).abs().total_cmp(&(b.1 - rel).abs()))
1943             .map(|&(b, _)| b)
1944             .unwrap_or(text.len())
1945     }
1946 
1947     /// X offset (text-origin relative) of a byte index, off the same shaped
1948     /// buffer as `cursor_from_click`.
1949     fn x_offset(&mut self, byte: usize) -> f32 {
1950         let text = self.url.text.clone();
1951         let offsets =
1952             cce_ui::engine::shaped_cluster_offsets(&mut self.font_system, &text, URL_FONT, None);
1953         offsets
1954             .iter()
1955             .rev()
1956             .find(|&&(b, _)| b <= byte)
1957             .map(|&(_, x)| x)
1958             .unwrap_or(0.0)
1959     }
1960 
1961     /// Caret x offset for the current byte cursor.
1962     fn caret_offset(&mut self) -> f32 {
1963         self.x_offset(self.url.cursor)
1964     }
1965 
1966     /// Select the whole URL, caret at the end — what entering the bar does,
1967     /// whether from a click, Ctrl+L or Ctrl+A. No-op on an empty field.
1968     fn select_all_url(&mut self) {
1969         self.url.select_all();
1970     }
1971 
1972 
1973 
1974     /// URL-bar keys. Editing is the shared [`lineedit::LineEdit`]; only what
1975     /// makes this bar a *URL* bar — Enter navigates, Escape returns focus to
1976     /// the page — is decided here.
1977     fn edit_url(&mut self, event: &KeyEvent) {
1978         match self.url.handle_key(event) {
1979             lineedit::EditOutcome::Submit => self.navigate(),
1980             lineedit::EditOutcome::Cancel => {
1981                 self.url_focused = false;
1982                 self.url.selection = None;
1983                 self.sync_page_state();
1984             }
1985             lineedit::EditOutcome::Edited | lineedit::EditOutcome::Ignored => {}
1986         }
1987     }
1988 }
1989 
1990 impl Application for BrowserApp {
1991     type Message = Message;
1992 
1993     fn new(_qh: &QueueHandle<EngineState<Self>>, sender: calloop::channel::Sender<Self::Message>) -> Self {
1994         // Serve the instance socket claimed in main(), if this launch won it.
1995         instance::spawn_listener(sender.clone());
1996         let settings = settings::load();
1997         downloads::set_download_dir(settings.download_dir.clone());
1998         // Optional CLI arg: the start URL (same parsing as the URL bar).
1999         let arg = std::env::args()
2000             .nth(1)
2001             .and_then(|arg| parse_startup_arg(&arg, &settings.search_prefix));
2002         // The previous run's tabs. When there are some, they come back in
2003         // order and an argv URL opens as an extra tab on top of them —
2004         // otherwise the argument (or the configured homepage) is the one
2005         // starting tab, as before session restore existed.
2006         let mut session = session::Session::new();
2007         let (saved, saved_active) = session.load();
2008         let restored = !saved.is_empty();
2009         let mut queue = saved;
2010         if queue.is_empty() {
2011             queue.push(
2012                 arg.clone()
2013                     .or_else(|| parse_url_input(&settings.homepage, &settings.search_prefix))
2014                     .unwrap_or_else(|| {
2015                         Url::parse(settings::DEFAULT_HOMEPAGE).expect("home url")
2016                     }),
2017             );
2018         }
2019         let first = queue.remove(0);
2020 
2021         #[cfg(all(not(feature = "wpe"), feature = "servo"))]
2022         let mut host =
2023             Host::new(sender.clone(), first, (1200, 800), settings.color_scheme.forces_dark());
2024         #[cfg(feature = "wpe")]
2025         let mut host = {
2026             let _ = &sender; // WPE wakes through register_sources, not a waker
2027             Host::new(first, (1200, 800))
2028         };
2029         for url in queue {
2030             host.open_tab(url);
2031         }
2032         if restored {
2033             host.activate(saved_active.min(host.tab_count() - 1));
2034             if let Some(url) = arg {
2035                 host.open_tab(url);
2036             }
2037         }
2038         // The bar mirrors whichever tab ended up active.
2039         let url_text = host
2040             .url()
2041             .map(|u| u.to_string())
2042             .filter(|s| s != "about:blank")
2043             .unwrap_or_default();
2044         host.set_history_enabled(settings.history);
2045         host.set_color_scheme_dark(settings.color_scheme.is_dark());
2046         #[cfg(feature = "wpe")]
2047         host.set_force_dark(settings.color_scheme.forces_dark());
2048         #[cfg(feature = "wpe")]
2049         host.set_accounts_enabled(settings.accounts);
2050         let accounts = accounts::Accounts::spawn(sender.clone());
2051         let favorites = host.favorites();
2052         let favs = favorites.snapshot();
2053         let bookmarks = host.bookmarks();
2054         Self {
2055             host,
2056             seen_renderer: false,
2057             settings,
2058             win: (1200.0, 800.0),
2059             scale: 1.0,
2060             pointer: (0.0, 0.0),
2061             page_buttons: Vec::new(),
2062             url: lineedit::LineEdit::with_text(url_text),
2063             url_focused: false,
2064             chrome_open: false,
2065             chrome_t: 0.0,
2066             dot_hover: false,
2067             loading: true,
2068             title: None,
2069             #[cfg(feature = "wpe")]
2070             modal: None,
2071             #[cfg(feature = "wpe")]
2072             ctx_menu: None,
2073             #[cfg(feature = "wpe")]
2074             sender,
2075             font_system: cce_ui::create_font_system(),
2076             session,
2077             favorites,
2078             favs,
2079             fav_hover: None,
2080             bookmarks,
2081             bm_menu: None,
2082             scroll: cce_ui::widget::scroll_motion::ScrollMotion::new(),
2083             scroll_sent: (0.0, 0.0),
2084             accounts,
2085             #[cfg(feature = "wpe")]
2086             ac_menu: None,
2087             nav_url: None,
2088         }
2089     }
2090 
2091     /// Wake on GLib activity rather than polling for it.
2092     ///
2093     /// Servo pushed `Message::Spin` into calloop from its own threads; WPE
2094     /// runs a GLib main context, so we register the epoll fd carrying its
2095     /// pollfd set plus a timer for the timeout GLib asks for. Both just fire
2096     /// `Spin`, which lands in `update` and calls `pump` — the same path the
2097     /// Servo waker used, so nothing downstream changes.
2098     #[cfg(feature = "wpe")]
2099     fn register_sources(&mut self, handle: &calloop::LoopHandle<'_, EngineState<Self>>) {
2100         use calloop::{generic::Generic, Interest, Mode, PostAction};
2101 
2102         if let Some(fd) = self.host.poll_fd_owned() {
2103             let tx = self.sender.clone();
2104             // Level-triggered: `pump` drains the epoll, so an un-consumed
2105             // socket re-arms rather than being missed.
2106             let source = Generic::new(fd, Interest::READ, Mode::Level);
2107             if let Err(e) = handle.insert_source(source, move |_, _, _| {
2108                 let _ = tx.send(Message::Spin);
2109                 Ok(PostAction::Continue)
2110             }) {
2111                 log::warn!("could not watch the GLib fd ({e}); falling back to the timer alone");
2112             }
2113         }
2114 
2115         // GLib also asks to be woken on its own schedule (timeouts, animation
2116         // frames), which no fd reports. Re-armed from `poll_timeout` each
2117         // fire, so an idle page settles to long sleeps instead of a fixed tick.
2118         let tx = self.sender.clone();
2119         let timer = calloop::timer::Timer::from_duration(std::time::Duration::from_millis(16));
2120         if let Err(e) = handle.insert_source(timer, move |_, _, state| {
2121             let _ = tx.send(Message::Spin);
2122             let next = state
2123                 .inner
2124                 .as_ref()
2125                 .and_then(|app| app.host.poll_timeout())
2126                 .unwrap_or(std::time::Duration::from_millis(100))
2127                 .clamp(
2128                     std::time::Duration::from_millis(4),
2129                     std::time::Duration::from_millis(250),
2130                 );
2131             calloop::timer::TimeoutAction::ToDuration(next)
2132         }) {
2133             log::warn!("could not arm the GLib timer ({e})");
2134         }
2135     }
2136 
2137     fn settings(&self) -> WindowSettings {
2138         WindowSettings {
2139             title: self.title.clone().unwrap_or_else(|| "Browser".to_string()),
2140             app_id: "cce-browser".to_string(),
2141             width: 1200,
2142             height: 800,
2143             fullscreen: false,
2144             min_size: Some((480, 320)),
2145         }
2146     }
2147 
2148     fn update(&mut self, msg: Self::Message, needs_rebuild: &mut bool, exit: &mut bool) {
2149         match msg {
2150             Message::Spin => {
2151                 let (new_frame, dirty) = self.host.pump();
2152                 #[cfg(feature = "wpe")]
2153                 if self.sync_modal() {
2154                     *needs_rebuild = true;
2155                 }
2156                 #[cfg(feature = "wpe")]
2157                 if let Some(info) = self.host.take_context_menu() {
2158                     self.open_ctx_menu(info);
2159                     *needs_rebuild = true;
2160                 }
2161                 if self.host.take_download_started() {
2162                     self.open_internal_page("cce://downloads");
2163                 }
2164                 if dirty {
2165                     // A navigation retires whatever field was focused and
2166                     // whatever glide was in flight. Only a real one: the URL
2167                     // changing, not the dirty flag, which also fires while the
2168                     // page that owns them is still settling.
2169                     let now = self.host.url().map(|u| u.to_string());
2170                     if now != self.nav_url {
2171                         self.nav_url = now;
2172                         self.stop_scroll();
2173                         #[cfg(feature = "wpe")]
2174                         {
2175                             self.ac_menu = None;
2176                         }
2177                     }
2178                     self.sync_page_state();
2179                     // Navigation reaches the tab set through these signals,
2180                     // so this is where an address change gets persisted.
2181                     self.persist_session();
2182                 }
2183                 // Drained after the navigation check, so a field reported in
2184                 // the same pump that finished the load is not thrown away
2185                 // with the page it arrived on.
2186                 #[cfg(feature = "wpe")]
2187                 while let Some(event) = self.host.take_form_event() {
2188                     if self.on_form_event(event) {
2189                         *needs_rebuild = true;
2190                     }
2191                 }
2192                 if new_frame || dirty {
2193                     *needs_rebuild = true;
2194                 }
2195             }
2196             Message::Accounts(result) => {
2197                 match &result {
2198                     Ok(list) => log::info!("accounts: {} entries from the keyring", list.len()),
2199                     Err(e) => log::warn!("accounts unavailable: {e}"),
2200                 }
2201                 self.accounts.loaded(result);
2202                 // A field may have been focused while the index was still
2203                 // being read; this is when its list can finally open.
2204                 #[cfg(feature = "wpe")]
2205                 {
2206                     self.host.request_form_state();
2207                     *needs_rebuild = true;
2208                 }
2209             }
2210             Message::Credential(path, secret) => {
2211                 #[cfg(feature = "wpe")]
2212                 {
2213                     self.fill_account(&path, &secret);
2214                     *needs_rebuild = true;
2215                 }
2216                 #[cfg(not(feature = "wpe"))]
2217                 {
2218                     let _ = (path, secret);
2219                 }
2220             }
2221             Message::Quit => *exit = true,
2222             Message::OpenExternal(arg) => {
2223                 match arg {
2224                     Some(arg) => {
2225                         // Same parsing as the launch argument, and for the
2226                         // same reason: this *is* one, relayed.
2227                         if let Some(url) = parse_startup_arg(&arg, &self.settings.search_prefix) {
2228                             self.host.open_tab(url);
2229                             self.url_focused = false;
2230                             self.sync_page_state();
2231                             self.persist_session();
2232                         }
2233                     }
2234                     None => self.new_tab(),
2235                 }
2236                 // Bring the window to the user: focus + camera pan + raise
2237                 // over the control socket. A fresh launch used to get this
2238                 // from the compositor for free; without it the tab opens in
2239                 // a window parked somewhere off-camera and the click looks
2240                 // like it did nothing. (xdg-activation is not the route: the
2241                 // compositor deliberately answers it with an attention
2242                 // notification, not focus.)
2243                 std::thread::spawn(|| {
2244                     let _ = cce_ui::ipc::send_command("cce", "focus-window cce-browser");
2245                 });
2246                 *needs_rebuild = true;
2247             }
2248         }
2249     }
2250 
2251     fn tick(&mut self, dt: f32, needs_rebuild: &mut bool) {
2252         // The page's wheel glide, one frame's worth. It feeds the engine, so
2253         // the frame it produces is what actually redraws; asking for a
2254         // rebuild here is what keeps the loop turning until it lands.
2255         if self.advance_scroll(dt) {
2256             *needs_rebuild = true;
2257         }
2258         let target = if self.chrome_open { 1.0 } else { 0.0 };
2259         if self.chrome_t != target {
2260             let step = dt / CHROME_ANIM_S;
2261             self.chrome_t = if target > self.chrome_t {
2262                 (self.chrome_t + step).min(1.0)
2263             } else {
2264                 (self.chrome_t - step).max(0.0)
2265             };
2266             // Keeps the runner's warm loop alive until the morph lands.
2267             *needs_rebuild = true;
2268         }
2269     }
2270 
2271     fn handle_focus_change(&mut self, focused: bool, needs_rebuild: &mut bool) {
2272         // A settings change can move the bar to the other edge, so a reload
2273         // that changed anything has to redraw the chrome.
2274         if focused && self.reload_settings() {
2275             *needs_rebuild = true;
2276         }
2277     }
2278 
2279     fn handle_resize(&mut self, width: f32, height: f32, scale: f64) {
2280         self.win = (width, height);
2281         self.scale = scale;
2282         let (w, h) = self.content_px();
2283         self.host.resize(w, h, scale as f32);
2284     }
2285 
2286     /// Re-paint the page when the renderer is replaced.
2287     ///
2288     /// The tab images are **renderer** ids, and a renderer does not outlive
2289     /// its session — `window_runner` rebuilds it around the same
2290     /// `Application` after a lost Wayland transport, and a draw for an
2291     /// unknown id is skipped rather than reported. See
2292     /// `Host::renderer_replaced` for why dropping the ids is only half of it.
2293     ///
2294     /// Not on the first renderer: no page has rendered yet, and remapping the
2295     /// view before the first frame would only make the engine repeat work.
2296     fn renderer_init(&mut self, _renderer: &mut cce_ui::vk::VkRenderer) {
2297         if std::mem::replace(&mut self.seen_renderer, true) {
2298             log::info!("[browser] renderer replaced; re-painting the page");
2299             self.host.renderer_replaced();
2300         }
2301     }
2302 
2303     fn handle_pointer_move(&mut self, pos: LogicalPosition, _needs_rebuild: &mut bool) {
2304         self.pointer = (pos.x, pos.y);
2305         #[cfg(feature = "wpe")]
2306         if self.ctx_menu.is_some() {
2307             // Hover highlight tracks the pointer; the page underneath does
2308             // not see moves while the menu is up.
2309             *_needs_rebuild = true;
2310             return;
2311         }
2312         // The account list tracks hover the same way, and shields the page
2313         // under it.
2314         #[cfg(feature = "wpe")]
2315         if self.ac_menu.is_some() {
2316             let over = self.ac_hit(pos.x, pos.y);
2317             if self.ac_menu.as_ref().is_some_and(|m| m.hover != over) {
2318                 if let Some(m) = self.ac_menu.as_mut() {
2319                     m.hover = over;
2320                 }
2321                 *_needs_rebuild = true;
2322             }
2323             if over.is_some() {
2324                 return;
2325             }
2326         }
2327 
2328         // An open bookmarks menu tracks hover, and the page under it sees
2329         // no moves at all.
2330         if self.bm_menu.is_some() {
2331             let h = self.bm_hit(pos.x, pos.y);
2332             if self.bm_menu.as_ref().is_some_and(|m| m.hover != h) {
2333                 if let Some(m) = self.bm_menu.as_mut() {
2334                     m.hover = h;
2335                 }
2336                 *_needs_rebuild = true;
2337             }
2338             return;
2339         }
2340 
2341         let over_dot = self.dot_hit(pos.x, pos.y);
2342         if over_dot != self.dot_hover {
2343             self.dot_hover = over_dot;
2344             *_needs_rebuild = true;
2345         }
2346         let over_fav = if self.chrome_open && !self.favs.is_empty() {
2347             let bar = self.bar();
2348             self.fav_rects(&bar).iter().position(|r| hit(r, pos.x, pos.y))
2349         } else {
2350             None
2351         };
2352         if over_fav != self.fav_hover {
2353             self.fav_hover = over_fav;
2354             *_needs_rebuild = true;
2355         }
2356         if !self.chrome_hit(pos.x, pos.y) {
2357             let s = self.scale as f32;
2358             self.host.mouse_move(pos.x * s, pos.y * s);
2359         }
2360     }
2361 
2362     fn handle_mouse_input(
2363         &mut self,
2364         button: MouseButton,
2365         state: ElementState,
2366         pos: LogicalPosition,
2367         needs_rebuild: &mut bool,
2368     ) -> Option<Self::Message> {
2369         let pressed = state == ElementState::Pressed;
2370 
2371         // Before any of the branches that swallow a click: a button the page
2372         // is holding gets its release no matter where it was let go, or the
2373         // engine goes on believing it is still down.
2374         if !pressed {
2375             self.drain_page_release(button, pos);
2376         }
2377 
2378         #[cfg(feature = "wpe")]
2379         if self.modal.is_some() {
2380             if !pressed || button != MouseButton::Left {
2381                 return None;
2382             }
2383             *needs_rebuild = true;
2384             let (hit_ok, hit_cancel, field) = {
2385                 let m = self.modal.as_ref().unwrap();
2386                 let r = m.rect(self.win);
2387                 let (ok, cancel) = m.button_rects(&r);
2388                 (
2389                     hit(&ok, pos.x, pos.y),
2390                     cancel.is_some_and(|c| hit(&c, pos.x, pos.y)),
2391                     (0..m.fields.len()).find(|&i| hit(&m.field_rect(&r, i), pos.x, pos.y)),
2392                 )
2393             };
2394             if hit_ok {
2395                 self.close_modal(true);
2396             } else if hit_cancel {
2397                 self.close_modal(false);
2398             } else if let (Some(i), Some(m)) = (field, self.modal.as_mut()) {
2399                 m.focused = i;
2400             }
2401             // Anything else is swallowed: the page must not receive clicks
2402             // while it is blocked waiting on this.
2403             return None;
2404         }
2405 
2406         // An open context menu owns the next click: on an item it dispatches,
2407         // anywhere else it just closes — either way the click goes no further.
2408         #[cfg(feature = "wpe")]
2409         if let Some(menu) = self.ctx_menu.as_ref() {
2410             if pressed {
2411                 *needs_rebuild = true;
2412                 match (button, menu.item_at(pos.x, pos.y)) {
2413                     (MouseButton::Left, Some(i)) => self.dispatch_ctx_action(i),
2414                     _ => self.ctx_menu = None,
2415                 }
2416             }
2417             return None;
2418         }
2419 
2420         // A click on an account row picks it. A click anywhere else closes
2421         // the list and goes on to the page as usual — unlike the chrome's own
2422         // menus, this one sits over the page's own controls, and swallowing
2423         // the click that dismisses it would eat a button press.
2424         #[cfg(feature = "wpe")]
2425         if self.ac_menu.is_some() {
2426             if let Some(index) = self.ac_hit(pos.x, pos.y) {
2427                 if pressed && button == MouseButton::Left {
2428                     self.pick_account(index);
2429                 }
2430                 *needs_rebuild = true;
2431                 return None;
2432             }
2433             if pressed {
2434                 self.ac_menu = None;
2435                 *needs_rebuild = true;
2436             }
2437         }
2438 
2439         // The bookmarks menu owns the next click while it is open: a row
2440         // acts, a click off the plate closes it, and either way the click
2441         // goes no further — the rule the right-click menu already follows.
2442         if self.bm_menu.is_some() {
2443             if !pressed {
2444                 return None;
2445             }
2446             *needs_rebuild = true;
2447             let target = self.bm_hit(pos.x, pos.y);
2448             let inside = self.bm_layout().is_some_and(|l| hit(&l.plate, pos.x, pos.y));
2449             if target.is_some() {
2450                 self.bm_click(button, target);
2451             } else if !inside {
2452                 self.close_bm_menu();
2453             }
2454             return None;
2455         }
2456 
2457         let bar = self.bar();
2458         let pos_edge = self.settings.bar_position;
2459         if self.chrome_hit(pos.x, pos.y) {
2460             if !pressed || !matches!(button, MouseButton::Left | MouseButton::Middle) {
2461                 return None;
2462             }
2463             *needs_rebuild = true;
2464             // The corner control toggles the bar, open or closed.
2465             if self.dot_hit(pos.x, pos.y) {
2466                 if button == MouseButton::Left {
2467                     if self.chrome_open {
2468                         self.close_chrome();
2469                     } else {
2470                         self.open_chrome();
2471                     }
2472                 }
2473                 return None;
2474             }
2475             // Still folding shut: nothing under the plate is live.
2476             if !self.chrome_open {
2477                 return None;
2478             }
2479             // Tab strip: activate / close (x region or middle click) / new tab.
2480             let count = self.host.tab_count();
2481             for i in 0..count {
2482                 let pill = tab_rect(&bar, pos_edge, count, i);
2483                 if !hit(&pill, pos.x, pos.y) {
2484                     continue;
2485                 }
2486                 let on_close =
2487                     tab_close_rect(&pill).is_some_and(|r| hit(&r, pos.x, pos.y));
2488                 if button == MouseButton::Middle || on_close {
2489                     return self.close_tab(i);
2490                 }
2491                 self.switch_tab(i);
2492                 // Picking a tab is a menu choice: the bar folds away. Closing
2493                 // one is not — several may go in a row.
2494                 self.close_chrome();
2495                 return None;
2496             }
2497             // Favorites strip: a pill is a menu pick — load it here and fold
2498             // — or, middle-clicked, a new tab, with the bar left out so
2499             // several can be opened in a row.
2500             if let Some(i) = self.fav_rects(&bar).iter().position(|r| hit(r, pos.x, pos.y)) {
2501                 let Ok(url) = Url::parse(&self.favs[i].url) else { return None };
2502                 if button == MouseButton::Middle {
2503                     self.host.open_tab(url);
2504                     self.url_focused = false;
2505                     self.sync_page_state();
2506                     self.persist_session();
2507                 } else {
2508                     self.host.load(url);
2509                     self.loading = true;
2510                     self.close_chrome();
2511                     self.sync_page_state();
2512                 }
2513                 return None;
2514             }
2515             if button != MouseButton::Left {
2516                 return None;
2517             }
2518             if hit(&plus_rect(&bar, pos_edge), pos.x, pos.y) {
2519                 self.new_tab();
2520             } else if hit(&btn_rect(&bar, 0), pos.x, pos.y) {
2521                 self.host.back();
2522             } else if hit(&btn_rect(&bar, 1), pos.x, pos.y) {
2523                 self.host.forward();
2524             } else if hit(&btn_rect(&bar, 2), pos.x, pos.y) {
2525                 self.host.reload();
2526             } else if hit(&star_rect(&bar, pos_edge), pos.x, pos.y) {
2527                 self.host.toggle_bookmark();
2528             } else if hit(&bm_btn_rect(&bar, pos_edge), pos.x, pos.y) {
2529                 self.open_bm_menu();
2530             } else {
2531                 let field = url_rect(&bar, pos_edge);
2532                 if hit(&field, pos.x, pos.y) {
2533                     if self.url_focused {
2534                         self.url.cursor = self.cursor_from_click(pos.x, &field);
2535                         self.url.selection = None;
2536                     } else {
2537                         // Entering the bar selects the whole URL, so typing
2538                         // replaces it instead of appending to it.
2539                         self.url_focused = true;
2540                         self.select_all_url();
2541                     }
2542                 } else {
2543                     self.url_focused = false;
2544                     self.url.selection = None;
2545                 }
2546             }
2547             return None;
2548         }
2549 
2550         // Page area: a click folds the menu (and URL-bar focus with it),
2551         // then goes to the page.
2552         if self.chrome_open && pressed {
2553             self.close_chrome();
2554             *needs_rebuild = true;
2555         }
2556         match button {
2557             MouseButton::Back if pressed => self.host.back(),
2558             MouseButton::Forward if pressed => self.host.forward(),
2559             _ => self.page_press(button, pressed, pos),
2560         }
2561         None
2562     }
2563 
2564     fn handle_mouse_wheel(&mut self, delta: &MouseScrollDelta, pos: LogicalPosition, needs_rebuild: &mut bool) {
2565         // The account list moves with its field, so the page keeps the
2566         // wheel — but not under the plate itself.
2567         #[cfg(feature = "wpe")]
2568         if self
2569             .ac_layout()
2570             .is_some_and(|(plate, _)| hit(&plate, pos.x, pos.y))
2571         {
2572             return;
2573         }
2574 
2575         // An open menu takes the wheel: over its plate it scrolls the list,
2576         // anywhere else it is swallowed rather than scrolling the page
2577         // behind it.
2578         if self.bm_menu.is_some() {
2579             if self.bm_layout().is_some_and(|l| hit(&l.plate, pos.x, pos.y)) {
2580                 let dy = match delta {
2581                     MouseScrollDelta::LineDelta(_, y) => *y as f64,
2582                     MouseScrollDelta::PixelDelta(p) => p.y,
2583                 };
2584                 self.bm_scroll(dy);
2585                 *needs_rebuild = true;
2586             }
2587             return;
2588         }
2589         if self.chrome_hit(pos.x, pos.y) {
2590             return;
2591         }
2592         // A wheel notch eases instead of jumping, through the same model
2593         // every other cce app scrolls by (`smooth_scroll` / `scroll_ease` in
2594         // input.kdl, this app's domain then `cce-ui`'s). Without it a notch
2595         // moved the page LINE_PX in one step, which is the browser feeling
2596         // unlike the rest of the desktop.
2597         //
2598         // Only a *notch* takes this path. A trackpad's pixel deltas already
2599         // follow the finger, and the engine runs its own kinetic scrolling off
2600         // the gesture phases this passes it — two coast models fighting over
2601         // one page would be worse than either.
2602         let discrete = matches!(delta, MouseScrollDelta::LineDelta(..));
2603         let phase = cce_ui::widget::scroll_motion::current_scroll_phase();
2604         if discrete
2605             && phase == cce_ui::widget::ScrollPhase::Wheel
2606             && cce_ui::widget::scroll_motion::scroll_settings().smooth
2607         {
2608             use cce_ui::widget::scroll_motion::Bounds;
2609             // Unbounded: the page's real limits are WebKit's business, and it
2610             // clamps. Notches arriving mid-glide accumulate into one movement
2611             // rather than a staircase.
2612             self.scroll.apply(
2613                 delta,
2614                 (LINE_PX as f32, LINE_PX as f32),
2615                 Bounds::UNBOUNDED,
2616                 Bounds::UNBOUNDED,
2617             );
2618             // Keeps the runner's loop warm until the glide lands, the same
2619             // way the chrome's unfold does.
2620             *needs_rebuild = true;
2621             return;
2622         }
2623 
2624         // WheelDelta keeps cce-ui's winit sign convention (positive = scroll
2625         // up); the engine inverts it into the scroll offset internally, after
2626         // the page has had its preventDefault chance.
2627         let (dx, dy) = match delta {
2628             MouseScrollDelta::LineDelta(x, y) => (*x as f64 * LINE_PX, *y as f64 * LINE_PX),
2629             MouseScrollDelta::PixelDelta(p) => (p.x, p.y),
2630         };
2631         let s = self.scale;
2632         self.host.wheel(dx * s, dy * s, pos.x * s as f32, pos.y * s as f32);
2633     }
2634 
2635     fn handle_key_input(&mut self, event: &KeyEvent, needs_rebuild: &mut bool) -> Option<Self::Message> {
2636         #[cfg(feature = "wpe")]
2637         if self.ctx_menu.is_some() && event.state == ElementState::Pressed {
2638             // Any key dismisses; Escape is just the one people will mean.
2639             self.ctx_menu = None;
2640             *needs_rebuild = true;
2641             return None;
2642         }
2643 
2644         // A modal is exactly that: the page is blocked inside WebKit, so the
2645         // chrome's own chords must not fire behind it either.
2646         #[cfg(feature = "wpe")]
2647         if self.modal.is_some() {
2648             *needs_rebuild = true;
2649             if event.state == ElementState::Pressed
2650                 && event.logical_key == Key::Named(NamedKey::Tab)
2651             {
2652                 if let Some(m) = self.modal.as_mut() {
2653                     if !m.fields.is_empty() {
2654                         let n = m.fields.len();
2655                         m.focused = if event.shift {
2656                             (m.focused + n - 1) % n
2657                         } else {
2658                             (m.focused + 1) % n
2659                         };
2660                     }
2661                 }
2662                 return None;
2663             }
2664             let outcome = match self.modal.as_mut() {
2665                 Some(m) if !m.fields.is_empty() => {
2666                     let i = m.focused;
2667                     m.fields[i].1.handle_key(event)
2668                 }
2669                 // No field: Enter accepts, Escape cancels, nothing else acts.
2670                 Some(_) => match (&event.logical_key, event.state) {
2671                     (Key::Named(NamedKey::Enter), ElementState::Pressed) => {
2672                         lineedit::EditOutcome::Submit
2673                     }
2674                     (Key::Named(NamedKey::Escape), ElementState::Pressed) => {
2675                         lineedit::EditOutcome::Cancel
2676                     }
2677                     _ => lineedit::EditOutcome::Ignored,
2678                 },
2679                 None => lineedit::EditOutcome::Ignored,
2680             };
2681             match outcome {
2682                 lineedit::EditOutcome::Submit => self.close_modal(true),
2683                 lineedit::EditOutcome::Cancel => self.close_modal(false),
2684                 _ => {}
2685             }
2686             return None;
2687         }
2688 
2689         // An open account list takes the keys that drive it, and passes on
2690         // everything else — the person is typing into the page's own field,
2691         // and that typing is what filters the list.
2692         #[cfg(feature = "wpe")]
2693         if self.ac_menu.is_some() && event.state == ElementState::Pressed && !self.url_focused {
2694             match &event.logical_key {
2695                 Key::Named(NamedKey::ArrowDown) => {
2696                     if let Some(m) = self.ac_menu.as_mut() {
2697                         m.step(1);
2698                     }
2699                     *needs_rebuild = true;
2700                     return None;
2701                 }
2702                 Key::Named(NamedKey::ArrowUp) => {
2703                     if let Some(m) = self.ac_menu.as_mut() {
2704                         m.step(-1);
2705                     }
2706                     *needs_rebuild = true;
2707                     return None;
2708                 }
2709                 Key::Named(NamedKey::Enter) => {
2710                     let selected = self.ac_menu.as_ref().map(|m| m.selected);
2711                     if let Some(i) = selected {
2712                         self.pick_account(i);
2713                     }
2714                     *needs_rebuild = true;
2715                     return None;
2716                 }
2717                 Key::Named(NamedKey::Escape) => {
2718                     self.ac_menu = None;
2719                     *needs_rebuild = true;
2720                     return None;
2721                 }
2722                 _ => {}
2723             }
2724         }
2725 
2726         // An open bookmarks menu owns Escape, ahead of the URL bar and the
2727         // page both.
2728         if self.bm_menu.is_some()
2729             && event.state == ElementState::Pressed
2730             && event.logical_key == Key::Named(NamedKey::Escape)
2731         {
2732             self.close_bm_menu();
2733             *needs_rebuild = true;
2734             return None;
2735         }
2736 
2737         // Tab shortcuts work regardless of URL-bar focus.
2738         if event.state == ElementState::Pressed && event.ctrl {
2739             let count = self.host.tab_count();
2740             match &event.logical_key {
2741                 Key::Character(c) if c == "t" => {
2742                     self.new_tab();
2743                     *needs_rebuild = true;
2744                     return None;
2745                 }
2746                 Key::Character(c) if c == "w" => {
2747                     *needs_rebuild = true;
2748                     return self.close_tab(self.host.active_index());
2749                 }
2750                 // Ctrl+Shift+Delete opens the cookie page rather than
2751                 // clearing outright; the page asks first.
2752                 Key::Named(NamedKey::Delete) if event.shift => {
2753                     self.open_internal_page("cce://cookies");
2754                     *needs_rebuild = true;
2755                     return None;
2756                 }
2757                 // The favorites pair sits a Shift above the bookmarks pair:
2758                 // Ctrl+Shift+D toggles the page in the strip, Ctrl+Shift+B
2759                 // opens the page that manages it.
2760                 Key::Character(c) if event.shift && c.eq_ignore_ascii_case("d") => {
2761                     self.toggle_favorite();
2762                     *needs_rebuild = true;
2763                     return None;
2764                 }
2765                 Key::Character(c) if event.shift && c.eq_ignore_ascii_case("b") => {
2766                     self.open_internal_page("cce://favorites");
2767                     *needs_rebuild = true;
2768                     return None;
2769                 }
2770                 Key::Character(c) if c == "h" || c == "b" || c == "j" => {
2771                     let page = match c.as_str() {
2772                         "h" => "cce://history",
2773                         "b" => "cce://bookmarks",
2774                         _ => "cce://downloads",
2775                     };
2776                     self.open_internal_page(page);
2777                     *needs_rebuild = true;
2778                     return None;
2779                 }
2780                 Key::Character(c) if c == "d" => {
2781                     self.host.toggle_bookmark();
2782                     *needs_rebuild = true;
2783                     return None;
2784                 }
2785                 // Ctrl+Shift+O: open the current page in another browser.
2786                 Key::Character(c) if event.shift && c.eq_ignore_ascii_case("o") => {
2787                     self.open_external();
2788                     return None;
2789                 }
2790                 Key::Named(NamedKey::Tab) if count > 1 => {
2791                     let cur = self.host.active_index();
2792                     let next = if event.shift { (cur + count - 1) % count } else { (cur + 1) % count };
2793                     self.switch_tab(next);
2794                     *needs_rebuild = true;
2795                     return None;
2796                 }
2797                 _ => {}
2798             }
2799         }
2800 
2801         if self.url_focused {
2802             if event.state == ElementState::Pressed {
2803                 self.edit_url(event);
2804                 *needs_rebuild = true;
2805             }
2806             return None;
2807         }
2808 
2809         if event.state == ElementState::Pressed {
2810             if event.ctrl {
2811                 if let Key::Character(c) = &event.logical_key {
2812                     match c.as_str() {
2813                         // Page clipboard: Servo needs the chord as an
2814                         // editing action, not as the raw keystroke.
2815                         "c" | "x" | "v" => {
2816                             self.host.editing_action_cmd(match c.as_str() {
2817                                 "c" => EditingCommand::Copy,
2818                                 "x" => EditingCommand::Cut,
2819                                 _ => EditingCommand::Paste,
2820                             });
2821                             return None;
2822                         }
2823                         "l" => {
2824                             self.open_chrome();
2825                             self.url_focused = true;
2826                             self.select_all_url();
2827                             *needs_rebuild = true;
2828                             return None;
2829                         }
2830                         "r" => {
2831                             self.host.reload();
2832                             return None;
2833                         }
2834                         _ => {}
2835                     }
2836                 }
2837             }
2838             if event.logical_key == Key::Named(NamedKey::F5) {
2839                 self.host.reload();
2840                 return None;
2841             }
2842             // An open menu owns Escape; closed, the page keeps it.
2843             if self.chrome_open && event.logical_key == Key::Named(NamedKey::Escape) {
2844                 self.close_chrome();
2845                 *needs_rebuild = true;
2846                 return None;
2847             }
2848         }
2849 
2850         self.host.key_ui(event);
2851         None
2852     }
2853 
2854     fn display_list(&mut self, size: LogicalSize, _scale: f64) -> Option<DisplayList> {
2855         // Whatever the engine last handed over is about to be on screen. That
2856         // is what lets the next one be read: until a frame is drawn, reading
2857         // another would be copying over a picture nobody saw.
2858         #[cfg(feature = "wpe")]
2859         self.host.frame_drawn();
2860         self.win = (size.width, size.height);
2861         let mut pc = PaintCtx::new();
2862         let w = size.width;
2863 
2864         let bar = self.bar();
2865         let pos_edge = self.settings.bar_position;
2866 
2867         // The standard root plate (cce-ui PlateSpec::window); the page is full-bleed content drawn on it.
2868         pc.root_plate(w, size.height);
2869         // Page: full-bleed under the floating bar.
2870         let content = Rect { x: 0.0, y: 0.0, width: w, height: size.height };
2871         if let Some((id, ..)) = self.host.image() {
2872             pc.image(id, content, 1.0);
2873         } else {
2874             // Just clear of the bar, whichever edge it is on.
2875             let y = match self.settings.bar_position {
2876                 settings::BarPosition::Top => bar.y + bar.height + item_gap(),
2877                 settings::BarPosition::Bottom => bar_margin(),
2878             };
2879             pc.text("Loading...", bar_margin(), y, 13.0, TEXT_DIM);
2880         }
2881 
2882         // The bar plate — or the shape it is unfolding through. Nothing but
2883         // the corner control shows while closed. Blur-behind, frosting the
2884         // page under it; the corner exponent eases from circular at the
2885         // dot-sized seed to the DE's own once it is the bar.
2886         let e = self.chrome_ease();
2887         let (plate, radius) = self.chrome_plate();
2888         let (sans, ..) = cce_ui::layout::read_preferred_fonts();
2889         if e > 0.0 {
2890             let shape = 2.0 + (cce_ui::layout::corner_shape() - 2.0) * e;
2891             pc.plate_shaped(
2892                 plate,
2893                 (radius, radius, radius, radius),
2894                 &cce_ui::scene::Material::from_fill(BAR_FILL),
2895                 cce_ui::layout::bevel_width().min(4.0),
2896                 Some(shape),
2897             );
2898         }
2899 
2900         // Open: the bar's contents, laid out at their final positions and
2901         // clipped to the plate, so they are revealed as it unfolds.
2902         if e > 0.0 {
2903             pc.clip_rounded(plate, radius, |pc| {
2904                 if self.loading {
2905                     pc.quad(
2906                         Rect { x: bar.x, y: bar.y + bar.height - 2.0, width: bar.width, height: 2.0 },
2907                         ACCENT,
2908                     );
2909                 }
2910 
2911                 // Tab strip.
2912             let count = self.host.tab_count();
2913             let active = self.host.active_index();
2914             for i in 0..count {
2915                 let pill = tab_rect(&bar, pos_edge, count, i);
2916                 let is_active = i == active;
2917                 pc.rounded_rect(
2918                     pill,
2919                     7.0,
2920                     (true, true, true, true),
2921                     if is_active { TAB_ACTIVE_BG } else { TAB_BG },
2922                 );
2923                 let tab = self.host.tab(i);
2924                 let title = tab
2925                     .and_then(|t| t.title.clone().filter(|s| !s.is_empty()))
2926                     .or_else(|| tab.and_then(|t| t.url.clone()).map(|u| u.to_string()))
2927                     .filter(|s| s != "about:blank")
2928                     .unwrap_or_else(|| "New Tab".to_string());
2929                 let close = tab_close_rect(&pill);
2930                 let text_avail = pill.width - 2.0 * text_pad() - close.map_or(0.0, |_| TAB_CLOSE_W - 4.0);
2931                 let label = Self::fit_text(&title, &sans, 12.0, text_avail);
2932                 let color = if is_active { TEXT } else { TEXT_DIM };
2933                 pc.text(
2934                     label,
2935                     pill.x + text_pad(),
2936                     cce_ui::layout::align_text_y(pill.y, pill.height, 12.0, 0.0),
2937                     12.0,
2938                     color,
2939                 );
2940                 if tab.is_some_and(|t| t.loading) {
2941                     pc.quad(
2942                         Rect { x: pill.x, y: pill.y + pill.height - 2.0, width: pill.width, height: 2.0 },
2943                         ACCENT,
2944                     );
2945                 }
2946                 if let Some(cr) = close {
2947                     let xw = measure_text_width("x", &sans, 11.0);
2948                     pc.text(
2949                         "x",
2950                         cr.x + (cr.width - xw) / 2.0 - 2.0,
2951                         cce_ui::layout::align_text_y(cr.y, cr.height, 11.0, 0.0),
2952                         11.0,
2953                         TEXT_DIM,
2954                     );
2955                 }
2956             }
2957             let plus = plus_rect(&bar, pos_edge);
2958             pc.rounded_rect(plus, 7.0, (true, true, true, true), BTN_BG);
2959             let pw = measure_text_width("+", &sans, 14.0);
2960             pc.text(
2961                 "+",
2962                 plus.x + (plus.width - pw) / 2.0,
2963                 cce_ui::layout::align_text_y(plus.y, plus.height, 14.0, 0.0),
2964                 14.0,
2965                 TEXT,
2966             );
2967 
2968             // Favorites strip: label pills, the hovered one lifted like an
2969             // active tab. Labels are cut to the pill, never the other way.
2970             let fav_rects = self.fav_rects(&bar);
2971             for (i, r) in fav_rects.iter().enumerate() {
2972                 let hovered = self.fav_hover == Some(i);
2973                 pc.rounded_rect(
2974                     *r,
2975                     7.0,
2976                     (true, true, true, true),
2977                     if hovered { TAB_ACTIVE_BG } else { TAB_BG },
2978                 );
2979                 let label =
2980                     Self::fit_text(&self.favs[i].label, &sans, FAV_FONT, r.width - 2.0 * text_pad());
2981                 pc.text(
2982                     label,
2983                     r.x + text_pad(),
2984                     cce_ui::layout::align_text_y(r.y, r.height, FAV_FONT, 0.0),
2985                     FAV_FONT,
2986                     if hovered { TEXT } else { TEXT_DIM },
2987                 );
2988             }
2989 
2990             let labels = ["<", ">", "R"];
2991             let enabled = [self.host.can_go_back(), self.host.can_go_forward(), true];
2992             for (i, label) in labels.iter().enumerate() {
2993                 let r = btn_rect(&bar, i);
2994                 pc.rounded_rect(r, 6.0, (true, true, true, true), BTN_BG);
2995                 let color = if enabled[i] { TEXT } else { TEXT_DIM };
2996                 let (sans, ..) = cce_ui::layout::read_preferred_fonts();
2997                 let lw = measure_text_width(label, &sans, 14.0);
2998                 pc.text(
2999                     *label,
3000                     r.x + (r.width - lw) / 2.0,
3001                     cce_ui::layout::align_text_y(r.y, r.height, 14.0, 0.0),
3002                     14.0,
3003                     color,
3004                 );
3005             }
3006 
3007             // Bookmark star: accent-lit when the page is bookmarked.
3008             let star = star_rect(&bar, pos_edge);
3009             pc.rounded_rect(star, 6.0, (true, true, true, true), BTN_BG);
3010             let starred = self.host.active_bookmarked();
3011             let star_color: [u8; 3] = if starred { [150, 190, 240] } else { TEXT_DIM };
3012             let sw = measure_text_width("*", &sans, 17.0);
3013             pc.text(
3014                 "*",
3015                 star.x + (star.width - sw) / 2.0,
3016                 cce_ui::layout::align_text_y(star.y, star.height, 17.0, 0.0) + 3.0,
3017                 17.0,
3018                 star_color,
3019             );
3020 
3021             // Bookmarks menu button: all the saved pages, where the star
3022             // beside it is only this one. Lit while its menu is open.
3023             let bmb = bm_btn_rect(&bar, pos_edge);
3024             pc.rounded_rect(bmb, 6.0, (true, true, true, true), BTN_BG);
3025             let bw = measure_text_width("B", &sans, 14.0);
3026             pc.text(
3027                 "B",
3028                 bmb.x + (bmb.width - bw) / 2.0,
3029                 cce_ui::layout::align_text_y(bmb.y, bmb.height, 14.0, 0.0),
3030                 14.0,
3031                 if self.bm_menu.is_some() { [150, 190, 240] } else { TEXT },
3032             );
3033 
3034             // URL field: rim + recess, brighter rim when focused.
3035             let f = url_rect(&bar, pos_edge);
3036             let rim = if self.url_focused { RIM_FOCUS } else { RIM };
3037             pc.rounded_rect(
3038                 Rect { x: f.x - 1.0, y: f.y - 1.0, width: f.width + 2.0, height: f.height + 2.0 },
3039                 7.0,
3040                 (true, true, true, true),
3041                 rim,
3042             );
3043             pc.rounded_rect(f, 6.0, (true, true, true, true), FIELD_BG);
3044             let ty = cce_ui::layout::align_text_y(f.y, f.height, URL_FONT, 0.0);
3045             let caret_x = if self.url_focused { Some(self.caret_offset()) } else { None };
3046             let sel_x = self
3047                 .url
3048                 .selection
3049                 .filter(|&(a, b)| a < b)
3050                 .map(|(a, b)| (self.x_offset(a), self.x_offset(b)));
3051             // style: deliberate — the caret and selection stand 4px inside
3052             // the field's rim: the glyph box's inset, not a gap.
3053             pc.clip(f, |pc| {
3054                 if let Some((x0, x1)) = sel_x {
3055                     pc.quad(
3056                         Rect {
3057                             x: f.x + text_pad() + x0,
3058                             y: f.y + 4.0,
3059                             width: x1 - x0,
3060                             height: f.height - 8.0,
3061                         },
3062                         SEL_BG,
3063                     );
3064                 }
3065                 pc.text(self.url.text.clone(), f.x + text_pad(), ty, URL_FONT, TEXT);
3066                 if let Some(offset) = caret_x {
3067                     pc.quad(
3068                         Rect { x: f.x + text_pad() + offset, y: f.y + 4.0, width: 1.0, height: f.height - 8.0 },
3069                         [0.85, 0.87, 0.92, 1.0],
3070                     );
3071                 }
3072             });
3073 
3074             });
3075         }
3076 
3077         // The corner control, over the bar: the DE's dot, emphasized while
3078         // hovered or while the bar it opens is out.
3079         plate_dock::draw_corner_dot(&mut pc, self.dot_center(), self.dot_hover || self.chrome_open);
3080 
3081         self.paint_bm_menu(&mut pc, &sans);
3082         #[cfg(feature = "wpe")]
3083         self.paint_ac_menu(&mut pc, &sans);
3084         #[cfg(feature = "wpe")]
3085         self.paint_ctx_menu(&mut pc, &sans);
3086         #[cfg(feature = "wpe")]
3087         self.paint_modal(&mut pc, &sans);
3088 
3089         Some(pc.finish())
3090     }
3091 
3092     fn display_list_text(&self) -> bool {
3093         true
3094     }
3095 
3096     fn clear_color(&self) -> [f32; 4] {
3097         PAGE_BG
3098     }
3099 }
3100 
3101 fn main() {
3102     env_logger::init();
3103     // Hand the launch to a running instance before any engine work: an
3104     // external open (`xdg-open` → `cce-browser %u`) becomes a tab there,
3105     // and this process never touches Wayland or the shared profile dir.
3106     if instance::forward_or_claim(std::env::args().nth(1).as_deref()) {
3107         return;
3108     }
3109     cce_ui::engine::run::<BrowserApp>();
3110     instance::cleanup();
3111 }
3112 
3113 #[cfg(test)]
3114 mod tests {
3115     use super::*;
3116 
3117     const SEARCH: &str = "https://duckduckgo.com/?q=";
3118 
3119     #[test]
3120     fn startup_arg_resolves_an_existing_path_to_a_file_url() {
3121         // Scoped to this process, like every other scratch directory in the
3122         // crate: /tmp is one namespace shared by every user of the machine.
3123         let dir = std::env::temp_dir()
3124             .join(format!("cce-browser-argv-test-{}", std::process::id()));
3125         std::fs::create_dir_all(&dir).unwrap();
3126         let page = dir.join("page.html");
3127         std::fs::write(&page, "<html></html>").unwrap();
3128 
3129         let u = parse_startup_arg(page.to_str().unwrap(), SEARCH).unwrap();
3130         assert_eq!(u.scheme(), "file");
3131         assert!(u.path().ends_with("page.html"), "got {u}");
3132 
3133         // The bar parser is what this guards against: a dotted, space-free
3134         // path takes its bare-host branch and becomes a bogus https URL.
3135         let bar = parse_url_input(page.to_str().unwrap(), SEARCH).unwrap();
3136         assert_eq!(bar.scheme(), "https");
3137 
3138         // Sole user of this directory, so it can go whole.
3139         let _ = std::fs::remove_dir_all(&dir);
3140     }
3141 
3142     #[cfg(feature = "wpe")]
3143     #[test]
3144     fn only_loopback_escapes_the_insecure_warning() {
3145         assert!(!insecure_origin("https://example.com", "example.com"));
3146         assert!(insecure_origin("http://example.com", "example.com"));
3147         assert!(!insecure_origin("http://localhost:8731", "localhost"));
3148         assert!(!insecure_origin("http://127.0.0.1:8080", "127.0.0.1"));
3149         assert!(!insecure_origin("http://dev.localhost", "dev.localhost"));
3150         // A file: page has no transport to secure, and no origin worth the
3151         // name; say so rather than stay quiet.
3152         assert!(insecure_origin("null", ""));
3153     }
3154 
3155     #[test]
3156     fn startup_arg_still_takes_urls_and_searches() {
3157         let u = parse_startup_arg("https://example.com/x", SEARCH).unwrap();
3158         assert_eq!(u.as_str(), "https://example.com/x");
3159 
3160         // A bare host that is not a path still guesses https.
3161         assert_eq!(parse_startup_arg("example.com", SEARCH).unwrap().scheme(), "https");
3162 
3163         // A non-existent path is not a file: it falls through to the bar rules.
3164         let missing = parse_startup_arg("/nonexistent/nope.html", SEARCH).unwrap();
3165         assert_ne!(missing.scheme(), "file");
3166     }
3167 }