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