web browser (Servo)
git clone https://git.lucas.co/cce-browser.git
cce-browser: Servo-embedded web browser MVP
Embeds Servo 0.4 (crates.io) with a SoftwareRenderingContext; finished
frames are read back and uploaded through cce-ui's image registry, drawn
as one quad under a 42px chrome bar (back/forward/reload, hand-rolled
URL editor with measured cursor, loading strip). Pointer/wheel/keys over
the page translate to Servo input events; Ctrl+L focuses the URL bar,
F5/Ctrl+R reloads, mouse Back/Forward traverse history. Page title
drives the toplevel title.
Note: workspace Cargo.lock pins primeorder 0.14.0-rc.14 (cargo update
--precise) — the 0.14.0 final breaks servo-script's p256/p384/p521 rcs.
Co-Authored-By: Claude Fable 5 <[email protected]>
.gitignore | 2 +
Cargo.toml | 16 ++
src/main.rs | 512 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/webview.rs | 231 ++++++++++++++++++++++++++
4 files changed, 761 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..96ef6c0
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+/target
+Cargo.lock
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..36c409f
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,16 @@
+[package]
+name = "cce-browser"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
+cce-ui = { path = "../cce-ui" }
+calloop = "0.13.0"
+wayland-client = { version = "0.31", features = ["system"] }
+servo = "0.4"
+url = "2"
+dpi = "0.1"
+euclid = "0.22"
+rustls = { version = "0.23", features = ["aws-lc-rs"] }
+log = "0.4"
+env_logger = "0.11"
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..26cb670
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,512 @@
+//! cce-browser — a web browser on the embedded Servo engine.
+//!
+//! Servo renders pages into a CPU (software) rendering context; each
+//! finished frame is read back and uploaded to cce-ui's image registry,
+//! then drawn as a single quad under a thin chrome bar (back / forward /
+//! reload / URL field). Input over the page area is translated into Servo
+//! input events; the URL bar is a small hand-rolled line editor.
+
+mod webview;
+
+use url::Url;
+use wayland_client::QueueHandle;
+
+use cce_ui::engine::{Application, EngineState, LogicalPosition, LogicalSize, WindowSettings};
+use cce_ui::scene::layout::Rect;
+use cce_ui::scene::paint::{DisplayList, PaintCtx};
+use cce_ui::widget::display::measure_text_width;
+use cce_ui::widget::{ElementState, Key, KeyEvent, MouseButton, MouseScrollDelta, NamedKey};
+
+use webview::ServoHost;
+
+const CHROME_H: f32 = 42.0;
+const BTN_W: f32 = 30.0;
+const BTN_H: f32 = 26.0;
+const BTN_GAP: f32 = 6.0;
+const PAD: f32 = 8.0;
+const URL_FONT: f32 = 14.0;
+const URL_PAD_X: f32 = 9.0;
+/// Pixels per wheel notch when the DE reports discrete line deltas.
+const LINE_PX: f64 = 76.0;
+
+const HOME_URL: &str = "https://servo.org";
+
+const CHROME_BG: [f32; 4] = [0.13, 0.14, 0.15, 1.0];
+const PAGE_BG: [f32; 4] = [0.10, 0.10, 0.11, 1.0];
+const FIELD_BG: [f32; 4] = [0.09, 0.09, 0.10, 1.0];
+const BTN_BG: [f32; 4] = [0.18, 0.19, 0.21, 1.0];
+const RIM: [f32; 4] = [0.22, 0.23, 0.25, 1.0];
+const RIM_FOCUS: [f32; 4] = [0.33, 0.48, 0.72, 1.0];
+const ACCENT: [f32; 4] = [0.35, 0.55, 0.85, 1.0];
+const TEXT: [u8; 3] = [220, 220, 225];
+const TEXT_DIM: [u8; 3] = [120, 122, 128];
+
+#[derive(Debug, Clone)]
+pub enum Message {
+ /// Servo requested an event-loop spin (waker or delegate signal).
+ Spin,
+}
+
+struct BrowserApp {
+ host: ServoHost,
+ win: (f32, f32),
+ scale: f64,
+ pointer: (f32, f32),
+ /// URL bar contents; mirrors the page URL unless the bar is focused.
+ url_input: String,
+ url_focused: bool,
+ /// Byte index of the URL-bar cursor.
+ cursor: usize,
+ loading: bool,
+ /// Page title; drives the toplevel title (the engine re-applies
+ /// `settings().title` whenever it changes).
+ title: Option<String>,
+}
+
+fn hit(r: &Rect, x: f32, y: f32) -> bool {
+ x >= r.x && x < r.x + r.width && y >= r.y && y < r.y + r.height
+}
+
+fn btn_rect(i: usize) -> Rect {
+ Rect {
+ x: PAD + i as f32 * (BTN_W + BTN_GAP),
+ y: (CHROME_H - BTN_H) / 2.0,
+ width: BTN_W,
+ height: BTN_H,
+ }
+}
+
+fn url_rect(win_w: f32) -> Rect {
+ let x = PAD + 3.0 * (BTN_W + BTN_GAP) + 4.0;
+ Rect {
+ x,
+ y: (CHROME_H - BTN_H) / 2.0,
+ width: (win_w - x - PAD).max(60.0),
+ height: BTN_H,
+ }
+}
+
+/// Turn URL-bar input into something loadable: a real URL as-is, a bare
+/// host gets https://, anything else becomes a search.
+fn parse_url_input(input: &str) -> Option<Url> {
+ let s = input.trim();
+ if s.is_empty() {
+ return None;
+ }
+ if let Ok(u) = Url::parse(s) {
+ if matches!(u.scheme(), "http" | "https" | "file" | "data" | "about") {
+ return Some(u);
+ }
+ }
+ if !s.contains(' ') && s.contains('.') {
+ if let Ok(u) = Url::parse(&format!("https://{s}")) {
+ return Some(u);
+ }
+ }
+ let q: String = url::form_urlencoded::byte_serialize(s.as_bytes()).collect();
+ Url::parse(&format!("https://duckduckgo.com/html/?q={q}")).ok()
+}
+
+fn dom_button(button: MouseButton) -> Option<servo::MouseButton> {
+ match button {
+ MouseButton::Left => Some(servo::MouseButton::Left),
+ MouseButton::Right => Some(servo::MouseButton::Right),
+ MouseButton::Middle => Some(servo::MouseButton::Middle),
+ _ => None,
+ }
+}
+
+fn dom_key(key: &Key) -> Option<servo::Key> {
+ Some(match key {
+ Key::Character(s) => servo::Key::Character(s.clone()),
+ Key::Named(NamedKey::Space) => servo::Key::Character(" ".into()),
+ Key::Named(n) => servo::Key::Named(match n {
+ NamedKey::Backspace => servo::NamedKey::Backspace,
+ NamedKey::Tab => servo::NamedKey::Tab,
+ NamedKey::Enter => servo::NamedKey::Enter,
+ NamedKey::Escape => servo::NamedKey::Escape,
+ NamedKey::ArrowDown => servo::NamedKey::ArrowDown,
+ NamedKey::ArrowLeft => servo::NamedKey::ArrowLeft,
+ NamedKey::ArrowRight => servo::NamedKey::ArrowRight,
+ NamedKey::ArrowUp => servo::NamedKey::ArrowUp,
+ NamedKey::End => servo::NamedKey::End,
+ NamedKey::Home => servo::NamedKey::Home,
+ NamedKey::PageDown => servo::NamedKey::PageDown,
+ NamedKey::PageUp => servo::NamedKey::PageUp,
+ NamedKey::Delete => servo::NamedKey::Delete,
+ NamedKey::Control => servo::NamedKey::Control,
+ NamedKey::Shift => servo::NamedKey::Shift,
+ NamedKey::Alt => servo::NamedKey::Alt,
+ NamedKey::Super => servo::NamedKey::Meta,
+ NamedKey::F5 => servo::NamedKey::F5,
+ NamedKey::Space => unreachable!(),
+ }),
+ })
+}
+
+fn prev_boundary(s: &str, i: usize) -> usize {
+ let mut j = i;
+ while j > 0 {
+ j -= 1;
+ if s.is_char_boundary(j) {
+ return j;
+ }
+ }
+ 0
+}
+
+fn next_boundary(s: &str, i: usize) -> usize {
+ let mut j = i;
+ while j < s.len() {
+ j += 1;
+ if s.is_char_boundary(j) {
+ return j;
+ }
+ }
+ s.len()
+}
+
+impl BrowserApp {
+ fn content_rect(&self) -> Rect {
+ Rect {
+ x: 0.0,
+ y: CHROME_H,
+ width: self.win.0,
+ height: (self.win.1 - CHROME_H).max(1.0),
+ }
+ }
+
+ fn content_px(&self) -> (u32, u32) {
+ let r = self.content_rect();
+ (
+ (r.width as f64 * self.scale) as u32,
+ (r.height as f64 * self.scale) as u32,
+ )
+ }
+
+ /// Pull delegate-observed page state into the chrome.
+ fn sync_page_state(&mut self) {
+ self.loading = self.host.loading();
+ self.title = self.host.title().filter(|t| !t.is_empty());
+ if !self.url_focused {
+ if let Some(u) = self.host.url() {
+ self.url_input = u.to_string();
+ self.cursor = self.url_input.len();
+ }
+ }
+ }
+
+ fn navigate(&mut self) {
+ if let Some(url) = parse_url_input(&self.url_input) {
+ self.host.load(url);
+ self.url_focused = false;
+ self.loading = true;
+ }
+ }
+
+ fn cursor_from_click(&self, click_x: f32, field: &Rect) -> usize {
+ let rel = click_x - field.x - URL_PAD_X;
+ let (sans, ..) = cce_ui::layout::read_preferred_fonts();
+ let mut i = 0;
+ while i < self.url_input.len() {
+ let next = next_boundary(&self.url_input, i);
+ if measure_text_width(&self.url_input[..next], &sans, URL_FONT) > rel {
+ return i;
+ }
+ i = next;
+ }
+ self.url_input.len()
+ }
+
+ fn edit_url(&mut self, event: &KeyEvent) {
+ match &event.logical_key {
+ Key::Named(NamedKey::Enter) => self.navigate(),
+ Key::Named(NamedKey::Escape) => {
+ self.url_focused = false;
+ self.sync_page_state();
+ }
+ Key::Named(NamedKey::Backspace) => {
+ if self.cursor > 0 {
+ let prev = prev_boundary(&self.url_input, self.cursor);
+ self.url_input.replace_range(prev..self.cursor, "");
+ self.cursor = prev;
+ }
+ }
+ Key::Named(NamedKey::Delete) => {
+ if self.cursor < self.url_input.len() {
+ let next = next_boundary(&self.url_input, self.cursor);
+ self.url_input.replace_range(self.cursor..next, "");
+ }
+ }
+ Key::Named(NamedKey::ArrowLeft) => self.cursor = prev_boundary(&self.url_input, self.cursor),
+ Key::Named(NamedKey::ArrowRight) => self.cursor = next_boundary(&self.url_input, self.cursor),
+ Key::Named(NamedKey::Home) => self.cursor = 0,
+ Key::Named(NamedKey::End) => self.cursor = self.url_input.len(),
+ Key::Character(c) if event.ctrl => {
+ if c == "u" {
+ self.url_input.clear();
+ self.cursor = 0;
+ }
+ }
+ _ => {
+ let insert = match (&event.text, &event.logical_key) {
+ (Some(t), _) if !event.ctrl && !t.chars().any(char::is_control) => Some(t.clone()),
+ (None, Key::Named(NamedKey::Space)) => Some(" ".to_string()),
+ (None, Key::Character(c)) if !event.ctrl => Some(c.clone()),
+ _ => None,
+ };
+ if let Some(t) = insert {
+ self.url_input.insert_str(self.cursor, &t);
+ self.cursor += t.len();
+ }
+ }
+ }
+ }
+}
+
+impl Application for BrowserApp {
+ type Message = Message;
+
+ fn new(_qh: &QueueHandle<EngineState<Self>>, sender: calloop::channel::Sender<Self::Message>) -> Self {
+ let url = Url::parse(HOME_URL).expect("home url");
+ let host = ServoHost::new(sender, url, (1200, (800.0 - CHROME_H) as u32));
+ Self {
+ host,
+ win: (1200.0, 800.0),
+ scale: 1.0,
+ pointer: (0.0, 0.0),
+ url_input: HOME_URL.to_string(),
+ url_focused: false,
+ cursor: HOME_URL.len(),
+ loading: true,
+ title: None,
+ }
+ }
+
+ fn settings(&self) -> WindowSettings {
+ WindowSettings {
+ title: self.title.clone().unwrap_or_else(|| "Browser".to_string()),
+ app_id: "cce-browser".to_string(),
+ width: 1200,
+ height: 800,
+ fullscreen: false,
+ min_size: Some((480, 320)),
+ }
+ }
+
+ fn update(&mut self, msg: Self::Message, needs_rebuild: &mut bool, _exit: &mut bool) {
+ match msg {
+ Message::Spin => {
+ let (new_frame, dirty) = self.host.pump();
+ if dirty {
+ self.sync_page_state();
+ }
+ if new_frame || dirty {
+ *needs_rebuild = true;
+ }
+ }
+ }
+ }
+
+ fn tick(&mut self, _dt: f32, _needs_rebuild: &mut bool) {}
+
+ fn handle_resize(&mut self, width: f32, height: f32, scale: f64) {
+ self.win = (width, height);
+ self.scale = scale;
+ let (w, h) = self.content_px();
+ self.host.resize(w, h, scale as f32);
+ }
+
+ fn handle_pointer_move(&mut self, pos: LogicalPosition, _needs_rebuild: &mut bool) {
+ self.pointer = (pos.x, pos.y);
+ if pos.y >= CHROME_H {
+ let s = self.scale as f32;
+ self.host.mouse_move(pos.x * s, (pos.y - CHROME_H) * s);
+ }
+ }
+
+ fn handle_mouse_input(
+ &mut self,
+ button: MouseButton,
+ state: ElementState,
+ pos: LogicalPosition,
+ needs_rebuild: &mut bool,
+ ) -> Option<Self::Message> {
+ let pressed = state == ElementState::Pressed;
+
+ if pos.y < CHROME_H {
+ if !pressed || button != MouseButton::Left {
+ return None;
+ }
+ *needs_rebuild = true;
+ if hit(&btn_rect(0), pos.x, pos.y) {
+ self.host.back();
+ } else if hit(&btn_rect(1), pos.x, pos.y) {
+ self.host.forward();
+ } else if hit(&btn_rect(2), pos.x, pos.y) {
+ self.host.reload();
+ } else {
+ let field = url_rect(self.win.0);
+ if hit(&field, pos.x, pos.y) {
+ self.cursor = self.cursor_from_click(pos.x, &field);
+ self.url_focused = true;
+ } else {
+ self.url_focused = false;
+ }
+ }
+ return None;
+ }
+
+ // Page area: a click dismisses URL-bar focus, then goes to the page.
+ if self.url_focused && pressed {
+ self.url_focused = false;
+ self.sync_page_state();
+ *needs_rebuild = true;
+ }
+ match button {
+ MouseButton::Back if pressed => self.host.back(),
+ MouseButton::Forward if pressed => self.host.forward(),
+ _ => {
+ if let Some(b) = dom_button(button) {
+ let s = self.scale as f32;
+ self.host.mouse_button(b, pressed, pos.x * s, (pos.y - CHROME_H) * s);
+ }
+ }
+ }
+ None
+ }
+
+ fn handle_mouse_wheel(&mut self, delta: &MouseScrollDelta, pos: LogicalPosition, _needs_rebuild: &mut bool) {
+ if pos.y < CHROME_H {
+ return;
+ }
+ // cce-ui deltas are winit-signed (positive = scroll up); the DOM and
+ // Servo's Scroll::Delta want positive = reveal content below.
+ let (dx, dy) = match delta {
+ MouseScrollDelta::LineDelta(x, y) => (-(*x as f64) * LINE_PX, -(*y as f64) * LINE_PX),
+ MouseScrollDelta::PixelDelta(p) => (-p.x, -p.y),
+ };
+ let s = self.scale;
+ self.host.wheel(
+ dx * s,
+ dy * s,
+ pos.x * s as f32,
+ (pos.y - CHROME_H) * s as f32,
+ );
+ }
+
+ fn handle_key_input(&mut self, event: &KeyEvent, needs_rebuild: &mut bool) -> Option<Self::Message> {
+ if self.url_focused {
+ if event.state == ElementState::Pressed {
+ self.edit_url(event);
+ *needs_rebuild = true;
+ }
+ return None;
+ }
+
+ if event.state == ElementState::Pressed {
+ if event.ctrl {
+ if let Key::Character(c) = &event.logical_key {
+ match c.as_str() {
+ "l" => {
+ self.url_focused = true;
+ self.cursor = self.url_input.len();
+ *needs_rebuild = true;
+ return None;
+ }
+ "r" => {
+ self.host.reload();
+ return None;
+ }
+ _ => {}
+ }
+ }
+ }
+ if event.logical_key == Key::Named(NamedKey::F5) {
+ self.host.reload();
+ return None;
+ }
+ }
+
+ if let Some(k) = dom_key(&event.logical_key) {
+ self.host.key(k, event.state == ElementState::Pressed);
+ }
+ None
+ }
+
+ fn display_list(&mut self, size: LogicalSize, _scale: f64) -> Option<DisplayList> {
+ self.win = (size.width, size.height);
+ let mut pc = PaintCtx::new();
+ let w = size.width;
+
+ // Page.
+ let content = self.content_rect();
+ pc.quad(content, PAGE_BG);
+ if let Some((id, ..)) = self.host.image() {
+ pc.image(id, content, 1.0);
+ } else {
+ pc.text("Loading...", content.x + 16.0, content.y + 18.0, 13.0, TEXT_DIM);
+ }
+
+ // Chrome bar.
+ pc.quad(Rect { x: 0.0, y: 0.0, width: w, height: CHROME_H }, CHROME_BG);
+ if self.loading {
+ pc.quad(Rect { x: 0.0, y: CHROME_H - 2.0, width: w, height: 2.0 }, ACCENT);
+ }
+
+ let labels = ["<", ">", "R"];
+ let enabled = [self.host.can_go_back(), self.host.can_go_forward(), true];
+ for (i, label) in labels.iter().enumerate() {
+ let r = btn_rect(i);
+ pc.rounded_rect(r, 6.0, (true, true, true, true), BTN_BG);
+ let color = if enabled[i] { TEXT } else { TEXT_DIM };
+ let (sans, ..) = cce_ui::layout::read_preferred_fonts();
+ let lw = measure_text_width(label, &sans, 14.0);
+ pc.text(
+ *label,
+ r.x + (r.width - lw) / 2.0,
+ cce_ui::layout::align_text_y(r.y, r.height, 14.0, 0.0),
+ 14.0,
+ color,
+ );
+ }
+
+ // URL field: rim + recess, brighter rim when focused.
+ let f = url_rect(w);
+ let rim = if self.url_focused { RIM_FOCUS } else { RIM };
+ pc.rounded_rect(
+ Rect { x: f.x - 1.0, y: f.y - 1.0, width: f.width + 2.0, height: f.height + 2.0 },
+ 7.0,
+ (true, true, true, true),
+ rim,
+ );
+ pc.rounded_rect(f, 6.0, (true, true, true, true), FIELD_BG);
+ let ty = cce_ui::layout::align_text_y(f.y, f.height, URL_FONT, 0.0);
+ pc.clip(f, |pc| {
+ pc.text(self.url_input.clone(), f.x + URL_PAD_X, ty, URL_FONT, TEXT);
+ if self.url_focused {
+ let (sans, ..) = cce_ui::layout::read_preferred_fonts();
+ let cx = f.x + URL_PAD_X + measure_text_width(&self.url_input[..self.cursor], &sans, URL_FONT);
+ pc.quad(
+ Rect { x: cx, y: f.y + 4.0, width: 1.0, height: f.height - 8.0 },
+ [0.85, 0.87, 0.92, 1.0],
+ );
+ }
+ });
+
+ Some(pc.finish())
+ }
+
+ fn display_list_text(&self) -> bool {
+ true
+ }
+
+ fn clear_color(&self) -> [f32; 4] {
+ CHROME_BG
+ }
+}
+
+fn main() {
+ env_logger::init();
+ cce_ui::engine::run::<BrowserApp>();
+}
diff --git a/src/webview.rs b/src/webview.rs
new file mode 100644
index 0000000..985c347
--- /dev/null
+++ b/src/webview.rs
@@ -0,0 +1,231 @@
+//! Servo embedding host: boots an in-process Servo against a software
+//! (CPU) rendering context, owns the single WebView, and pumps finished
+//! frames into cce-ui's image registry as RGBA uploads.
+//!
+//! Everything here lives on the main thread. Servo wakes the calloop loop
+//! through `Waker` (a channel sender); the app then calls [`ServoHost::pump`],
+//! which spins Servo's event loop and, when the delegate has flagged a ready
+//! frame, paints and reads back pixels. `read_to_image` happens *without*
+//! `present()` so the buffer is still there to read.
+
+use std::cell::{Cell, RefCell};
+use std::rc::Rc;
+
+use dpi::PhysicalSize;
+use euclid::Scale;
+use servo::{
+ DeviceIntRect, DevicePoint, DeviceVector2D, EventLoopWaker, InputEvent, Key as DomKey,
+ KeyState, KeyboardEvent, LoadStatus, MouseButton as DomMouseButton, MouseButtonAction,
+ MouseButtonEvent, MouseMoveEvent, RenderingContext, Scroll, Servo, ServoBuilder,
+ SoftwareRenderingContext, WebView, WebViewBuilder, WebViewDelegate, WheelDelta, WheelEvent,
+ WheelMode,
+};
+use url::Url;
+
+use crate::Message;
+
+/// Page state observed by the delegate, polled by the app after each pump.
+#[derive(Default)]
+pub struct PageState {
+ frame_ready: Cell<bool>,
+ dirty: Cell<bool>,
+ title: RefCell<Option<String>>,
+ url: RefCell<Option<Url>>,
+ loading: Cell<bool>,
+}
+
+struct Delegate {
+ state: Rc<PageState>,
+ wake: calloop::channel::Sender<Message>,
+}
+
+impl Delegate {
+ fn touch(&self) {
+ self.state.dirty.set(true);
+ let _ = self.wake.send(Message::Spin);
+ }
+}
+
+impl WebViewDelegate for Delegate {
+ fn notify_new_frame_ready(&self, _webview: WebView) {
+ self.state.frame_ready.set(true);
+ self.touch();
+ }
+
+ fn notify_page_title_changed(&self, _webview: WebView, title: Option<String>) {
+ *self.state.title.borrow_mut() = title;
+ self.touch();
+ }
+
+ fn notify_url_changed(&self, _webview: WebView, url: Url) {
+ *self.state.url.borrow_mut() = Some(url);
+ self.touch();
+ }
+
+ fn notify_load_status_changed(&self, _webview: WebView, status: LoadStatus) {
+ self.state.loading.set(status != LoadStatus::Complete);
+ self.touch();
+ }
+}
+
+/// Wakes the calloop event loop from Servo's internal threads.
+#[derive(Clone)]
+struct Waker(calloop::channel::Sender<Message>);
+
+impl EventLoopWaker for Waker {
+ fn clone_box(&self) -> Box<dyn EventLoopWaker> {
+ Box::new(self.clone())
+ }
+
+ fn wake(&self) {
+ let _ = self.0.send(Message::Spin);
+ }
+}
+
+pub struct ServoHost {
+ servo: Servo,
+ webview: WebView,
+ context: Rc<SoftwareRenderingContext>,
+ state: Rc<PageState>,
+ /// Current page frame in the cce-ui image registry: (id, w px, h px).
+ image: Option<(u32, u32, u32)>,
+}
+
+impl ServoHost {
+ pub fn new(wake: calloop::channel::Sender<Message>, url: Url, size_px: (u32, u32)) -> Self {
+ // Servo's TLS stack looks up the process-wide rustls crypto provider.
+ let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
+
+ let context = Rc::new(
+ SoftwareRenderingContext::new(PhysicalSize::new(size_px.0.max(1), size_px.1.max(1)))
+ .expect("create software rendering context"),
+ );
+ context
+ .make_current()
+ .expect("make software rendering context current");
+
+ let servo = ServoBuilder::default()
+ .event_loop_waker(Box::new(Waker(wake.clone())))
+ .build();
+
+ let state = Rc::new(PageState::default());
+ let webview = WebViewBuilder::new(&servo, context.clone())
+ .url(url)
+ .delegate(Rc::new(Delegate { state: state.clone(), wake }))
+ .build();
+ webview.show();
+ webview.focus();
+
+ Self { servo, webview, context, state, image: None }
+ }
+
+ /// Spin Servo and swap any finished frame into the image registry.
+ /// Returns (new frame uploaded, page state changed).
+ pub fn pump(&mut self) -> (bool, bool) {
+ self.servo.spin_event_loop();
+ let dirty = self.state.dirty.take();
+ let mut new_frame = false;
+ if self.state.frame_ready.take() {
+ self.webview.paint();
+ let rect = DeviceIntRect::from_size(self.context.size2d().to_i32());
+ if let Some(img) = self.context.read_to_image(rect) {
+ let (w, h) = img.dimensions();
+ let id = cce_ui::vk::upload_rgba(img.into_raw(), w, h);
+ if let Some((old, ..)) = self.image.replace((id, w, h)) {
+ cce_ui::vk::free_image(old);
+ }
+ new_frame = true;
+ }
+ }
+ (new_frame, dirty)
+ }
+
+ pub fn image(&self) -> Option<(u32, u32, u32)> {
+ self.image
+ }
+
+ pub fn title(&self) -> Option<String> {
+ self.state.title.borrow().clone()
+ }
+
+ pub fn url(&self) -> Option<Url> {
+ self.state.url.borrow().clone()
+ }
+
+ pub fn loading(&self) -> bool {
+ self.state.loading.get()
+ }
+
+ pub fn can_go_back(&self) -> bool {
+ self.webview.can_go_back()
+ }
+
+ pub fn can_go_forward(&self) -> bool {
+ self.webview.can_go_forward()
+ }
+
+ pub fn load(&self, url: Url) {
+ self.webview.load(url);
+ }
+
+ pub fn reload(&self) {
+ self.webview.reload();
+ }
+
+ pub fn back(&self) {
+ if self.webview.can_go_back() {
+ let _ = self.webview.go_back(1);
+ }
+ }
+
+ pub fn forward(&self) {
+ if self.webview.can_go_forward() {
+ let _ = self.webview.go_forward(1);
+ }
+ }
+
+ /// Resize the webview (and its rendering context) to a physical size.
+ pub fn resize(&self, width_px: u32, height_px: u32, scale: f32) {
+ self.webview.set_hidpi_scale_factor(Scale::new(scale));
+ self.webview
+ .resize(PhysicalSize::new(width_px.max(1), height_px.max(1)));
+ }
+
+ /// Pointer position in device pixels relative to the webview origin.
+ pub fn mouse_move(&self, x_px: f32, y_px: f32) {
+ let _ = self.webview.notify_input_event(InputEvent::MouseMove(MouseMoveEvent::new(
+ DevicePoint::new(x_px, y_px).into(),
+ )));
+ }
+
+ pub fn mouse_button(&self, button: DomMouseButton, pressed: bool, x_px: f32, y_px: f32) {
+ let action = if pressed { MouseButtonAction::Down } else { MouseButtonAction::Up };
+ let _ = self.webview.notify_input_event(InputEvent::MouseButton(MouseButtonEvent::new(
+ action,
+ button,
+ DevicePoint::new(x_px, y_px).into(),
+ )));
+ }
+
+ /// Wheel/scroll in device pixels, DOM sign convention (positive y
+ /// reveals content below). Sends both the DOM wheel event and the
+ /// compositor scroll.
+ pub fn wheel(&self, dx_px: f64, dy_px: f64, x_px: f32, y_px: f32) {
+ let point = DevicePoint::new(x_px, y_px);
+ let _ = self.webview.notify_input_event(InputEvent::Wheel(WheelEvent::new(
+ WheelDelta { x: dx_px, y: dy_px, z: 0.0, mode: WheelMode::DeltaPixel },
+ point.into(),
+ )));
+ self.webview.notify_scroll_event(
+ Scroll::Delta(DeviceVector2D::new(dx_px as f32, dy_px as f32).into()),
+ point.into(),
+ );
+ }
+
+ pub fn key(&self, key: DomKey, pressed: bool) {
+ let state = if pressed { KeyState::Down } else { KeyState::Up };
+ let _ = self
+ .webview
+ .notify_input_event(InputEvent::Keyboard(KeyboardEvent::from_state_and_key(state, key)));
+ }
+}