From 5c888f835730beb063cc920fa28ef9ab27c903ad Mon Sep 17 00:00:00 2001 From: Mollusk Date: Sat, 4 Jul 2026 01:25:01 -0400 Subject: [PATCH] context_input: middle-click pastes the X11 PRIMARY selection Joe (X11) reported that middle-click paste did nothing in the ticket and node-ID fields. iced's base text_input only binds Ctrl+V to the Standard (CLIPBOARD) selection and never reads PRIMARY or binds mouse button 2, so the "select text, middle-click to paste" workflow was dead. Add a Button::Middle branch to ContextInput::update that reads clipboard::Kind::Primary, sanitizes it, and pastes at the cursor (reusing the already-tested pure paste()). Factor the control-char stripping into a shared, unit-tested sanitize_clip() helper also used by the menu Paste, so a trailing newline on the PRIMARY selection is dropped. Respects `locked` so read-only display fields still reject paste. Co-Authored-By: Claude Opus 4.8 --- src/widget/context_input.rs | 66 +++++++++++++++++++++++++++++++++---- 1 file changed, 60 insertions(+), 6 deletions(-) diff --git a/src/widget/context_input.rs b/src/widget/context_input.rs index 914138d..ea5ca30 100644 --- a/src/widget/context_input.rs +++ b/src/widget/context_input.rs @@ -70,6 +70,13 @@ pub fn paste(value: &str, start: usize, end: usize, clip: &str) -> Edit { } } +/// Strip control characters (e.g. a trailing newline on an X11 PRIMARY +/// selection) from clipboard text before it is pasted. Shared by the +/// right-click menu Paste and the middle-click PRIMARY paste. +pub fn sanitize_clip(raw: &str) -> String { + raw.chars().filter(|c| !c.is_control()).collect() +} + pub fn select_all_range(value: &str) -> (usize, usize) { let value = text_input::Value::new(value); @@ -362,6 +369,48 @@ where return; } + // Middle-click pastes the X11 PRIMARY selection at the cursor. iced's + // base text_input only wires Ctrl+V to the Standard (CLIPBOARD) + // selection, so without this the common "select text, middle-click to + // paste" workflow does nothing on X11. + let middle_click_on_input = matches!( + event, + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Middle)) + ) && cursor.is_over(layout.bounds()); + + if middle_click_on_input && !self.locked { + let clip = sanitize_clip(&clipboard.read(clipboard::Kind::Primary).unwrap_or_default()); + + if !clip.is_empty() { + let value = text_input::Value::new(&self.value); + let input_state = tree.children[0] + .state + .downcast_mut::>(); + let (start, end) = match input_state.cursor().state(&value) { + text_input::cursor::State::Index(index) => { + let index = index.min(value.len()); + (index, index) + } + text_input::cursor::State::Selection { start, end } => { + normalized_range(&value, start, end) + } + }; + + let edit = paste(&self.value, start, end, &clip); + input_state.move_cursor_to(edit.cursor); + + if let Some(on_paste) = &self.on_paste { + shell.publish(on_paste.as_ref()(edit.value)); + } else if let Some(on_input) = &self.on_input { + shell.publish(on_input.as_ref()(edit.value)); + } + } + + shell.capture_event(); + shell.request_redraw(); + return; + } + Widget::update( &mut self.input, &mut tree.children[0], @@ -717,12 +766,7 @@ where } } MenuAction::Paste => { - let clip = clipboard - .read(clipboard::Kind::Standard) - .unwrap_or_default() - .chars() - .filter(|c| !c.is_control()) - .collect::(); + let clip = sanitize_clip(&clipboard.read(clipboard::Kind::Standard).unwrap_or_default()); let edit = paste(self.value, start, end, &clip); self.publish_paste(edit, shell); @@ -842,6 +886,16 @@ mod tests { assert_eq!(clip, None); } + #[test] + fn sanitize_clip_strips_control_chars_keeps_text() { + // An X11 PRIMARY selection commonly carries a trailing newline. + assert_eq!(sanitize_clip("pixelpassF1:abc\n"), "pixelpassF1:abc"); + assert_eq!(sanitize_clip("a\tb\r\nc"), "abc"); + // Non-control unicode is preserved. + assert_eq!(sanitize_clip("héllo🦀"), "héllo🦀"); + assert_eq!(sanitize_clip(""), ""); + } + #[test] fn paste_replaces_selection_or_inserts_at_cursor() { assert_eq!(