返回 CodeWhale
primary_tests.rs
根目录 / crates / tui / src / tui / mouse_ui / primary_tests.rs
1 use super::tests::create_test_app;
2 use super::*;
3 use crate::tui::clipboard::ClipboardHandler;
4 use crate::tui::history::HistoryCell;
5 use crate::tui::selection::TranscriptSelectionPoint;
6
7 fn composer() -> (App, Rect) {
8 let mut app = create_test_app();
9 app.launch.visible = false;
10 app.clipboard = ClipboardHandler::for_test(false, false);
11 app.clipboard.enable_primary_for_test();
12 app.clipboard.write_text("REGULAR_CLIPBOARD").unwrap();
13 app.viewport.last_composer_area = Some(Rect::new(0, 20, 80, 3));
14 let input = Rect::new(1, 21, 78, 1);
15 app.viewport.last_composer_content = Some(input);
16 let text = crate::tui::widgets::composer_content_geometry(input, false).text_area;
17 (app, text)
18 }
19
20 fn event(kind: MouseEventKind, column: u16, row: u16) -> MouseEvent {
21 MouseEvent {
22 kind,
23 column,
24 row,
25 modifiers: KeyModifiers::NONE,
26 }
27 }
28
29 #[test]
30 fn composer_selection_publishes_primary_quietly_even_when_released_outside() {
31 let (mut app, text) = composer();
32 app.input = "alpha beta".into();
33 handle_mouse_event(
34 &mut app,
35 event(MouseEventKind::Down(MouseButton::Left), text.x, text.y),
36 );
37 handle_mouse_event(
38 &mut app,
39 event(MouseEventKind::Drag(MouseButton::Left), text.x + 5, text.y),
40 );
41 let status = app.status_message.clone();
42 handle_mouse_event(
43 &mut app,
44 event(MouseEventKind::Up(MouseButton::Left), 90, 25),
45 );
46 assert_eq!(app.clipboard.read_primary_text().as_deref(), Some("alpha"));
47 assert_eq!(app.selected_text(), "alpha");
48 assert_eq!(app.clipboard.last_written_text(), Some("REGULAR_CLIPBOARD"));
49 assert_eq!(app.status_message, status);
50 copy_active_selection(&mut app);
51 assert_eq!(app.clipboard.last_written_text(), Some("alpha"));
52 assert_eq!(app.clipboard.read_primary_text().as_deref(), Some("alpha"));
53 }
54
55 #[test]
56 fn transcript_selection_uses_clean_text_and_preserves_regular_clipboard() {
57 let (mut app, _) = composer();
58 app.history = vec![HistoryCell::User {
59 content: "COPY_TRANSCRIPT".into(),
60 }];
61 app.resync_history_revisions();
62 app.viewport.transcript_cache.ensure(
63 &app.history,
64 &app.history_revisions,
65 80,
66 app.transcript_render_options(),
67 );
68 let last = app
69 .viewport
70 .transcript_cache
71 .lines()
72 .len()
73 .saturating_sub(1);
74 app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {
75 line_index: 0,
76 column: 0,
77 });
78 app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {
79 line_index: last,
80 column: 80,
81 });
82 app.viewport.transcript_selection.dragging = true;
83 let expected = selection_to_text(&app).unwrap();
84 let status = app.status_message.clone();
85 handle_mouse_event(
86 &mut app,
87 event(MouseEventKind::Up(MouseButton::Left), 90, 10),
88 );
89 assert_eq!(app.clipboard.read_primary_text(), Some(expected));
90 assert_eq!(app.clipboard.last_written_text(), Some("REGULAR_CLIPBOARD"));
91 assert_eq!(app.status_message, status);
92 assert!(!app.viewport.transcript_selection.dragging);
93 }
94
95 #[test]
96 fn middle_click_inserts_guarded_text_at_pointer_without_cutting_or_submitting() {
97 let (mut app, text) = composer();
98 app.input = "abc".into();
99 app.selection_anchor = Some(0);
100 app.cursor_position = 3;
101 app.clipboard.write_primary_text("X\r\n/quit\n").unwrap();
102 handle_mouse_event(
103 &mut app,
104 event(
105 MouseEventKind::Down(MouseButton::Middle),
106 text.x + 1,
107 text.y,
108 ),
109 );
110 assert_eq!(app.input, "aX\n/quit\nbc");
111 assert!(app.selection_anchor.is_none());
112 assert!(app.pending_composer_submit.is_none());
113 assert!(app.pending_launch_action.is_none());
114 assert_eq!(app.clipboard.last_written_text(), Some("REGULAR_CLIPBOARD"));
115 handle_mouse_event(
116 &mut app,
117 event(MouseEventKind::Up(MouseButton::Middle), text.x + 1, text.y),
118 );
119 assert_eq!(app.input, "aX\n/quit\nbc", "release must not paste twice");
120 }
121
122 #[test]
123 fn middle_click_respects_overlays_missing_composer_and_remote_clipboard() {
124 for mode in ["modal", "disabled", "ssh", "unavailable", "other-platform"] {
125 let (mut app, text) = composer();
126 app.input = "KEEP_DRAFT".into();
127 app.cursor_position = 4;
128 app.selection_anchor = Some(1);
129 app.clipboard
130 .write_primary_text("SHOULD_NOT_PASTE")
131 .unwrap();
132 match mode {
133 "modal" => {
134 super::open_context_menu(
135 &mut app,
136 event(MouseEventKind::Down(MouseButton::Right), text.x, text.y),
137 );
138 }
139 "disabled" => app.viewport.last_composer_area = None,
140 "ssh" => {
141 app.clipboard = ClipboardHandler::for_test(true, false);
142 app.clipboard.enable_primary_for_test();
143 }
144 "unavailable" => {
145 app.clipboard = ClipboardHandler::unavailable_for_test(false);
146 app.clipboard.enable_primary_for_test();
147 }
148 _ => app.clipboard = ClipboardHandler::for_test(false, false),
149 }
150 let status = app.status_message.clone();
151 handle_mouse_event(
152 &mut app,
153 event(
154 MouseEventKind::Down(MouseButton::Middle),
155 text.x + 1,
156 text.y,
157 ),
158 );
159 assert_eq!(app.input, "KEEP_DRAFT", "{mode}");
160 assert_eq!(app.cursor_position, 4, "{mode}");
161 assert_eq!(app.selection_anchor, Some(1), "{mode}");
162 assert!(app.pending_composer_submit.is_none(), "{mode}");
163 assert_eq!(app.status_message, status, "{mode}");
164 }
165 }
166
167 #[test]
168 fn transcript_drag_released_over_composer_keeps_caret_and_clears_drag() {
169 let (mut app, text) = composer();
170 app.input = "draft".into();
171 app.cursor_position = 5;
172 app.history = vec![HistoryCell::User {
173 content: "SELECTED_LINE".into(),
174 }];
175 app.resync_history_revisions();
176 app.viewport.transcript_cache.ensure(
177 &app.history,
178 &app.history_revisions,
179 80,
180 app.transcript_render_options(),
181 );
182 app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {
183 line_index: 0,
184 column: 0,
185 });
186 app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {
187 line_index: 0,
188 column: 80,
189 });
190 app.viewport.transcript_selection.dragging = true;
191 let expected = selection_to_text(&app).unwrap();
192 // Dragging across the composer must not move its caret or start a
193 // composer selection.
194 handle_mouse_event(
195 &mut app,
196 event(MouseEventKind::Drag(MouseButton::Left), text.x + 2, text.y),
197 );
198 assert_eq!(app.cursor_position, 5);
199 assert!(app.selection_anchor.is_none());
200 assert!(app.viewport.transcript_selection.dragging);
201 // Releasing over the composer finishes the transcript drag.
202 handle_mouse_event(
203 &mut app,
204 event(MouseEventKind::Up(MouseButton::Left), text.x + 2, text.y),
205 );
206 assert!(!app.viewport.transcript_selection.dragging);
207 assert_eq!(app.cursor_position, 5);
208 assert_eq!(app.input, "draft");
209 assert_eq!(app.clipboard.read_primary_text(), Some(expected));
210 assert_eq!(app.clipboard.last_written_text(), Some("REGULAR_CLIPBOARD"));
211 }
212
213 fn select_all_transcript_lines(app: &mut App) {
214 let last = app
215 .viewport
216 .transcript_cache
217 .lines()
218 .len()
219 .saturating_sub(1);
220 app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {
221 line_index: 0,
222 column: 0,
223 });
224 app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {
225 line_index: last,
226 column: 80,
227 });
228 }
229
230 fn ensure_transcript(app: &mut App) {
231 app.resync_history_revisions();
232 app.viewport.transcript_cache.ensure(
233 &app.history,
234 &app.history_revisions,
235 80,
236 app.transcript_render_options(),
237 );
238 }
239
240 #[test]
241 fn transcript_copy_defaults_to_markdown_source_and_names_cell_count() {
242 let (mut app, _) = composer();
243 assert!(
244 app.viewport.selection_copy_markdown,
245 "markdown copy defaults on"
246 );
247 let content = "# Title\n\n```rust\nlet x = 1;\n```".to_string();
248 app.history = vec![HistoryCell::Assistant {
249 content: content.clone(),
250 streaming: false,
251 }];
252 ensure_transcript(&mut app);
253 select_all_transcript_lines(&mut app);
254 let toasts_before = app.status_toasts.len();
255 copy_active_selection(&mut app);
256 assert_eq!(app.clipboard.last_written_text(), Some(content.as_str()));
257 assert_eq!(app.status_toasts.len(), toasts_before + 1);
258 let toast = app.status_toasts.back().expect("markdown copy toast");
259 assert!(
260 toast.text.contains("(1)"),
261 "toast names the projected cell count, got {:?}",
262 toast.text
263 );
264 assert!(
265 app.status_message.is_none(),
266 "toast path must not touch the legacy status sink"
267 );
268 }
269
270 #[test]
271 fn transcript_copy_falls_back_to_rendered_text_when_markdown_is_off() {
272 let (mut app, _) = composer();
273 app.viewport.selection_copy_markdown = false;
274 app.history = vec![HistoryCell::Assistant {
275 content: "# Title\n\n```rust\nlet x = 1;\n```".to_string(),
276 streaming: false,
277 }];
278 ensure_transcript(&mut app);
279 select_all_transcript_lines(&mut app);
280 let expected = selection_to_text(&app).expect("rendered fallback text");
281 let toasts_before = app.status_toasts.len();
282 copy_active_selection(&mut app);
283 assert_eq!(app.clipboard.last_written_text(), Some(expected.as_str()));
284 assert_eq!(app.status_message.as_deref(), Some("Selection copied"));
285 assert_eq!(app.status_toasts.len(), toasts_before);
286 }
287
288 #[test]
289 fn transcript_copy_joins_cells_with_blank_lines_and_counts_them() {
290 let (mut app, _) = composer();
291 app.history = vec![
292 HistoryCell::User {
293 content: "first question".into(),
294 },
295 HistoryCell::Assistant {
296 content: "second answer".into(),
297 streaming: false,
298 },
299 ];
300 ensure_transcript(&mut app);
301 select_all_transcript_lines(&mut app);
302 // A partial drag still rounds out to whole cells.
303 app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {
304 line_index: 0,
305 column: 2,
306 });
307 let toasts_before = app.status_toasts.len();
308 copy_active_selection(&mut app);
309 assert_eq!(
310 app.clipboard.last_written_text(),
311 Some("first question\n\nsecond answer")
312 );
313 assert_eq!(app.status_toasts.len(), toasts_before + 1);
314 let toast = app.status_toasts.back().expect("multi-cell copy toast");
315 assert!(
316 toast.text.contains("(2)"),
317 "toast names the projected cell count, got {:?}",
318 toast.text
319 );
320 }
321
321 lines RUST