chore: refactor middle-click paste

This commit is contained in:
2026-10-10 08:10:57 +07:00
parent 602a91b4f0
commit 4b6f4b3797
4 changed files with 52 additions and 325 deletions
Generated
+9 -108
View File
@@ -674,30 +674,15 @@ dependencies = [
"unicode-normalization",
]
[[package]]
name = "bit-set"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
dependencies = [
"bit-vec 0.8.0",
]
[[package]]
name = "bit-set"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34ddef2995421ab6a5c779542c81ee77c115206f4ad9d5a8e05f4ff49716a3dd"
dependencies = [
"bit-vec 0.9.1",
"bit-vec",
]
[[package]]
name = "bit-vec"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
[[package]]
name = "bit-vec"
version = "0.9.1"
@@ -1377,15 +1362,6 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "convert_case"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49"
dependencies = [
"unicode-segmentation",
]
[[package]]
name = "cookie"
version = "0.18.2"
@@ -1822,7 +1798,7 @@ version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
dependencies = [
"convert_case 0.10.0",
"convert_case",
"proc-macro2",
"quote",
"rustc_version",
@@ -2782,7 +2758,6 @@ dependencies = [
"pollster 0.4.0",
"postage",
"profiling",
"proptest",
"rand 0.9.5",
"raw-window-handle",
"regex",
@@ -3800,7 +3775,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
dependencies = [
"byteorder-lite",
"quick-error 2.0.1",
"quick-error",
]
[[package]]
@@ -4488,7 +4463,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2bf919621e7975acb27d881bae2fb993e0d45c8e0446e85e6272971e00dc8df"
dependencies = [
"arrayvec",
"bit-set 0.9.1",
"bit-set",
"bitflags 2.13.2",
"cfg-if",
"cfg_aliases",
@@ -5839,38 +5814,6 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "proptest"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
dependencies = [
"bit-set 0.8.0",
"bit-vec 0.8.0",
"bitflags 2.13.2",
"num-traits",
"proptest-macro",
"rand 0.9.5",
"rand_chacha 0.9.0",
"rand_xorshift",
"regex-syntax",
"rusty-fork",
"tempfile",
"unarray",
]
[[package]]
name = "proptest-macro"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "efaa288b896cb2b345da7b7f2110ab19e51565b83495b56fcec98a62f8b1f33e"
dependencies = [
"convert_case 0.11.0",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "pulp"
version = "0.22.3"
@@ -5909,12 +5852,6 @@ dependencies = [
"bytemuck",
]
[[package]]
name = "quick-error"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
[[package]]
name = "quick-error"
version = "2.0.1"
@@ -6092,15 +6029,6 @@ dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "rand_xorshift"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
dependencies = [
"rand_core 0.9.5",
]
[[package]]
name = "range-alloc"
version = "0.1.5"
@@ -6157,7 +6085,7 @@ dependencies = [
"avif-serialize",
"imgref",
"loop9",
"quick-error 2.0.1",
"quick-error",
"rav1e",
"rayon",
"rgb",
@@ -6584,18 +6512,6 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "rusty-fork"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2"
dependencies = [
"fnv",
"quick-error 1.2.3",
"tempfile",
"wait-timeout",
]
[[package]]
name = "rustybuzz"
version = "0.20.1"
@@ -7587,7 +7503,7 @@ dependencies = [
"fax",
"flate2",
"half",
"quick-error 2.0.1",
"quick-error",
"weezl",
"zune-jpeg",
]
@@ -8048,12 +7964,6 @@ dependencies = [
"uuid",
]
[[package]]
name = "unarray"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
[[package]]
name = "unicase"
version = "2.10.0"
@@ -8350,15 +8260,6 @@ dependencies = [
"libc",
]
[[package]]
name = "wait-timeout"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
dependencies = [
"libc",
]
[[package]]
name = "waker-fn"
version = "1.2.0"
@@ -8685,8 +8586,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f519832254e56965a9940c4af57dcb75f702b6f6fa4a0b172f685395843a4d7"
dependencies = [
"arrayvec",
"bit-set 0.9.1",
"bit-vec 0.9.1",
"bit-set",
"bit-vec",
"bitflags 2.13.2",
"bytemuck",
"cfg_aliases",
@@ -8757,7 +8658,7 @@ dependencies = [
"android_system_properties",
"arrayvec",
"ash",
"bit-set 0.9.1",
"bit-set",
"bitflags 2.13.2",
"block2 0.6.2",
"bytemuck",
-8
View File
@@ -1,13 +1,5 @@
Coop is a simple, fast, and reliable nostr client for secure messaging across all platforms.
### Pasting into text fields
Click a field and use Ctrl+V (Cmd+V on macOS), or right-click and choose Paste.
Shift+Insert also pastes from the clipboard on Linux and Windows. On Linux,
middle-click inserts the primary selection at the clicked position: select text
in another application first, then middle-click the field. This also works in
the identity import dialog.
### License
Copyright (C) 2025 Ren Amamiya & other Coop contributors
-3
View File
@@ -14,6 +14,3 @@ smallvec.workspace = true
log.workspace = true
uuid = "1.10"
[dev-dependencies]
gpui = { workspace = true, features = ["test-support"] }
+43 -206
View File
@@ -13,7 +13,7 @@ use crate::input::clear_button;
use crate::{Sizable, Size, StyleSized, StyledExt, h_flex, v_flex};
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
fn primary_paste_offset(
fn nearest_caret_offset(
value: &str,
position: gpui::Point<Pixels>,
mut caret_bounds: impl FnMut(usize) -> Option<gpui::Bounds<Pixels>>,
@@ -41,6 +41,38 @@ fn primary_paste_offset(
.map(|(offset, _, _)| offset)
}
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
fn paste_from_primary<M: InputModeKind>(
state: &Entity<InputBaseState<M>>,
position: gpui::Point<Pixels>,
window: &mut Window,
cx: &mut App,
) {
if !state.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.update(cx, |state, cx| {
let Some(offset) = nearest_caret_offset(&state.value(), 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();
}
/// The background of an input frame, which reads muted while the input is disabled.
fn input_background(disabled: bool, cx: &App) -> Hsla {
if disabled {
@@ -182,7 +214,7 @@ impl<M: InputModeKind> RenderOnce for Input<M> {
let state_entity = self.state.clone();
InputBase::new(("input", self.state.entity_id()))
let input = InputBase::new(("input", self.state.entity_id()))
.flex()
.size_full()
.line_height(LINE_HEIGHT)
@@ -194,46 +226,15 @@ impl<M: InputModeKind> RenderOnce for Input<M> {
.on_mouse_down(MouseButton::Left, {
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
},
)
});
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
let input = input.on_mouse_down(MouseButton::Middle, {
let state_entity = state_entity.clone();
move |event, window, cx| paste_from_primary(&state_entity, event.position, window, cx)
});
input
.items_center()
.when(multi_line, |this| this.h_auto())
.when(self.appearance, |this| {
@@ -274,167 +275,3 @@ 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");
}
}