web browser (Servo)
git clone https://git.lucas.co/cce-browser.git
feat: copy/cut/paste in the URL bar and in pages
Two surfaces, two mechanisms.
The URL bar is our own editor, so Ctrl+C/X/V work off the selection added
in 9b091fe and go through cce_ui's wl-copy/wl-paste helpers — the same
clipboard path as the rest of the DE. A paste is stripped of control
characters: the bar is one line, and text past a newline would sit where
the caret math cannot reach it.
Pages need Servo told twice. Servo has no built-in binding for the chords,
so the embedder translates them into InputEvent::EditingAction; and its
bundled arboard clipboard delegate puts nothing on the clipboard in this
embedding (verified: copy in a page, read the seat's clipboard, empty), so
the browser supplies a delegate routing through the same cce_ui helpers.
Fixed underneath: the key bridge built its KeyboardEvent with
from_state_and_key, which defaults the modifiers to EMPTY, so every chord
reached the page as a bare character — Ctrl+A typed a literal "a" into a
focused textarea instead of selecting it. Modifiers are now carried
through, which is also what makes select-all work ahead of a cut.
Shadow-verified end to end against a page with an input and a textarea,
reading the seat's clipboard back with wl-paste: page copy, page
select-all + cut, page paste; URL-bar copy of the current URL, paste over
a selection, and cut leaving the bar empty with the text on the clipboard.
Co-Authored-By: Claude Opus 5 <[email protected]>
src/main.rs | 47 +++++++++++++++++++++++++++++++++++++++++++-
src/webview.rs | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++++----
2 files changed, 104 insertions(+), 5 deletions(-)
diff --git a/src/main.rs b/src/main.rs
index 205acc3..4c06990 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -450,6 +450,13 @@ impl BrowserApp {
self.selection = (self.cursor > 0).then_some((0, self.cursor));
}
+ /// The selected substring, if a selection covers any text.
+ fn selected_text(&self) -> Option<String> {
+ self.selection
+ .filter(|&(a, b)| a < b && b <= self.url_input.len())
+ .map(|(a, b)| self.url_input[a..b].to_string())
+ }
+
/// Drop a selection, deleting its text first if it covers any. Returns
/// whether text was removed, so edits can treat "replace the selection"
/// and "act at the cursor" as one path.
@@ -514,6 +521,30 @@ impl BrowserApp {
self.selection = None;
}
"a" => self.select_all_url(),
+ "c" => {
+ if let Some(text) = self.selected_text() {
+ cce_ui::widget::clipboard::copy_to_clipboard(&text);
+ }
+ }
+ "x" => {
+ if let Some(text) = self.selected_text() {
+ cce_ui::widget::clipboard::copy_to_clipboard(&text);
+ self.take_selection();
+ }
+ }
+ "v" => {
+ if let Some(text) = cce_ui::widget::clipboard::read_from_clipboard() {
+ // The bar is one line: a multi-line paste would put
+ // text where the caret math cannot reach it.
+ let flat: String =
+ text.chars().filter(|c| !c.is_control()).collect();
+ if !flat.is_empty() {
+ self.take_selection();
+ self.url_input.insert_str(self.cursor, &flat);
+ self.cursor += flat.len();
+ }
+ }
+ }
_ => {}
},
_ => {
@@ -771,6 +802,16 @@ impl Application for BrowserApp {
if event.ctrl {
if let Key::Character(c) = &event.logical_key {
match c.as_str() {
+ // Page clipboard: Servo needs the chord as an
+ // editing action, not as the raw keystroke.
+ "c" | "x" | "v" => {
+ self.host.editing_action(match c.as_str() {
+ "c" => servo::EditingActionEvent::Copy,
+ "x" => servo::EditingActionEvent::Cut,
+ _ => servo::EditingActionEvent::Paste,
+ });
+ return None;
+ }
"l" => {
self.url_focused = true;
self.select_all_url();
@@ -792,7 +833,11 @@ impl Application for BrowserApp {
}
if let Some(k) = dom_key(&event.logical_key) {
- self.host.key(k, event.state == ElementState::Pressed);
+ let mut modifiers = servo::Modifiers::empty();
+ modifiers.set(servo::Modifiers::CONTROL, event.ctrl);
+ modifiers.set(servo::Modifiers::SHIFT, event.shift);
+ modifiers.set(servo::Modifiers::ALT, event.alt);
+ self.host.key(k, event.state == ElementState::Pressed, modifiers);
}
None
}
diff --git a/src/webview.rs b/src/webview.rs
index b1e074b..75c4273 100644
--- a/src/webview.rs
+++ b/src/webview.rs
@@ -20,8 +20,10 @@ use servo::{
CreateNewWebViewRequest, DeviceIntRect, DevicePoint, EventLoopWaker, InputEvent,
Key as DomKey, KeyState, KeyboardEvent, LoadStatus, MouseButton as DomMouseButton,
MouseButtonAction, MouseButtonEvent, MouseMoveEvent, NavigationRequest, RenderingContext,
- Servo, ServoBuilder, SoftwareRenderingContext, Theme, UserContentManager, WebView,
- WebViewBuilder, WebViewDelegate, WebViewId, WheelDelta, WheelEvent, WheelMode,
+ ClipboardDelegate, Code, EditingActionEvent, Location, Modifiers, Servo, ServoBuilder,
+ SoftwareRenderingContext, StringRequest, Theme,
+ UserContentManager, WebView, WebViewBuilder, WebViewDelegate, WebViewId, WheelDelta,
+ WheelEvent, WheelMode,
};
use servo::user_contents::UserStyleSheet;
use servo::protocol_handler::ProtocolRegistry;
@@ -117,6 +119,7 @@ impl WebViewDelegate for Delegate {
.builder(self.context.clone())
.delegate(delegate)
.user_content_manager(self.ucm.clone())
+ .clipboard_delegate(Rc::new(CceClipboard))
.build();
self.shared.pending_new.borrow_mut().push(webview);
self.shared.dirty.set(true);
@@ -165,6 +168,32 @@ const USER_CONTENT_SETTLE: std::time::Duration = std::time::Duration::from_milli
/// Second reload, after any accompanying scheme flip has certainly landed.
const SCHEME_SETTLE: std::time::Duration = std::time::Duration::from_millis(2500);
+/// Page clipboard, routed through the toolkit's wl-copy/wl-paste helpers.
+///
+/// Servo ships an arboard-backed delegate behind its default `clipboard`
+/// feature, but it lands nothing on the clipboard in this embedding —
+/// verified by copying in a page and reading the seat's clipboard back,
+/// which came up empty. Going through `cce_ui`'s helpers also keeps the
+/// browser on the same clipboard path as the rest of the DE.
+struct CceClipboard;
+
+impl ClipboardDelegate for CceClipboard {
+ fn get_text(&self, _webview: WebView, request: StringRequest) {
+ match cce_ui::widget::clipboard::read_from_clipboard() {
+ Some(text) => request.success(text),
+ None => request.failure("clipboard is empty".into()),
+ }
+ }
+
+ fn set_text(&self, _webview: WebView, new_contents: String) {
+ cce_ui::widget::clipboard::copy_to_clipboard(&new_contents);
+ }
+
+ fn clear(&self, _webview: WebView) {
+ cce_ui::widget::clipboard::copy_to_clipboard("");
+ }
+}
+
/// Wakes the calloop event loop from Servo's internal threads.
#[derive(Clone)]
struct Waker(calloop::channel::Sender<Message>);
@@ -368,6 +397,7 @@ impl ServoHost {
.url(url)
.delegate(self.delegate.clone())
.user_content_manager(self.ucm.clone())
+ .clipboard_delegate(Rc::new(CceClipboard))
.build();
webview.notify_theme_change(self.theme);
webview
@@ -628,11 +658,35 @@ impl ServoHost {
)));
}
- pub fn key(&self, key: DomKey, pressed: bool) {
+ /// Forward a key to the page, modifiers included.
+ ///
+ /// `from_state_and_key` defaults the modifiers to empty, which delivers
+ /// every chord to the page as a bare character — Ctrl+A typed a literal
+ /// "a" into a focused textarea rather than selecting its contents.
+ pub fn key(&self, key: DomKey, pressed: bool, modifiers: Modifiers) {
let state = if pressed { KeyState::Down } else { KeyState::Up };
+ let event = KeyboardEvent::new_without_event(
+ state,
+ key,
+ Code::Unidentified,
+ Location::Standard,
+ modifiers,
+ false,
+ false,
+ );
+ let _ = self
+ .active_tab()
+ .webview
+ .notify_input_event(InputEvent::Keyboard(event));
+ }
+
+ /// Clipboard action on the page. Servo has no built-in binding for the
+ /// chords — the embedder translates them and the engine then goes
+ /// through the clipboard delegate.
+ pub fn editing_action(&self, action: EditingActionEvent) {
let _ = self
.active_tab()
.webview
- .notify_input_event(InputEvent::Keyboard(KeyboardEvent::from_state_and_key(state, key)));
+ .notify_input_event(InputEvent::EditingAction(action));
}
}