web browser (Servo)
git clone https://git.lucas.co/cce-browser.git
src/webview.rs (34.9K)
1 //! Servo embedding host: boots an in-process Servo against a software
2 //! (CPU) rendering context and owns one WebView per tab, all sharing that
3 //! context — only the active tab is painted and read back (servoshell's
4 //! model). Finished frames upload into cce-ui's image registry; each tab
5 //! keeps its last frame so switching is instant.
6 //!
7 //! Everything here lives on the main thread. Servo wakes the calloop loop
8 //! through `Waker` (a channel sender); the app then calls [`ServoHost::pump`],
9 //! which spins Servo's event loop and, when the delegate has flagged a ready
10 //! frame on the active tab, paints and reads back pixels. `read_to_image`
11 //! happens *without* `present()` so the buffer is still there to read.
12
13 use std::cell::{Cell, RefCell};
14 use std::collections::HashMap;
15 use std::rc::Rc;
16
17 use dpi::PhysicalSize;
18 use euclid::Scale;
19 use servo::{
20 CreateNewWebViewRequest, DeviceIntRect, DevicePoint, EventLoopWaker, InputEvent,
21 Key as DomKey, KeyState, KeyboardEvent, LoadStatus, MouseButton as DomMouseButton,
22 MouseButtonAction, MouseButtonEvent, MouseMoveEvent, NavigationRequest, RenderingContext,
23 ClipboardDelegate, Code, EditingActionEvent, Location, Modifiers, Servo, ServoBuilder,
24 SoftwareRenderingContext, StringRequest, Theme,
25 UserContentManager, WebView, WebViewBuilder, WebViewDelegate, WebViewId, WheelDelta,
26 WheelEvent, WheelMode,
27 };
28 use servo::user_contents::UserStyleSheet;
29 use servo::protocol_handler::ProtocolRegistry;
30 use url::Url;
31
32 use crate::downloads::{is_download_url, Downloads};
33 use crate::pages::{Bookmarks, CceProtocol, Favorites, History};
34 use crate::Message;
35
36 /// Delegate-observed signals for one webview, polled by the app after each
37 /// pump.
38 #[derive(Default)]
39 struct TabSignals {
40 frame_ready: bool,
41 title: Option<String>,
42 url: Option<Url>,
43 /// None until Servo reports a load status — the sync must not mistake
44 /// the default for "finished loading" (that swallows the completion
45 /// transition history recording depends on).
46 loading: Option<bool>,
47 }
48
49 #[derive(Default)]
50 struct HostShared {
51 dirty: Cell<bool>,
52 per: RefCell<HashMap<WebViewId, TabSignals>>,
53 /// WebViews created by pages (window.open / target=_blank), built in the
54 /// delegate and adopted as tabs by the next `pump`.
55 pending_new: RefCell<Vec<WebView>>,
56 /// A navigation was diverted into a download; the app surfaces the
57 /// downloads page.
58 download_started: Cell<bool>,
59 }
60
61 struct Delegate {
62 shared: Rc<HostShared>,
63 wake: calloop::channel::Sender<Message>,
64 context: Rc<SoftwareRenderingContext>,
65 downloads: std::sync::Arc<Downloads>,
66 /// Shared with `ServoHost` so page-opened webviews carry the same user
67 /// content (the force-dark stylesheet) as the tabs the host builds.
68 ucm: Rc<UserContentManager>,
69 /// Handle to this same Rc'd delegate, so page-opened webviews can be
70 /// delegated back here; filled right after construction.
71 self_rc: RefCell<std::rc::Weak<Delegate>>,
72 }
73
74 impl Delegate {
75 fn with_tab(&self, webview: &WebView, f: impl FnOnce(&mut TabSignals)) {
76 f(self.shared.per.borrow_mut().entry(webview.id()).or_default());
77 self.shared.dirty.set(true);
78 let _ = self.wake.send(Message::Spin);
79 }
80 }
81
82 impl WebViewDelegate for Delegate {
83 fn notify_new_frame_ready(&self, webview: WebView) {
84 self.with_tab(&webview, |t| t.frame_ready = true);
85 }
86
87 fn notify_page_title_changed(&self, webview: WebView, title: Option<String>) {
88 self.with_tab(&webview, |t| t.title = title);
89 }
90
91 fn notify_url_changed(&self, webview: WebView, url: Url) {
92 self.with_tab(&webview, |t| t.url = Some(url));
93 }
94
95 fn notify_load_status_changed(&self, webview: WebView, status: LoadStatus) {
96 self.with_tab(&webview, |t| t.loading = Some(status != LoadStatus::Complete));
97 }
98
99 fn request_navigation(&self, _webview: WebView, request: NavigationRequest) {
100 // Navigations to downloadable files become chrome downloads —
101 // Servo has no download path of its own.
102 if is_download_url(&request.url) {
103 let url = request.url.clone();
104 request.deny();
105 self.downloads.start(url);
106 self.shared.download_started.set(true);
107 self.shared.dirty.set(true);
108 let _ = self.wake.send(Message::Spin);
109 } else {
110 request.allow();
111 }
112 }
113
114 fn request_create_new(&self, _parent_webview: WebView, request: CreateNewWebViewRequest) {
115 let Some(delegate) = self.self_rc.borrow().upgrade() else {
116 return; // dropping the request denies it
117 };
118 let webview = request
119 .builder(self.context.clone())
120 .delegate(delegate)
121 .user_content_manager(self.ucm.clone())
122 .clipboard_delegate(Rc::new(CceClipboard))
123 .build();
124 self.shared.pending_new.borrow_mut().push(webview);
125 self.shared.dirty.set(true);
126 let _ = self.wake.send(Message::Spin);
127 }
128 }
129
130 /// Force-dark user stylesheet: invert the whole page, then rotate hues back
131 /// so blues stay blue rather than turning orange, and invert media a second
132 /// time so photos and video keep their own colors. This is the crude tier —
133 /// it fights the site's palette rather than asking for its dark theme — but
134 /// it is the only thing that darkens a page like google.com, which serves a
135 /// hardcoded white with no `prefers-color-scheme` rule to honor.
136 ///
137 /// Servo parses user stylesheets with `Origin::User`, where `!important`
138 /// outranks the page's own `!important`, which is what lets these win.
139 const FORCE_DARK_CSS: &str = "\
140 html {
141 background-color: #ffffff !important;
142 filter: invert(1) hue-rotate(180deg) !important;
143 }
144 img, video, picture, canvas, svg, iframe, embed, object,
145 [style*=\"background-image\"], [style*=\"background:url\"] {
146 filter: invert(1) hue-rotate(180deg) !important;
147 }
148 ";
149
150 /// When to reload pages after the color-scheme setting changes.
151 ///
152 /// TWO reloads, both needed, for two different in-flight changes:
153 ///
154 /// * The constellation hands a new user stylesheet to the script thread as a
155 /// separate `SetUserContents` message, so a reload issued in the same
156 /// breath as `add_stylesheet` can rebuild the document before the sheet
157 /// lands. The first deadline covers that.
158 /// * Force-dark also flips the reported scheme (it reports light, so pages
159 /// render the light theme the filter then inverts). That notification is
160 /// likewise asynchronous, and a page reloaded too soon comes back rendered
161 /// for the OLD scheme — under the filter that means a dark page inverted
162 /// into a light one, and it stays that way because nothing reloads it
163 /// again. Measured on google.com: dark -> force-dark reproduces it every
164 /// time even with a 5s single reload, while the same transition from
165 /// light -> force-dark (no scheme flip) is correct, and one more reload
166 /// always settles it. Hence the second deadline.
167 const USER_CONTENT_SETTLE: std::time::Duration = std::time::Duration::from_millis(400);
168 /// Second reload, after any accompanying scheme flip has certainly landed.
169 const SCHEME_SETTLE: std::time::Duration = std::time::Duration::from_millis(2500);
170
171 /// Page clipboard, routed through the toolkit's wl-copy/wl-paste helpers.
172 ///
173 /// Servo ships an arboard-backed delegate behind its default `clipboard`
174 /// feature, but it lands nothing on the clipboard in this embedding —
175 /// verified by copying in a page and reading the seat's clipboard back,
176 /// which came up empty. Going through `cce_ui`'s helpers also keeps the
177 /// browser on the same clipboard path as the rest of the DE.
178 struct CceClipboard;
179
180 impl ClipboardDelegate for CceClipboard {
181 fn get_text(&self, _webview: WebView, request: StringRequest) {
182 match cce_ui::widget::clipboard::read_from_clipboard() {
183 Some(text) => request.success(text),
184 None => request.failure("clipboard is empty".into()),
185 }
186 }
187
188 fn set_text(&self, _webview: WebView, new_contents: String) {
189 cce_ui::widget::clipboard::copy_to_clipboard(&new_contents);
190 }
191
192 fn clear(&self, _webview: WebView) {
193 cce_ui::widget::clipboard::copy_to_clipboard("");
194 }
195 }
196
197 /// Wakes the calloop event loop from Servo's internal threads.
198 #[derive(Clone)]
199 struct Waker(calloop::channel::Sender<Message>);
200
201 impl EventLoopWaker for Waker {
202 fn clone_box(&self) -> Box<dyn EventLoopWaker> {
203 Box::new(self.clone())
204 }
205
206 fn wake(&self) {
207 let _ = self.0.send(Message::Spin);
208 }
209 }
210
211 /// One tab: its webview plus the app-visible page state and the last frame
212 /// uploaded to the image registry (id, w px, h px).
213 pub struct Tab {
214 webview: WebView,
215 pub title: Option<String>,
216 pub url: Option<Url>,
217 pub loading: bool,
218 image: Option<(u32, u32, u32)>,
219 }
220
221 pub struct ServoHost {
222 servo: Servo,
223 context: Rc<SoftwareRenderingContext>,
224 shared: Rc<HostShared>,
225 delegate: Rc<Delegate>,
226 history: std::sync::Arc<History>,
227 bookmarks: std::sync::Arc<Bookmarks>,
228 favorites: std::sync::Arc<Favorites>,
229 tabs: Vec<Tab>,
230 active: usize,
231 size_px: (u32, u32),
232 scale: f32,
233 /// Settings gate for cce://history recording.
234 history_enabled: bool,
235 /// What every webview reports as `prefers-color-scheme`. Held here
236 /// because the theme is per-webview: tabs opened later have to be told.
237 theme: Theme,
238 /// User content shared by every webview; owns the force-dark stylesheet's
239 /// registration and must outlive the webviews (dropping it tells the
240 /// constellation to destroy the manager).
241 ucm: Rc<UserContentManager>,
242 force_dark_sheet: Rc<UserStyleSheet>,
243 force_dark: bool,
244 /// Deadlines for pending reloads, earliest last (popped off the back).
245 reload_at: Vec<std::time::Instant>,
246 /// Raised by the cce://cookies/clear page; acted on here in `pump`.
247 clear_cookies: std::sync::Arc<std::sync::atomic::AtomicBool>,
248 /// Shared with the delegate and the `cce:` handler. The host needs it
249 /// directly because the delegate's sniff cannot see every navigation —
250 /// see [`ServoHost::take_as_download`].
251 downloads: std::sync::Arc<Downloads>,
252 }
253
254 impl ServoHost {
255 pub fn set_history_enabled(&mut self, on: bool) {
256 self.history_enabled = on;
257 }
258
259 /// Install or remove the inverting user stylesheet. Servo applies user
260 /// content at page load, so open tabs are reloaded to pick up the change.
261 pub fn set_force_dark(&mut self, on: bool) {
262 if on == self.force_dark {
263 return;
264 }
265 self.force_dark = on;
266 if on {
267 self.ucm.add_stylesheet(self.force_dark_sheet.clone());
268 } else {
269 self.ucm.remove_stylesheet(self.force_dark_sheet.clone());
270 }
271 // Let the change reach the script thread before rebuilding the
272 // documents that have to pick it up. The wake is what guarantees a
273 // pump once the deadline passes: an idle page produces no frames of
274 // its own, so nothing else would turn the loop.
275 let now = std::time::Instant::now();
276 self.reload_at = vec![now + SCHEME_SETTLE, now + USER_CONTENT_SETTLE];
277 // The wakes are what guarantee a pump once each deadline passes: an
278 // idle page produces no frames of its own, so nothing else would turn
279 // the loop.
280 for delay in [USER_CONTENT_SETTLE, SCHEME_SETTLE] {
281 let wake = self.delegate.wake.clone();
282 std::thread::spawn(move || {
283 std::thread::sleep(delay + std::time::Duration::from_millis(20));
284 let _ = wake.send(Message::Spin);
285 });
286 }
287 }
288
289 /// Set the color scheme pages see, now and for tabs opened later.
290 pub fn set_color_scheme(&mut self, theme: Theme) {
291 self.theme = theme;
292 for tab in &self.tabs {
293 tab.webview.notify_theme_change(theme);
294 }
295 }
296 }
297
298 impl ServoHost {
299 /// `force_dark` is taken up front rather than set afterwards: user content
300 /// only applies at page load, so flipping it later would mean reloading
301 /// the tab that was just opened.
302 pub fn new(
303 wake: calloop::channel::Sender<Message>,
304 url: Url,
305 size_px: (u32, u32),
306 force_dark: bool,
307 ) -> Self {
308 // Servo's TLS stack looks up the process-wide rustls crypto provider.
309 let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
310
311 let context = Rc::new(
312 SoftwareRenderingContext::new(PhysicalSize::new(size_px.0.max(1), size_px.1.max(1)))
313 .expect("create software rendering context"),
314 );
315 context
316 .make_current()
317 .expect("make software rendering context current");
318
319 let history = std::sync::Arc::new(History::load());
320 let bookmarks = std::sync::Arc::new(Bookmarks::load());
321 let favorites = std::sync::Arc::new(Favorites::load());
322 let downloads = std::sync::Arc::new(Downloads::default());
323 let clear_cookies = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
324 let mut protocols = ProtocolRegistry::default();
325 let handler = CceProtocol {
326 history: history.clone(),
327 bookmarks: bookmarks.clone(),
328 favorites: favorites.clone(),
329 downloads: downloads.clone(),
330 clear_cookies: clear_cookies.clone(),
331 };
332 if let Err(e) = protocols.register("cce", handler) {
333 log::error!("failed to register cce: protocol: {e:?}");
334 }
335
336 // Give Servo somewhere to persist per-profile state. Without a
337 // `config_dir` it keeps the cookie jar in memory only, so every
338 // launch starts logged out of every site; with one it reads and
339 // writes cookie_jar.json (plus the auth cache and HSTS list) there.
340 // Note the jar is plaintext JSON — live sessions for signed-in
341 // accounts sit in it, so it is deliberately under the state dir
342 // rather than anywhere shared or synced.
343 let profile_dir = crate::pages::state_dir().join("profile");
344 if let Err(e) = std::fs::create_dir_all(&profile_dir) {
345 log::warn!("no browser profile dir ({e}); sessions will not persist");
346 } else {
347 // Servo writes the jar 0644. $HOME is 0700 here so that is not
348 // exposed today, but the sessions inside are worth an owner-only
349 // directory of their own rather than relying on that.
350 use std::os::unix::fs::PermissionsExt;
351 let _ = std::fs::set_permissions(&profile_dir, std::fs::Permissions::from_mode(0o700));
352 }
353 // CSS Grid ships disabled in Servo (`layout.grid.enabled` defaults to
354 // false), so every `display: grid` declaration is refused and the
355 // element falls back to block flow — 31 refusals on one mainstream
356 // login page, which is a lot of modern layout quietly dropped.
357 let mut preferences = servo::Preferences::default();
358 preferences.layout_grid_enabled = true;
359 let servo = ServoBuilder::default()
360 .preferences(preferences)
361 .opts(servo::Opts {
362 config_dir: Some(profile_dir),
363 ..Default::default()
364 })
365 .event_loop_waker(Box::new(Waker(wake.clone())))
366 .protocol_registry(protocols)
367 .build();
368
369 let ucm = Rc::new(UserContentManager::new(&servo));
370 let force_dark_sheet = Rc::new(UserStyleSheet::new(
371 FORCE_DARK_CSS.to_string(),
372 Url::parse("cce://force-dark.css").expect("force-dark url"),
373 ));
374 if force_dark {
375 ucm.add_stylesheet(force_dark_sheet.clone());
376 }
377
378 let shared = Rc::new(HostShared::default());
379 let delegate = Rc::new(Delegate {
380 shared: shared.clone(),
381 wake,
382 context: context.clone(),
383 downloads: downloads.clone(),
384 ucm: ucm.clone(),
385 self_rc: RefCell::new(std::rc::Weak::new()),
386 });
387 *delegate.self_rc.borrow_mut() = Rc::downgrade(&delegate);
388
389 let mut host = Self {
390 servo,
391 context,
392 shared,
393 delegate,
394 history,
395 bookmarks,
396 favorites,
397 tabs: Vec::new(),
398 // Sentinel so the first open_tab's activate() does the full
399 // show/focus/resize dance instead of early-returning on 0 == 0.
400 active: usize::MAX,
401 size_px,
402 scale: 1.0,
403 history_enabled: true,
404 theme: Theme::Light,
405 ucm,
406 force_dark_sheet,
407 force_dark,
408 reload_at: Vec::new(),
409 clear_cookies,
410 downloads,
411 };
412 // argv can name a download, and the first tab's URL is one of the
413 // navigations the delegate never sees, so it has to be sniffed here.
414 let start = if host.take_as_download(&url) {
415 Url::parse("cce://downloads").expect("downloads url")
416 } else {
417 url
418 };
419 host.open_tab(start);
420 host
421 }
422
423 /// Take `url` as a download instead of a navigation, if it looks like
424 /// one. Returns whether it was taken.
425 ///
426 /// `WebViewDelegate::request_navigation` — where the sniff normally
427 /// happens — only fires for navigations the *content* starts. A URL the
428 /// embedder supplies never reaches it: not the first tab's (Servo loads
429 /// it straight from `WebViewBuilder::url`), and not one typed in the URL
430 /// bar. Passing an archive URL as argv therefore rendered Servo's
431 /// "Unknown content type (application/octet-stream)" page instead of
432 /// downloading it.
433 ///
434 /// Callers must return without navigating when this returns true, which
435 /// is also what keeps the delegate from starting the same download twice.
436 /// Raising `download_started` (so the app surfaces the downloads page) is
437 /// left to the caller: at startup the first tab opens on that page
438 /// already, and the flag would add a second one — `open_internal_page`
439 /// cannot dedupe against a tab whose URL the delegate has not reported
440 /// yet.
441 fn take_as_download(&self, url: &Url) -> bool {
442 if !is_download_url(url) {
443 return false;
444 }
445 self.downloads.start(url.clone());
446 true
447 }
448
449 fn build_webview(&self, url: Url) -> WebView {
450 let webview = WebViewBuilder::new(&self.servo, self.context.clone())
451 .url(url)
452 .delegate(self.delegate.clone())
453 .user_content_manager(self.ucm.clone())
454 .clipboard_delegate(Rc::new(CceClipboard))
455 .build();
456 webview.notify_theme_change(self.theme);
457 webview
458 }
459
460 /// Open a new tab and make it active.
461 pub fn open_tab(&mut self, url: Url) {
462 let webview = self.build_webview(url);
463 self.tabs.push(Tab {
464 webview,
465 title: None,
466 url: None,
467 loading: true,
468 image: None,
469 });
470 self.activate(self.tabs.len() - 1);
471 }
472
473 /// Open a new tab behind the active one, which stays shown and focused.
474 pub fn open_background_tab(&mut self, url: Url) {
475 let back = self.active;
476 self.open_tab(url);
477 self.activate(back);
478 }
479
480 /// Close a tab. Returns false when that was the last tab (the app should
481 /// exit; the tab is gone either way).
482 pub fn close_tab(&mut self, index: usize) -> bool {
483 if index >= self.tabs.len() {
484 return true;
485 }
486 let was_active = index == self.active;
487 let old_active = self.active;
488 let tab = self.tabs.remove(index);
489 self.shared.per.borrow_mut().remove(&tab.webview.id());
490 if let Some((id, ..)) = tab.image {
491 cce_ui::vk::free_image(id);
492 }
493 drop(tab); // last WebView handle: servo tears the page down
494 if self.tabs.is_empty() {
495 return false;
496 }
497 // Closing the active tab moves to its neighbor; closing a background
498 // tab keeps the current one (its index may have shifted down).
499 let next = if was_active {
500 index.min(self.tabs.len() - 1)
501 } else if old_active > index {
502 old_active - 1
503 } else {
504 old_active
505 };
506 self.active = usize::MAX; // force activate() to do the work
507 self.activate(next);
508 true
509 }
510
511 /// Make tab `index` the visible, focused one.
512 pub fn activate(&mut self, index: usize) {
513 if index >= self.tabs.len() || index == self.active {
514 return;
515 }
516 if let Some(old) = self.tabs.get(self.active) {
517 old.webview.blur();
518 old.webview.hide();
519 }
520 self.active = index;
521 let tab = &self.tabs[index];
522 tab.webview.show();
523 tab.webview.focus();
524 tab.webview.set_hidpi_scale_factor(Scale::new(self.scale));
525 tab.webview
526 .resize(PhysicalSize::new(self.size_px.0.max(1), self.size_px.1.max(1)));
527 // Composite whatever frame the tab already has so the switch shows
528 // content immediately; the resize above refreshes it right after.
529 self.paint_active();
530 }
531
532 pub fn tab_count(&self) -> usize {
533 self.tabs.len()
534 }
535
536 pub fn active_index(&self) -> usize {
537 self.active
538 }
539
540 pub fn tab(&self, index: usize) -> Option<&Tab> {
541 self.tabs.get(index)
542 }
543
544 fn active_tab(&self) -> &Tab {
545 &self.tabs[self.active]
546 }
547
548 /// Paint the active webview into the shared context and swap the read
549 /// pixels into its registry image.
550 fn paint_active(&mut self) {
551 self.active_tab().webview.paint();
552 let rect = DeviceIntRect::from_size(self.context.size2d().to_i32());
553 if let Some(img) = self.context.read_to_image(rect) {
554 let (w, h) = img.dimensions();
555 let id = cce_ui::vk::upload_rgba(img.into_raw(), w, h);
556 let tab = &mut self.tabs[self.active];
557 if let Some((old, ..)) = tab.image.replace((id, w, h)) {
558 cce_ui::vk::free_image(old);
559 }
560 }
561 }
562
563 /// Re-paint the page into a renderer that has just replaced the one the
564 /// tab images were uploaded to.
565 ///
566 /// An image id belongs to a **renderer**, not to the process: `cce-ui`'s
567 /// `window_runner` repairs a lost Wayland transport by opening a new
568 /// session around the same `Application`, which rebuilds the renderer and
569 /// with it the image table, and a draw for an unknown id is skipped
570 /// silently. `paint_active` is the repaint primitive here — the same one
571 /// `activate` uses to show a switched-to tab immediately — so the page
572 /// comes back without a reload.
573 pub fn renderer_replaced(&mut self) {
574 for tab in &mut self.tabs {
575 if let Some((id, ..)) = tab.image.take() {
576 cce_ui::vk::free_image(id);
577 }
578 }
579 self.paint_active();
580 }
581
582 /// Spin Servo, sync delegate signals into tabs, and repaint the active
583 /// tab if it produced a frame. Returns (new frame, any state change).
584 pub fn pump(&mut self) -> (bool, bool) {
585 self.servo.spin_event_loop();
586 if self.clear_cookies.swap(false, std::sync::atomic::Ordering::SeqCst) {
587 self.servo.site_data_manager().clear_cookies(None);
588 log::info!("cleared all cookies");
589 }
590 if self.reload_at.last().is_some_and(|at| std::time::Instant::now() >= *at) {
591 self.reload_at.pop();
592 for tab in &self.tabs {
593 tab.webview.reload();
594 }
595 }
596 // Adopt page-opened webviews as tabs; like a browser popup, the
597 // newest one takes focus.
598 let opened: Vec<WebView> = self.shared.pending_new.borrow_mut().drain(..).collect();
599 for webview in opened {
600 // Built by the delegate, so it has not been told the theme yet.
601 webview.notify_theme_change(self.theme);
602 self.tabs.push(Tab {
603 webview,
604 title: None,
605 url: None,
606 loading: true,
607 image: None,
608 });
609 self.activate(self.tabs.len() - 1);
610 }
611 let dirty = self.shared.dirty.take();
612 let mut active_frame = false;
613 if dirty {
614 let mut per = self.shared.per.borrow_mut();
615 for (i, tab) in self.tabs.iter_mut().enumerate() {
616 if let Some(sig) = per.get_mut(&tab.webview.id()) {
617 tab.title = sig.title.clone();
618 tab.url = sig.url.clone();
619 if let Some(loading) = sig.loading.take() {
620 let was_loading = tab.loading;
621 tab.loading = loading;
622 // Load-complete transition: log the visit.
623 if was_loading && !loading && self.history_enabled {
624 if let Some(url) = &tab.url {
625 self.history
626 .record(url.as_str(), tab.title.as_deref().unwrap_or(""));
627 }
628 }
629 }
630 if std::mem::take(&mut sig.frame_ready) && i == self.active {
631 active_frame = true;
632 }
633 }
634 }
635 }
636 if active_frame {
637 self.paint_active();
638 }
639 (active_frame, dirty)
640 }
641
642 pub fn image(&self) -> Option<(u32, u32, u32)> {
643 self.active_tab().image
644 }
645
646 pub fn title(&self) -> Option<String> {
647 self.active_tab().title.clone()
648 }
649
650 pub fn url(&self) -> Option<Url> {
651 self.active_tab().url.clone()
652 }
653
654 pub fn loading(&self) -> bool {
655 self.active_tab().loading
656 }
657
658 /// A navigation became a download since the last check.
659 pub fn take_download_started(&self) -> bool {
660 self.shared.download_started.take()
661 }
662
663 /// Whether the active tab's page is bookmarked.
664 pub fn active_bookmarked(&self) -> bool {
665 self.active_tab()
666 .url
667 .as_ref()
668 .is_some_and(|u| self.bookmarks.contains(u.as_str()))
669 }
670
671 /// Toggle the bookmark for the active tab's page.
672 pub fn toggle_bookmark(&self) {
673 let tab = self.active_tab();
674 if let Some(url) = &tab.url {
675 self.bookmarks
676 .toggle(url.as_str(), tab.title.as_deref().unwrap_or(""));
677 }
678 }
679
680 /// The bookmarks store, shared with the `cce://bookmarks` page; the
681 /// chrome's bookmarks menu lists and edits it directly.
682 pub fn bookmarks(&self) -> std::sync::Arc<Bookmarks> {
683 self.bookmarks.clone()
684 }
685
686 /// The favorites store, shared with the `cce://favorites` page; the
687 /// chrome reads the strip from it.
688 pub fn favorites(&self) -> std::sync::Arc<Favorites> {
689 self.favorites.clone()
690 }
691
692 /// Whether the active tab's page is in the favorites strip.
693 pub fn active_favorited(&self) -> bool {
694 self.active_tab()
695 .url
696 .as_ref()
697 .is_some_and(|u| self.favorites.contains(u.as_str()))
698 }
699
700 /// Toggle the favorite for the active tab's page.
701 pub fn toggle_favorite(&self) {
702 let tab = self.active_tab();
703 if let Some(url) = &tab.url {
704 self.favorites
705 .toggle(url.as_str(), tab.title.as_deref().unwrap_or(""));
706 }
707 }
708
709 pub fn can_go_back(&self) -> bool {
710 self.active_tab().webview.can_go_back()
711 }
712
713 pub fn can_go_forward(&self) -> bool {
714 self.active_tab().webview.can_go_forward()
715 }
716
717 pub fn load(&self, url: Url) {
718 if self.take_as_download(&url) {
719 self.shared.download_started.set(true);
720 return;
721 }
722 self.active_tab().webview.load(url);
723 }
724
725 pub fn reload(&self) {
726 self.active_tab().webview.reload();
727 }
728
729 pub fn back(&self) {
730 let webview = &self.active_tab().webview;
731 if webview.can_go_back() {
732 let _ = webview.go_back(1);
733 }
734 }
735
736 pub fn forward(&self) {
737 let webview = &self.active_tab().webview;
738 if webview.can_go_forward() {
739 let _ = webview.go_forward(1);
740 }
741 }
742
743 /// Resize the active webview (and the shared rendering context) to a
744 /// physical size. Inactive tabs are brought up to size on activation.
745 pub fn resize(&mut self, width_px: u32, height_px: u32, scale: f32) {
746 self.size_px = (width_px, height_px);
747 self.scale = scale;
748 let webview = &self.active_tab().webview;
749 webview.set_hidpi_scale_factor(Scale::new(scale));
750 webview.resize(PhysicalSize::new(width_px.max(1), height_px.max(1)));
751 }
752
753 /// Pointer position in device pixels relative to the webview origin.
754 pub fn mouse_move(&self, x_px: f32, y_px: f32) {
755 let _ = self.active_tab().webview.notify_input_event(InputEvent::MouseMove(
756 MouseMoveEvent::new(DevicePoint::new(x_px, y_px).into()),
757 ));
758 }
759
760 pub fn mouse_button(&self, button: DomMouseButton, pressed: bool, x_px: f32, y_px: f32) {
761 let action = if pressed { MouseButtonAction::Down } else { MouseButtonAction::Up };
762 let _ = self.active_tab().webview.notify_input_event(InputEvent::MouseButton(
763 MouseButtonEvent::new(action, button, DevicePoint::new(x_px, y_px).into()),
764 ));
765 }
766
767 /// Wheel in device pixels, winit sign convention (positive y = scroll
768 /// up). Servo hit-tests the wheel event, lets the page preventDefault,
769 /// and applies the inverted delta as the scroll itself — no separate
770 /// scroll event wanted.
771 pub fn wheel(&self, dx_px: f64, dy_px: f64, x_px: f32, y_px: f32) {
772 let _ = self.active_tab().webview.notify_input_event(InputEvent::Wheel(WheelEvent::new(
773 WheelDelta { x: dx_px, y: dy_px, z: 0.0, mode: WheelMode::DeltaPixel },
774 DevicePoint::new(x_px, y_px).into(),
775 )));
776 }
777
778 /// Clipboard action on the page, in backend-neutral terms.
779 pub fn editing_action_cmd(&self, command: crate::EditingCommand) {
780 self.editing_action(match command {
781 crate::EditingCommand::Copy => EditingActionEvent::Copy,
782 crate::EditingCommand::Cut => EditingActionEvent::Cut,
783 crate::EditingCommand::Paste => EditingActionEvent::Paste,
784 });
785 }
786
787 /// Pointer button in cce-ui's vocabulary. The Servo mapping lives here
788 /// rather than in `main.rs` so the chrome names no engine's types — the
789 /// WPE backend takes the same arguments.
790 pub fn mouse_button_ui(
791 &self,
792 button: cce_ui::widget::MouseButton,
793 pressed: bool,
794 x_px: f32,
795 y_px: f32,
796 ) {
797 use cce_ui::widget::MouseButton as Ui;
798 let Some(b) = (match button {
799 Ui::Left => Some(DomMouseButton::Left),
800 Ui::Right => Some(DomMouseButton::Right),
801 Ui::Middle => Some(DomMouseButton::Middle),
802 _ => None,
803 }) else {
804 return;
805 };
806 self.mouse_button(b, pressed, x_px, y_px);
807 }
808
809 // ---- vi mode ----
810 //
811 // The WPE host's surface, as no-ops: Servo has no script worlds to run
812 // the focus watcher or the hint script in, and no find controller. Keys
813 // still bind; hints, `gi`, half-page scrolls and search do nothing.
814
815 pub fn set_vi_enabled(&mut self, _on: bool) {}
816 pub fn take_vi_focus(&self) -> Option<bool> {
817 None
818 }
819 pub fn vi_eval(&self, _script: &str, _tag: u32) {}
820 pub fn take_vi_result(&self) -> Option<(u32, String)> {
821 None
822 }
823 pub fn find(&self, _text: &str, _backwards: bool) {}
824 pub fn find_next(&self) {}
825 pub fn find_prev(&self) {}
826 pub fn find_finish(&self) {}
827 pub fn take_find_result(&self) -> Option<u32> {
828 None
829 }
830
831 /// A cce-ui key event, translated and forwarded. Same signature as the
832 /// WPE backend's `key`.
833 pub fn key_ui(&self, event: &cce_ui::widget::KeyEvent) {
834 use cce_ui::widget::{ElementState as St, Key as UiKey, NamedKey as Nk};
835 let Some(k) = (match &event.logical_key {
836 UiKey::Character(s) => Some(DomKey::Character(s.clone())),
837 UiKey::Named(Nk::Space) => Some(DomKey::Character(" ".into())),
838 UiKey::Named(n) => Some(DomKey::Named(match n {
839 Nk::Backspace => servo::NamedKey::Backspace,
840 Nk::Tab => servo::NamedKey::Tab,
841 Nk::Enter => servo::NamedKey::Enter,
842 Nk::Escape => servo::NamedKey::Escape,
843 Nk::ArrowDown => servo::NamedKey::ArrowDown,
844 Nk::ArrowLeft => servo::NamedKey::ArrowLeft,
845 Nk::ArrowRight => servo::NamedKey::ArrowRight,
846 Nk::ArrowUp => servo::NamedKey::ArrowUp,
847 Nk::End => servo::NamedKey::End,
848 Nk::Home => servo::NamedKey::Home,
849 Nk::PageDown => servo::NamedKey::PageDown,
850 Nk::PageUp => servo::NamedKey::PageUp,
851 Nk::Delete => servo::NamedKey::Delete,
852 Nk::Control => servo::NamedKey::Control,
853 Nk::Shift => servo::NamedKey::Shift,
854 Nk::Alt => servo::NamedKey::Alt,
855 Nk::Super => servo::NamedKey::Meta,
856 Nk::F5 => servo::NamedKey::F5,
857 Nk::Space => unreachable!("handled above"),
858 })),
859 }) else {
860 return;
861 };
862 let mut modifiers = Modifiers::empty();
863 modifiers.set(Modifiers::CONTROL, event.ctrl);
864 modifiers.set(Modifiers::SHIFT, event.shift);
865 modifiers.set(Modifiers::ALT, event.alt);
866 self.key(k, event.state == St::Pressed, modifiers);
867 }
868
869 /// Colour scheme in backend-neutral terms: dark or not.
870 pub fn set_color_scheme_dark(&mut self, dark: bool) {
871 self.set_color_scheme(if dark { Theme::Dark } else { Theme::Light });
872 }
873
874 /// Page focus. A no-op for Servo, which tracks focus itself; present so
875 /// both backends accept the same call.
876 pub fn focus(&mut self, _focused: bool) {}
877
878 /// Forward a key to the page, modifiers included.
879 ///
880 /// `from_state_and_key` defaults the modifiers to empty, which delivers
881 /// every chord to the page as a bare character — Ctrl+A typed a literal
882 /// "a" into a focused textarea rather than selecting its contents.
883 pub fn key(&self, key: DomKey, pressed: bool, modifiers: Modifiers) {
884 let state = if pressed { KeyState::Down } else { KeyState::Up };
885 let event = KeyboardEvent::new_without_event(
886 state,
887 key,
888 Code::Unidentified,
889 Location::Standard,
890 modifiers,
891 false,
892 false,
893 );
894 let _ = self
895 .active_tab()
896 .webview
897 .notify_input_event(InputEvent::Keyboard(event));
898 }
899
900 /// Clipboard action on the page. Servo has no built-in binding for the
901 /// chords — the embedder translates them and the engine then goes
902 /// through the clipboard delegate.
903 pub fn editing_action(&self, action: EditingActionEvent) {
904 let _ = self
905 .active_tab()
906 .webview
907 .notify_input_event(InputEvent::EditingAction(action));
908 }
909 }