web browser (Servo)
git clone https://git.lucas.co/cce-browser.git
feat(wpe): a right-click context menu on the web view
Driven by WebKit's hit test rather than guessed from position: right-click
reaches the page as button 3, and if the page does not preventDefault,
WebKit raises context-menu with what was under the pointer. Returning TRUE
claims presentation, so the chrome draws its own menu — the same PaintCtx
primitives as the rest of this widgetless app — at the pointer position it
already tracks, since the hit test carries no coordinates.
Items follow the hit: a link adds Open Link in New Tab / Copy Link /
Download Link, an image adds Copy Image Address / Download Image, a
selection adds Copy, an editable adds Paste (through the clipboard sync
path built for Ctrl+V), and Back / Forward / Reload / Open in Other
Browser always close the list, the first two dimmed against real history
state. Download items go through webkit_web_view_download_uri, so they
land in the same signals, store and cce://downloads page as a navigated
download.
Unlike the script-dialog modal, the menu is not modal — the page is not
blocked — so it owns exactly one click: on an item it dispatches,
anywhere else it closes, and the click goes no further either way. Any
key dismisses it. Pointer moves feed the hover highlight and stop
reaching the page while it is open.
Verified in three layers: examples/wpe_ctx proves right-click on a link
yields that link's exact URI and label and an input yields is_editable;
a shadow-session screenshot shows the drawn menu with Back and Forward
correctly dimmed on a fresh session; and clicking Open Link in New Tab
opened a second tab on https://example.org/target — hit test to menu to
action, end to end.
One injection footnote for future shadow tests: a freshly spawned session
delivered no click until the pointer had produced at least one motion
event; the first right-click vanished and a pointer-move-by fixed it.
That is the harness, not the app.
Co-Authored-By: Claude Opus 5 <[email protected]>
Cargo.toml | 4 ++
examples/wpe_ctx.rs | 55 ++++++++++++++
src/main.rs | 204 ++++++++++++++++++++++++++++++++++++++++++++++++++++
src/wpe/host.rs | 65 +++++++++++++++++
src/wpe/mod.rs | 2 +-
5 files changed, 329 insertions(+), 1 deletion(-)
diff --git a/Cargo.toml b/Cargo.toml
index 9be486e..cdf799a 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -69,3 +69,7 @@ required-features = ["wpe"]
[[example]]
name = "wpe_paste"
required-features = ["wpe"]
+
+[[example]]
+name = "wpe_ctx"
+required-features = ["wpe"]
diff --git a/examples/wpe_ctx.rs b/examples/wpe_ctx.rs
new file mode 100644
index 0000000..6387b24
--- /dev/null
+++ b/examples/wpe_ctx.rs
@@ -0,0 +1,55 @@
+//! Right-click → context-menu signal → hit-test info, end to end.
+//! `cce-shadow --instance <n> run ./target/release/examples/wpe_ctx`
+
+#[cfg(not(feature = "wpe"))]
+fn main() { eprintln!("build with --features wpe"); }
+
+#[cfg(feature = "wpe")]
+#[derive(Debug, Clone, Copy)]
+pub enum EditingCommand { Copy, Cut, Paste }
+
+#[cfg(feature = "wpe")]
+#[path = "../src/pages.rs"]
+mod pages;
+#[cfg(feature = "wpe")]
+#[path = "../src/downloads.rs"]
+mod downloads;
+#[cfg(feature = "wpe")]
+#[path = "../src/wpe/mod.rs"]
+mod wpe;
+
+#[cfg(feature = "wpe")]
+fn main() {
+ use cce_ui::widget::MouseButton;
+ let url = "http://127.0.0.1:8795/ctx.html";
+ let mut host = wpe::WebKitHost::new(url::Url::parse(url).unwrap(), (1200, 800));
+ let settle = |h: &mut wpe::WebKitHost, n: u32| {
+ for _ in 0..n { h.pump(); std::thread::sleep(std::time::Duration::from_millis(50)); }
+ };
+ settle(&mut host, 30);
+ println!("loaded: {:?}", host.title());
+
+ let rclick = |h: &mut wpe::WebKitHost, x: f32, y: f32| {
+ h.mouse_move(x, y);
+ h.mouse_button_ui(MouseButton::Right, true, x, y);
+ h.mouse_button_ui(MouseButton::Right, false, x, y);
+ };
+
+ println!("-- right-click the link (150,40) --");
+ rclick(&mut host, 150.0, 40.0);
+ settle(&mut host, 8);
+ let on_link = host.take_context_menu();
+ println!(" {on_link:?}");
+
+ println!("-- right-click the input (150,175) --");
+ rclick(&mut host, 150.0, 175.0);
+ settle(&mut host, 8);
+ let on_input = host.take_context_menu();
+ println!(" {on_input:?}");
+
+ println!("\n=== RESULT ===");
+ let link_ok = on_link.as_ref().and_then(|i| i.link.as_ref())
+ .is_some_and(|(u, _)| u == "https://example.org/target");
+ println!(" link uri {}", if link_ok { "OK" } else { "MISSING" });
+ println!(" editable flag {}", if on_input.is_some_and(|i| i.is_editable) { "OK" } else { "MISSING" });
+}
diff --git a/src/main.rs b/src/main.rs
index e74f68b..9ef181a 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -169,6 +169,74 @@ impl Modal {
}
}
+/// The right-click menu, drawn by the chrome at the pointer.
+///
+/// Not modal: the page is not blocked (unlike a script dialog), so this only
+/// intercepts input for as long as it is open, and any click outside closes
+/// it and is otherwise swallowed.
+#[cfg(feature = "wpe")]
+struct CtxMenu {
+ items: Vec<CtxItem>,
+ /// Top-left corner, already clamped to the window.
+ pos: (f32, f32),
+}
+
+#[cfg(feature = "wpe")]
+struct CtxItem {
+ label: String,
+ action: CtxAction,
+ enabled: bool,
+}
+
+#[cfg(feature = "wpe")]
+enum CtxAction {
+ Back,
+ Forward,
+ Reload,
+ /// Copy the page's current selection (through the engine, so it lands on
+ /// the system clipboard via the clipboard bridge).
+ CopySelection,
+ Paste,
+ OpenInTab(String),
+ /// Put this text on the clipboard directly (link/image addresses).
+ CopyText(String),
+ /// Fetch through WebKit's download pipeline.
+ Download(String),
+ OpenExternal,
+}
+
+#[cfg(feature = "wpe")]
+const CTX_ROW_H: f32 = 24.0;
+#[cfg(feature = "wpe")]
+const CTX_W: f32 = 200.0;
+#[cfg(feature = "wpe")]
+const CTX_PAD: f32 = 6.0;
+
+#[cfg(feature = "wpe")]
+impl CtxMenu {
+ fn rect(&self) -> Rect {
+ Rect {
+ x: self.pos.0,
+ y: self.pos.1,
+ width: CTX_W,
+ height: CTX_PAD * 2.0 + self.items.len() as f32 * CTX_ROW_H,
+ }
+ }
+
+ fn row_rect(&self, i: usize) -> Rect {
+ Rect {
+ x: self.pos.0 + 2.0,
+ y: self.pos.1 + CTX_PAD + i as f32 * CTX_ROW_H,
+ width: CTX_W - 4.0,
+ height: CTX_ROW_H,
+ }
+ }
+
+ fn item_at(&self, x: f32, y: f32) -> Option<usize> {
+ (0..self.items.len()).find(|&i| hit(&self.row_rect(i), x, y))
+ }
+}
+
#[derive(Debug, Clone)]
pub enum Message {
/// Servo requested an event-loop spin (waker or delegate signal).
@@ -198,6 +266,9 @@ struct BrowserApp {
/// for them, which is why they were listed as "not implemented".
#[cfg(feature = "wpe")]
modal: Option<Modal>,
+ /// Open right-click menu, if any.
+ #[cfg(feature = "wpe")]
+ ctx_menu: Option<CtxMenu>,
/// Kept so the WPE backend's calloop sources can fire `Spin`; Servo
/// wakes the loop itself through its `EventLoopWaker`.
#[cfg(feature = "wpe")]
@@ -458,6 +529,71 @@ impl BrowserApp {
}
}
+ /// Build the right-click menu from what the hit test found, placed at
+ /// the pointer and clamped to the window.
+ #[cfg(feature = "wpe")]
+ fn open_ctx_menu(&mut self, info: wpe::ContextMenuInfo) {
+ let mut items = Vec::new();
+ let item = |label: &str, action: CtxAction, enabled: bool| CtxItem {
+ label: label.to_string(),
+ action,
+ enabled,
+ };
+ if let Some((uri, _label)) = info.link {
+ items.push(item("Open Link in New Tab", CtxAction::OpenInTab(uri.clone()), true));
+ items.push(item("Copy Link", CtxAction::CopyText(uri.clone()), true));
+ items.push(item("Download Link", CtxAction::Download(uri), true));
+ }
+ if let Some(uri) = info.image_uri {
+ items.push(item("Copy Image Address", CtxAction::CopyText(uri.clone()), true));
+ items.push(item("Download Image", CtxAction::Download(uri), true));
+ }
+ if info.is_selection {
+ items.push(item("Copy", CtxAction::CopySelection, true));
+ }
+ if info.is_editable {
+ items.push(item("Paste", CtxAction::Paste, true));
+ }
+ items.push(item("Back", CtxAction::Back, self.host.can_go_back()));
+ items.push(item("Forward", CtxAction::Forward, self.host.can_go_forward()));
+ items.push(item("Reload", CtxAction::Reload, true));
+ items.push(item("Open in Other Browser", CtxAction::OpenExternal, true));
+
+ let h = CTX_PAD * 2.0 + items.len() as f32 * CTX_ROW_H;
+ let pos = (
+ self.pointer.0.min(self.win.0 - CTX_W - 4.0).max(0.0),
+ self.pointer.1.min(self.win.1 - h - 4.0).max(0.0),
+ );
+ self.ctx_menu = Some(CtxMenu { items, pos });
+ }
+
+ #[cfg(feature = "wpe")]
+ fn dispatch_ctx_action(&mut self, index: usize) {
+ let Some(menu) = self.ctx_menu.take() else { return };
+ let Some(it) = menu.items.get(index) else { return };
+ if !it.enabled {
+ return;
+ }
+ match &it.action {
+ CtxAction::Back => self.host.back(),
+ CtxAction::Forward => self.host.forward(),
+ CtxAction::Reload => self.host.reload(),
+ CtxAction::CopySelection => self.host.editing_action_cmd(EditingCommand::Copy),
+ CtxAction::Paste => self.host.editing_action_cmd(EditingCommand::Paste),
+ CtxAction::OpenInTab(uri) => {
+ if let Ok(url) = Url::parse(uri) {
+ self.host.open_tab(url);
+ self.sync_page_state();
+ }
+ }
+ CtxAction::CopyText(text) => {
+ cce_ui::widget::clipboard::copy_to_clipboard(text);
+ }
+ CtxAction::Download(uri) => self.host.download_uri(uri),
+ CtxAction::OpenExternal => self.open_external(),
+ }
+ }
+
fn navigate(&mut self) {
if let Some(url) = parse_url_input(&self.url.text, &self.settings.search_prefix) {
self.host.load(url);
@@ -663,6 +799,36 @@ impl BrowserApp {
}
}
+ /// Draw the right-click menu: a small plate at the pointer, rows with a
+ /// hover highlight, disabled rows dimmed. Same primitives as everything
+ /// else in this chrome.
+ #[cfg(feature = "wpe")]
+ fn paint_ctx_menu(&mut self, pc: &mut PaintCtx, sans: &str) {
+ let Some(menu) = self.ctx_menu.as_ref() else { return };
+ let r = menu.rect();
+ pc.plate(
+ r,
+ (8.0, 8.0, 8.0, 8.0),
+ [0.13, 0.14, 0.16, 1.0],
+ cce_ui::layout::bevel_width().min(3.0),
+ );
+ let hovered = menu.item_at(self.pointer.0, self.pointer.1);
+ for (i, it) in menu.items.iter().enumerate() {
+ let row = menu.row_rect(i);
+ if hovered == Some(i) && it.enabled {
+ pc.rounded_rect(row, 5.0, (true, true, true, true), TAB_ACTIVE_BG);
+ }
+ let color = if it.enabled { TEXT } else { TEXT_DIM };
+ pc.text(
+ Self::fit_text(&it.label, sans, 13.0, row.width - 20.0),
+ row.x + 10.0,
+ cce_ui::layout::align_text_y(row.y, row.height, 13.0, 0.0),
+ 13.0,
+ color,
+ );
+ }
+ }
+
fn cursor_from_click(&mut self, click_x: f32, field: &Rect) -> usize {
let rel = click_x - field.x - URL_PAD_X;
// Boundary x offsets from the same shaped buffer the bar draws (font=None,
@@ -760,6 +926,8 @@ impl Application for BrowserApp {
#[cfg(feature = "wpe")]
modal: None,
#[cfg(feature = "wpe")]
+ ctx_menu: None,
+ #[cfg(feature = "wpe")]
sender,
font_system: cce_ui::create_font_system(),
}
@@ -830,6 +998,11 @@ impl Application for BrowserApp {
if self.sync_modal() {
*needs_rebuild = true;
}
+ #[cfg(feature = "wpe")]
+ if let Some(info) = self.host.take_context_menu() {
+ self.open_ctx_menu(info);
+ *needs_rebuild = true;
+ }
if self.host.take_download_started() {
self.open_internal_page("cce://downloads");
}
@@ -863,6 +1036,13 @@ impl Application for BrowserApp {
fn handle_pointer_move(&mut self, pos: LogicalPosition, _needs_rebuild: &mut bool) {
self.pointer = (pos.x, pos.y);
+ #[cfg(feature = "wpe")]
+ if self.ctx_menu.is_some() {
+ // Hover highlight tracks the pointer; the page underneath does
+ // not see moves while the menu is up.
+ *_needs_rebuild = true;
+ return;
+ }
if !hit(&self.bar(), pos.x, pos.y) {
let s = self.scale as f32;
self.host.mouse_move(pos.x * s, pos.y * s);
@@ -906,6 +1086,20 @@ impl Application for BrowserApp {
return None;
}
+ // An open context menu owns the next click: on an item it dispatches,
+ // anywhere else it just closes — either way the click goes no further.
+ #[cfg(feature = "wpe")]
+ if let Some(menu) = self.ctx_menu.as_ref() {
+ if pressed {
+ *needs_rebuild = true;
+ match (button, menu.item_at(pos.x, pos.y)) {
+ (MouseButton::Left, Some(i)) => self.dispatch_ctx_action(i),
+ _ => self.ctx_menu = None,
+ }
+ }
+ return None;
+ }
+
let bar = self.bar();
if hit(&bar, pos.x, pos.y) {
if !pressed || !matches!(button, MouseButton::Left | MouseButton::Middle) {
@@ -995,6 +1189,14 @@ impl Application for BrowserApp {
}
fn handle_key_input(&mut self, event: &KeyEvent, needs_rebuild: &mut bool) -> Option<Self::Message> {
+ #[cfg(feature = "wpe")]
+ if self.ctx_menu.is_some() && event.state == ElementState::Pressed {
+ // Any key dismisses; Escape is just the one people will mean.
+ self.ctx_menu = None;
+ *needs_rebuild = true;
+ return None;
+ }
+
// A modal is exactly that: the page is blocked inside WebKit, so the
// chrome's own chords must not fire behind it either.
#[cfg(feature = "wpe")]
@@ -1297,6 +1499,8 @@ impl Application for BrowserApp {
}
});
+ #[cfg(feature = "wpe")]
+ self.paint_ctx_menu(&mut pc, &sans);
#[cfg(feature = "wpe")]
self.paint_modal(&mut pc, &sans);
diff --git a/src/wpe/host.rs b/src/wpe/host.rs
index c3378fa..e618d71 100644
--- a/src/wpe/host.rs
+++ b/src/wpe/host.rs
@@ -315,6 +315,15 @@ impl WebKitHost {
on_authenticate as *const () as usize,
&self.prompts,
);
+ // Right-click reaches the page as button 3; if the page does not
+ // preventDefault, WebKit asks for a menu here. Returning TRUE
+ // claims presentation, so the chrome draws it.
+ connect_raw(
+ wv,
+ "context-menu",
+ on_context_menu as *const () as usize,
+ &self.prompts,
+ );
let (lw, lh) = self.logical_size();
wpe_view_resized(view, lw, lh);
wpe_view_set_visible(view, 1);
@@ -633,6 +642,22 @@ impl WebKitHost {
self.prompts.borrow().dialog.as_ref().map(|(_, d)| d.clone())
}
+ /// One-shot: the context menu the page just requested, if any. Taken
+ /// rather than cloned — the chrome opens it once, at the pointer.
+ pub fn take_context_menu(&self) -> Option<ContextMenuInfo> {
+ self.prompts.borrow_mut().context_menu.take()
+ }
+
+ /// Fetch `uri` through WebKit's download pipeline — same signals, same
+ /// store, same `cce://downloads` page as a navigated download. This is
+ /// what "Download Link/Image" in the context menu dispatches to.
+ pub fn download_uri(&self, uri: &str) {
+ unsafe {
+ let c = cstr(uri);
+ webkit_web_view_download_uri(self.active_tab().webview, c.as_ptr());
+ }
+ }
+
pub fn pending_auth(&self) -> Option<PendingAuth> {
self.prompts.borrow().auth.as_ref().map(|(_, a)| a.clone())
}
@@ -1057,6 +1082,19 @@ unsafe extern "C" fn on_failed(_d: *mut WebKitDownload, error: *mut GError, data
pub(super) struct Prompts {
dialog: Option<(*mut WebKitScriptDialog, PendingDialog)>,
auth: Option<(*mut WebKitAuthenticationRequest, PendingAuth)>,
+ /// The page asked for a context menu; the chrome draws its own.
+ context_menu: Option<ContextMenuInfo>,
+}
+
+/// What was under the pointer when the page asked for a context menu, read
+/// off WebKit's hit test. The chrome builds its menu from this.
+#[derive(Debug, Clone, Default)]
+pub struct ContextMenuInfo {
+ /// `(uri, label)` when the hit was a link.
+ pub link: Option<(String, Option<String>)>,
+ pub image_uri: Option<String>,
+ pub is_selection: bool,
+ pub is_editable: bool,
}
/// A page's `alert` / `confirm` / `prompt`, waiting on the chrome.
@@ -1140,3 +1178,30 @@ unsafe extern "C" fn on_authenticate(
prompts.borrow_mut().auth = Some((request, pending));
1
}
+
+/// The page asked for a context menu. Stash what the hit test says was under
+/// the pointer and claim presentation; the chrome draws the menu at the
+/// pointer position it already tracks (the hit test carries no coordinates).
+unsafe extern "C" fn on_context_menu(
+ _wv: *mut WebKitWebView,
+ _menu: *mut WebKitContextMenu,
+ hit: *mut WebKitHitTestResult,
+ data: gpointer,
+) -> gboolean {
+ let prompts = &*(data as *const RefCell<Prompts>);
+ let mut info = ContextMenuInfo::default();
+ if !hit.is_null() {
+ if webkit_hit_test_result_context_is_link(hit) != 0 {
+ if let Some(uri) = from_cstr(webkit_hit_test_result_get_link_uri(hit)) {
+ info.link = Some((uri, from_cstr(webkit_hit_test_result_get_link_label(hit))));
+ }
+ }
+ if webkit_hit_test_result_context_is_image(hit) != 0 {
+ info.image_uri = from_cstr(webkit_hit_test_result_get_image_uri(hit));
+ }
+ info.is_selection = webkit_hit_test_result_context_is_selection(hit) != 0;
+ info.is_editable = webkit_hit_test_result_context_is_editable(hit) != 0;
+ }
+ prompts.borrow_mut().context_menu = Some(info);
+ 1
+}
diff --git a/src/wpe/mod.rs b/src/wpe/mod.rs
index 07c6756..59405be 100644
--- a/src/wpe/mod.rs
+++ b/src/wpe/mod.rs
@@ -16,4 +16,4 @@ mod host;
// Not consumed yet — main.rs still drives ServoHost.
#[allow(unused_imports)]
-pub use host::{PendingAuth, PendingDialog, Tab, WebKitHost};
+pub use host::{ContextMenuInfo, PendingAuth, PendingDialog, Tab, WebKitHost};