git.lucas.co / cce-browser
web browser (Servo)
git clone https://git.lucas.co/cce-browser.git

commit6ff53d5eb65d8378c6e3de1db7f28ccdbd9b62dd
parentf9748f9a81
authorLucas Galante <[email protected]>
date2026-08-26 21:02
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));
     }
 }