1pub mod cursor_position;
2
3use cursor_position::{LineIndicatorFormat, UserCaretPosition};
4use editor::{
5 Anchor, Editor, MultiBufferSnapshot, RowHighlightOptions, SelectionEffects, ToOffset, ToPoint,
6 actions::Tab, scroll::Autoscroll,
7};
8use gpui::{
9 App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString, Styled,
10 Subscription, div, prelude::*,
11};
12use language::Buffer;
13use settings::Settings;
14use text::{Bias, Point};
15use theme::ActiveTheme;
16use ui::prelude::*;
17use util::paths::FILE_ROW_COLUMN_DELIMITER;
18use workspace::ModalView;
19
20pub fn init(cx: &mut App) {
21 LineIndicatorFormat::register(cx);
22 cx.observe_new(GoToLine::register).detach();
23}
24
25pub struct GoToLine {
26 line_editor: Entity<Editor>,
27 active_editor: Entity<Editor>,
28 current_text: SharedString,
29 prev_scroll_position: Option<gpui::Point<f32>>,
30 _subscriptions: Vec<Subscription>,
31}
32
33impl ModalView for GoToLine {}
34
35impl Focusable for GoToLine {
36 fn focus_handle(&self, cx: &App) -> FocusHandle {
37 self.line_editor.focus_handle(cx)
38 }
39}
40impl EventEmitter<DismissEvent> for GoToLine {}
41
42enum GoToLineRowHighlights {}
43
44impl GoToLine {
45 fn register(editor: &mut Editor, _window: Option<&mut Window>, cx: &mut Context<Editor>) {
46 let handle = cx.entity().downgrade();
47 editor
48 .register_action(move |_: &editor::actions::ToggleGoToLine, window, cx| {
49 let Some(editor_handle) = handle.upgrade() else {
50 return;
51 };
52 let Some(workspace) = editor_handle.read(cx).workspace() else {
53 return;
54 };
55 let editor = editor_handle.read(cx);
56 let Some((_, buffer, _)) = editor.active_excerpt(cx) else {
57 return;
58 };
59 workspace.update(cx, |workspace, cx| {
60 workspace.toggle_modal(window, cx, move |window, cx| {
61 GoToLine::new(editor_handle, buffer, window, cx)
62 });
63 })
64 })
65 .detach();
66 }
67
68 pub fn new(
69 active_editor: Entity<Editor>,
70 active_buffer: Entity<Buffer>,
71 window: &mut Window,
72 cx: &mut Context<Self>,
73 ) -> Self {
74 let (user_caret, last_line, scroll_position) = active_editor.update(cx, |editor, cx| {
75 let user_caret = UserCaretPosition::at_selection_end(
76 &editor.selections.last::<Point>(cx),
77 &editor.buffer().read(cx).snapshot(cx),
78 );
79
80 let snapshot = active_buffer.read(cx).snapshot();
81 let last_line = editor
82 .buffer()
83 .read(cx)
84 .excerpts_for_buffer(snapshot.remote_id(), cx)
85 .into_iter()
86 .map(move |(_, range)| text::ToPoint::to_point(&range.context.end, &snapshot).row)
87 .max()
88 .unwrap_or(0);
89
90 (user_caret, last_line, editor.scroll_position(cx))
91 });
92
93 let line = user_caret.line.get();
94 let column = user_caret.character.get();
95
96 let line_editor = cx.new(|cx| {
97 let mut editor = Editor::single_line(window, cx);
98 let editor_handle = cx.entity().downgrade();
99 editor
100 .register_action::<Tab>({
101 move |_, window, cx| {
102 let Some(editor) = editor_handle.upgrade() else {
103 return;
104 };
105 editor.update(cx, |editor, cx| {
106 if let Some(placeholder_text) = editor.placeholder_text()
107 && editor.text(cx).is_empty()
108 {
109 let placeholder_text = placeholder_text.to_string();
110 editor.set_text(placeholder_text, window, cx);
111 }
112 });
113 }
114 })
115 .detach();
116 editor.set_placeholder_text(format!("{line}{FILE_ROW_COLUMN_DELIMITER}{column}"), cx);
117 editor
118 });
119 let line_editor_change = cx.subscribe_in(&line_editor, window, Self::on_line_editor_event);
120
121 let current_text = format!(
122 "Current Line: {} of {} (column {})",
123 line,
124 last_line + 1,
125 column
126 );
127
128 Self {
129 line_editor,
130 active_editor,
131 current_text: current_text.into(),
132 prev_scroll_position: Some(scroll_position),
133 _subscriptions: vec![line_editor_change, cx.on_release_in(window, Self::release)],
134 }
135 }
136
137 fn release(&mut self, window: &mut Window, cx: &mut App) {
138 let scroll_position = self.prev_scroll_position.take();
139 self.active_editor.update(cx, |editor, cx| {
140 editor.clear_row_highlights::<GoToLineRowHighlights>();
141 if let Some(scroll_position) = scroll_position {
142 editor.set_scroll_position(scroll_position, window, cx);
143 }
144 cx.notify();
145 })
146 }
147
148 fn on_line_editor_event(
149 &mut self,
150 _: &Entity<Editor>,
151 event: &editor::EditorEvent,
152 _window: &mut Window,
153 cx: &mut Context<Self>,
154 ) {
155 match event {
156 editor::EditorEvent::Blurred => {
157 self.prev_scroll_position.take();
158 cx.emit(DismissEvent)
159 }
160 editor::EditorEvent::BufferEdited => self.highlight_current_line(cx),
161 _ => {}
162 }
163 }
164
165 fn highlight_current_line(&mut self, cx: &mut Context<Self>) {
166 self.active_editor.update(cx, |editor, cx| {
167 editor.clear_row_highlights::<GoToLineRowHighlights>();
168 let snapshot = editor.buffer().read(cx).snapshot(cx);
169 let Some(start) = self.anchor_from_query(&snapshot, cx) else {
170 return;
171 };
172 let mut start_point = start.to_point(&snapshot);
173 start_point.column = 0;
174 // Force non-empty range to ensure the line is highlighted.
175 let mut end_point = snapshot.clip_point(start_point + Point::new(0, 1), Bias::Left);
176 if start_point == end_point {
177 end_point = snapshot.clip_point(start_point + Point::new(1, 0), Bias::Left);
178 }
179
180 let end = snapshot.anchor_after(end_point);
181 editor.highlight_rows::<GoToLineRowHighlights>(
182 start..end,
183 cx.theme().colors().editor_highlighted_line_background,
184 RowHighlightOptions {
185 autoscroll: true,
186 ..Default::default()
187 },
188 cx,
189 );
190 editor.request_autoscroll(Autoscroll::center(), cx);
191 });
192 cx.notify();
193 }
194
195 fn anchor_from_query(
196 &self,
197 snapshot: &MultiBufferSnapshot,
198 cx: &Context<Editor>,
199 ) -> Option<Anchor> {
200 let (query_row, query_char) = self.line_and_char_from_query(cx)?;
201 let row = query_row.saturating_sub(1);
202 let character = query_char.unwrap_or(0).saturating_sub(1);
203
204 let start_offset = Point::new(row, 0).to_offset(snapshot);
205 const MAX_BYTES_IN_UTF_8: u32 = 4;
206 let max_end_offset = snapshot
207 .clip_point(
208 Point::new(row, character * MAX_BYTES_IN_UTF_8 + 1),
209 Bias::Right,
210 )
211 .to_offset(snapshot);
212
213 let mut chars_to_iterate = character;
214 let mut end_offset = start_offset;
215 'outer: for text_chunk in snapshot.text_for_range(start_offset..max_end_offset) {
216 let mut offset_increment = 0;
217 for c in text_chunk.chars() {
218 if chars_to_iterate == 0 {
219 end_offset += offset_increment;
220 break 'outer;
221 } else {
222 chars_to_iterate -= 1;
223 offset_increment += c.len_utf8();
224 }
225 }
226 end_offset += offset_increment;
227 }
228 Some(snapshot.anchor_before(snapshot.clip_offset(end_offset, Bias::Left)))
229 }
230
231 fn line_and_char_from_query(&self, cx: &App) -> Option<(u32, Option<u32>)> {
232 let input = self.line_editor.read(cx).text(cx);
233 let mut components = input
234 .splitn(2, FILE_ROW_COLUMN_DELIMITER)
235 .map(str::trim)
236 .fuse();
237 let row = components.next().and_then(|row| row.parse::<u32>().ok())?;
238 let column = components.next().and_then(|col| col.parse::<u32>().ok());
239 Some((row, column))
240 }
241
242 fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
243 cx.emit(DismissEvent);
244 }
245
246 fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
247 self.active_editor.update(cx, |editor, cx| {
248 let snapshot = editor.buffer().read(cx).snapshot(cx);
249 let Some(start) = self.anchor_from_query(&snapshot, cx) else {
250 return;
251 };
252 editor.change_selections(
253 SelectionEffects::scroll(Autoscroll::center()),
254 window,
255 cx,
256 |s| s.select_anchor_ranges([start..start]),
257 );
258 editor.focus_handle(cx).focus(window);
259 cx.notify()
260 });
261 self.prev_scroll_position.take();
262
263 cx.emit(DismissEvent);
264 }
265}
266
267impl Render for GoToLine {
268 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
269 let help_text = match self.line_and_char_from_query(cx) {
270 Some((line, Some(character))) => {
271 format!("Go to line {line}, character {character}").into()
272 }
273 Some((line, None)) => format!("Go to line {line}").into(),
274 None => self.current_text.clone(),
275 };
276
277 v_flex()
278 .w(rems(24.))
279 .elevation_2(cx)
280 .key_context("GoToLine")
281 .on_action(cx.listener(Self::cancel))
282 .on_action(cx.listener(Self::confirm))
283 .child(
284 div()
285 .border_b_1()
286 .border_color(cx.theme().colors().border_variant)
287 .px_2()
288 .py_1()
289 .child(self.line_editor.clone()),
290 )
291 .child(
292 h_flex()
293 .px_2()
294 .py_1()
295 .gap_1()
296 .child(Label::new(help_text).color(Color::Muted)),
297 )
298 }
299}
300
301#[cfg(test)]
302mod tests {
303 use super::*;
304 use cursor_position::{CursorPosition, SelectionStats, UserCaretPosition};
305 use editor::actions::{MoveRight, MoveToBeginning, SelectAll};
306 use gpui::{TestAppContext, VisualTestContext};
307 use indoc::indoc;
308 use project::{FakeFs, Project};
309 use serde_json::json;
310 use std::{num::NonZeroU32, sync::Arc, time::Duration};
311 use util::path;
312 use workspace::{AppState, Workspace};
313
314 #[gpui::test]
315 async fn test_go_to_line_view_row_highlights(cx: &mut TestAppContext) {
316 init_test(cx);
317 let fs = FakeFs::new(cx.executor());
318 fs.insert_tree(
319 path!("/dir"),
320 json!({
321 "a.rs": indoc!{"
322 struct SingleLine; // display line 0
323 // display line 1
324 struct MultiLine { // display line 2
325 field_1: i32, // display line 3
326 field_2: i32, // display line 4
327 } // display line 5
328 // display line 6
329 struct Another { // display line 7
330 field_1: i32, // display line 8
331 field_2: i32, // display line 9
332 field_3: i32, // display line 10
333 field_4: i32, // display line 11
334 } // display line 12
335 "}
336 }),
337 )
338 .await;
339
340 let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
341 let (workspace, cx) =
342 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
343 let worktree_id = workspace.update(cx, |workspace, cx| {
344 workspace.project().update(cx, |project, cx| {
345 project.worktrees(cx).next().unwrap().read(cx).id()
346 })
347 });
348 let _buffer = project
349 .update(cx, |project, cx| {
350 project.open_local_buffer(path!("/dir/a.rs"), cx)
351 })
352 .await
353 .unwrap();
354 let editor = workspace
355 .update_in(cx, |workspace, window, cx| {
356 workspace.open_path((worktree_id, "a.rs"), None, true, window, cx)
357 })
358 .await
359 .unwrap()
360 .downcast::<Editor>()
361 .unwrap();
362
363 let go_to_line_view = open_go_to_line_view(&workspace, cx);
364 assert_eq!(
365 highlighted_display_rows(&editor, cx),
366 Vec::<u32>::new(),
367 "Initially opened go to line modal should not highlight any rows"
368 );
369 assert_single_caret_at_row(&editor, 0, cx);
370
371 cx.simulate_input("1");
372 assert_eq!(
373 highlighted_display_rows(&editor, cx),
374 vec![0],
375 "Go to line modal should highlight a row, corresponding to the query"
376 );
377 assert_single_caret_at_row(&editor, 0, cx);
378
379 cx.simulate_input("8");
380 assert_eq!(
381 highlighted_display_rows(&editor, cx),
382 vec![13],
383 "If the query is too large, the last row should be highlighted"
384 );
385 assert_single_caret_at_row(&editor, 0, cx);
386
387 cx.dispatch_action(menu::Cancel);
388 drop(go_to_line_view);
389 editor.update(cx, |_, _| {});
390 assert_eq!(
391 highlighted_display_rows(&editor, cx),
392 Vec::<u32>::new(),
393 "After cancelling and closing the modal, no rows should be highlighted"
394 );
395 assert_single_caret_at_row(&editor, 0, cx);
396
397 let go_to_line_view = open_go_to_line_view(&workspace, cx);
398 assert_eq!(
399 highlighted_display_rows(&editor, cx),
400 Vec::<u32>::new(),
401 "Reopened modal should not highlight any rows"
402 );
403 assert_single_caret_at_row(&editor, 0, cx);
404
405 let expected_highlighted_row = 4;
406 cx.simulate_input("5");
407 assert_eq!(
408 highlighted_display_rows(&editor, cx),
409 vec![expected_highlighted_row]
410 );
411 assert_single_caret_at_row(&editor, 0, cx);
412 cx.dispatch_action(menu::Confirm);
413 drop(go_to_line_view);
414 editor.update(cx, |_, _| {});
415 assert_eq!(
416 highlighted_display_rows(&editor, cx),
417 Vec::<u32>::new(),
418 "After confirming and closing the modal, no rows should be highlighted"
419 );
420 // On confirm, should place the caret on the highlighted row.
421 assert_single_caret_at_row(&editor, expected_highlighted_row, cx);
422 }
423
424 #[gpui::test]
425 async fn test_unicode_characters_selection(cx: &mut TestAppContext) {
426 init_test(cx);
427
428 let fs = FakeFs::new(cx.executor());
429 fs.insert_tree(
430 path!("/dir"),
431 json!({
432 "a.rs": "ēlo"
433 }),
434 )
435 .await;
436
437 let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
438 let (workspace, cx) =
439 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
440 workspace.update_in(cx, |workspace, window, cx| {
441 let cursor_position = cx.new(|_| CursorPosition::new(workspace));
442 workspace.status_bar().update(cx, |status_bar, cx| {
443 status_bar.add_right_item(cursor_position, window, cx);
444 });
445 });
446
447 let worktree_id = workspace.update(cx, |workspace, cx| {
448 workspace.project().update(cx, |project, cx| {
449 project.worktrees(cx).next().unwrap().read(cx).id()
450 })
451 });
452 let _buffer = project
453 .update(cx, |project, cx| {
454 project.open_local_buffer(path!("/dir/a.rs"), cx)
455 })
456 .await
457 .unwrap();
458 let editor = workspace
459 .update_in(cx, |workspace, window, cx| {
460 workspace.open_path((worktree_id, "a.rs"), None, true, window, cx)
461 })
462 .await
463 .unwrap()
464 .downcast::<Editor>()
465 .unwrap();
466
467 cx.executor().advance_clock(Duration::from_millis(200));
468 workspace.update(cx, |workspace, cx| {
469 assert_eq!(
470 &SelectionStats {
471 lines: 0,
472 characters: 0,
473 selections: 1,
474 },
475 workspace
476 .status_bar()
477 .read(cx)
478 .item_of_type::<CursorPosition>()
479 .expect("missing cursor position item")
480 .read(cx)
481 .selection_stats(),
482 "No selections should be initially"
483 );
484 });
485 editor.update_in(cx, |editor, window, cx| {
486 editor.select_all(&SelectAll, window, cx)
487 });
488 cx.executor().advance_clock(Duration::from_millis(200));
489 workspace.update(cx, |workspace, cx| {
490 assert_eq!(
491 &SelectionStats {
492 lines: 1,
493 characters: 3,
494 selections: 1,
495 },
496 workspace
497 .status_bar()
498 .read(cx)
499 .item_of_type::<CursorPosition>()
500 .expect("missing cursor position item")
501 .read(cx)
502 .selection_stats(),
503 "After selecting a text with multibyte unicode characters, the character count should be correct"
504 );
505 });
506 }
507
508 #[gpui::test]
509 async fn test_unicode_line_numbers(cx: &mut TestAppContext) {
510 init_test(cx);
511
512 let text = "ēlo你好";
513 let fs = FakeFs::new(cx.executor());
514 fs.insert_tree(
515 path!("/dir"),
516 json!({
517 "a.rs": text
518 }),
519 )
520 .await;
521
522 let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
523 let (workspace, cx) =
524 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
525 workspace.update_in(cx, |workspace, window, cx| {
526 let cursor_position = cx.new(|_| CursorPosition::new(workspace));
527 workspace.status_bar().update(cx, |status_bar, cx| {
528 status_bar.add_right_item(cursor_position, window, cx);
529 });
530 });
531
532 let worktree_id = workspace.update(cx, |workspace, cx| {
533 workspace.project().update(cx, |project, cx| {
534 project.worktrees(cx).next().unwrap().read(cx).id()
535 })
536 });
537 let _buffer = project
538 .update(cx, |project, cx| {
539 project.open_local_buffer(path!("/dir/a.rs"), cx)
540 })
541 .await
542 .unwrap();
543 let editor = workspace
544 .update_in(cx, |workspace, window, cx| {
545 workspace.open_path((worktree_id, "a.rs"), None, true, window, cx)
546 })
547 .await
548 .unwrap()
549 .downcast::<Editor>()
550 .unwrap();
551
552 editor.update_in(cx, |editor, window, cx| {
553 editor.move_to_beginning(&MoveToBeginning, window, cx)
554 });
555 cx.executor().advance_clock(Duration::from_millis(200));
556 assert_eq!(
557 user_caret_position(1, 1),
558 current_position(&workspace, cx),
559 "Beginning of the line should be at first line, before any characters"
560 );
561
562 for (i, c) in text.chars().enumerate() {
563 let i = i as u32 + 1;
564 editor.update_in(cx, |editor, window, cx| {
565 editor.move_right(&MoveRight, window, cx)
566 });
567 cx.executor().advance_clock(Duration::from_millis(200));
568 assert_eq!(
569 user_caret_position(1, i + 1),
570 current_position(&workspace, cx),
571 "Wrong position for char '{c}' in string '{text}'",
572 );
573 }
574
575 editor.update_in(cx, |editor, window, cx| {
576 editor.move_right(&MoveRight, window, cx)
577 });
578 cx.executor().advance_clock(Duration::from_millis(200));
579 assert_eq!(
580 user_caret_position(1, text.chars().count() as u32 + 1),
581 current_position(&workspace, cx),
582 "After reaching the end of the text, position should not change when moving right"
583 );
584 }
585
586 #[gpui::test]
587 async fn test_go_into_unicode(cx: &mut TestAppContext) {
588 init_test(cx);
589
590 let text = "ēlo你好";
591 let fs = FakeFs::new(cx.executor());
592 fs.insert_tree(
593 path!("/dir"),
594 json!({
595 "a.rs": text
596 }),
597 )
598 .await;
599
600 let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
601 let (workspace, cx) =
602 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
603 workspace.update_in(cx, |workspace, window, cx| {
604 let cursor_position = cx.new(|_| CursorPosition::new(workspace));
605 workspace.status_bar().update(cx, |status_bar, cx| {
606 status_bar.add_right_item(cursor_position, window, cx);
607 });
608 });
609
610 let worktree_id = workspace.update(cx, |workspace, cx| {
611 workspace.project().update(cx, |project, cx| {
612 project.worktrees(cx).next().unwrap().read(cx).id()
613 })
614 });
615 let _buffer = project
616 .update(cx, |project, cx| {
617 project.open_local_buffer(path!("/dir/a.rs"), cx)
618 })
619 .await
620 .unwrap();
621 let editor = workspace
622 .update_in(cx, |workspace, window, cx| {
623 workspace.open_path((worktree_id, "a.rs"), None, true, window, cx)
624 })
625 .await
626 .unwrap()
627 .downcast::<Editor>()
628 .unwrap();
629
630 editor.update_in(cx, |editor, window, cx| {
631 editor.move_to_beginning(&MoveToBeginning, window, cx)
632 });
633 cx.executor().advance_clock(Duration::from_millis(200));
634 assert_eq!(user_caret_position(1, 1), current_position(&workspace, cx));
635
636 for (i, c) in text.chars().enumerate() {
637 let i = i as u32 + 1;
638 let point = user_caret_position(1, i + 1);
639 go_to_point(point, user_caret_position(1, i), &workspace, cx);
640 cx.executor().advance_clock(Duration::from_millis(200));
641 assert_eq!(
642 point,
643 current_position(&workspace, cx),
644 "When going to {point:?}, expecting the cursor to be at char '{c}' in string '{text}'",
645 );
646 }
647
648 go_to_point(
649 user_caret_position(111, 222),
650 user_caret_position(1, text.chars().count() as u32 + 1),
651 &workspace,
652 cx,
653 );
654 cx.executor().advance_clock(Duration::from_millis(200));
655 assert_eq!(
656 user_caret_position(1, text.chars().count() as u32 + 1),
657 current_position(&workspace, cx),
658 "When going into too large point, should go to the end of the text"
659 );
660 }
661
662 fn current_position(
663 workspace: &Entity<Workspace>,
664 cx: &mut VisualTestContext,
665 ) -> UserCaretPosition {
666 workspace.update(cx, |workspace, cx| {
667 workspace
668 .status_bar()
669 .read(cx)
670 .item_of_type::<CursorPosition>()
671 .expect("missing cursor position item")
672 .read(cx)
673 .position()
674 .expect("No position found")
675 })
676 }
677
678 fn user_caret_position(line: u32, character: u32) -> UserCaretPosition {
679 UserCaretPosition {
680 line: NonZeroU32::new(line).unwrap(),
681 character: NonZeroU32::new(character).unwrap(),
682 }
683 }
684
685 fn go_to_point(
686 new_point: UserCaretPosition,
687 expected_placeholder: UserCaretPosition,
688 workspace: &Entity<Workspace>,
689 cx: &mut VisualTestContext,
690 ) {
691 let go_to_line_view = open_go_to_line_view(workspace, cx);
692 go_to_line_view.update(cx, |go_to_line_view, cx| {
693 assert_eq!(
694 go_to_line_view
695 .line_editor
696 .read(cx)
697 .placeholder_text()
698 .expect("No placeholder text"),
699 format!(
700 "{}:{}",
701 expected_placeholder.line, expected_placeholder.character
702 )
703 );
704 });
705 cx.simulate_input(&format!("{}:{}", new_point.line, new_point.character));
706 cx.dispatch_action(menu::Confirm);
707 }
708
709 fn open_go_to_line_view(
710 workspace: &Entity<Workspace>,
711 cx: &mut VisualTestContext,
712 ) -> Entity<GoToLine> {
713 cx.dispatch_action(editor::actions::ToggleGoToLine);
714 workspace.update(cx, |workspace, cx| {
715 workspace.active_modal::<GoToLine>(cx).unwrap()
716 })
717 }
718
719 fn highlighted_display_rows(editor: &Entity<Editor>, cx: &mut VisualTestContext) -> Vec<u32> {
720 editor.update_in(cx, |editor, window, cx| {
721 editor
722 .highlighted_display_rows(window, cx)
723 .into_keys()
724 .map(|r| r.0)
725 .collect()
726 })
727 }
728
729 #[track_caller]
730 fn assert_single_caret_at_row(
731 editor: &Entity<Editor>,
732 buffer_row: u32,
733 cx: &mut VisualTestContext,
734 ) {
735 let selections = editor.update(cx, |editor, cx| {
736 editor
737 .selections
738 .all::<rope::Point>(cx)
739 .into_iter()
740 .map(|s| s.start..s.end)
741 .collect::<Vec<_>>()
742 });
743 assert!(
744 selections.len() == 1,
745 "Expected one caret selection but got: {selections:?}"
746 );
747 let selection = &selections[0];
748 assert!(
749 selection.start == selection.end,
750 "Expected a single caret selection, but got: {selection:?}"
751 );
752 assert_eq!(selection.start.row, buffer_row);
753 }
754
755 fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
756 cx.update(|cx| {
757 let state = AppState::test(cx);
758 language::init(cx);
759 crate::init(cx);
760 editor::init(cx);
761 workspace::init_settings(cx);
762 Project::init_settings(cx);
763 state
764 })
765 }
766}