diff --git a/crates/chatd/tests/slack.rs b/crates/chatd/tests/slack.rs index 36249db..dbf4e4c 100644 --- a/crates/chatd/tests/slack.rs +++ b/crates/chatd/tests/slack.rs @@ -2156,7 +2156,8 @@ fn info_mute_pins_files_emoji_threads_and_a_scheduled_send_ask_slack_once_each() }; threads(&world); let thread = format!("C0GEN:{ROOT}"); - let followed = world.until("the followed thread", || world.conversation(&alpha(&thread)).filter(|row| row.followed)); + // Filing and unread counts are committed separately; wait for both updates. + let followed = world.until("the followed thread and its unread count", || world.conversation(&alpha(&thread)).filter(|row| row.followed && row.unread == 1)); assert_eq!(followed.unread, 1, "one reply after the last read"); assert!(world.messages(&alpha(&thread)).iter().any(|message| message.text == "moved to 3pm")); assert!(world.conversation(&alpha("C0ELSEWHERE:1760000100.000100")).is_none()); diff --git a/crates/tinyclient/src/gui/input.rs b/crates/tinyclient/src/gui/input.rs index b8c805b..b7f5acb 100644 --- a/crates/tinyclient/src/gui/input.rs +++ b/crates/tinyclient/src/gui/input.rs @@ -427,6 +427,7 @@ impl TextInput { fn go(&mut self, motion: Motion, extend: bool, cx: &mut Context) { let rows = self.rows(); self.editor.go(motion, extend, rows.as_ref()); + if extend { self.publish_primary(cx); } cx.notify(); } @@ -585,6 +586,7 @@ impl TextInput { fn select_all(&mut self, _: &SelectAll, _: &mut Window, cx: &mut Context) { self.editor.select_all(); + self.publish_primary(cx); cx.notify(); } @@ -694,22 +696,38 @@ impl TextInput { } } - fn on_mouse_down(&mut self, event: &MouseDownEvent, _: &mut Window, cx: &mut Context) { + /// Linux PRIMARY is selected text, separate from the Copy/Paste clipboard. + fn publish_primary(&self, _cx: &App) { + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + if !self.editor.selection().is_empty() { + _cx.write_to_primary(ClipboardItem::new_string(self.editor.selected_text().to_owned())); + } + } + + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + fn on_middle_down(&mut self, event: &MouseDownEvent, window: &mut Window, cx: &mut Context) { + // Read before moving the caret: the selected text may belong to this field. + let text = cx.read_from_primary().and_then(|item| item.text()); let offset = self.index_for_mouse_position(event.position); - match (event.modifiers.shift, event.click_count) { - (true, _) => self.editor.select_to(offset), - (false, 2) => self.editor.select_word_at(offset), - (false, 3..) => self.editor.select_line_at(offset), - (false, _) => { - self.is_selecting = true; - self.editor.place(offset); - } + window.focus(&self.focus_handle(cx), cx); + self.is_selecting = false; + if paste_primary_text(&mut self.editor, text.as_deref(), offset) { + self.edited(cx); } + cx.stop_propagation(); + } + + fn on_mouse_down(&mut self, event: &MouseDownEvent, _: &mut Window, cx: &mut Context) { + let offset = self.index_for_mouse_position(event.position); + self.is_selecting = select_on_press(&mut self.editor, offset, event.modifiers.shift, event.click_count); + if !self.is_selecting { self.publish_primary(cx); } cx.notify(); } - fn on_mouse_up(&mut self, _: &MouseUpEvent, _: &mut Window, _: &mut Context) { - self.is_selecting = false; + fn on_mouse_up(&mut self, _: &MouseUpEvent, _: &mut Window, cx: &mut Context) { + if std::mem::replace(&mut self.is_selecting, false) { + self.publish_primary(cx); + } } fn on_mouse_move(&mut self, event: &MouseMoveEvent, _: &mut Window, cx: &mut Context) { @@ -771,6 +789,26 @@ enum Pasted { Nothing, } +/// Only a plain click starts a drag. Word, line and shift selections are complete +/// on press and must survive a move event at the same pointer position. +fn select_on_press(editor: &mut Editor, offset: usize, shift: bool, clicks: usize) -> bool { + match (shift, clicks) { + (true, _) => editor.select_to(offset), + (false, 2) => editor.select_word_at(offset), + (false, 3..) => editor.select_line_at(offset), + (false, _) => { editor.place(offset); return true; } + } + false +} + +#[cfg(any(target_os = "linux", target_os = "freebsd", test))] +fn paste_primary_text(editor: &mut Editor, text: Option<&str>, offset: usize) -> bool { + let Some(text) = text.filter(|text| !text.is_empty()) else { return false; }; + editor.place(offset); + editor.insert(text); + true +} + fn pasted(entries: &[ClipboardEntry]) -> Pasted { let paths: Vec = entries .iter() @@ -1237,6 +1275,11 @@ impl Render for TextInput { .on_action(cx.listener(Self::escape)) .on_action(cx.listener(Self::tab)) .on_mouse_down(MouseButton::Left, cx.listener(Self::on_mouse_down)) + .map(|input| { + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + let input = input.on_mouse_down(MouseButton::Middle, cx.listener(Self::on_middle_down)); + input + }) .on_mouse_up(MouseButton::Left, cx.listener(Self::on_mouse_up)) .on_mouse_up_out(MouseButton::Left, cx.listener(Self::on_mouse_up)) .on_mouse_move(cx.listener(Self::on_mouse_move)) @@ -1257,6 +1300,51 @@ mod tests { use gpui::{ClipboardEntry, ClipboardString, ExternalPaths, Image, ImageFormat}; use std::path::PathBuf; + #[test] + fn word_line_and_shift_selections_survive_a_stationary_move() { + for (clicks, shift, expected) in [(2, false, "second"), (3, false, "first second third"), (1, true, "second thi")] { + let mut editor = super::Editor::new(true); + editor.set_text("first second third"); + editor.place(16); + let dragging = super::select_on_press(&mut editor, 6, shift, clicks); + if dragging { editor.select_to(6); } + assert!(!dragging); + assert_eq!(editor.selected_text(), expected); + } + let mut editor = super::Editor::new(true); + editor.set_text("first second"); + assert!(super::select_on_press(&mut editor, 6, false, 1)); + editor.select_to(12); + assert_eq!(editor.selected_text(), "second"); + } + + #[test] + fn middle_paste_inserts_at_the_click_without_replacing_selected_text() { + let mut editor = super::Editor::new(true); + editor.set_text("hello 世界"); + editor.select_all(); + assert!(super::paste_primary_text(&mut editor, Some("https://example.invalid "), 6)); + assert_eq!(editor.text(), "hello https://example.invalid 世界"); + assert!(editor.selection().is_empty()); + editor.undo(); + assert_eq!(editor.text(), "hello 世界"); + } + + #[test] + fn an_empty_primary_selection_does_not_move_or_replace_the_draft() { + let mut editor = super::Editor::new(true); + editor.set_text("draft"); + editor.select_all(); + for text in [None, Some("")] { + assert!(!super::paste_primary_text(&mut editor, text, 2)); + assert_eq!(editor.text(), "draft"); + assert_eq!(editor.selected_text(), "draft"); + } + let mut search = super::Editor::new(false); + assert!(super::paste_primary_text(&mut search, Some("one\r\ntwo"), 0)); + assert_eq!(search.text(), "one two"); + } + #[test] fn finder_files_come_first_then_a_picture_without_words_else_words() { let words = ClipboardEntry::String(ClipboardString::new("trail.pdf".into())); diff --git a/docs/design/polish/06-composer.md b/docs/design/polish/06-composer.md index 8559f5b..d0a8c0f 100644 --- a/docs/design/polish/06-composer.md +++ b/docs/design/polish/06-composer.md @@ -169,6 +169,14 @@ removing a file (Discord) and replacing a photo (Telegram) are P2. `deferred` absolute child so the list does not reflow. `Exited` clears it. - **Clipboard**: `read_from_clipboard()` entries are `String`, `Image` (with `ImageFormat`) and `ExternalPaths`; walk them in §2 order. Image bytes are written to `outbox/` on a background task; the chip keeps the path only. +- **Linux selection paste**: middle-click inserts PRIMARY's text at the clicked + position and focuses the field, without sending a message or replacing another + selected range. PRIMARY is separate from the Copy/Paste clipboard; selecting + text in a field publishes it for middle-click paste. Word, line, and Shift-click + selections publish on press; only a plain click starts a drag and publishes + on release, so a stationary move cannot shrink a word or line selection. + An empty selection does + nothing. This also works in single-line fields, which flatten pasted newlines. - **File panel**: `cx.prompt_for_paths` (files, multiple). **Progress**: bar width from the last `Upload`; no timer. - **Picker**: `deferred(anchored().position_mode(..).snap_to_window_with_margin(px(8.)))` gives shift; flip and shrink are computed from the trigger bounds before layout. Grid = `uniform_list` of rows (9 emoji / 1 justified GIF row / 4