web browser (Servo)
git clone https://git.lucas.co/cce-browser.git
src/webview.rs (34K)
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 /// Close a tab. Returns false when that was the last tab (the app should
474 /// exit; the tab is gone either way).
475 pub fn close_tab(&mut self, index: usize) -> bool {
476 if index >= self.tabs.len() {
477 return true;
478 }
479 let was_active = index == self.active;
480 let old_active = self.active;
481 let tab = self.tabs.remove(index);
482 self.shared.per.borrow_mut().remove(&tab.webview.id());
483 if let Some((id, ..)) = tab.image {
484 cce_ui::vk::free_image(id);
485 }
486 drop(tab); // last WebView handle: servo tears the page down
487 if self.tabs.is_empty() {
488 return false;
489 }
490 // Closing the active tab moves to its neighbor; closing a background
491 // tab keeps the current one (its index may have shifted down).
492 let next = if was_active {
493 index.min(self.tabs.len() - 1)
494 } else if old_active > index {
495 old_active - 1
496 } else {
497 old_active
498 };
499 self.active = usize::MAX; // force activate() to do the work
500 self.activate(next);
501 true
502 }
503
504 /// Make tab `index` the visible, focused one.
505 pub fn activate(&mut self, index: usize) {
506 if index >= self.tabs.len() || index == self.active {
507 return;
508 }
509 if let Some(old) = self.tabs.get(self.active) {
510 old.webview.blur();
511 old.webview.hide();
512 }
513 self.active = index;
514 let tab = &self.tabs[index];
515 tab.webview.show();
516 tab.webview.focus();
517 tab.webview.set_hidpi_scale_factor(Scale::new(self.scale));
518 tab.webview
519 .resize(PhysicalSize::new(self.size_px.0.max(1), self.size_px.1.max(1)));
520 // Composite whatever frame the tab already has so the switch shows
521 // content immediately; the resize above refreshes it right after.
522 self.paint_active();
523 }
524
525 pub fn tab_count(&self) -> usize {
526 self.tabs.len()
527 }
528
529 pub fn active_index(&self) -> usize {
530 self.active
531 }
532
533 pub fn tab(&self, index: usize) -> Option<&Tab> {
534 self.tabs.get(index)
535 }
536
537 fn active_tab(&self) -> &Tab {
538 &self.tabs[self.active]
539 }
540
541 /// Paint the active webview into the shared context and swap the read
542 /// pixels into its registry image.
543 fn paint_active(&mut self) {
544 self.active_tab().webview.paint();
545 let rect = DeviceIntRect::from_size(self.context.size2d().to_i32());
546 if let Some(img) = self.context.read_to_image(rect) {
547 let (w, h) = img.dimensions();
548 let id = cce_ui::vk::upload_rgba(img.into_raw(), w, h);
549 let tab = &mut self.tabs[self.active];
550 if let Some((old, ..)) = tab.image.replace((id, w, h)) {
551 cce_ui::vk::free_image(old);
552 }
553 }
554 }
555
556 /// Re-paint the page into a renderer that has just replaced the one the
557 /// tab images were uploaded to.
558 ///
559 /// An image id belongs to a **renderer**, not to the process: `cce-ui`'s
560 /// `window_runner` repairs a lost Wayland transport by opening a new
561 /// session around the same `Application`, which rebuilds the renderer and
562 /// with it the image table, and a draw for an unknown id is skipped
563 /// silently. `paint_active` is the repaint primitive here — the same one
564 /// `activate` uses to show a switched-to tab immediately — so the page
565 /// comes back without a reload.
566 pub fn renderer_replaced(&mut self) {
567 for tab in &mut self.tabs {
568 if let Some((id, ..)) = tab.image.take() {
569 cce_ui::vk::free_image(id);
570 }
571 }
572 self.paint_active();
573 }
574
575 /// Spin Servo, sync delegate signals into tabs, and repaint the active
576 /// tab if it produced a frame. Returns (new frame, any state change).
577 pub fn pump(&mut self) -> (bool, bool) {
578 self.servo.spin_event_loop();
579 if self.clear_cookies.swap(false, std::sync::atomic::Ordering::SeqCst) {
580 self.servo.site_data_manager().clear_cookies(None);
581 log::info!("cleared all cookies");
582 }
583 if self.reload_at.last().is_some_and(|at| std::time::Instant::now() >= *at) {
584 self.reload_at.pop();
585 for tab in &self.tabs {
586 tab.webview.reload();
587 }
588 }
589 // Adopt page-opened webviews as tabs; like a browser popup, the
590 // newest one takes focus.
591 let opened: Vec<WebView> = self.shared.pending_new.borrow_mut().drain(..).collect();
592 for webview in opened {
593 // Built by the delegate, so it has not been told the theme yet.
594 webview.notify_theme_change(self.theme);
595 self.tabs.push(Tab {
596 webview,
597 title: None,
598 url: None,
599 loading: true,
600 image: None,
601 });
602 self.activate(self.tabs.len() - 1);
603 }
604 let dirty = self.shared.dirty.take();
605 let mut active_frame = false;
606 if dirty {
607 let mut per = self.shared.per.borrow_mut();
608 for (i, tab) in self.tabs.iter_mut().enumerate() {
609 if let Some(sig) = per.get_mut(&tab.webview.id()) {
610 tab.title = sig.title.clone();
611 tab.url = sig.url.clone();
612 if let Some(loading) = sig.loading.take() {
613 let was_loading = tab.loading;
614 tab.loading = loading;
615 // Load-complete transition: log the visit.
616 if was_loading && !loading && self.history_enabled {
617 if let Some(url) = &tab.url {
618 self.history
619 .record(url.as_str(), tab.title.as_deref().unwrap_or(""));
620 }
621 }
622 }
623 if std::mem::take(&mut sig.frame_ready) && i == self.active {
624 active_frame = true;
625 }
626 }
627 }
628 }
629 if active_frame {
630 self.paint_active();
631 }
632 (active_frame, dirty)
633 }
634
635 pub fn image(&self) -> Option<(u32, u32, u32)> {
636 self.active_tab().image
637 }
638
639 pub fn title(&self) -> Option<String> {
640 self.active_tab().title.clone()
641 }
642
643 pub fn url(&self) -> Option<Url> {
644 self.active_tab().url.clone()
645 }
646
647 pub fn loading(&self) -> bool {
648 self.active_tab().loading
649 }
650
651 /// A navigation became a download since the last check.
652 pub fn take_download_started(&self) -> bool {
653 self.shared.download_started.take()
654 }
655
656 /// Whether the active tab's page is bookmarked.
657 pub fn active_bookmarked(&self) -> bool {
658 self.active_tab()
659 .url
660 .as_ref()
661 .is_some_and(|u| self.bookmarks.contains(u.as_str()))
662 }
663
664 /// Toggle the bookmark for the active tab's page.
665 pub fn toggle_bookmark(&self) {
666 let tab = self.active_tab();
667 if let Some(url) = &tab.url {
668 self.bookmarks
669 .toggle(url.as_str(), tab.title.as_deref().unwrap_or(""));
670 }
671 }
672
673 /// The bookmarks store, shared with the `cce://bookmarks` page; the
674 /// chrome's bookmarks menu lists and edits it directly.
675 pub fn bookmarks(&self) -> std::sync::Arc<Bookmarks> {
676 self.bookmarks.clone()
677 }
678
679 /// The favorites store, shared with the `cce://favorites` page; the
680 /// chrome reads the strip from it.
681 pub fn favorites(&self) -> std::sync::Arc<Favorites> {
682 self.favorites.clone()
683 }
684
685 /// Whether the active tab's page is in the favorites strip.
686 pub fn active_favorited(&self) -> bool {
687 self.active_tab()
688 .url
689 .as_ref()
690 .is_some_and(|u| self.favorites.contains(u.as_str()))
691 }
692
693 /// Toggle the favorite for the active tab's page.
694 pub fn toggle_favorite(&self) {
695 let tab = self.active_tab();
696 if let Some(url) = &tab.url {
697 self.favorites
698 .toggle(url.as_str(), tab.title.as_deref().unwrap_or(""));
699 }
700 }
701
702 pub fn can_go_back(&self) -> bool {
703 self.active_tab().webview.can_go_back()
704 }
705
706 pub fn can_go_forward(&self) -> bool {
707 self.active_tab().webview.can_go_forward()
708 }
709
710 pub fn load(&self, url: Url) {
711 if self.take_as_download(&url) {
712 self.shared.download_started.set(true);
713 return;
714 }
715 self.active_tab().webview.load(url);
716 }
717
718 pub fn reload(&self) {
719 self.active_tab().webview.reload();
720 }
721
722 pub fn back(&self) {
723 let webview = &self.active_tab().webview;
724 if webview.can_go_back() {
725 let _ = webview.go_back(1);
726 }
727 }
728
729 pub fn forward(&self) {
730 let webview = &self.active_tab().webview;
731 if webview.can_go_forward() {
732 let _ = webview.go_forward(1);
733 }
734 }
735
736 /// Resize the active webview (and the shared rendering context) to a
737 /// physical size. Inactive tabs are brought up to size on activation.
738 pub fn resize(&mut self, width_px: u32, height_px: u32, scale: f32) {
739 self.size_px = (width_px, height_px);
740 self.scale = scale;
741 let webview = &self.active_tab().webview;
742 webview.set_hidpi_scale_factor(Scale::new(scale));
743 webview.resize(PhysicalSize::new(width_px.max(1), height_px.max(1)));
744 }
745
746 /// Pointer position in device pixels relative to the webview origin.
747 pub fn mouse_move(&self, x_px: f32, y_px: f32) {
748 let _ = self.active_tab().webview.notify_input_event(InputEvent::MouseMove(
749 MouseMoveEvent::new(DevicePoint::new(x_px, y_px).into()),
750 ));
751 }
752
753 pub fn mouse_button(&self, button: DomMouseButton, pressed: bool, x_px: f32, y_px: f32) {
754 let action = if pressed { MouseButtonAction::Down } else { MouseButtonAction::Up };
755 let _ = self.active_tab().webview.notify_input_event(InputEvent::MouseButton(
756 MouseButtonEvent::new(action, button, DevicePoint::new(x_px, y_px).into()),
757 ));
758 }
759
760 /// Wheel in device pixels, winit sign convention (positive y = scroll
761 /// up). Servo hit-tests the wheel event, lets the page preventDefault,
762 /// and applies the inverted delta as the scroll itself — no separate
763 /// scroll event wanted.
764 pub fn wheel(&self, dx_px: f64, dy_px: f64, x_px: f32, y_px: f32) {
765 let _ = self.active_tab().webview.notify_input_event(InputEvent::Wheel(WheelEvent::new(
766 WheelDelta { x: dx_px, y: dy_px, z: 0.0, mode: WheelMode::DeltaPixel },
767 DevicePoint::new(x_px, y_px).into(),
768 )));
769 }
770
771 /// Clipboard action on the page, in backend-neutral terms.
772 pub fn editing_action_cmd(&self, command: crate::EditingCommand) {
773 self.editing_action(match command {
774 crate::EditingCommand::Copy => EditingActionEvent::Copy,
775 crate::EditingCommand::Cut => EditingActionEvent::Cut,
776 crate::EditingCommand::Paste => EditingActionEvent::Paste,
777 });
778 }
779
780 /// Pointer button in cce-ui's vocabulary. The Servo mapping lives here
781 /// rather than in `main.rs` so the chrome names no engine's types — the
782 /// WPE backend takes the same arguments.
783 pub fn mouse_button_ui(
784 &self,
785 button: cce_ui::widget::MouseButton,
786 pressed: bool,
787 x_px: f32,
788 y_px: f32,
789 ) {
790 use cce_ui::widget::MouseButton as Ui;
791 let Some(b) = (match button {
792 Ui::Left => Some(DomMouseButton::Left),
793 Ui::Right => Some(DomMouseButton::Right),
794 Ui::Middle => Some(DomMouseButton::Middle),
795 _ => None,
796 }) else {
797 return;
798 };
799 self.mouse_button(b, pressed, x_px, y_px);
800 }
801
802 /// A cce-ui key event, translated and forwarded. Same signature as the
803 /// WPE backend's `key`.
804 pub fn key_ui(&self, event: &cce_ui::widget::KeyEvent) {
805 use cce_ui::widget::{ElementState as St, Key as UiKey, NamedKey as Nk};
806 let Some(k) = (match &event.logical_key {
807 UiKey::Character(s) => Some(DomKey::Character(s.clone())),
808 UiKey::Named(Nk::Space) => Some(DomKey::Character(" ".into())),
809 UiKey::Named(n) => Some(DomKey::Named(match n {
810 Nk::Backspace => servo::NamedKey::Backspace,
811 Nk::Tab => servo::NamedKey::Tab,
812 Nk::Enter => servo::NamedKey::Enter,
813 Nk::Escape => servo::NamedKey::Escape,
814 Nk::ArrowDown => servo::NamedKey::ArrowDown,
815 Nk::ArrowLeft => servo::NamedKey::ArrowLeft,
816 Nk::ArrowRight => servo::NamedKey::ArrowRight,
817 Nk::ArrowUp => servo::NamedKey::ArrowUp,
818 Nk::End => servo::NamedKey::End,
819 Nk::Home => servo::NamedKey::Home,
820 Nk::PageDown => servo::NamedKey::PageDown,
821 Nk::PageUp => servo::NamedKey::PageUp,
822 Nk::Delete => servo::NamedKey::Delete,
823 Nk::Control => servo::NamedKey::Control,
824 Nk::Shift => servo::NamedKey::Shift,
825 Nk::Alt => servo::NamedKey::Alt,
826 Nk::Super => servo::NamedKey::Meta,
827 Nk::F5 => servo::NamedKey::F5,
828 Nk::Space => unreachable!("handled above"),
829 })),
830 }) else {
831 return;
832 };
833 let mut modifiers = Modifiers::empty();
834 modifiers.set(Modifiers::CONTROL, event.ctrl);
835 modifiers.set(Modifiers::SHIFT, event.shift);
836 modifiers.set(Modifiers::ALT, event.alt);
837 self.key(k, event.state == St::Pressed, modifiers);
838 }
839
840 /// Colour scheme in backend-neutral terms: dark or not.
841 pub fn set_color_scheme_dark(&mut self, dark: bool) {
842 self.set_color_scheme(if dark { Theme::Dark } else { Theme::Light });
843 }
844
845 /// Page focus. A no-op for Servo, which tracks focus itself; present so
846 /// both backends accept the same call.
847 pub fn focus(&self, _focused: bool) {}
848
849 /// Forward a key to the page, modifiers included.
850 ///
851 /// `from_state_and_key` defaults the modifiers to empty, which delivers
852 /// every chord to the page as a bare character — Ctrl+A typed a literal
853 /// "a" into a focused textarea rather than selecting its contents.
854 pub fn key(&self, key: DomKey, pressed: bool, modifiers: Modifiers) {
855 let state = if pressed { KeyState::Down } else { KeyState::Up };
856 let event = KeyboardEvent::new_without_event(
857 state,
858 key,
859 Code::Unidentified,
860 Location::Standard,
861 modifiers,
862 false,
863 false,
864 );
865 let _ = self
866 .active_tab()
867 .webview
868 .notify_input_event(InputEvent::Keyboard(event));
869 }
870
871 /// Clipboard action on the page. Servo has no built-in binding for the
872 /// chords — the embedder translates them and the engine then goes
873 /// through the clipboard delegate.
874 pub fn editing_action(&self, action: EditingActionEvent) {
875 let _ = self
876 .active_tab()
877 .webview
878 .notify_input_event(InputEvent::EditingAction(action));
879 }
880 }