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