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 }