Skip to content

Commit 712e2db

Browse files
authored
fix: support Linux middle-click selection paste
Read PRIMARY on middle-click and insert it at the clicked caret without replacing another selection. Word, line, and Shift-click selections publish on press so a stationary move cannot shrink them.
1 parent 889a452 commit 712e2db

3 files changed

Lines changed: 109 additions & 12 deletions

File tree

‎crates/chatd/tests/slack.rs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2156,7 +2156,8 @@ fn info_mute_pins_files_emoji_threads_and_a_scheduled_send_ask_slack_once_each()
21562156
};
21572157
threads(&world);
21582158
let thread = format!("C0GEN:{ROOT}");
2159-
let followed = world.until("the followed thread", || world.conversation(&alpha(&thread)).filter(|row| row.followed));
2159+
// Filing and unread counts are committed separately; wait for both updates.
2160+
let followed = world.until("the followed thread and its unread count", || world.conversation(&alpha(&thread)).filter(|row| row.followed && row.unread == 1));
21602161
assert_eq!(followed.unread, 1, "one reply after the last read");
21612162
assert!(world.messages(&alpha(&thread)).iter().any(|message| message.text == "moved to 3pm"));
21622163
assert!(world.conversation(&alpha("C0ELSEWHERE:1760000100.000100")).is_none());

‎crates/tinyclient/src/gui/input.rs‎

Lines changed: 99 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,7 @@ impl TextInput {
427427
fn go(&mut self, motion: Motion, extend: bool, cx: &mut Context<Self>) {
428428
let rows = self.rows();
429429
self.editor.go(motion, extend, rows.as_ref());
430+
if extend { self.publish_primary(cx); }
430431
cx.notify();
431432
}
432433

@@ -585,6 +586,7 @@ impl TextInput {
585586

586587
fn select_all(&mut self, _: &SelectAll, _: &mut Window, cx: &mut Context<Self>) {
587588
self.editor.select_all();
589+
self.publish_primary(cx);
588590
cx.notify();
589591
}
590592

@@ -694,22 +696,38 @@ impl TextInput {
694696
}
695697
}
696698

697-
fn on_mouse_down(&mut self, event: &MouseDownEvent, _: &mut Window, cx: &mut Context<Self>) {
699+
/// Linux PRIMARY is selected text, separate from the Copy/Paste clipboard.
700+
fn publish_primary(&self, _cx: &App) {
701+
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
702+
if !self.editor.selection().is_empty() {
703+
_cx.write_to_primary(ClipboardItem::new_string(self.editor.selected_text().to_owned()));
704+
}
705+
}
706+
707+
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
708+
fn on_middle_down(&mut self, event: &MouseDownEvent, window: &mut Window, cx: &mut Context<Self>) {
709+
// Read before moving the caret: the selected text may belong to this field.
710+
let text = cx.read_from_primary().and_then(|item| item.text());
698711
let offset = self.index_for_mouse_position(event.position);
699-
match (event.modifiers.shift, event.click_count) {
700-
(true, _) => self.editor.select_to(offset),
701-
(false, 2) => self.editor.select_word_at(offset),
702-
(false, 3..) => self.editor.select_line_at(offset),
703-
(false, _) => {
704-
self.is_selecting = true;
705-
self.editor.place(offset);
706-
}
712+
window.focus(&self.focus_handle(cx), cx);
713+
self.is_selecting = false;
714+
if paste_primary_text(&mut self.editor, text.as_deref(), offset) {
715+
self.edited(cx);
707716
}
717+
cx.stop_propagation();
718+
}
719+
720+
fn on_mouse_down(&mut self, event: &MouseDownEvent, _: &mut Window, cx: &mut Context<Self>) {
721+
let offset = self.index_for_mouse_position(event.position);
722+
self.is_selecting = select_on_press(&mut self.editor, offset, event.modifiers.shift, event.click_count);
723+
if !self.is_selecting { self.publish_primary(cx); }
708724
cx.notify();
709725
}
710726

711-
fn on_mouse_up(&mut self, _: &MouseUpEvent, _: &mut Window, _: &mut Context<Self>) {
712-
self.is_selecting = false;
727+
fn on_mouse_up(&mut self, _: &MouseUpEvent, _: &mut Window, cx: &mut Context<Self>) {
728+
if std::mem::replace(&mut self.is_selecting, false) {
729+
self.publish_primary(cx);
730+
}
713731
}
714732

715733
fn on_mouse_move(&mut self, event: &MouseMoveEvent, _: &mut Window, cx: &mut Context<Self>) {
@@ -771,6 +789,26 @@ enum Pasted {
771789
Nothing,
772790
}
773791

792+
/// Only a plain click starts a drag. Word, line and shift selections are complete
793+
/// on press and must survive a move event at the same pointer position.
794+
fn select_on_press(editor: &mut Editor, offset: usize, shift: bool, clicks: usize) -> bool {
795+
match (shift, clicks) {
796+
(true, _) => editor.select_to(offset),
797+
(false, 2) => editor.select_word_at(offset),
798+
(false, 3..) => editor.select_line_at(offset),
799+
(false, _) => { editor.place(offset); return true; }
800+
}
801+
false
802+
}
803+
804+
#[cfg(any(target_os = "linux", target_os = "freebsd", test))]
805+
fn paste_primary_text(editor: &mut Editor, text: Option<&str>, offset: usize) -> bool {
806+
let Some(text) = text.filter(|text| !text.is_empty()) else { return false; };
807+
editor.place(offset);
808+
editor.insert(text);
809+
true
810+
}
811+
774812
fn pasted(entries: &[ClipboardEntry]) -> Pasted {
775813
let paths: Vec<std::path::PathBuf> = entries
776814
.iter()
@@ -1237,6 +1275,11 @@ impl Render for TextInput {
12371275
.on_action(cx.listener(Self::escape))
12381276
.on_action(cx.listener(Self::tab))
12391277
.on_mouse_down(MouseButton::Left, cx.listener(Self::on_mouse_down))
1278+
.map(|input| {
1279+
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
1280+
let input = input.on_mouse_down(MouseButton::Middle, cx.listener(Self::on_middle_down));
1281+
input
1282+
})
12401283
.on_mouse_up(MouseButton::Left, cx.listener(Self::on_mouse_up))
12411284
.on_mouse_up_out(MouseButton::Left, cx.listener(Self::on_mouse_up))
12421285
.on_mouse_move(cx.listener(Self::on_mouse_move))
@@ -1257,6 +1300,51 @@ mod tests {
12571300
use gpui::{ClipboardEntry, ClipboardString, ExternalPaths, Image, ImageFormat};
12581301
use std::path::PathBuf;
12591302

1303+
#[test]
1304+
fn word_line_and_shift_selections_survive_a_stationary_move() {
1305+
for (clicks, shift, expected) in [(2, false, "second"), (3, false, "first second third"), (1, true, "second thi")] {
1306+
let mut editor = super::Editor::new(true);
1307+
editor.set_text("first second third");
1308+
editor.place(16);
1309+
let dragging = super::select_on_press(&mut editor, 6, shift, clicks);
1310+
if dragging { editor.select_to(6); }
1311+
assert!(!dragging);
1312+
assert_eq!(editor.selected_text(), expected);
1313+
}
1314+
let mut editor = super::Editor::new(true);
1315+
editor.set_text("first second");
1316+
assert!(super::select_on_press(&mut editor, 6, false, 1));
1317+
editor.select_to(12);
1318+
assert_eq!(editor.selected_text(), "second");
1319+
}
1320+
1321+
#[test]
1322+
fn middle_paste_inserts_at_the_click_without_replacing_selected_text() {
1323+
let mut editor = super::Editor::new(true);
1324+
editor.set_text("hello 世界");
1325+
editor.select_all();
1326+
assert!(super::paste_primary_text(&mut editor, Some("https://example.invalid "), 6));
1327+
assert_eq!(editor.text(), "hello https://example.invalid 世界");
1328+
assert!(editor.selection().is_empty());
1329+
editor.undo();
1330+
assert_eq!(editor.text(), "hello 世界");
1331+
}
1332+
1333+
#[test]
1334+
fn an_empty_primary_selection_does_not_move_or_replace_the_draft() {
1335+
let mut editor = super::Editor::new(true);
1336+
editor.set_text("draft");
1337+
editor.select_all();
1338+
for text in [None, Some("")] {
1339+
assert!(!super::paste_primary_text(&mut editor, text, 2));
1340+
assert_eq!(editor.text(), "draft");
1341+
assert_eq!(editor.selected_text(), "draft");
1342+
}
1343+
let mut search = super::Editor::new(false);
1344+
assert!(super::paste_primary_text(&mut search, Some("one\r\ntwo"), 0));
1345+
assert_eq!(search.text(), "one two");
1346+
}
1347+
12601348
#[test]
12611349
fn finder_files_come_first_then_a_picture_without_words_else_words() {
12621350
let words = ClipboardEntry::String(ClipboardString::new("trail.pdf".into()));

‎docs/design/polish/06-composer.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,14 @@ removing a file (Discord) and replacing a photo (Telegram) are P2.
169169
`deferred` absolute child so the list does not reflow. `Exited` clears it.
170170
- **Clipboard**: `read_from_clipboard()` entries are `String`, `Image` (with `ImageFormat`) and `ExternalPaths`; walk
171171
them in §2 order. Image bytes are written to `outbox/` on a background task; the chip keeps the path only.
172+
- **Linux selection paste**: middle-click inserts PRIMARY's text at the clicked
173+
position and focuses the field, without sending a message or replacing another
174+
selected range. PRIMARY is separate from the Copy/Paste clipboard; selecting
175+
text in a field publishes it for middle-click paste. Word, line, and Shift-click
176+
selections publish on press; only a plain click starts a drag and publishes
177+
on release, so a stationary move cannot shrink a word or line selection.
178+
An empty selection does
179+
nothing. This also works in single-line fields, which flatten pasted newlines.
172180
- **File panel**: `cx.prompt_for_paths` (files, multiple). **Progress**: bar width from the last `Upload`; no timer.
173181
- **Picker**: `deferred(anchored().position_mode(..).snap_to_window_with_margin(px(8.)))` gives shift; flip and shrink
174182
are computed from the trigger bounds before layout. Grid = `uniform_list` of rows (9 emoji / 1 justified GIF row / 4

0 commit comments

Comments
 (0)