feat: support middle-click paste in text fields (#60)

Selecting an nsec in another Linux application and middle-clicking Coop's identity import field currently does nothing. The shared input widget handles left and right clicks but never reads the primary selection for a middle click.

Add middle-click paste from PRIMARY on Linux and FreeBSD, inserting at the nearest visible caret to the click. Read the selection before changing focus, preserve disabled/read-only inputs, strip line breaks in single-line fields, and use the input state's atomic insertion path so Undo removes the paste. Also register Shift+Insert for standard clipboard paste on non-macOS platforms. The existing right-click editing menu and Ctrl+V/Cmd+V remain available.

Document the gestures in the README. Enable GPUI test support for the UI crate; the lockfile additions are its test dependencies.

Validation:

- `cargo check -p ui --locked`
- `cargo test -p ui --lib --locked` — all five tests pass, including empty-field paste, Unicode caret placement, primary/clipboard separation, Undo, and disabled/read-only protection
- `git -c core.whitespace=cr-at-eol diff --check` (preserves the existing CRLF Cargo manifest)

Validated with GPUI's simulated input and clipboard platform; no live X11/Wayland compositor test was performed.

Release Notes:

- Fixed middle-click paste in Linux text fields, including identity import, and added Shift+Insert clipboard paste.

Reviewed-on: #60
Co-authored-by: Vincent Bernardoff <vb@luminar.eu.org>
This commit was merged in pull request #60.
This commit is contained in:
2026-10-10 00:28:07 +00:00
committed by reya
parent 193df36dae
commit 4ef764984b
5 changed files with 357 additions and 9 deletions
+232
View File
@@ -13,6 +13,35 @@ use crate::indicator::Indicator;
use crate::input::clear_button;
use crate::{IconName, Selectable, Sizable, Size, StyleSized, StyledExt, h_flex, v_flex};
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
fn primary_paste_offset(
value: &str,
position: gpui::Point<Pixels>,
mut caret_bounds: impl FnMut(usize) -> Option<gpui::Bounds<Pixels>>,
) -> Option<usize> {
value
.char_indices()
.map(|(offset, _)| offset)
.chain(std::iter::once(value.len()))
.filter_map(|offset| {
let bounds = caret_bounds(offset)?;
let vertical_distance = if position.y < bounds.top() {
bounds.top() - position.y
} else if position.y > bounds.bottom() {
position.y - bounds.bottom()
} else {
px(0.)
};
Some((
offset,
vertical_distance,
(position.x - bounds.left()).abs(),
))
})
.min_by(|left, right| left.1.cmp(&right.1).then_with(|| left.2.cmp(&right.2)))
.map(|(offset, _, _)| offset)
}
/// The background of an input frame, which reads muted while the input is disabled.
fn input_background(disabled: bool, cx: &App) -> Hsla {
if disabled {
@@ -225,6 +254,45 @@ impl<M: InputModeKind> RenderOnce for Input<M> {
let state_entity = state_entity.clone();
move |_, window, cx| state_entity.update(cx, |state, cx| state.focus(window, cx))
})
.when(
cfg!(any(target_os = "linux", target_os = "freebsd")),
|this| {
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
let this = this.on_mouse_down(MouseButton::Middle, {
let state_entity = state_entity.clone();
move |event, window, cx| {
if disabled || !state_entity.read(cx).is_editable() {
return;
}
// Read before changing focus or selection: those can change PRIMARY.
let Some(text) = cx.read_from_primary().and_then(|item| item.text())
else {
return;
};
state_entity.update(cx, |state, cx| {
let value = state.value();
let Some(offset) =
primary_paste_offset(&value, event.position, |offset| {
state.range_to_bounds(&(offset..offset))
})
else {
return;
};
let text = if state.is_multi_line() {
text
} else {
text.replace(['\r', '\n'], "")
};
state.focus(window, cx);
state.set_selected_range(offset..offset, cx);
state.insert(text, window, cx);
});
cx.stop_propagation();
}
});
this
},
)
.items_center()
.when(multi_line, |this| {
this.h_auto()
@@ -273,3 +341,167 @@ impl<M: InputModeKind> RenderOnce for Input<M> {
})
}
}
#[cfg(all(test, any(target_os = "linux", target_os = "freebsd")))]
mod tests {
use super::*;
use crate::input::InputState;
use gpui::{AppContext, ClipboardItem, Context, Render, TestAppContext, point};
struct TestInput {
input: Entity<InputState>,
disabled: bool,
}
impl Render for TestInput {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
div()
.w(px(300.))
.h(px(40.))
.child(Input::new(&self.input).disabled(self.disabled))
}
}
#[gpui::test]
fn middle_click_pastes_primary_at_clicked_position(cx: &mut TestAppContext) {
cx.update(|cx| {
theme::init(cx);
crate::init(cx);
});
let (view, cx) = cx.add_window_view(|window, cx| TestInput {
input: cx.new(|cx| InputState::new(window, cx).default_value("aéz")),
disabled: false,
});
let input = view.read_with(cx, |view, _| view.input.clone());
cx.update(|window, cx| {
window.draw(cx).clear(cx);
cx.write_to_clipboard(ClipboardItem::new_string("wrong clipboard".into()));
cx.write_to_primary(ClipboardItem::new_string("nsec1test\r\n".into()));
});
let position = input.read_with(cx, |state, _| {
state.range_to_bounds(&(3..3)).map(|bounds| bounds.center())
});
assert!(position.is_some());
if let Some(position) = position {
cx.simulate_mouse_down(position, MouseButton::Middle, Default::default());
cx.simulate_mouse_up(position, MouseButton::Middle, Default::default());
}
assert_eq!(
input.read_with(cx, |state, _| state.value()),
"aénsec1testz"
);
cx.update(|window, cx| {
window.dispatch_action(Box::new(gpui_base::input::Undo), cx);
});
assert_eq!(input.read_with(cx, |state, _| state.value()), "aéz");
cx.update(|window, cx| {
input.update(cx, |state, cx| state.set_value("", window, cx));
window.draw(cx).clear(cx);
});
cx.simulate_mouse_down(
point(px(200.), px(20.)),
MouseButton::Middle,
Default::default(),
);
cx.simulate_mouse_up(
point(px(200.), px(20.)),
MouseButton::Middle,
Default::default(),
);
assert_eq!(input.read_with(cx, |state, _| state.value()), "nsec1test");
}
#[gpui::test]
fn clipboard_shortcuts_paste_without_using_primary(cx: &mut TestAppContext) {
cx.update(|cx| {
theme::init(cx);
crate::init(cx);
});
let (view, cx) = cx.add_window_view(|window, cx| TestInput {
input: cx.new(|cx| InputState::new(window, cx)),
disabled: false,
});
let input = view.read_with(cx, |view, _| view.input.clone());
cx.update(|window, cx| {
input.update(cx, |state, cx| state.focus(window, cx));
window.draw(cx).clear(cx);
cx.write_to_clipboard(ClipboardItem::new_string("clipboard".into()));
cx.write_to_primary(ClipboardItem::new_string("primary".into()));
});
cx.simulate_keystrokes("shift-insert");
assert_eq!(input.read_with(cx, |state, _| state.value()), "clipboard");
cx.simulate_keystrokes("ctrl-a ctrl-v");
assert_eq!(input.read_with(cx, |state, _| state.value()), "clipboard");
}
#[test]
fn primary_paste_uses_visible_unicode_carets_and_clicked_row() {
let offset = primary_paste_offset("é\nx", point(px(8.), px(35.)), |offset| {
let (x, y) = match offset {
0 => return None,
2 => (10., 0.),
3 => (0., 20.),
4 => (10., 20.),
_ => panic!("offset must be a UTF-8 character boundary"),
};
Some(gpui::Bounds::new(
point(px(x), px(y)),
gpui::size(px(0.), px(20.)),
))
});
assert_eq!(offset, Some(4));
assert_eq!(
primary_paste_offset("text", point(px(0.), px(0.)), |_| None),
None
);
}
#[gpui::test]
fn middle_click_preserves_disabled_and_readonly_fields(cx: &mut TestAppContext) {
cx.update(|cx| {
theme::init(cx);
crate::init(cx);
});
let (view, cx) = cx.add_window_view(|window, cx| TestInput {
input: cx.new(|cx| InputState::new(window, cx).default_value("unchanged")),
disabled: true,
});
let input = view.read_with(cx, |view, _| view.input.clone());
cx.update(|window, cx| {
window.draw(cx).clear(cx);
cx.write_to_primary(ClipboardItem::new_string("must not paste".into()));
});
cx.simulate_mouse_down(
point(px(20.), px(20.)),
MouseButton::Middle,
Default::default(),
);
cx.simulate_mouse_up(
point(px(20.), px(20.)),
MouseButton::Middle,
Default::default(),
);
assert_eq!(input.read_with(cx, |state, _| state.value()), "unchanged");
cx.update(|window, cx| {
view.update(cx, |view, cx| {
view.disabled = false;
cx.notify();
});
input.update(cx, |state, cx| state.set_readonly(true, cx));
window.draw(cx).clear(cx);
});
cx.simulate_mouse_down(
point(px(20.), px(20.)),
MouseButton::Middle,
Default::default(),
);
cx.simulate_mouse_up(
point(px(20.), px(20.)),
MouseButton::Middle,
Default::default(),
);
assert_eq!(input.read_with(cx, |state, _| state.value()), "unchanged");
}
}