1use futures::Stream as _;
2#[cfg(not(target_family = "wasm"))]
3use std::time::Instant;
4use std::{
5 ops::{Range, RangeInclusive},
6 pin::Pin,
7 sync::{Arc, Mutex, OnceLock},
8 task::Poll,
9 time::Duration,
10};
11#[cfg(target_family = "wasm")]
12use web_time::Instant;
13
14use gpui::{
15 App, AppContext as _, Bounds, Context, EntityId, FocusHandle, IntoElement, KeyBinding,
16 ListState, ParentElement as _, Pixels, Point, Render, SharedString, Styled as _, Task, Window,
17 prelude::FluentBuilder as _, px,
18};
19
20use crate::{
21 AutoScroll, ElementExt, TextSelection,
22 async_util::{Receiver, Sender, unbounded},
23 input::{self, SelectAll},
24 text::{
25 CodeBlockActionsFn, CodeBlockHighlighterFn, LinkClickHandlerFn, MarkdownExtensions,
26 RangeHighlight, RangeHighlightError, RenderedText, TableActionsFn, TextViewStyle,
27 document::ParsedDocument,
28 format,
29 node::{self, NodeContext},
30 range_highlight::{
31 LeafRemap, PendingReveal, RangeHighlightFrame, RenderedIndex, RevealRequest,
32 },
33 selection_adapter::TextViewSelectionAdapter,
34 stream_fade::{StreamFadeTracker, TextViewMotion},
35 },
36 v_flex,
37};
38
39const CONTEXT: &'static str = "TextView";
40const MAX_COALESCED_UPDATES_PER_PARSE: usize = 64;
42const MAX_SYNC_FULL_REPLACE_BYTES: usize = 4 * 1024;
45const STREAM_FADE_TICK: Duration = Duration::from_millis(33);
47
48pub(crate) fn init(cx: &mut App) {
49 cx.bind_keys(vec![
50 #[cfg(target_os = "macos")]
51 KeyBinding::new("cmd-c", input::Copy, Some(CONTEXT)),
52 #[cfg(not(target_os = "macos"))]
53 KeyBinding::new("ctrl-c", input::Copy, Some(CONTEXT)),
54 #[cfg(target_os = "macos")]
55 KeyBinding::new("cmd-a", input::SelectAll, Some(CONTEXT)),
56 #[cfg(not(target_os = "macos"))]
57 KeyBinding::new("ctrl-a", input::SelectAll, Some(CONTEXT)),
58 ]);
59}
60
61#[derive(Clone, Copy, PartialEq, Eq)]
63pub(super) enum TextViewFormat {
64 Markdown,
66 Html,
68}
69
70#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
74pub enum SelectionFormat {
75 #[default]
77 Plain,
78 Source,
84 SourceAcrossStyles,
97}
98
99#[derive(Clone, Copy)]
103pub(super) struct LineSpan {
104 pub(super) top: Pixels,
105 pub(super) bottom: Pixels,
106 pub(super) line_height: Pixels,
107}
108
109pub struct TextViewState {
111 entity_id: EntityId,
112 pub(super) focus_handle: FocusHandle,
113 pub(super) list_state: ListState,
114
115 bounds: Bounds<Pixels>,
117
118 pub(super) selectable: bool,
119 pub(super) selection_format: SelectionFormat,
120 pub(super) scrollable: bool,
121 pub(super) max_lines: Option<usize>,
122 pub(super) line_spans: Arc<Mutex<Vec<LineSpan>>>,
125 pub(super) clamped: bool,
127 pub(super) text_view_style: Arc<TextViewStyle>,
128 pub(super) code_block_actions: Option<std::sync::Arc<CodeBlockActionsFn>>,
129 pub(super) code_block_highlighter: Option<std::sync::Arc<CodeBlockHighlighterFn>>,
130 pub(super) table_actions: Option<std::sync::Arc<TableActionsFn>>,
131 pub(super) image_source: Option<std::sync::Arc<super::text_view::ImageSourceFn>>,
132 pub(super) link_click_handler: Option<std::sync::Arc<LinkClickHandlerFn>>,
133 pub(super) markdown_extensions: Arc<MarkdownExtensions>,
134
135 pub(super) is_selecting: bool,
136 pub(super) preserve_inline_selection: bool,
138 multi_click_selection: Option<TextViewMultiClickSelection>,
139 selected_text_override: Option<String>,
140 select_all: bool,
141 pub(super) auto_scroll: AutoScroll,
142 pub(super) selection_adapter: TextViewSelectionAdapter,
143
144 pub(super) parsed_content: ParsedContent,
145 pub(super) stream_fade: StreamFadeTracker,
146 fade_tick: Option<Task<()>>,
147 format: TextViewFormat,
150 text: String,
151 element_text: Option<SharedString>,
154 revision: usize,
155 committed_revision: usize,
157 full_update_revision: usize,
160 rendered_index: Arc<OnceLock<RenderedIndex>>,
162 range_highlights: Option<Arc<RangeHighlightFrame>>,
163 pub(super) pending_reveal: Option<PendingReveal>,
164 pub(super) selection_revision: usize,
165 compatible_layout_update: bool,
166 layout_text_style: Option<(gpui::TextStyle, Pixels)>,
167 parsed_error: Option<SharedString>,
168 tx: Sender<UpdateOptions>,
169 _parse_task: Task<()>,
170 _receive_task: Task<()>,
171}
172
173impl TextViewState {
174 pub fn markdown(text: &str, cx: &mut Context<Self>) -> Self {
176 Self::new(TextViewFormat::Markdown, text, cx)
177 }
178
179 pub fn html(text: &str, cx: &mut Context<Self>) -> Self {
181 Self::new(TextViewFormat::Html, text, cx)
182 }
183
184 fn new(format: TextViewFormat, text: &str, cx: &mut Context<Self>) -> Self {
186 let focus_handle = cx.focus_handle();
187 let selection_adapter = TextViewSelectionAdapter::new(cx.entity().downgrade(), cx);
188
189 let (tx, rx) = unbounded::<UpdateOptions>();
190 let (tx_result, rx_result) = unbounded::<ParsedUpdate>();
191 let _receive_task = cx.spawn({
192 async move |weak_self, cx| {
193 while let Ok(parsed_update) = rx_result.recv().await {
194 let Ok(commits) =
199 weak_self.read_with(cx, |state, _| state.commits(&parsed_update))
200 else {
201 break;
202 };
203 if !commits {
204 continue;
205 }
206 _ = weak_self.update(cx, |state, cx| {
207 state.commit_parsed_update(parsed_update, cx);
208 });
209 }
210 }
211 });
212
213 let _parse_task = cx.background_spawn(UpdateFuture::new(format, rx, tx_result));
214
215 let mut this = Self {
216 entity_id: cx.entity_id(),
217 focus_handle,
218 bounds: Bounds::default(),
219 multi_click_selection: None,
220 selected_text_override: None,
221 select_all: false,
222 selectable: false,
223 selection_format: SelectionFormat::default(),
224 scrollable: false,
225 max_lines: None,
226 line_spans: Arc::default(),
227 clamped: false,
228 list_state: ListState::new(0, gpui::ListAlignment::Top, px(1000.)).measure_all(),
233 text_view_style: Arc::default(),
234 code_block_actions: None,
235 code_block_highlighter: None,
236 table_actions: None,
237 link_click_handler: None,
238 image_source: None,
239 markdown_extensions: Arc::default(),
240 is_selecting: false,
241 preserve_inline_selection: false,
242 auto_scroll: AutoScroll::default(),
243 selection_adapter,
244 parsed_content: Default::default(),
245 stream_fade: StreamFadeTracker::default(),
246 fade_tick: None,
247 format,
248 parsed_error: None,
249 text: text.to_string(),
250 element_text: None,
251 revision: 0,
252 committed_revision: 0,
253 full_update_revision: 0,
254 rendered_index: Arc::default(),
255 range_highlights: None,
256 pending_reveal: None,
257 selection_revision: 0,
258 compatible_layout_update: false,
259 layout_text_style: None,
260 tx,
261 _parse_task,
262 _receive_task,
263 };
264 this.increment_update(&text, false, cx);
265 this
266 }
267
268 pub(crate) fn source(&self) -> SharedString {
270 self.parsed_content.document.source.clone()
271 }
272
273 pub fn selectable(mut self, selectable: bool) -> Self {
275 self.selectable = selectable;
276 self
277 }
278
279 pub fn set_selectable(&mut self, selectable: bool, cx: &mut Context<Self>) {
281 self.selectable = selectable;
282 cx.notify();
283 }
284
285 pub fn selection_format(mut self, selection_format: SelectionFormat) -> Self {
287 self.selection_format = selection_format;
288 self
289 }
290
291 pub fn set_selection_format(
293 &mut self,
294 selection_format: SelectionFormat,
295 cx: &mut Context<Self>,
296 ) {
297 self.selection_format = selection_format;
298 cx.notify();
299 }
300
301 pub fn scrollable(mut self, scrollable: bool) -> Self {
303 self.scrollable = scrollable;
304 self
305 }
306
307 pub fn set_scrollable(&mut self, scrollable: bool, cx: &mut Context<Self>) {
309 if !scrollable {
310 self.reset_selection_and_adapter(cx);
311 }
312 self.scrollable = scrollable;
313 cx.notify();
314 }
315
316 pub fn is_clamped(&self) -> bool {
319 self.clamped
320 }
321
322 pub fn set_text(&mut self, text: &str, cx: &mut Context<Self>) {
329 if self.text.as_str() == text {
330 return;
331 }
332 if self.format == TextViewFormat::Markdown
336 && self.parsed_error.is_none()
337 && !self.text.is_empty()
338 && let Some(delta) = text.strip_prefix(self.text.as_str())
339 {
340 self.push_str(delta, cx);
341 return;
342 }
343 self.stream_fade.note_replace();
344
345 self.text.clear();
346 self.text.push_str(text);
347 self.parsed_error = None;
348 self.increment_update(text, false, cx);
349 }
350
351 pub(super) fn set_element_text(&mut self, text: &SharedString, cx: &mut Context<Self>) {
356 let same_string = self.element_text.as_ref().is_some_and(|current| {
357 current.as_ptr() == text.as_ptr() && current.len() == text.len()
358 });
359 if same_string && self.text.len() == text.len() {
360 return;
361 }
362 self.element_text = Some(text.clone());
363 self.set_text(text, cx);
364 }
365
366 pub fn push_str(&mut self, new_text: &str, cx: &mut Context<Self>) {
368 if new_text.is_empty() {
369 return;
370 }
371 self.stream_fade.note_extend(self.text.len());
372 self.text.push_str(new_text);
373 self.increment_update(new_text, true, cx);
374 }
375
376 pub fn motion(mut self, motion: TextViewMotion) -> Self {
378 self.set_motion(motion);
379 self
380 }
381
382 pub fn set_motion(&mut self, motion: TextViewMotion) {
384 self.stream_fade.set_motion(motion);
385 }
386
387 pub(crate) fn set_markdown_extensions(
388 &mut self,
389 markdown_extensions: Arc<MarkdownExtensions>,
390 cx: &mut Context<Self>,
391 ) {
392 if self.markdown_extensions.revision() == markdown_extensions.revision() {
393 return;
394 }
395
396 let parser_configuration_changed = !self
397 .markdown_extensions
398 .has_same_parser_configuration(&markdown_extensions);
399 self.markdown_extensions = markdown_extensions;
400 if parser_configuration_changed && self.format == TextViewFormat::Markdown {
401 let text = self.text.clone();
402 self.increment_update(&text, false, cx);
403 }
404 }
405
406 pub fn selected_text(&self) -> String {
408 self.selected_text_in(None)
409 }
410
411 pub fn selected_source_range(&self) -> Option<Range<usize>> {
422 if self.format != TextViewFormat::Markdown {
423 return None;
424 }
425 if self.select_all {
426 return Some(0..self.source().len());
427 }
428 self.parsed_content.document.selected_source_range()
429 }
430
431 fn effective_format(&self) -> SelectionFormat {
441 match self.format {
442 TextViewFormat::Markdown => self.selection_format,
443 TextViewFormat::Html => SelectionFormat::Plain,
444 }
445 }
446
447 pub(super) fn has_current_copy_document(&self) -> bool {
450 self.committed_revision >= self.full_update_revision
451 }
452
453 pub(super) fn selected_text_in(&self, blocks: Option<RangeInclusive<usize>>) -> String {
460 let format = self.effective_format();
461
462 if self.select_all {
463 if format != SelectionFormat::Plain {
464 return self.source().to_string();
465 }
466
467 return self.parsed_content.document.text();
468 }
469
470 if format == SelectionFormat::Plain
475 && let Some(text) = &self.selected_text_override
476 {
477 return text.clone();
478 }
479
480 self.parsed_content.document.selected_text(format, blocks)
481 }
482
483 fn invalidate_measured_heights(&self) {
499 let count = self.list_state.item_count();
500 if count > 0 {
501 self.list_state.remeasure_items(0..count);
502 }
503 }
504
505 pub fn invalidate_inline_layout(&mut self, cx: &mut Context<Self>) {
511 self.preserve_selection_for_reflow();
512 cx.notify();
513 }
514
515 pub(super) fn preserve_selection_for_reflow(&mut self) {
516 self.preserve_inline_selection = true;
517 self.compatible_layout_update = true;
518 self.invalidate_measured_heights();
519 }
520
521 fn increment_update(&mut self, text: &str, append: bool, cx: &mut Context<Self>) {
522 self.revision += 1;
523 if !append {
524 self.full_update_revision = self.revision;
525 self.selection_revision = self.selection_revision.wrapping_add(1);
526 self.selection_adapter.clear_copy_projections();
529 self.parsed_content.document.clear_selection();
530 }
531 let parse_synchronously = !append && text.len() <= MAX_SYNC_FULL_REPLACE_BYTES;
532 let update_options = UpdateOptions {
533 revision: self.revision,
534 append,
535 mode: if append {
536 ParseMode::Compatible
537 } else if parse_synchronously {
538 ParseMode::BaselineAck
539 } else {
540 ParseMode::Replace
541 },
542 pending_text: text.to_string(),
543 markdown_extensions: self.markdown_extensions.clone(),
544 };
545
546 if parse_synchronously {
550 match parse_content(self.format, ParsedContent::default(), &update_options) {
551 Ok(content) => {
552 self.reconcile_range_highlights(&content.document, self.revision, false);
553 self.stream_fade.record(
554 &self.parsed_content.document,
555 &content.document,
556 Instant::now(),
557 );
558 self.parsed_content = content;
559 self.parsed_error = None;
560 self.invalidate_measured_heights();
561 if !self.is_selecting {
562 self.reset_selection_and_adapter(cx);
563 }
564 }
565 Err(err) => {
566 self.stream_fade.discard_pending();
567 self.parsed_error = Some(err);
568 }
569 }
570 _ = self.tx.try_send(update_options);
574 cx.notify();
575 return;
576 }
577
578 _ = self.tx.try_send(update_options);
579 }
580
581 fn commits(&self, parsed_update: &ParsedUpdate) -> bool {
585 if parsed_update.revision < self.full_update_revision
586 || parsed_update.revision <= self.committed_revision
587 {
588 return false;
589 }
590 debug_assert!(!parsed_update.baseline_ack || parsed_update.full_parse);
591 !parsed_update.baseline_ack
592 }
593
594 fn commit_parsed_update(&mut self, parsed_update: ParsedUpdate, cx: &mut Context<Self>) {
603 if !self.commits(&parsed_update) {
604 return;
605 }
606
607 match parsed_update.result {
608 Ok(content) => {
609 let append = parsed_update.selection_compatible && !parsed_update.full_parse;
610 if append && self.full_update_revision <= self.committed_revision {
611 self.splice_appended_blocks(&content.document);
612 }
613 self.reconcile_range_highlights(&content.document, parsed_update.revision, append);
614 self.stream_fade.record(
615 &self.parsed_content.document,
616 &content.document,
617 Instant::now(),
618 );
619 if parsed_update.revision < self.revision {
623 self.stream_fade.note_extend(content.document.source.len());
624 }
625 self.parsed_content = content;
626 self.parsed_error = None;
627 self.compatible_layout_update = parsed_update.selection_compatible;
628 if parsed_update.full_parse {
629 self.invalidate_measured_heights();
630 }
631 }
632 Err(err) => {
633 self.stream_fade.discard_pending();
634 self.parsed_error = Some(err);
635 }
636 }
637 if !parsed_update.selection_compatible && !self.is_selecting {
641 self.reset_selection_and_adapter(cx);
642 }
643 cx.notify();
644 }
645
646 pub fn rendered_text(&self) -> RenderedText {
651 RenderedText::new(
652 self.entity_id,
653 self.committed_revision,
654 self.parsed_content.document.clone(),
655 self.rendered_index.clone(),
656 )
657 }
658
659 pub fn set_range_highlights(
675 &mut self,
676 highlights: impl IntoIterator<Item = RangeHighlight>,
677 cx: &mut Context<Self>,
678 ) -> Result<(), RangeHighlightError> {
679 if self.format != TextViewFormat::Markdown {
680 return Err(RangeHighlightError::Unsupported);
681 }
682 let text = self.rendered_text();
683 self.range_highlights = RangeHighlightFrame::new(&text, highlights)?.map(Arc::new);
684 cx.notify();
685 Ok(())
686 }
687
688 pub fn reveal_range(
711 &mut self,
712 range: Range<usize>,
713 cx: &mut Context<Self>,
714 ) -> Result<(), RangeHighlightError> {
715 if self.format != TextViewFormat::Markdown {
716 return Err(RangeHighlightError::Unsupported);
717 }
718 let text = self.rendered_text();
719 if text.is_empty() && range == (0..0) {
720 self.pending_reveal = None;
722 return Ok(());
723 }
724 let now = cx.background_executor().now();
725 self.pending_reveal = Some(
726 PendingReveal::new(&text, &range, now).ok_or(RangeHighlightError::InvalidRange(0))?,
727 );
728 cx.notify();
729 Ok(())
730 }
731
732 pub fn clear_range_highlights(&mut self, cx: &mut Context<Self>) {
734 if self.range_highlights.take().is_some() {
735 cx.notify();
736 }
737 }
738
739 fn reconcile_range_highlights(&mut self, new: &ParsedDocument, revision: usize, append: bool) {
747 let tail_only = append && self.full_update_revision <= self.committed_revision;
748 self.committed_revision = revision;
749 self.rendered_index = Arc::default();
750 if self.range_highlights.is_some() || self.pending_reveal.is_some() {
751 let remap = LeafRemap::new(&self.parsed_content.document, new, tail_only);
752 self.range_highlights = self
753 .range_highlights
754 .take()
755 .and_then(|highlights| highlights.remap(&remap))
756 .map(Arc::new);
757 self.pending_reveal = self
758 .pending_reveal
759 .take()
760 .and_then(|reveal| reveal.remap(&remap));
761 }
762 }
763
764 fn splice_appended_blocks(&self, new: &ParsedDocument) {
774 let old = &self.parsed_content.document.blocks;
775 let (old_count, new_count) = (old.len(), new.blocks.len());
776 if old_count == 0 || new_count <= old_count || self.list_state.item_count() != old_count {
777 return;
778 }
779
780 let unchanged = old
783 .iter()
784 .zip(new.blocks.iter())
785 .take(old_count - 1)
786 .take_while(|(old_block, new_block)| {
787 old_block
788 .span()
789 .is_some_and(|span| new_block.span() == Some(span))
790 })
791 .count();
792 self.list_state.remeasure_items(unchanged..old_count);
796 self.list_state
797 .splice(old_count..old_count, new_count - old_count);
798 }
799
800 pub(super) fn update_bounds(&mut self, bounds: Bounds<Pixels>, _cx: &mut App) {
802 self.bounds = bounds;
803 }
804
805 pub(super) fn block_ix_at(&self, content_y: Pixels) -> Option<usize> {
813 if !self.scrollable {
814 return None;
815 }
816
817 let origin = self.bounds.origin.y + self.scroll_offset().y;
818 let count = self.list_state.item_count();
819 let mut ix = self.list_state.logical_scroll_top().item_ix;
820 while ix < count {
821 let bounds = self.list_state.bounds_for_item(ix)?;
822 if content_y < bounds.bottom() - origin {
823 return Some(ix);
824 }
825 ix += 1;
826 }
827
828 count.checked_sub(1)
829 }
830
831 #[doc(hidden)]
832 pub fn bounds(&self) -> Bounds<Pixels> {
833 self.bounds
834 }
835
836 #[doc(hidden)]
837 pub fn list_state(&self) -> &ListState {
838 &self.list_state
839 }
840
841 #[doc(hidden)]
842 pub fn is_selecting(&self) -> bool {
843 self.is_selecting
844 }
845
846 #[doc(hidden)]
847 pub fn focus_handle(&self) -> &FocusHandle {
848 &self.focus_handle
849 }
850
851 pub(super) fn has_view_selection(&self) -> bool {
854 self.select_all
855 || self.multi_click_selection.is_some()
856 || self.selected_text_override.is_some()
857 }
858
859 pub(super) fn stop_auto_scroll(&mut self) {
860 self.auto_scroll.stop();
861 }
862
863 pub(super) fn reset_selection(&mut self) {
864 self.preserve_inline_selection = false;
865 self.multi_click_selection = None;
866 self.selected_text_override = None;
867 self.select_all = false;
868 self.is_selecting = false;
869 self.auto_scroll.stop();
870 self.parsed_content.document.clear_selection();
874 }
875
876 fn reset_selection_and_adapter(&mut self, cx: &mut App) {
877 self.reset_selection();
878 self.selection_adapter.set_local_selection(false, cx);
879 }
880
881 pub fn clear_selection(&mut self, cx: &mut Context<Self>) {
883 self.reset_selection_and_adapter(cx);
884 cx.notify();
885 }
886
887 pub(super) fn scroll_offset(&self) -> Point<Pixels> {
888 if self.scrollable {
889 self.list_state.scroll_px_offset_for_scrollbar()
890 } else {
891 Point::default()
892 }
893 }
894
895 pub fn select_all(&mut self, cx: &mut Context<Self>) {
897 self.multi_click_selection = None;
898 self.selected_text_override = None;
899 self.select_all = true;
900 self.is_selecting = false;
901 self.auto_scroll.stop();
902 self.selection_adapter.set_local_selection(true, cx);
903 cx.notify();
904 }
905
906 pub(crate) fn set_multi_click_selection(
907 &mut self,
908 pos: Point<Pixels>,
909 kind: TextViewMultiClickKind,
910 selected_text: String,
911 cx: &mut App,
912 ) {
913 self.preserve_inline_selection = false;
914 let scroll_offset = self.scroll_offset();
915 let pos = pos - self.bounds.origin - scroll_offset;
916 self.multi_click_selection = Some(TextViewMultiClickSelection {
917 pos,
918 kind,
919 line_bounds: None,
920 });
921 self.selected_text_override = Some(selected_text);
922 self.select_all = false;
923 self.is_selecting = false;
924 self.auto_scroll.stop();
925 self.selection_adapter.set_local_selection(true, cx);
926 }
927
928 pub(crate) fn set_multi_click_line(&mut self, bounds: Bounds<Pixels>, cx: &mut App) {
929 self.set_multi_click_selection(
930 bounds.center(),
931 TextViewMultiClickKind::Line,
932 String::new(),
933 cx,
934 );
935 self.selected_text_override = None;
936 let offset = self.bounds.origin + self.scroll_offset();
937 if let Some(selection) = self.multi_click_selection.as_mut() {
938 selection.line_bounds = Some(Bounds::new(bounds.origin - offset, bounds.size));
939 }
940 }
941
942 pub(super) fn set_auto_scroll(&mut self, delta: Option<Pixels>, cx: &mut Context<Self>) {
943 self.auto_scroll.set(delta, cx, |delta, state, cx| {
944 state.list_state.scroll_by(delta);
945 cx.notify();
946 });
947 }
948
949 pub(crate) fn selection_points(&self, cx: &App) -> Option<(Point<Pixels>, Point<Pixels>)> {
956 if !self.selectable {
957 return None;
958 }
959 self.selection_adapter.selection_points(cx)
960 }
961
962 pub(crate) fn has_selection(&self, cx: &App) -> bool {
963 self.has_view_selection() || self.selection_points(cx).is_some()
964 }
965
966 pub(super) fn on_action_select_all(
967 &mut self,
968 _: &SelectAll,
969 _: &mut Window,
970 cx: &mut Context<Self>,
971 ) {
972 if !self.selectable {
973 cx.propagate();
974 return;
975 }
976
977 self.select_all(cx);
978 }
979
980 pub(crate) fn is_selectable(&self) -> bool {
981 self.selectable
982 }
983
984 pub(crate) fn is_all_selected(&self) -> bool {
985 self.select_all
986 }
987
988 pub(crate) fn multi_click_selection(&self) -> Option<TextViewMultiClickSelection> {
989 let scroll_offset = self.scroll_offset();
990 self.multi_click_selection.map(|selection| {
991 let pos = selection.pos + scroll_offset + self.bounds.origin;
992 let line_bounds = selection.line_bounds.map(|bounds| {
993 Bounds::new(
994 bounds.origin + scroll_offset + self.bounds.origin,
995 bounds.size,
996 )
997 });
998 TextViewMultiClickSelection {
999 pos,
1000 line_bounds,
1001 ..selection
1002 }
1003 })
1004 }
1005}
1006
1007#[derive(Clone, Copy, Debug, PartialEq)]
1008pub(crate) struct TextViewMultiClickSelection {
1009 pub(crate) pos: Point<Pixels>,
1010 pub(crate) kind: TextViewMultiClickKind,
1011 pub(crate) line_bounds: Option<Bounds<Pixels>>,
1012}
1013
1014#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1015pub(crate) enum TextViewMultiClickKind {
1016 Word,
1017 Paragraph,
1018 Line,
1019}
1020
1021impl TextViewState {
1022 fn reveal_frame(
1026 &mut self,
1027 now: Instant,
1028 window: &mut Window,
1029 ) -> (Option<RevealRequest>, Option<usize>) {
1030 if self.max_lines.is_some()
1031 || self
1032 .pending_reveal
1033 .as_ref()
1034 .is_some_and(|reveal| reveal.is_expired(now))
1035 {
1036 self.pending_reveal = None;
1037 }
1038 let Some(pending) = &self.pending_reveal else {
1039 return (None, None);
1040 };
1041
1042 let block_ix = pending.block_ix(&self.parsed_content.document);
1043 let block_off_screen = |ix: usize| {
1044 let viewport = self.list_state.viewport_bounds();
1045 self.list_state.bounds_for_item(ix).is_none_or(|item| {
1046 item.bottom() <= viewport.top() || item.top() >= viewport.bottom()
1047 })
1048 };
1049 let reveal_block = block_ix.filter(|ix| {
1050 self.scrollable
1051 && (pending.is_block() || !pending.was_laid_out())
1052 && block_off_screen(*ix)
1053 });
1054 let request = pending.request();
1055 if pending.is_block() {
1056 self.pending_reveal = None;
1058 } else {
1059 window.request_animation_frame();
1060 }
1061 (request, reveal_block)
1062 }
1063}
1064
1065impl Render for TextViewState {
1066 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1067 let typography = (window.text_style(), window.rem_size());
1068 let body_font_size = typography.0.font_size.to_pixels(typography.1);
1069 if self
1070 .layout_text_style
1071 .as_ref()
1072 .is_some_and(|previous| previous != &typography)
1073 {
1074 self.preserve_selection_for_reflow();
1078 }
1079 self.layout_text_style = Some(typography);
1080 let state = cx.entity();
1081 let stream_fade = self.stream_fade.frame(Instant::now(), cx.reduce_motion());
1082 if stream_fade.is_some() && self.fade_tick.is_none() {
1083 self.fade_tick = Some(cx.spawn(async move |this, cx| {
1084 cx.background_executor().timer(STREAM_FADE_TICK).await;
1085 _ = this.update(cx, |state, cx| {
1086 state.fade_tick = None;
1087 cx.notify();
1088 });
1089 }));
1090 }
1091 let (reveal, reveal_block) = self.reveal_frame(cx.background_executor().now(), window);
1092 let node_cx = NodeContext {
1094 offset: self.parsed_content.node_cx.offset,
1095 link_refs: self.parsed_content.node_cx.link_refs.clone(),
1096 style: self.text_view_style.clone(),
1097 code_block_actions: self.code_block_actions.clone(),
1098 code_block_highlighter: self.code_block_highlighter.clone(),
1099 table_actions: self.table_actions.clone(),
1100 link_click_handler: self.link_click_handler.clone(),
1101 image_source: self.image_source.clone(),
1102 markdown_extensions: self.markdown_extensions.clone(),
1103 stream_fade,
1104 range_highlights: self.range_highlights.clone(),
1105 reveal,
1106 body_font_size,
1107 };
1108
1109 let content = v_flex()
1110 .w_full()
1111 .when(self.max_lines.is_none(), |this| this.h_full())
1114 .map(|this| match &self.parsed_error {
1115 None => this.child(self.parsed_content.document.render_root(
1116 if self.scrollable {
1117 Some(self.list_state.clone())
1118 } else {
1119 None
1120 },
1121 &node_cx,
1122 window,
1123 cx,
1124 )),
1125 Some(err) => this.child(
1126 v_flex()
1127 .gap_1()
1128 .child("Failed to parse content")
1129 .child(err.to_string()),
1130 ),
1131 })
1132 .on_prepaint(move |bounds, window, cx| {
1133 let (
1134 size_changed,
1135 selection_involves_view,
1136 has_selection_snapshot,
1137 is_selecting,
1138 compatible_layout_update,
1139 preserve_width_selection,
1140 ) = {
1141 let state = state.read(cx);
1142 let has_selection_snapshot = state.selection_adapter.has_selection_snapshot(cx);
1143 let preserve_width_selection = !state.is_selecting
1144 && state.bounds().size.width != bounds.size.width
1145 && (has_selection_snapshot || state.has_view_selection());
1146 (
1147 state.bounds().size != bounds.size,
1148 state.selection_adapter.is_part_of_window_selection(cx),
1149 has_selection_snapshot,
1150 state.is_selecting,
1151 state.compatible_layout_update
1154 || preserve_width_selection
1155 || state.preserve_inline_selection,
1156 preserve_width_selection,
1157 )
1158 };
1159 let mut revision_changed = false;
1160 state.update(cx, |state, cx| {
1161 revision_changed = state
1162 .selection_adapter
1163 .update_layout_revision(state.selection_revision, state.is_selecting);
1164 if preserve_width_selection {
1165 state.preserve_inline_selection = true;
1168 }
1169 state.update_bounds(bounds, cx);
1170 state.compatible_layout_update = false;
1171 });
1172 if !is_selecting
1173 && ((size_changed && selection_involves_view && !compatible_layout_update)
1174 || (revision_changed && has_selection_snapshot))
1175 {
1176 TextSelection::clear(window, cx);
1177 }
1178 });
1179 if let Some(block_ix) = reveal_block {
1182 self.list_state.scroll_to_reveal_item(block_ix);
1183 }
1184 content
1185 }
1186}
1187
1188#[derive(Clone, PartialEq, Default)]
1189pub(crate) struct ParsedContent {
1190 pub(crate) document: ParsedDocument,
1191 pub(crate) node_cx: node::NodeContext,
1192}
1193
1194struct UpdateFuture {
1195 format: TextViewFormat,
1196 content: ParsedContent,
1197 rx: Pin<Box<Receiver<UpdateOptions>>>,
1198 tx_result: Sender<ParsedUpdate>,
1199}
1200
1201impl UpdateFuture {
1202 fn new(
1203 format: TextViewFormat,
1204 rx: Receiver<UpdateOptions>,
1205 tx_result: Sender<ParsedUpdate>,
1206 ) -> Self {
1207 Self {
1208 format,
1209 content: Default::default(),
1210 rx: Box::pin(rx),
1211 tx_result,
1212 }
1213 }
1214}
1215
1216impl Future for UpdateFuture {
1217 type Output = ();
1218
1219 fn poll(mut self: Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> Poll<Self::Output> {
1220 loop {
1221 match self.rx.as_mut().poll_next(cx) {
1222 Poll::Ready(Some(mut options)) => {
1223 let hit_coalesce_budget =
1224 merge_pending_options(&mut options, self.rx.as_ref().get_ref());
1225
1226 let res = parse_content(self.format, self.content.clone(), &options);
1227 if let Ok(content) = &res {
1228 self.content = content.clone();
1229 }
1230 _ = self.tx_result.try_send(ParsedUpdate {
1231 revision: options.revision,
1232 full_parse: !options.append,
1233 selection_compatible: options.mode == ParseMode::Compatible,
1234 baseline_ack: options.mode == ParseMode::BaselineAck,
1235 result: res,
1236 });
1237 if hit_coalesce_budget {
1238 cx.waker().wake_by_ref();
1239 return Poll::Pending;
1240 }
1241 continue;
1242 }
1243 Poll::Ready(None) => return Poll::Ready(()),
1244 Poll::Pending => return Poll::Pending,
1245 }
1246 }
1247 }
1248}
1249
1250#[derive(Clone)]
1251struct UpdateOptions {
1252 revision: usize,
1253 pending_text: String,
1254 append: bool,
1255 mode: ParseMode,
1256 markdown_extensions: Arc<MarkdownExtensions>,
1257}
1258
1259impl UpdateOptions {
1260 fn merge(&mut self, next: UpdateOptions) {
1261 if next.append {
1262 self.pending_text.push_str(&next.pending_text);
1263 self.revision = next.revision;
1264 if self.mode != ParseMode::Replace {
1265 self.mode = ParseMode::Compatible;
1266 }
1267 } else {
1268 *self = next;
1269 }
1270 }
1271}
1272
1273struct ParsedUpdate {
1274 revision: usize,
1275 full_parse: bool,
1276 selection_compatible: bool,
1277 baseline_ack: bool,
1278 result: Result<ParsedContent, SharedString>,
1279}
1280
1281#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1282enum ParseMode {
1283 BaselineAck,
1284 Replace,
1285 Compatible,
1286}
1287
1288fn merge_pending_options(options: &mut UpdateOptions, rx: &Receiver<UpdateOptions>) -> bool {
1289 let mut update_count = 1;
1290
1291 while update_count < MAX_COALESCED_UPDATES_PER_PARSE {
1292 match rx.try_recv() {
1293 Ok(next_options) => {
1294 options.merge(next_options);
1295 update_count += 1;
1296 }
1297 Err(_) => return false,
1298 }
1299 }
1300
1301 true
1302}
1303
1304fn parse_content(
1305 format: TextViewFormat,
1306 mut content: ParsedContent,
1307 options: &UpdateOptions,
1308) -> Result<ParsedContent, SharedString> {
1309 let mut node_cx = NodeContext {
1310 markdown_extensions: options.markdown_extensions.clone(),
1311 ..NodeContext::default()
1312 };
1313
1314 let last_span = options
1320 .append
1321 .then(|| {
1322 content
1323 .document
1324 .blocks
1325 .last()
1326 .and_then(|block| block.span())
1327 })
1328 .flatten();
1329
1330 let mut source = String::new();
1331 if let Some(span) = last_span {
1332 Arc::make_mut(&mut content.document.blocks).pop();
1333 node_cx.offset = span.start;
1334 source.push_str(&content.document.source[span.start..]);
1335 source.push_str(&options.pending_text);
1336 } else {
1337 if options.append {
1338 node_cx.offset = content.document.source.len();
1339 }
1340 source.push_str(&options.pending_text);
1341 }
1342
1343 let new_document = match format {
1344 TextViewFormat::Markdown => format::markdown::parse(&source, &mut node_cx),
1345 TextViewFormat::Html => format::html::parse(&source, &mut node_cx),
1346 }?;
1347
1348 if options.append {
1349 content.document.source =
1350 format!("{}{}", content.document.source, options.pending_text).into();
1351 Arc::make_mut(&mut content.document.blocks)
1352 .extend(Arc::unwrap_or_clone(new_document.blocks));
1353 } else {
1354 content.document = new_document;
1355 }
1356
1357 Ok(content)
1358}
1359
1360#[cfg(test)]
1361mod tests {
1362 use super::*;
1363 use crate::text::{
1364 MarkdownNode,
1365 node::{BlockNode, Span},
1366 };
1367 use gpui::{Entity, TestAppContext};
1368
1369 #[cfg(feature = "gpui-fast")]
1372 mod parse_ack {
1373 use std::{cell::Cell, rc::Rc};
1374
1375 use gpui::{
1376 AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render,
1377 TestAppContext, Window, div,
1378 };
1379
1380 use super::super::TextViewState;
1381
1382 struct Reader {
1383 state: Entity<TextViewState>,
1384 renders: Rc<Cell<usize>>,
1385 }
1386
1387 impl Render for Reader {
1388 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1389 self.renders.set(self.renders.get() + 1);
1390 div().child(self.state.read(cx).text.clone())
1391 }
1392 }
1393
1394 struct Holder {
1395 reader: Entity<Reader>,
1396 }
1397
1398 impl Render for Holder {
1399 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1400 div().child(self.reader.clone())
1401 }
1402 }
1403
1404 #[gpui::test]
1408 fn acknowledging_a_parse_leaves_its_readers_retained(cx: &mut TestAppContext) {
1409 cx.update(crate::init);
1410 let renders = Rc::new(Cell::new(0));
1411 let (holder, cx) = cx.add_window_view(|_, cx| Holder {
1412 reader: cx.new(|cx| Reader {
1413 state: cx.new(|cx| TextViewState::markdown("Some **text**.", cx)),
1414 renders: renders.clone(),
1415 }),
1416 });
1417 cx.update(|window, cx| window.draw(cx).clear(cx));
1418 let before = renders.get();
1419 cx.run_until_parked();
1420 holder.update(cx, |_, cx| cx.notify());
1421 cx.update(|window, cx| window.draw(cx).clear(cx));
1422 assert_eq!(renders.get(), before, "the reader rendered again");
1423 }
1424 }
1425
1426 mod stream_fade {
1427 use std::{cell::Cell, ops::Range, rc::Rc, time::Duration};
1428
1429 use gpui::{Entity, TestAppContext};
1430
1431 use super::super::*;
1432 use crate::{motion::Easing, text::stream_fade::TextLeafKey};
1433
1434 const FADE: Duration = Duration::from_secs(10);
1437
1438 fn fading_state(markdown: &str, cx: &mut TestAppContext) -> Entity<TextViewState> {
1439 cx.update(crate::init);
1440 let state = cx.update(|cx| {
1441 cx.new(|cx| {
1442 TextViewState::markdown(markdown, cx).motion(
1443 TextViewMotion::default()
1444 .with_stream_fade(FADE)
1445 .with_stream_fade_easing(Easing::Linear),
1446 )
1447 })
1448 });
1449 cx.run_until_parked();
1450 state
1451 }
1452
1453 fn fades(
1456 state: &Entity<TextViewState>,
1457 key: TextLeafKey,
1458 cx: &mut TestAppContext,
1459 ) -> Option<Vec<Range<usize>>> {
1460 state.update(cx, |state, _| {
1461 let frame = state.stream_fade.frame(Instant::now(), false)?;
1462 let fades = frame.fades(key)?;
1463 assert!(
1464 fades.iter().all(|(_, fade_out)| *fade_out > 0.9),
1465 "a fade sampled right after it starts is still transparent: {fades:?}"
1466 );
1467 Some(fades.iter().map(|(range, _)| range.clone()).collect())
1468 })
1469 }
1470
1471 #[gpui::test]
1472 fn push_str_fades_only_the_appended_text(cx: &mut TestAppContext) {
1473 let state = fading_state("hello", cx);
1474 state.update(cx, |state, cx| state.push_str(" world", cx));
1475 cx.run_until_parked();
1476
1477 assert_eq!(fades(&state, TextLeafKey::block(0), cx), Some(vec![5..11]));
1478
1479 state.update(cx, |state, _| {
1480 let later = Instant::now() + FADE + Duration::from_secs(1);
1481 assert!(state.stream_fade.frame(later, false).is_none());
1482 assert!(state.stream_fade.frame(Instant::now(), false).is_none());
1483 });
1484 }
1485
1486 #[gpui::test]
1487 fn overtaken_parse_preserves_the_remaining_chunks_fade(cx: &mut TestAppContext) {
1488 let state = fading_state("hello", cx);
1489 let parsed = super::push_and_parse(&state, " one", cx);
1490 state.update(cx, |state, cx| {
1491 state.push_str(" two", cx);
1492 state.commit_parsed_update(parsed, cx);
1493 });
1494 assert_eq!(fades(&state, TextLeafKey::block(0), cx), Some(vec![5..9]));
1495
1496 cx.run_until_parked();
1497 assert_eq!(
1498 fades(&state, TextLeafKey::block(0), cx),
1499 Some(vec![5..9, 9..13])
1500 );
1501 }
1502
1503 #[gpui::test]
1504 fn overtaken_parse_preserves_a_remaining_blocks_fade(cx: &mut TestAppContext) {
1505 let state = fading_state("hello", cx);
1506 let parsed = super::push_and_parse(&state, " one", cx);
1507 state.update(cx, |state, cx| {
1508 state.push_str("\n\nsecond", cx);
1509 state.commit_parsed_update(parsed, cx);
1510 });
1511 assert_eq!(fades(&state, TextLeafKey::block(0), cx), Some(vec![5..9]));
1512
1513 state.update(cx, |state, cx| state.push_str(" third", cx));
1515 cx.run_until_parked();
1516 let ranges = fades(&state, TextLeafKey::block(11), cx).expect("new paragraph fades");
1517 assert_eq!(
1518 ranges.into_iter().flatten().collect::<Vec<_>>(),
1519 (0..12).collect::<Vec<_>>()
1520 );
1521 }
1522
1523 #[gpui::test]
1524 fn set_text_extending_the_text_fades_like_push_str(cx: &mut TestAppContext) {
1525 let state = fading_state("hello", cx);
1526 state.update(cx, |state, cx| state.set_text("hello world", cx));
1527 cx.run_until_parked();
1528
1529 assert_eq!(fades(&state, TextLeafKey::block(0), cx), Some(vec![5..11]));
1530 }
1531
1532 #[gpui::test]
1533 fn set_text_replacing_the_text_shows_it_at_once(cx: &mut TestAppContext) {
1534 let state = fading_state("hello", cx);
1535 state.update(cx, |state, cx| state.push_str(" world", cx));
1536 cx.run_until_parked();
1537 assert!(fades(&state, TextLeafKey::block(0), cx).is_some());
1538
1539 state.update(cx, |state, cx| state.set_text("other", cx));
1540 cx.run_until_parked();
1541
1542 assert_eq!(fades(&state, TextLeafKey::block(0), cx), None);
1543 }
1544
1545 #[gpui::test]
1546 fn completed_markup_refades_from_the_divergence(cx: &mut TestAppContext) {
1547 let state = fading_state("text ", cx);
1550 state.update(cx, |state, cx| state.push_str("**bo", cx));
1551 cx.run_until_parked();
1552 assert_eq!(fades(&state, TextLeafKey::block(0), cx), Some(vec![4..9]));
1553
1554 state.update(cx, |state, cx| state.push_str("ld**", cx));
1557 cx.run_until_parked();
1558 assert_eq!(
1559 fades(&state, TextLeafKey::block(0), cx),
1560 Some(vec![4..5, 5..9])
1561 );
1562 }
1563
1564 #[gpui::test]
1565 fn without_stagger_an_update_fades_as_one_chunk(cx: &mut TestAppContext) {
1566 let state = fading_state("hello", cx);
1567 state.update(cx, |state, cx| state.push_str(" one two three", cx));
1568 cx.run_until_parked();
1569
1570 assert_eq!(fades(&state, TextLeafKey::block(0), cx), Some(vec![5..19]));
1571 }
1572
1573 #[gpui::test]
1574 fn words_of_one_update_start_one_after_another(cx: &mut TestAppContext) {
1575 let stagger = Duration::from_millis(100);
1576 let state = fading_state("hello", cx);
1577 state.update(cx, |state, _| {
1578 state.set_motion(
1579 TextViewMotion::default()
1580 .with_stream_fade(FADE)
1581 .with_stream_fade_stagger(stagger)
1582 .with_stream_fade_easing(Easing::Linear),
1583 )
1584 });
1585 state.update(cx, |state, cx| state.push_str(" one two three", cx));
1586 cx.run_until_parked();
1587
1588 state.update(cx, |state, _| {
1589 let frame = state
1590 .stream_fade
1591 .frame(Instant::now() + stagger * 3, false)
1592 .expect("words still fading");
1593 let fades = frame.fades(TextLeafKey::block(0)).expect("paragraph fades");
1594 let ranges: Vec<_> = fades.iter().map(|(range, _)| range.clone()).collect();
1595 assert_eq!(ranges, vec![5..10, 10..14, 14..19]);
1596 assert!(
1598 fades[0].1 < fades[1].1 && fades[1].1 < fades[2].1,
1599 "{fades:?}"
1600 );
1601 });
1602 }
1603
1604 #[gpui::test]
1605 fn a_new_paragraph_fades_as_a_whole(cx: &mut TestAppContext) {
1606 let state = fading_state("first", cx);
1607 state.update(cx, |state, cx| state.push_str("\n\nsecond", cx));
1608 cx.run_until_parked();
1609
1610 assert_eq!(fades(&state, TextLeafKey::block(0), cx), None);
1611 assert_eq!(fades(&state, TextLeafKey::block(7), cx), Some(vec![0..6]));
1612 }
1613
1614 #[gpui::test]
1615 fn code_block_text_fades_by_block(cx: &mut TestAppContext) {
1616 let state = fading_state("```rs\nlet", cx);
1617 state.update(cx, |state, cx| state.push_str(" x", cx));
1618 cx.run_until_parked();
1619
1620 assert_eq!(fades(&state, TextLeafKey::block(0), cx), Some(vec![3..5]));
1621 }
1622
1623 #[gpui::test]
1624 fn table_cells_fade_by_ordinal(cx: &mut TestAppContext) {
1625 let state = fading_state("| a | b |\n|---|---|\n| c | d", cx);
1626 state.update(cx, |state, cx| state.push_str("e |", cx));
1627 cx.run_until_parked();
1628
1629 assert_eq!(fades(&state, TextLeafKey::table_cell(0, 2), cx), None);
1630 assert_eq!(
1631 fades(&state, TextLeafKey::table_cell(0, 3), cx),
1632 Some(vec![1..2])
1633 );
1634 }
1635
1636 #[gpui::test]
1637 fn zero_duration_records_nothing(cx: &mut TestAppContext) {
1638 cx.update(crate::init);
1639 let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("hello", cx)));
1640 cx.run_until_parked();
1641 state.update(cx, |state, cx| state.push_str(" world", cx));
1642 cx.run_until_parked();
1643
1644 state.update(cx, |state, _| {
1645 assert!(state.stream_fade.frame(Instant::now(), false).is_none());
1646 });
1647 }
1648
1649 #[gpui::test]
1650 fn reduced_motion_drops_the_fade(cx: &mut TestAppContext) {
1651 let state = fading_state("hello", cx);
1652 state.update(cx, |state, cx| state.push_str(" world", cx));
1653 cx.run_until_parked();
1654
1655 state.update(cx, |state, _| {
1656 assert!(state.stream_fade.frame(Instant::now(), true).is_none());
1657 assert!(state.stream_fade.frame(Instant::now(), false).is_none());
1658 });
1659 }
1660
1661 #[gpui::test]
1662 fn a_fade_repaints_on_a_timer_until_nothing_fades(cx: &mut TestAppContext) {
1663 struct FadeRoot(Entity<TextViewState>);
1664 impl Render for FadeRoot {
1665 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1666 crate::text::TextView::new(&self.0)
1667 }
1668 }
1669
1670 let state = fading_state("hello", cx);
1671 let (_, cx) = cx.add_window_view(|_, _| FadeRoot(state.clone()));
1672 let notifies = Rc::new(Cell::new(0));
1673 let _subscription = cx.update(|_, cx| {
1674 let notifies = notifies.clone();
1675 cx.observe(&state, move |_, _| notifies.set(notifies.get() + 1))
1676 });
1677
1678 state.update(cx, |state, cx| state.push_str(" world", cx));
1679 cx.run_until_parked();
1680 cx.update(|window, cx| window.draw(cx).clear(cx));
1681 let before = notifies.get();
1682
1683 cx.executor().advance_clock(STREAM_FADE_TICK / 2);
1684 cx.run_until_parked();
1685 assert_eq!(notifies.get(), before);
1686
1687 for tick in 1..=3 {
1689 cx.executor().advance_clock(STREAM_FADE_TICK);
1690 cx.run_until_parked();
1691 assert_eq!(notifies.get(), before + tick);
1692 }
1693
1694 state.update(cx, |state, cx| state.set_text("other", cx));
1696 cx.run_until_parked();
1697 cx.executor().advance_clock(STREAM_FADE_TICK);
1698 cx.run_until_parked();
1699 let after = notifies.get();
1700 cx.executor().advance_clock(STREAM_FADE_TICK * 10);
1701 cx.run_until_parked();
1702 assert_eq!(notifies.get(), after);
1703 assert!(state.read_with(cx, |state, _| state.fade_tick.is_none()));
1704 }
1705 }
1706
1707 #[gpui::test]
1708 fn small_full_replace_parses_before_background_executor_runs(cx: &mut TestAppContext) {
1709 cx.update(crate::init);
1710 let markdown = "# ready";
1711 let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown(markdown, cx)));
1712
1713 state.read_with(cx, |state, _| {
1714 assert_eq!(state.source().as_str(), markdown);
1715 assert_eq!(state.parsed_content.document.blocks.len(), 1);
1716 });
1717 }
1718
1719 #[gpui::test]
1720 fn large_markdown_and_html_full_replacements_wait_for_background_executor(
1721 cx: &mut TestAppContext,
1722 ) {
1723 cx.update(crate::init);
1724 let markdown = "# x\n\n".repeat(MAX_SYNC_FULL_REPLACE_BYTES / 5 + 1);
1725 let html = format!("<p>{}</p>", "x".repeat(MAX_SYNC_FULL_REPLACE_BYTES + 1));
1726 assert!(markdown.len() > MAX_SYNC_FULL_REPLACE_BYTES);
1727 assert!(html.len() > MAX_SYNC_FULL_REPLACE_BYTES);
1728
1729 let (markdown_state, html_state) = cx.update(|cx| {
1730 (
1731 cx.new(|cx| TextViewState::markdown(&markdown, cx)),
1732 cx.new(|cx| TextViewState::html(&html, cx)),
1733 )
1734 });
1735
1736 markdown_state.read_with(cx, |state, _| {
1737 assert_eq!(state.text.as_str(), markdown.as_str());
1738 assert!(state.source().as_str().is_empty());
1739 assert!(state.parsed_content.document.blocks.is_empty());
1740 });
1741 html_state.read_with(cx, |state, _| {
1742 assert_eq!(state.text.as_str(), html.as_str());
1743 assert!(state.source().as_str().is_empty());
1744 assert!(state.parsed_content.document.blocks.is_empty());
1745 });
1746
1747 cx.run_until_parked();
1748
1749 markdown_state.read_with(cx, |state, _| {
1750 assert_eq!(state.source().as_str(), markdown.as_str());
1751 assert!(!state.parsed_content.document.blocks.is_empty());
1752 });
1753 html_state.read_with(cx, |state, _| {
1754 assert_eq!(state.source().as_str(), html.as_str());
1755 assert!(!state.parsed_content.document.blocks.is_empty());
1756 });
1757 }
1758
1759 #[gpui::test]
1760 fn inline_source_ranges_follow_streamed_tail_reparsing(cx: &mut TestAppContext) {
1761 cx.update(crate::init);
1762 let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("中文 $a$\n\n尾 $x", cx)));
1763 state.update(cx, |state, cx| {
1764 let extensions = MarkdownExtensions::default().plugin(
1765 crate::text::markdown_ext::TestInlinePlugin::new("test").parse_with(|node, _| {
1766 let markdown::mdast::Node::InlineMath(math) = node else {
1767 return None;
1768 };
1769 Some(super::super::MarkdownNode::new("formula", ()).text(math.value.clone()))
1770 }),
1771 );
1772 state.set_markdown_extensions(Arc::new(extensions), cx);
1773 });
1774 cx.run_until_parked();
1775 state.update(cx, |state, cx| state.push_str("^2$ 后", cx));
1776 cx.run_until_parked();
1777 state.read_with(cx, |state, _| {
1778 let source = "中文 $a$\n\n尾 $x^2$ 后";
1779 assert_eq!(state.source().as_str(), source);
1780 let objects: Vec<_> = state
1781 .parsed_content
1782 .document
1783 .blocks
1784 .iter()
1785 .flat_map(|block| {
1786 let super::super::node::BlockNode::Paragraph(paragraph) = block else {
1787 panic!()
1788 };
1789 paragraph
1790 .children
1791 .iter()
1792 .filter_map(|node| node.custom.as_ref())
1793 })
1794 .collect();
1795 assert_eq!(objects.len(), 2);
1796 for (object, markdown) in objects.iter().zip(["$a$", "$x^2$"]) {
1797 let start = source.find(markdown).unwrap();
1798 assert_eq!(object.source_range(), Some(start..start + markdown.len()));
1799 assert_eq!(object.as_markdown(), markdown);
1800 }
1801 });
1802 }
1803
1804 #[gpui::test]
1805 fn async_full_replace_then_push_str_preserves_complete_source(cx: &mut TestAppContext) {
1806 cx.update(crate::init);
1807 let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("old", cx)));
1808 cx.run_until_parked();
1809
1810 let replacement = "x".repeat(MAX_SYNC_FULL_REPLACE_BYTES + 1);
1811 let expected = format!("{replacement} tail");
1812 state.update(cx, |state, cx| {
1813 state.set_text(&replacement, cx);
1814 state.push_str(" tail", cx);
1815 });
1816 cx.run_until_parked();
1817
1818 state.read_with(cx, |state, _| {
1819 assert_eq!(state.text.as_str(), expected.as_str());
1820 assert_eq!(state.source().as_str(), expected.as_str());
1821 });
1822 }
1823
1824 fn push_and_parse(
1827 state: &Entity<TextViewState>,
1828 chunk: &str,
1829 cx: &mut TestAppContext,
1830 ) -> ParsedUpdate {
1831 let (revision, baseline) = state.update(cx, |state, cx| {
1832 state.push_str(chunk, cx);
1833 (state.revision, state.parsed_content.clone())
1834 });
1835 let options = UpdateOptions {
1836 revision,
1837 pending_text: chunk.to_string(),
1838 append: true,
1839 mode: ParseMode::Compatible,
1840 markdown_extensions: Arc::default(),
1841 };
1842 ParsedUpdate {
1843 revision,
1844 full_parse: false,
1845 selection_compatible: true,
1846 baseline_ack: false,
1847 result: parse_content(TextViewFormat::Markdown, baseline, &options),
1848 }
1849 }
1850
1851 #[gpui::test]
1852 fn stream_commits_a_parse_that_a_newer_chunk_overtook(cx: &mut TestAppContext) {
1853 cx.update(crate::init);
1854 let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("# Answer\n\n", cx)));
1855 cx.run_until_parked();
1856
1857 let parsed = push_and_parse(&state, "Streaming", cx);
1860 state.update(cx, |state, cx| {
1861 state.push_str(" tokens", cx);
1862 state.commit_parsed_update(parsed, cx);
1863 assert_eq!(state.source().as_str(), "# Answer\n\nStreaming");
1864 });
1865
1866 cx.run_until_parked();
1867 state.read_with(cx, |state, _| {
1868 assert_eq!(state.source().as_str(), "# Answer\n\nStreaming tokens");
1869 });
1870 }
1871
1872 #[gpui::test]
1873 fn a_parse_from_before_a_replacement_is_discarded(cx: &mut TestAppContext) {
1874 cx.update(crate::init);
1875 let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("old", cx)));
1876 cx.run_until_parked();
1877
1878 let parsed = push_and_parse(&state, " text", cx);
1879 let replacement = "x".repeat(MAX_SYNC_FULL_REPLACE_BYTES + 1);
1882 state.update(cx, |state, cx| {
1883 state.set_text(&replacement, cx);
1884 state.commit_parsed_update(parsed, cx);
1885 assert_eq!(state.source().as_str(), "old");
1886 });
1887
1888 cx.run_until_parked();
1889 state.read_with(cx, |state, _| {
1890 assert_eq!(state.source().as_str(), replacement.as_str());
1891 });
1892 }
1893
1894 #[gpui::test]
1895 fn html_push_str_keeps_earlier_blocks(cx: &mut TestAppContext) {
1896 cx.update(crate::init);
1897 let state = cx.update(|cx| cx.new(|cx| TextViewState::html("<p>first</p>", cx)));
1898 cx.run_until_parked();
1899
1900 state.update(cx, |state, cx| {
1901 state.push_str("<p>second</p>", cx);
1902 });
1903 cx.run_until_parked();
1904
1905 state.read_with(cx, |state, _| {
1906 assert_eq!(state.source().as_str(), "<p>first</p><p>second</p>");
1907 let text = state
1908 .parsed_content
1909 .document
1910 .blocks
1911 .iter()
1912 .map(|block| block.text())
1913 .collect::<String>();
1914 assert!(text.contains("first"), "lost the first block: {text:?}");
1915 assert!(text.contains("second"), "lost the appended block: {text:?}");
1916 });
1917 }
1918
1919 #[gpui::test]
1920 fn element_text_of_the_same_string_is_not_compared_again(cx: &mut TestAppContext) {
1921 cx.update(crate::init);
1922 let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("", cx)));
1923 let revision = |cx: &mut TestAppContext| state.read_with(cx, |state, _| state.revision);
1924 let text = SharedString::from("hello");
1925
1926 state.update(cx, |state, cx| state.set_element_text(&text, cx));
1927 let parsed = revision(cx);
1928 assert!(parsed > 0);
1929
1930 state.update(cx, |state, cx| state.set_element_text(&text, cx));
1933 assert_eq!(revision(cx), parsed);
1934 let equal = SharedString::from("hello".to_string());
1935 state.update(cx, |state, cx| state.set_element_text(&equal, cx));
1936 assert_eq!(revision(cx), parsed);
1937
1938 state.update(cx, |state, cx| {
1940 state.push_str(" world", cx);
1941 state.set_element_text(&equal, cx);
1942 });
1943 state.read_with(cx, |state, _| assert_eq!(state.text.as_str(), "hello"));
1944 }
1945
1946 #[gpui::test]
1947 fn set_text_then_push_str_appends_to_replaced_content(cx: &mut TestAppContext) {
1948 cx.update(crate::init);
1949 let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("old", cx)));
1950 cx.run_until_parked();
1951
1952 state.update(cx, |state, cx| {
1953 state.set_text("", cx);
1954 state.push_str("new", cx);
1955 state.push_str(" text", cx);
1956 });
1957 cx.run_until_parked();
1958
1959 state.read_with(cx, |state, _| {
1960 assert_eq!(state.text.as_str(), "new text");
1961 assert_eq!(state.source().as_str(), "new text");
1962 });
1963
1964 state.update(cx, |state, cx| {
1965 state.set_text("", cx);
1966 });
1967 cx.run_until_parked();
1968
1969 state.read_with(cx, |state, _| {
1970 assert_eq!(state.text.as_str(), "");
1971 assert_eq!(state.source().as_str(), "");
1972 });
1973 }
1974
1975 #[gpui::test]
1976 fn full_parse_coalesced_with_append_preserves_new_select_all(cx: &mut TestAppContext) {
1977 cx.update(crate::init);
1978 let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("old", cx)));
1979 cx.run_until_parked();
1980
1981 state.update(cx, |state, cx| {
1982 state.set_text("new", cx);
1983 state.push_str(" text", cx);
1984 state.select_all(cx);
1985 });
1986 cx.run_until_parked();
1987
1988 state.read_with(cx, |state, _| {
1989 assert!(state.select_all);
1990 assert_eq!(state.selected_text().trim(), "new text");
1991 });
1992 }
1993
1994 fn parsed_blocks(
1995 state: &Entity<TextViewState>,
1996 cx: &mut TestAppContext,
1997 ) -> Vec<(String, Option<Span>)> {
1998 state.read_with(cx, |state, _| {
1999 state
2000 .parsed_content
2001 .document
2002 .blocks
2003 .iter()
2004 .map(|block| (block.text(), block.span()))
2005 .collect()
2006 })
2007 }
2008
2009 #[gpui::test]
2010 fn set_text_extending_markdown_appends_and_keeps_selection(cx: &mut TestAppContext) {
2011 cx.update(crate::init);
2012 let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("hello", cx)));
2013 cx.run_until_parked();
2014 let revisions =
2015 |state: &TextViewState| (state.selection_revision, state.full_update_revision);
2016 let initial = state.read_with(cx, |state, _| revisions(state));
2017
2018 state.update(cx, |state, cx| state.set_text("hello world", cx));
2019 cx.run_until_parked();
2020 state.read_with(cx, |state, _| {
2021 assert_eq!(state.source().as_str(), "hello world");
2022 assert_eq!(revisions(state), initial);
2023 });
2024
2025 state.update(cx, |state, cx| state.set_text("hello", cx));
2026 cx.run_until_parked();
2027 state.read_with(cx, |state, _| {
2028 assert_eq!(state.source().as_str(), "hello");
2029 assert_ne!(state.selection_revision, initial.0);
2030 assert_ne!(state.full_update_revision, initial.1);
2031 });
2032 }
2033
2034 #[gpui::test]
2035 fn set_text_streaming_markdown_matches_a_full_parse(cx: &mut TestAppContext) {
2036 cx.update(crate::init);
2037 let streamed = cx.update(|cx| cx.new(|cx| TextViewState::markdown("# Title", cx)));
2038 cx.run_until_parked();
2039
2040 let filler = "word ".repeat(MAX_SYNC_FULL_REPLACE_BYTES / 5 + 1);
2044 let chunks = [
2045 "\n\nfirst para",
2046 "graph with `co",
2047 "de`\n\n- one\n",
2048 "- two\n\n```rust\nfn main() {",
2049 "}\n```\n\n",
2050 filler.as_str(),
2051 "\n\nlast",
2052 ];
2053 let full_update_revision = streamed.read_with(cx, |state, _| state.full_update_revision);
2054 let mut text = "# Title".to_string();
2055 for chunk in chunks {
2056 text.push_str(chunk);
2057 streamed.update(cx, |state, cx| state.set_text(&text, cx));
2058 cx.run_until_parked();
2059 }
2060 assert_eq!(
2061 streamed.read_with(cx, |state, _| state.full_update_revision),
2062 full_update_revision
2063 );
2064
2065 let parsed = cx.update(|cx| cx.new(|cx| TextViewState::markdown(&text, cx)));
2066 cx.run_until_parked();
2067
2068 assert_eq!(
2069 streamed.read_with(cx, |state, _| state.source()),
2070 parsed.read_with(cx, |state, _| state.source())
2071 );
2072 assert_eq!(parsed_blocks(&streamed, cx), parsed_blocks(&parsed, cx));
2073 }
2074
2075 #[gpui::test]
2076 fn set_text_extending_html_parses_it_again(cx: &mut TestAppContext) {
2077 cx.update(crate::init);
2078 let streamed = cx.update(|cx| cx.new(|cx| TextViewState::html("<ul><li>a</li>", cx)));
2079 cx.run_until_parked();
2080
2081 let text = "<ul><li>a</li><li>b</li></ul>";
2082 streamed.update(cx, |state, cx| state.set_text(text, cx));
2083 cx.run_until_parked();
2084 assert_ne!(
2085 streamed.read_with(cx, |state, _| state.full_update_revision),
2086 0
2087 );
2088
2089 let parsed = cx.update(|cx| cx.new(|cx| TextViewState::html(text, cx)));
2090 cx.run_until_parked();
2091 assert_eq!(parsed_blocks(&streamed, cx), parsed_blocks(&parsed, cx));
2092 }
2093
2094 #[gpui::test]
2095 fn set_text_extending_after_a_parse_error_parses_it_again(cx: &mut TestAppContext) {
2096 cx.update(crate::init);
2097 let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("hello", cx)));
2098 cx.run_until_parked();
2099
2100 let full_update_revision = state.read_with(cx, |state, _| state.full_update_revision);
2101
2102 state.update(cx, |state, cx| {
2103 state.parsed_error = Some("failed".into());
2104 state.set_text("hello world", cx);
2105 });
2106 cx.run_until_parked();
2107
2108 state.read_with(cx, |state, _| {
2109 assert!(state.parsed_error.is_none());
2110 assert_ne!(state.full_update_revision, full_update_revision);
2111 assert_eq!(state.source().as_str(), "hello world");
2112 });
2113 }
2114
2115 #[test]
2116 fn update_options_merge_keeps_latest_full_text() {
2117 let mut options = UpdateOptions {
2118 revision: 1,
2119 pending_text: "old".to_string(),
2120 append: true,
2121 mode: ParseMode::Compatible,
2122 markdown_extensions: Arc::default(),
2123 };
2124
2125 options.merge(UpdateOptions {
2126 revision: 2,
2127 pending_text: "new".to_string(),
2128 append: false,
2129 mode: ParseMode::BaselineAck,
2130 markdown_extensions: Arc::default(),
2131 });
2132 options.merge(UpdateOptions {
2133 revision: 3,
2134 pending_text: " text".to_string(),
2135 append: true,
2136 mode: ParseMode::Compatible,
2137 markdown_extensions: Arc::default(),
2138 });
2139
2140 assert_eq!(options.revision, 3);
2141 assert_eq!(options.pending_text, "new text");
2142 assert!(!options.append);
2143 }
2144
2145 #[test]
2146 fn append_merged_into_async_replace_remains_a_replacement() {
2147 let mut options = UpdateOptions {
2148 revision: 1,
2149 pending_text: "new".to_string(),
2150 append: false,
2151 mode: ParseMode::Replace,
2152 markdown_extensions: Arc::default(),
2153 };
2154
2155 options.merge(UpdateOptions {
2156 revision: 2,
2157 pending_text: " text".to_string(),
2158 append: true,
2159 mode: ParseMode::Compatible,
2160 markdown_extensions: Arc::default(),
2161 });
2162
2163 assert_eq!(options.revision, 2);
2164 assert_eq!(options.pending_text, "new text");
2165 assert!(!options.append);
2166 assert_eq!(options.mode, ParseMode::Replace);
2167 }
2168
2169 #[test]
2170 fn update_future_yields_before_coalescing_all_queued_updates() {
2171 let (tx, rx) = unbounded::<UpdateOptions>();
2172 let (tx_result, rx_result) = unbounded::<ParsedUpdate>();
2173 let total_updates = 128;
2174
2175 for revision in 1..=total_updates {
2176 tx.try_send(UpdateOptions {
2177 revision,
2178 pending_text: format!("{revision}\n"),
2179 append: revision != 1,
2180 mode: if revision == 1 {
2181 ParseMode::BaselineAck
2182 } else {
2183 ParseMode::Compatible
2184 },
2185 markdown_extensions: Arc::default(),
2186 })
2187 .unwrap();
2188 }
2189
2190 let mut future = Box::pin(UpdateFuture::new(TextViewFormat::Markdown, rx, tx_result));
2191 let waker = futures::task::noop_waker();
2192 let mut task_cx = std::task::Context::from_waker(&waker);
2193
2194 assert!(matches!(
2195 std::future::Future::poll(future.as_mut(), &mut task_cx),
2196 Poll::Pending
2197 ));
2198 let parsed_update = rx_result.try_recv().expect("parse result");
2199
2200 assert!(
2201 parsed_update.revision < total_updates,
2202 "single poll coalesced every queued update through revision {}",
2203 parsed_update.revision
2204 );
2205
2206 assert!(matches!(
2207 std::future::Future::poll(future.as_mut(), &mut task_cx),
2208 Poll::Pending
2209 ));
2210 let parsed_update = rx_result.try_recv().expect("next parse result");
2211 assert_eq!(parsed_update.revision, total_updates);
2212 }
2213
2214 #[gpui::test]
2215 fn select_all_returns_rendered_text(cx: &mut TestAppContext) {
2216 cx.update(crate::init);
2217 let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("**quick** value", cx)));
2218 cx.run_until_parked();
2219
2220 state.update(cx, |state, cx| {
2221 state.select_all(cx);
2222 });
2223
2224 state.read_with(cx, |state, _| {
2225 assert!(state.has_view_selection());
2226 assert_eq!(state.selected_text().trim(), "quick value");
2227 });
2228
2229 state.update(cx, |state, cx| {
2230 state.clear_selection(cx);
2231 });
2232
2233 state.read_with(cx, |state, _| {
2234 assert!(!state.has_view_selection());
2235 assert_eq!(state.selected_text(), "");
2236 });
2237 }
2238
2239 #[gpui::test]
2240 fn select_all_in_source_format_returns_source(cx: &mut TestAppContext) {
2241 cx.update(crate::init);
2242 let markdown = "**quick** value";
2243 let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown(markdown, cx)));
2244 cx.run_until_parked();
2245
2246 state.update(cx, |state, cx| state.select_all(cx));
2247
2248 state.read_with(cx, |state, _| {
2250 assert_eq!(state.selected_text().trim(), "quick value");
2251 });
2252
2253 state.update(cx, |state, cx| {
2254 state.set_selection_format(SelectionFormat::Source, cx)
2255 });
2256
2257 state.read_with(cx, |state, _| {
2259 assert_eq!(state.selected_text().trim(), markdown);
2260 });
2261 }
2262
2263 #[gpui::test]
2264 fn selected_source_range_returns_full_markdown_source_for_select_all(cx: &mut TestAppContext) {
2265 cx.update(crate::init);
2266 let markdown = "**quick** value";
2267 let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown(markdown, cx)));
2268 cx.run_until_parked();
2269
2270 state.update(cx, |state, cx| state.select_all(cx));
2271
2272 state.read_with(cx, |state, _| {
2273 assert_eq!(state.selected_source_range(), Some(0..markdown.len()));
2274 });
2275 }
2276
2277 #[gpui::test]
2278 fn selected_source_range_returns_none_for_html(cx: &mut TestAppContext) {
2279 cx.update(crate::init);
2280 let state = cx.update(|cx| cx.new(|cx| TextViewState::html("<b>quick</b>", cx)));
2281 cx.run_until_parked();
2282
2283 state.update(cx, |state, cx| state.select_all(cx));
2284
2285 state.read_with(cx, |state, _| {
2286 assert_eq!(state.selected_source_range(), None);
2287 });
2288 }
2289
2290 #[test]
2291 fn selected_source_range_keeps_global_offsets_after_incremental_tail_parse() {
2292 let options = UpdateOptions {
2293 revision: 1,
2294 pending_text: "first\n\nsecond".to_string(),
2295 append: false,
2296 mode: ParseMode::Replace,
2297 markdown_extensions: Arc::default(),
2298 };
2299 let content = parse_content(TextViewFormat::Markdown, ParsedContent::default(), &options)
2300 .expect("initial parse");
2301 let content = parse_content(
2302 TextViewFormat::Markdown,
2303 content,
2304 &UpdateOptions {
2305 revision: 2,
2306 pending_text: "\n\n**écho**".to_string(),
2307 append: true,
2308 mode: ParseMode::Compatible,
2309 markdown_extensions: Arc::default(),
2310 },
2311 )
2312 .expect("incremental parse");
2313 let BlockNode::Paragraph(paragraph) = &content.document.blocks[2] else {
2314 panic!("expected appended paragraph");
2315 };
2316 let mut state = paragraph.state.lock().unwrap();
2317 state.set_text(paragraph.text().into());
2318 state.selection = Some((0.."écho".len()).into());
2319 drop(state);
2320
2321 assert_eq!(content.document.selected_source_range(), Some(17..22));
2322 }
2323
2324 #[test]
2325 fn streamed_ordered_list_continuation_preserves_start() {
2326 let initial = parse_content(
2327 TextViewFormat::Markdown,
2328 ParsedContent::default(),
2329 &UpdateOptions {
2330 revision: 1,
2331 pending_text: "3. three".to_string(),
2332 append: false,
2333 mode: ParseMode::Replace,
2334 markdown_extensions: Arc::default(),
2335 },
2336 )
2337 .expect("initial list parse");
2338 let continued = parse_content(
2339 TextViewFormat::Markdown,
2340 initial,
2341 &UpdateOptions {
2342 revision: 2,
2343 pending_text: "\n4. four".to_string(),
2344 append: true,
2345 mode: ParseMode::Compatible,
2346 markdown_extensions: Arc::default(),
2347 },
2348 )
2349 .expect("streamed list continuation parse");
2350
2351 let BlockNode::List {
2352 start, children, ..
2353 } = &continued.document.blocks[0]
2354 else {
2355 panic!("expected streamed ordered list");
2356 };
2357 assert_eq!(*start, Some(3));
2358 assert_eq!(children.len(), 2);
2359 }
2360
2361 #[gpui::test]
2362 fn parser_revision_reparses_same_name_inline_configuration(cx: &mut TestAppContext) {
2363 cx.update(crate::init);
2364 let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("@member", cx)));
2365 for (revision, label) in [(1, "Alice"), (2, "Bob")] {
2366 let extensions = MarkdownExtensions::default()
2367 .parser_revision(revision)
2368 .plugin(
2369 crate::text::markdown_ext::TestInlinePlugin::new("test").parse_with(
2370 move |node, _| {
2371 matches!(node, markdown::mdast::Node::Text(_))
2372 .then(|| MarkdownNode::new("mention", label).text(label))
2373 },
2374 ),
2375 );
2376 state.update(cx, |state, cx| {
2377 state.set_markdown_extensions(Arc::new(extensions), cx)
2378 });
2379 cx.run_until_parked();
2380 state.read_with(cx, |state, _| {
2381 let node::BlockNode::Paragraph(paragraph) =
2382 &state.parsed_content.document.blocks[0]
2383 else {
2384 panic!()
2385 };
2386 assert_eq!(
2387 paragraph.children[0].custom.as_ref().unwrap().as_text(),
2388 label
2389 );
2390 });
2391 }
2392 }
2393
2394 #[gpui::test]
2395 fn set_markdown_extensions_reparses_existing_text(cx: &mut TestAppContext) {
2396 cx.update(crate::init);
2397 let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("$TSLA.US", cx)));
2398 cx.run_until_parked();
2399
2400 let extensions = MarkdownExtensions::default().block_parser(|node, cx| {
2401 let markdown::mdast::Node::Paragraph(paragraph) = node else {
2402 return None;
2403 };
2404 let [markdown::mdast::Node::Text(text)] = paragraph.children.as_slice() else {
2405 return None;
2406 };
2407 let symbol = text.value.strip_prefix('$')?.to_string();
2408 let node_text = format!("${symbol}");
2409
2410 Some(
2411 MarkdownNode::new("ticker", symbol)
2412 .text(node_text)
2413 .markdown(cx.node_source(node).unwrap_or_default()),
2414 )
2415 });
2416
2417 state.update(cx, |state, cx| {
2418 state.set_markdown_extensions(Arc::new(extensions), cx);
2419 });
2420 cx.run_until_parked();
2421
2422 state.read_with(cx, |state, _| {
2423 let node::BlockNode::Custom { node, .. } = &state.parsed_content.document.blocks[0]
2424 else {
2425 panic!("expected custom markdown node");
2426 };
2427 assert_eq!(node.name(), "ticker");
2428 assert_eq!(node.data::<String>().map(String::as_str), Some("TSLA.US"));
2429 });
2430 }
2431
2432 mod range_highlights {
2433 use std::ops::Range;
2434
2435 use gpui::{Entity, TestAppContext};
2436
2437 use super::super::*;
2438 use crate::text::stream_fade::TextLeafKey;
2439
2440 fn state(markdown: &str, cx: &mut TestAppContext) -> Entity<TextViewState> {
2441 cx.update(crate::init);
2442 let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown(markdown, cx)));
2443 cx.run_until_parked();
2444 state
2445 }
2446
2447 fn highlight(range: Range<usize>) -> RangeHighlight {
2448 RangeHighlight::new(range, gpui::hsla(0.15, 1., 0.5, 0.4))
2449 }
2450
2451 fn set(
2453 state: &Entity<TextViewState>,
2454 ranges: impl IntoIterator<Item = Range<usize>>,
2455 cx: &mut TestAppContext,
2456 ) -> Result<(), RangeHighlightError> {
2457 state.update(cx, |state, cx| {
2458 state.set_range_highlights(ranges.into_iter().map(highlight), cx)
2459 })
2460 }
2461
2462 fn painted(
2464 state: &Entity<TextViewState>,
2465 key: TextLeafKey,
2466 cx: &mut TestAppContext,
2467 ) -> Vec<Range<usize>> {
2468 state.read_with(cx, |state, _| {
2469 state.range_highlights.as_ref().map_or(Vec::new(), |frame| {
2470 frame
2471 .backgrounds(key)
2472 .iter()
2473 .map(|(range, _)| range.clone())
2474 .collect()
2475 })
2476 })
2477 }
2478
2479 fn has_highlights(state: &Entity<TextViewState>, cx: &mut TestAppContext) -> bool {
2480 state.read_with(cx, |state, _| state.range_highlights.is_some())
2481 }
2482
2483 #[gpui::test]
2484 fn rendered_text_is_the_plain_copy_text(cx: &mut TestAppContext) {
2485 let state = state(
2486 "# Title\n\nhello **world** \\*\n\n- item\n\n| a | b |\n|---|---|\n| c | d |\n\n```\nlet x\n```",
2487 cx,
2488 );
2489 state.read_with(cx, |state, _| {
2490 assert_eq!(
2491 state.rendered_text().as_str(),
2492 "Title\nhello world *\nitem\na b\nc d\n\nlet x\n"
2493 );
2494 });
2495 }
2496
2497 #[gpui::test]
2498 fn a_match_across_marks_paints_in_its_paragraph(cx: &mut TestAppContext) {
2499 let state = state("hello **world**", cx);
2500 set(&state, [0.."hello world".len()], cx).unwrap();
2501 assert_eq!(painted(&state, TextLeafKey::block(0), cx), [0..11]);
2502 }
2503
2504 #[gpui::test]
2505 fn repeated_text_maps_to_the_occurrence_addressed(cx: &mut TestAppContext) {
2506 let state = state("foo\n\nfoo", cx);
2507 let second = state.read_with(cx, |state, _| {
2508 state.rendered_text().as_str().rfind("foo").unwrap()
2509 });
2510 set(&state, [second..second + 3], cx).unwrap();
2511 assert!(painted(&state, TextLeafKey::block(0), cx).is_empty());
2512 assert_eq!(painted(&state, TextLeafKey::block(5), cx), [0..3]);
2513 }
2514
2515 #[gpui::test]
2516 fn a_range_across_blocks_skips_the_separator(cx: &mut TestAppContext) {
2517 let state = state("ab\n\ncd", cx);
2518 set(&state, [1..4], cx).unwrap();
2520 assert_eq!(painted(&state, TextLeafKey::block(0), cx), [1..2]);
2521 assert_eq!(painted(&state, TextLeafKey::block(4), cx), [0..1]);
2522 }
2523
2524 #[gpui::test]
2525 fn table_cells_and_code_blocks_are_leaves(cx: &mut TestAppContext) {
2526 let state = state("| a | b |\n|---|---|\n| c | d |\n\n```\nlet x\n```", cx);
2527 set(&state, [0..3, 6..7, 9..12], cx).unwrap();
2529 assert_eq!(painted(&state, TextLeafKey::table_cell(0, 0), cx), [0..1]);
2530 assert_eq!(painted(&state, TextLeafKey::table_cell(0, 1), cx), [0..1]);
2531 assert_eq!(painted(&state, TextLeafKey::table_cell(0, 3), cx), [0..1]);
2532 let code_start = "| a | b |\n|---|---|\n| c | d |\n\n".len();
2533 assert_eq!(painted(&state, TextLeafKey::block(code_start), cx), [0..3]);
2534 }
2535
2536 #[gpui::test]
2537 fn invalid_ranges_reject_the_whole_set(cx: &mut TestAppContext) {
2538 let state = state("中文\n\nab", cx);
2539 set(&state, [0.."中".len()], cx).unwrap();
2541 let invalid = [Range { start: 6, end: 3 }, 0..1, 0..100];
2542 for range in invalid {
2543 assert_eq!(
2544 set(&state, [0..3, range.clone()], cx),
2545 Err(RangeHighlightError::InvalidRange(1)),
2546 "{range:?}"
2547 );
2548 }
2549 assert_eq!(painted(&state, TextLeafKey::block(0), cx), [0..3]);
2551 }
2552
2553 #[gpui::test]
2554 fn text_outside_every_block_is_left_unpainted(cx: &mut TestAppContext) {
2555 let source = "foo one\n\n<div>foo two</div>\n\n| foo | x |\n|---|---|\n\nfoo three";
2556 let state = state(source, cx);
2557 let text = state.read_with(cx, |state, _| state.rendered_text().as_str().to_string());
2558 let ranges = text
2562 .match_indices("foo")
2563 .chain(text.match_indices(' '))
2564 .map(|(start, found)| start..start + found.len())
2565 .chain([3..3, 7..8])
2566 .collect::<Vec<_>>();
2567 set(&state, ranges, cx).unwrap();
2568 assert_eq!(painted(&state, TextLeafKey::block(0), cx), [0..3, 3..4]);
2569 let table = source.find("| foo").unwrap();
2570 assert_eq!(
2571 painted(&state, TextLeafKey::table_cell(table, 0), cx),
2572 [0..3]
2573 );
2574 let last = source.find("foo three").unwrap();
2575 assert_eq!(painted(&state, TextLeafKey::block(last), cx), [0..3, 3..4]);
2576 }
2577
2578 #[gpui::test]
2579 fn a_later_highlight_paints_over_an_earlier_one(cx: &mut TestAppContext) {
2580 let state = state("abcdef", cx);
2581 set(&state, [0..6, 2..3], cx).unwrap();
2582 assert_eq!(painted(&state, TextLeafKey::block(0), cx), [0..6, 2..3]);
2583 }
2584
2585 #[gpui::test]
2586 fn html_text_is_unsupported(cx: &mut TestAppContext) {
2587 let html = cx.update(|cx| cx.new(|cx| TextViewState::html("<p>one</p>", cx)));
2588 cx.run_until_parked();
2589 html.update(cx, |html, cx| {
2590 assert_eq!(
2591 html.set_range_highlights([highlight(0..3)], cx),
2592 Err(RangeHighlightError::Unsupported)
2593 );
2594 });
2595 }
2596
2597 #[gpui::test]
2598 fn inline_objects_are_skipped(cx: &mut TestAppContext) {
2599 let state = state("x $a$ y", cx);
2600 state.update(cx, |state, cx| {
2601 let extensions = MarkdownExtensions::default().plugin(
2602 crate::text::markdown_ext::TestInlinePlugin::new("test").parse_with(
2603 |node, _| {
2604 let markdown::mdast::Node::InlineMath(math) = node else {
2605 return None;
2606 };
2607 Some(
2608 crate::text::MarkdownNode::new("formula", ())
2609 .text(math.value.clone()),
2610 )
2611 },
2612 ),
2613 );
2614 state.set_markdown_extensions(Arc::new(extensions), cx);
2615 });
2616 cx.run_until_parked();
2617 set(&state, [2..3], cx).unwrap();
2619 assert!(painted(&state, TextLeafKey::block(0), cx).is_empty());
2620 set(&state, [0..5], cx).unwrap();
2621 assert_eq!(painted(&state, TextLeafKey::block(0), cx), [0..2, 3..5]);
2622 }
2623
2624 #[gpui::test]
2625 fn push_str_keeps_earlier_blocks_and_clips_the_changed_tail(cx: &mut TestAppContext) {
2626 let state = state("first\n\na **b", cx);
2627 set(&state, [0..5, 6..11], cx).unwrap();
2629
2630 state.update(cx, |state, cx| state.push_str("c**", cx));
2632 cx.run_until_parked();
2633
2634 assert_eq!(painted(&state, TextLeafKey::block(0), cx), [0..5]);
2635 assert_eq!(painted(&state, TextLeafKey::block(7), cx), [0..2]);
2636 }
2637
2638 #[gpui::test]
2639 fn push_str_keeps_a_block_whose_text_is_unchanged(cx: &mut TestAppContext) {
2640 let state = state("first\n\nsecond", cx);
2641 set(&state, [6..12], cx).unwrap();
2642 state.update(cx, |state, cx| state.push_str("\n===", cx));
2645 cx.run_until_parked();
2646 assert_eq!(painted(&state, TextLeafKey::block(7), cx), [0..6]);
2647 }
2648
2649 #[gpui::test]
2650 fn push_str_drops_highlights_of_a_leaf_that_is_gone(cx: &mut TestAppContext) {
2651 let state = state("first\n\n| a |", cx);
2652 set(&state, [0..5, 6..11], cx).unwrap();
2653 state.update(cx, |state, cx| state.push_str("\n|---|", cx));
2656 cx.run_until_parked();
2657 assert_eq!(painted(&state, TextLeafKey::block(0), cx), [0..5]);
2658 assert!(painted(&state, TextLeafKey::block(7), cx).is_empty());
2659 assert!(painted(&state, TextLeafKey::table_cell(7, 0), cx).is_empty());
2660 }
2661
2662 #[gpui::test]
2663 fn replacing_text_drops_highlights_only_where_it_changed(cx: &mut TestAppContext) {
2664 let state = state("first\n\nsecond", cx);
2665 set(&state, [0..5, 6..12], cx).unwrap();
2666 state.update(cx, |state, cx| state.set_text("first\n\nchanged", cx));
2667 cx.run_until_parked();
2668 assert_eq!(painted(&state, TextLeafKey::block(0), cx), [0..5]);
2669 assert!(painted(&state, TextLeafKey::block(7), cx).is_empty());
2670
2671 let large = "x".repeat(MAX_SYNC_FULL_REPLACE_BYTES + 1);
2672 state.update(cx, |state, cx| state.set_text(&large, cx));
2673 cx.run_until_parked();
2674 assert!(!has_highlights(&state, cx));
2675 }
2676
2677 #[gpui::test]
2678 fn a_highlight_follows_its_block_past_an_earlier_edit(cx: &mut TestAppContext) {
2679 let state = state("foo\n\nbar\n\nbar", cx);
2680 set(&state, [4..7], cx).unwrap();
2682 state.update(cx, |state, cx| state.set_text("bar\n\nbar", cx));
2684 cx.run_until_parked();
2685 assert_eq!(painted(&state, TextLeafKey::block(0), cx), [0..3]);
2686 assert!(painted(&state, TextLeafKey::block(5), cx).is_empty());
2687
2688 let state = self::state("ERROR a\n\nERROR b\n\nERROR c", cx);
2689 set(&state, [8..15], cx).unwrap();
2690 state.update(cx, |state, cx| state.set_text("ERROR b\n\nERROR c", cx));
2691 cx.run_until_parked();
2692 assert_eq!(painted(&state, TextLeafKey::block(0), cx), [0..7]);
2693 assert!(painted(&state, TextLeafKey::block(9), cx).is_empty());
2694 }
2695
2696 #[gpui::test]
2697 fn appending_a_copy_of_the_last_block_keeps_the_highlight_on_it(cx: &mut TestAppContext) {
2698 let state = state("a\n\nfoo", cx);
2699 set(&state, [2..5], cx).unwrap();
2701 state.update(cx, |state, cx| state.set_text("a\n\nfoo\n\nfoo", cx));
2702 cx.run_until_parked();
2703 assert_eq!(painted(&state, TextLeafKey::block(3), cx), [0..3]);
2704 assert!(painted(&state, TextLeafKey::block(8), cx).is_empty());
2705 }
2706
2707 #[gpui::test]
2708 fn deleting_a_table_row_drops_highlights_in_the_rows_it_moves(cx: &mut TestAppContext) {
2709 let table = "| a | b |\n|---|---|\n| x | 1 |\n| x | 2 |";
2710 let without_middle_row = "| a | b |\n|---|---|\n| x | 2 |";
2711 for ranges in [vec![8..9, 10..11], vec![4..5]] {
2714 let state = state(table, cx);
2715 set(&state, [0..1].into_iter().chain(ranges), cx).unwrap();
2716 state.update(cx, |state, cx| state.set_text(without_middle_row, cx));
2717 cx.run_until_parked();
2718 assert_eq!(painted(&state, TextLeafKey::table_cell(0, 0), cx), [0..1]);
2719 for cell in 1..4 {
2720 assert!(
2721 painted(&state, TextLeafKey::table_cell(0, cell), cx).is_empty(),
2722 "cell {cell}"
2723 );
2724 }
2725 }
2726 }
2727
2728 #[gpui::test]
2729 fn streaming_table_rows_keeps_the_highlights_of_earlier_rows(cx: &mut TestAppContext) {
2730 let state = state("| a | b |\n|---|---|\n| x | 1 |", cx);
2731 set(&state, [0..1, 4..5], cx).unwrap();
2733 state.update(cx, |state, cx| {
2734 state.set_text("| a | b |\n|---|---|\n| x | 1 |\n| y | 2 |", cx)
2735 });
2736 cx.run_until_parked();
2737 assert_eq!(painted(&state, TextLeafKey::table_cell(0, 0), cx), [0..1]);
2738 assert_eq!(painted(&state, TextLeafKey::table_cell(0, 2), cx), [0..1]);
2739 }
2740
2741 #[gpui::test]
2742 fn an_edit_in_an_earlier_block_keeps_later_highlights(cx: &mut TestAppContext) {
2743 let state = state("one\n\ntwo", cx);
2744 set(&state, [0..3, 4..7], cx).unwrap();
2745 state.update(cx, |state, cx| state.set_text("one!\n\ntwo", cx));
2746 cx.run_until_parked();
2747 assert_eq!(painted(&state, TextLeafKey::block(0), cx), [0..3]);
2748 assert_eq!(painted(&state, TextLeafKey::block(6), cx), [0..3]);
2749 }
2750
2751 #[gpui::test]
2752 fn an_append_after_a_full_update_compares_every_block(cx: &mut TestAppContext) {
2753 let state = state("first\n\nsecond", cx);
2754 set(&state, [0..5], cx).unwrap();
2755 let options = UpdateOptions {
2758 revision: 0,
2759 pending_text: "FIRST\n\nsecond".to_string(),
2760 append: false,
2761 mode: ParseMode::Replace,
2762 markdown_extensions: Arc::default(),
2763 };
2764 let new = parse_content(TextViewFormat::Markdown, ParsedContent::default(), &options)
2765 .unwrap()
2766 .document;
2767 state.update(cx, |state, _| {
2768 state.full_update_revision = state.committed_revision + 1;
2769 let revision = state.committed_revision + 2;
2770 state.reconcile_range_highlights(&new, revision, true);
2771 });
2772 assert!(!has_highlights(&state, cx));
2773 }
2774
2775 #[gpui::test]
2776 fn streaming_through_set_text_keeps_highlights(cx: &mut TestAppContext) {
2777 let state = state("first\n\nsec", cx);
2778 set(&state, [0..5, 6..9], cx).unwrap();
2780 state.update(cx, |state, cx| state.set_text("first\n\nsecond", cx));
2781 cx.run_until_parked();
2782 assert_eq!(painted(&state, TextLeafKey::block(0), cx), [0..5]);
2783 assert_eq!(painted(&state, TextLeafKey::block(7), cx), [0..3]);
2784 }
2785
2786 #[gpui::test]
2787 fn a_full_parse_merged_with_an_append_compares_every_block(cx: &mut TestAppContext) {
2788 let state = state("", cx);
2789 state.update(cx, |state, cx| {
2793 state.set_text("[foo] and some text\n\nmore", cx);
2794 let text = state.rendered_text();
2795 let some = text.as_str().find("some").unwrap();
2796 state
2797 .set_range_highlights([highlight(some..some + 4)], cx)
2798 .unwrap();
2799 state.push_str("\n\n[foo]: https://example.com", cx);
2800 });
2801 cx.run_until_parked();
2802 state.read_with(cx, |state, _| {
2803 assert!(state.rendered_text().as_str().starts_with("foo and some"));
2804 });
2805 assert!(painted(&state, TextLeafKey::block(0), cx).is_empty());
2806 }
2807
2808 #[gpui::test]
2809 fn a_full_update_before_an_append_drops_replaced_highlights(cx: &mut TestAppContext) {
2810 let state = state("first", cx);
2811 set(&state, [0..5], cx).unwrap();
2812 let large = "x".repeat(MAX_SYNC_FULL_REPLACE_BYTES + 1);
2813 state.update(cx, |state, cx| {
2814 state.set_text(&large, cx);
2815 state.push_str(" tail", cx);
2816 });
2817 cx.run_until_parked();
2818 assert!(!has_highlights(&state, cx));
2819 }
2820
2821 #[gpui::test]
2822 fn reparsing_unchanged_text_keeps_highlights(cx: &mut TestAppContext) {
2823 let state = state("first", cx);
2824 set(&state, [0..5], cx).unwrap();
2825 state.update(cx, |state, cx| {
2826 state.set_markdown_extensions(
2827 Arc::new(MarkdownExtensions::default().parser_revision(1)),
2828 cx,
2829 )
2830 });
2831 cx.run_until_parked();
2832 assert_eq!(painted(&state, TextLeafKey::block(0), cx), [0..5]);
2833 }
2834
2835 pub(super) struct Root {
2836 pub(super) state: Entity<TextViewState>,
2837 }
2838
2839 impl Render for Root {
2840 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2841 gpui::div()
2842 .w(px(120.))
2843 .child(crate::text::TextView::new(&self.state).selectable(true))
2844 }
2845 }
2846
2847 #[gpui::test]
2848 fn highlights_paint_across_inline_code_tables_and_code_blocks(cx: &mut TestAppContext) {
2849 cx.update(crate::init);
2850 let markdown = "wrapping text with `inline code` and a [link](https://x.y) \
2851 that wraps\n\n| a | b |\n|---|---|\n| c | d |\n\n```\nlet x = 1;\n```";
2852 let (root, cx) = cx.add_window_view(|_, cx| Root {
2853 state: cx.new(|cx| TextViewState::markdown(markdown, cx)),
2854 });
2855 cx.run_until_parked();
2856 let state = root.read_with(cx, |root, _| root.state.clone());
2857 let text = state.read_with(cx, |state, _| state.rendered_text().as_str().to_string());
2858 let code = text.find("code").unwrap();
2859 let len = text.len();
2860 let ranges = [code..code + 2]
2862 .into_iter()
2863 .chain(text.char_indices().map(|(ix, c)| ix..ix + c.len_utf8()))
2864 .chain([0..len])
2865 .filter(|range| !text[range.clone()].trim().is_empty());
2866 state.update(cx, |state, cx| {
2867 state
2868 .set_range_highlights(ranges.map(highlight), cx)
2869 .unwrap();
2870 });
2871 cx.update(|window, cx| window.draw(cx).clear(cx));
2872 assert_eq!(
2873 painted(&state, TextLeafKey::block(0), cx)[0],
2874 code..code + 2
2875 );
2876 }
2877
2878 fn converted(
2880 state: &Entity<TextViewState>,
2881 sources: impl IntoIterator<Item = Range<usize>>,
2882 cx: &mut TestAppContext,
2883 ) -> Vec<Option<Range<usize>>> {
2884 state.read_with(cx, |state, _| {
2885 let text = state.rendered_text();
2886 sources
2887 .into_iter()
2888 .map(|source| text.range_for_source(source))
2889 .collect()
2890 })
2891 }
2892
2893 fn find(haystack: &str, needle: &str) -> Range<usize> {
2894 let start = haystack.find(needle).unwrap();
2895 start..start + needle.len()
2896 }
2897
2898 #[gpui::test]
2899 fn source_ranges_highlight_the_text_rendered_from_them(cx: &mut TestAppContext) {
2900 let markdown =
2901 "In Rust, we use `u128` to handle **larger** numbers\n\n| a | **b** |\n|---|---|";
2902 let state = state(markdown, cx);
2903 state.update(cx, |state, cx| {
2904 let text = state.rendered_text();
2905 assert_eq!(text.source(), markdown);
2906 let highlights = ["`u128` to handle **larger**", "**b**"]
2907 .into_iter()
2908 .map(|needle| text.range_for_source(find(markdown, needle)).unwrap())
2909 .map(highlight)
2910 .collect::<Vec<_>>();
2911 state.set_range_highlights(highlights, cx).unwrap();
2912 });
2913 let rendered = "In Rust, we use u128 to handle larger numbers";
2914 assert_eq!(
2915 painted(&state, TextLeafKey::block(0), cx),
2916 [find(rendered, "u128 to handle larger")]
2917 );
2918 let table = markdown.find("| a").unwrap();
2919 assert_eq!(
2920 painted(&state, TextLeafKey::table_cell(table, 1), cx),
2921 [0..1]
2922 );
2923 }
2924
2925 #[gpui::test]
2926 fn select_all_converts_to_every_rendered_character(cx: &mut TestAppContext) {
2927 let markdown = "# Title\n\nhello **world**\n\n- item";
2928 let state = state(markdown, cx);
2929 state.update(cx, |state, cx| state.select_all(cx));
2930 state.read_with(cx, |state, _| {
2931 let text = state.rendered_text();
2932 assert_eq!(text.as_str(), "Title\nhello world\nitem\n");
2933 let source = state.selected_source_range().unwrap();
2934 assert_eq!(text.range_for_source(source), Some(0..text.len() - 1));
2936 });
2937 }
2938
2939 #[gpui::test]
2940 fn a_snapshot_converts_against_its_own_source(cx: &mut TestAppContext) {
2941 let state = state("first", cx);
2942 let before = state.read_with(cx, |state, _| state.rendered_text());
2943 state.update(cx, |state, cx| state.push_str("\n\nsecond **part**", cx));
2944 let source = "first\n\nsecond **part**";
2945 let second = find(source, "second **part**");
2946
2947 state.read_with(cx, |state, _| {
2950 let text = state.rendered_text();
2951 assert_eq!(text, before);
2952 assert_eq!(text.source(), "first");
2953 assert_eq!(text.range_for_source(second.clone()), None);
2954 });
2955 cx.run_until_parked();
2956
2957 assert_eq!(
2958 converted(&state, [second.clone(), 0..5], cx),
2959 [Some(6..17), Some(0..5)]
2960 );
2961 state.read_with(cx, |state, _| {
2962 let text = state.rendered_text();
2963 assert_ne!(text, before);
2964 assert_eq!(text.source(), source);
2965 assert_eq!(&text.as_str()[6..17], "second part");
2966 });
2967 assert_eq!(before.source(), "first");
2969 assert_eq!(before.range_for_source(0..5), Some(0..5));
2970 assert_eq!(before.range_for_source(second), None);
2971 }
2972
2973 #[gpui::test]
2974 fn appended_blocks_convert_at_their_place_in_the_whole_source(cx: &mut TestAppContext) {
2975 let state = state("first\n\nsecond", cx);
2976 state.update(cx, |state, cx| state.push_str(" more\n\n**écho** end", cx));
2979 cx.run_until_parked();
2980 let source = "first\n\nsecond more\n\n**écho** end";
2981 let converted = converted(
2982 &state,
2983 [
2984 find(source, "second more"),
2985 find(source, "**écho**"),
2986 find(source, "end"),
2987 ],
2988 cx,
2989 );
2990 state.read_with(cx, |state, _| {
2991 let text = state.rendered_text();
2992 assert_eq!(text.source(), source);
2993 let texts = converted
2994 .into_iter()
2995 .map(|range| &text.as_str()[range.unwrap()])
2996 .collect::<Vec<_>>();
2997 assert_eq!(texts, ["second more", "écho", "end"]);
2998 });
2999 }
3000
3001 #[gpui::test]
3002 fn a_background_parse_converts_once_it_lands(cx: &mut TestAppContext) {
3003 cx.update(crate::init);
3004 let markdown = format!(
3005 "{}\n\n**tail**",
3006 "word ".repeat(MAX_SYNC_FULL_REPLACE_BYTES / 4)
3007 );
3008 let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown(&markdown, cx)));
3009 let tail = find(&markdown, "**tail**");
3010 state.read_with(cx, |state, _| {
3011 let text = state.rendered_text();
3012 assert!(text.source().is_empty());
3013 assert!(text.is_empty());
3014 assert_eq!(text.range_for_source(tail.clone()), None);
3015 });
3016 cx.run_until_parked();
3017 state.read_with(cx, |state, _| {
3018 let text = state.rendered_text();
3019 assert_eq!(text.source(), markdown);
3020 let range = text.range_for_source(tail).unwrap();
3021 assert_eq!(&text.as_str()[range], "tail");
3022 });
3023 }
3024
3025 #[gpui::test]
3026 fn inline_objects_convert_whole_and_stay_unpainted(cx: &mut TestAppContext) {
3027 let markdown = "x $ab$ y";
3028 let state = state(markdown, cx);
3029 state.update(cx, |state, cx| {
3030 let extensions = MarkdownExtensions::default().plugin(
3031 crate::text::markdown_ext::TestInlinePlugin::new("test").parse_with(
3032 |node, _| {
3033 let markdown::mdast::Node::InlineMath(math) = node else {
3034 return None;
3035 };
3036 Some(
3037 crate::text::MarkdownNode::new("formula", ())
3038 .text(math.value.clone()),
3039 )
3040 },
3041 ),
3042 );
3043 state.set_markdown_extensions(Arc::new(extensions), cx);
3044 });
3045 cx.run_until_parked();
3046 let formula = find(markdown, "$ab$");
3048 assert_eq!(
3049 converted(
3050 &state,
3051 [
3052 formula.clone(),
3053 formula.start + 1..formula.start + 2,
3054 0..markdown.len(),
3055 find(markdown, "ab$ y"),
3056 ],
3057 cx
3058 ),
3059 [Some(2..4), Some(2..4), Some(0..6), Some(2..6)]
3060 );
3061 let ranges = converted(&state, [formula, 0..markdown.len()], cx);
3062 set(&state, [ranges[0].clone().unwrap()], cx).unwrap();
3063 assert!(painted(&state, TextLeafKey::block(0), cx).is_empty());
3064 set(&state, [ranges[1].clone().unwrap()], cx).unwrap();
3065 assert_eq!(painted(&state, TextLeafKey::block(0), cx), [0..2, 4..6]);
3066 }
3067
3068 #[gpui::test]
3069 fn a_dragged_selection_highlights_through_its_source_range(cx: &mut TestAppContext) {
3070 struct SelectionRoot {
3071 state: Entity<TextViewState>,
3072 }
3073
3074 impl Render for SelectionRoot {
3075 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
3076 gpui::div()
3077 .w(px(160.))
3078 .child(crate::TextSelectionLayer)
3079 .child(crate::text::TextView::new(&self.state))
3080 }
3081 }
3082
3083 cx.update(crate::init);
3084 let markdown = "hello **world** and `code` & more";
3085 let (root, cx) = cx.add_window_view(|_, cx| SelectionRoot {
3086 state: cx.new(|cx| TextViewState::markdown(markdown, cx)),
3087 });
3088 cx.run_until_parked();
3089 let state = root.read_with(cx, |root, _| root.state.clone());
3090 cx.update(|window, cx| window.draw(cx).clear(cx));
3091 let bounds = state.read_with(cx, |state, _| state.bounds());
3092 assert!(bounds.size.height > px(30.), "the text wraps, {bounds:?}");
3093 let start = bounds.origin + gpui::point(px(2.), px(4.));
3094 let end = bounds.bottom_right() - gpui::point(px(2.), px(4.));
3095 cx.simulate_mouse_down(start, gpui::MouseButton::Left, gpui::Modifiers::default());
3096 cx.update(|window, cx| window.draw(cx).clear(cx));
3097 cx.simulate_mouse_move(end, gpui::MouseButton::Left, gpui::Modifiers::default());
3098 cx.update(|window, cx| window.draw(cx).clear(cx));
3099 cx.simulate_mouse_up(end, gpui::MouseButton::Left, gpui::Modifiers::default());
3100 cx.update(|window, cx| window.draw(cx).clear(cx));
3101
3102 let range = state.update(cx, |state, cx| {
3103 let selected = state.selected_text();
3104 let source = state
3105 .selected_source_range()
3106 .expect("selection maps to source");
3107 let text = state.rendered_text();
3108 let range = text.range_for_source(source).expect("source renders text");
3109 assert!(selected.contains("code &"), "selected {selected:?}");
3110 assert_eq!(
3111 &text.as_str()[range.clone()],
3112 selected.trim_end_matches('\n')
3113 );
3114 state.clear_selection(cx);
3115 state
3116 .set_range_highlights([highlight(range.clone())], cx)
3117 .unwrap();
3118 range
3119 });
3120 cx.update(|window, cx| window.draw(cx).clear(cx));
3121 assert_eq!(painted(&state, TextLeafKey::block(0), cx), [range]);
3122 }
3123
3124 #[gpui::test]
3125 fn html_views_convert_no_source(cx: &mut TestAppContext) {
3126 let html = cx.update(|cx| cx.new(|cx| TextViewState::html("<p>one</p>", cx)));
3127 cx.run_until_parked();
3128 html.read_with(cx, |html, _| {
3129 let text = html.rendered_text();
3130 assert_eq!(text.source(), "<p>one</p>");
3131 assert_eq!(text.range_for_source(0..10), None);
3132 assert_eq!(text.range_for_source(3..6), None);
3133 });
3134 }
3135
3136 #[gpui::test]
3137 fn clear_range_highlights_removes_them(cx: &mut TestAppContext) {
3138 let state = state("first", cx);
3139 set(&state, [0..5], cx).unwrap();
3140 state.update(cx, |state, cx| state.clear_range_highlights(cx));
3141 assert!(!has_highlights(&state, cx));
3142 }
3143 }
3144
3145 mod reveal_range {
3146 use gpui::{
3147 Entity, InteractiveElement as _, ListAlignment, ListState, ScrollHandle,
3148 StatefulInteractiveElement as _, TestAppContext, VisualTestContext, div, list,
3149 };
3150
3151 use super::super::*;
3152 use crate::text::TextView;
3153
3154 #[derive(Clone)]
3156 enum Container {
3157 Scrollable,
3159 List(ListState),
3161 Div(ScrollHandle),
3164 Clamped(ListState),
3166 Fixed,
3168 }
3169
3170 struct Root {
3171 state: Entity<TextViewState>,
3172 container: Container,
3173 }
3174
3175 impl Render for Root {
3176 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
3177 let state = self.state.clone();
3178 let frame = div().w(px(200.)).h(px(100.));
3179 let row = |list_state: &ListState, clamp: bool| {
3180 let state = state.clone();
3181 list(list_state.clone(), move |ix, _, _| match ix {
3182 0 => div().h(px(40.)).into_any_element(),
3183 _ => TextView::new(&state)
3184 .when(clamp, |view| view.max_lines(2))
3185 .into_any_element(),
3186 })
3187 .size_full()
3188 };
3189 match &self.container {
3190 Container::Scrollable => frame
3191 .child(TextView::new(&state).scrollable(true))
3192 .into_any_element(),
3193 Container::List(list_state) => {
3194 frame.child(row(list_state, false)).into_any_element()
3195 }
3196 Container::Clamped(list_state) => {
3197 frame.child(row(list_state, true)).into_any_element()
3198 }
3199 Container::Div(handle) => {
3200 let scroll = handle.clone();
3201 frame
3202 .child(
3203 div()
3204 .id("scroll")
3205 .size_full()
3206 .overflow_y_scroll()
3207 .track_scroll(handle)
3208 .child(TextView::new(&state).on_reveal(move |line, _, _| {
3209 let viewport = scroll.bounds();
3210 let mut offset = scroll.offset();
3211 if line.bottom() > viewport.bottom() {
3212 offset.y -= line.bottom() - viewport.bottom();
3213 } else if line.top() < viewport.top() {
3214 offset.y += viewport.top() - line.top();
3215 }
3216 scroll.set_offset(offset);
3217 })),
3218 )
3219 .into_any_element()
3220 }
3221 Container::Fixed => frame.child(TextView::new(&state)).into_any_element(),
3222 }
3223 }
3224 }
3225
3226 fn window<'a>(
3227 markdown: &str,
3228 container: Container,
3229 cx: &'a mut TestAppContext,
3230 ) -> (Entity<TextViewState>, &'a mut VisualTestContext) {
3231 cx.update(crate::init);
3232 let (root, cx) = cx.add_window_view(|_, cx| Root {
3233 state: cx.new(|cx| TextViewState::markdown(markdown, cx)),
3234 container,
3235 });
3236 cx.run_until_parked();
3237 draw(cx);
3238 let state = root.read_with(cx, |root, _| root.state.clone());
3239 (state, cx)
3240 }
3241
3242 fn draw(cx: &mut VisualTestContext) {
3243 cx.update(|window, cx| window.draw(cx).clear(cx));
3244 }
3245
3246 fn request(
3249 state: &Entity<TextViewState>,
3250 needle: &str,
3251 cx: &mut VisualTestContext,
3252 then: impl FnOnce(&mut TextViewState, &mut Context<TextViewState>),
3253 ) {
3254 state.update(cx, |state, cx| {
3255 let text = state.rendered_text();
3256 let start = text.as_str().find(needle).unwrap();
3257 state.reveal_range(start..start + needle.len(), cx).unwrap();
3258 then(state, cx);
3259 });
3260 }
3261
3262 fn reveal(state: &Entity<TextViewState>, needle: &str, cx: &mut VisualTestContext) {
3264 request(state, needle, cx, |_, _| {});
3265 for _ in 0..3 {
3266 draw(cx);
3267 }
3268 }
3269
3270 fn is_pending(state: &Entity<TextViewState>, cx: &mut VisualTestContext) -> bool {
3271 state.read_with(cx, |state, _| state.pending_reveal.is_some())
3272 }
3273
3274 fn scroll_top(
3275 state: &Entity<TextViewState>,
3276 cx: &mut VisualTestContext,
3277 ) -> gpui::ListOffset {
3278 state.read_with(cx, |state, _| state.list_state.logical_scroll_top())
3279 }
3280
3281 fn paragraphs(count: usize) -> String {
3282 (0..count)
3283 .map(|ix| format!("paragraph {ix}"))
3284 .collect::<Vec<_>>()
3285 .join("\n\n")
3286 }
3287
3288 fn words(count: usize) -> String {
3289 (0..count)
3290 .map(|ix| format!("w{ix}"))
3291 .collect::<Vec<_>>()
3292 .join(" ")
3293 }
3294
3295 #[gpui::test]
3296 fn a_scrollable_view_scrolls_to_an_offscreen_block(cx: &mut TestAppContext) {
3297 let (state, cx) = window(¶graphs(200), Container::Scrollable, cx);
3298 reveal(&state, "paragraph 150", cx);
3299 assert!(!is_pending(&state, cx));
3300 let top = scroll_top(&state, cx);
3301 assert!((140..=150).contains(&top.item_ix), "{top:?}");
3302
3303 reveal(&state, "paragraph 3", cx);
3304 assert!(!is_pending(&state, cx));
3305 assert!(scroll_top(&state, cx).item_ix <= 3);
3306 }
3307
3308 #[gpui::test]
3309 fn an_append_adding_blocks_keeps_the_scroll_position(cx: &mut TestAppContext) {
3310 let (state, cx) = window(¶graphs(200), Container::Scrollable, cx);
3311 reveal(&state, "paragraph 100", cx);
3312 let top = scroll_top(&state, cx);
3313 assert!(top.item_ix > 0, "{top:?}");
3314
3315 state.update(cx, |state, cx| state.push_str("\n\nparagraph 200", cx));
3316 cx.run_until_parked();
3317 draw(cx);
3318
3319 state.read_with(cx, |state, _| {
3320 assert_eq!(state.list_state.item_count(), 201);
3321 });
3322 assert_unmoved(&state, top, cx);
3323 }
3324
3325 #[gpui::test]
3326 fn a_scrollable_view_scrolls_to_a_line_inside_a_long_paragraph(cx: &mut TestAppContext) {
3327 let (state, cx) = window(&words(400), Container::Scrollable, cx);
3328 reveal(&state, "w390", cx);
3329 assert!(!is_pending(&state, cx));
3330 let top = scroll_top(&state, cx);
3331 assert_eq!(top.item_ix, 0);
3332 assert!(top.offset_in_item > px(100.), "{top:?}");
3333 }
3334
3335 #[gpui::test]
3336 fn revealing_a_visible_line_does_not_scroll(cx: &mut TestAppContext) {
3337 let (state, cx) = window(&words(400), Container::Scrollable, cx);
3338 reveal(&state, "w200", cx);
3339 let top = scroll_top(&state, cx);
3340 assert!(top.offset_in_item > px(0.), "{top:?}");
3341 for word in ["w199", "w198", "w197", "w196"] {
3342 reveal(&state, word, cx);
3343 assert!(!is_pending(&state, cx));
3344 let now = scroll_top(&state, cx);
3345 assert_eq!(
3346 (now.item_ix, now.offset_in_item),
3347 (top.item_ix, top.offset_in_item),
3348 "{word}"
3349 );
3350 }
3351 }
3352
3353 #[gpui::test]
3354 fn an_enclosing_list_scrolls_to_a_line_of_a_fit_content_view(cx: &mut TestAppContext) {
3355 let outer = ListState::new(2, ListAlignment::Top, px(1000.));
3356 let (state, cx) = window(&words(400), Container::List(outer.clone()), cx);
3357 reveal(&state, "w390", cx);
3358 assert!(!is_pending(&state, cx));
3359 let top = outer.logical_scroll_top();
3360 assert_eq!(top.item_ix, 1, "{top:?}");
3361 assert!(top.offset_in_item > px(100.), "{top:?}");
3362 }
3363
3364 #[gpui::test]
3365 fn on_reveal_scrolls_a_container_that_ignores_scroll_requests(cx: &mut TestAppContext) {
3366 let handle = ScrollHandle::new();
3367 let (state, cx) = window(&words(400), Container::Div(handle.clone()), cx);
3368 reveal(&state, "w390", cx);
3369 assert!(!is_pending(&state, cx));
3370 assert!(handle.offset().y < px(-100.), "{:?}", handle.offset());
3371 }
3372
3373 #[gpui::test]
3374 fn a_reveal_that_cannot_be_shown_gives_up(cx: &mut TestAppContext) {
3375 let (state, cx) = window(&words(4000), Container::Fixed, cx);
3376 reveal(&state, "w3990", cx);
3377 assert!(is_pending(&state, cx));
3378 for _ in 0..10 {
3379 draw(cx);
3380 }
3381 assert!(!is_pending(&state, cx));
3382 }
3383
3384 #[gpui::test]
3385 fn a_reveal_not_carried_out_in_time_is_dropped(cx: &mut TestAppContext) {
3386 let (state, cx) = window(¶graphs(200), Container::Scrollable, cx);
3387 request(&state, "paragraph 150", cx, |_, cx| {
3388 cx.background_executor()
3389 .advance_clock(std::time::Duration::from_secs(2))
3390 });
3391 draw(cx);
3392 assert!(!is_pending(&state, cx));
3393 assert_eq!(scroll_top(&state, cx).item_ix, 0);
3394 }
3395
3396 #[gpui::test]
3397 fn an_empty_range_reveals_its_line(cx: &mut TestAppContext) {
3398 let (state, cx) = window(&words(400), Container::Scrollable, cx);
3399 reveal(&state, "w390", cx);
3400 let top = scroll_top(&state, cx);
3401 state.update(cx, |state, cx| {
3403 let text = state.rendered_text();
3404 let start = text.as_str().find("w391").unwrap();
3405 state.reveal_range(start..start, cx).unwrap();
3406 });
3407 for _ in 0..3 {
3408 draw(cx);
3409 }
3410 assert!(!is_pending(&state, cx));
3411 let now = scroll_top(&state, cx);
3412 assert_eq!(
3413 (now.item_ix, now.offset_in_item),
3414 (top.item_ix, top.offset_in_item)
3415 );
3416 }
3417
3418 #[gpui::test]
3419 fn a_position_after_the_last_character_reveals_its_line(cx: &mut TestAppContext) {
3420 let (state, cx) = window(&words(400), Container::Scrollable, cx);
3421 reveal(&state, "w399", cx);
3422 let top = scroll_top(&state, cx);
3423 assert!(top.offset_in_item > px(1000.), "{top:?}");
3424 state.update(cx, |state, cx| {
3426 let text = state.rendered_text();
3427 let end = text.as_str().find("w399").unwrap() + "w399".len();
3428 state.reveal_range(end..end, cx).unwrap();
3429 });
3430 for _ in 0..3 {
3431 draw(cx);
3432 }
3433 assert!(!is_pending(&state, cx));
3434 let now = scroll_top(&state, cx);
3435 assert_eq!(
3436 (now.item_ix, now.offset_in_item),
3437 (top.item_ix, top.offset_in_item)
3438 );
3439 }
3440
3441 fn reveal_at(
3443 state: &Entity<TextViewState>,
3444 range: impl FnOnce(&str) -> std::ops::Range<usize>,
3445 cx: &mut VisualTestContext,
3446 ) {
3447 state.update(cx, |state, cx| {
3448 let text = state.rendered_text();
3449 let range = range(text.as_str());
3450 state.reveal_range(range, cx).unwrap();
3451 });
3452 for _ in 0..3 {
3453 draw(cx);
3454 }
3455 }
3456
3457 fn assert_unmoved(
3458 state: &Entity<TextViewState>,
3459 top: gpui::ListOffset,
3460 cx: &mut VisualTestContext,
3461 ) {
3462 assert!(!is_pending(state, cx));
3463 let now = scroll_top(state, cx);
3464 assert_eq!(
3465 (now.item_ix, now.offset_in_item),
3466 (top.item_ix, top.offset_in_item)
3467 );
3468 }
3469
3470 #[gpui::test]
3471 fn the_end_of_the_text_and_its_separators_reveal_the_last_line(cx: &mut TestAppContext) {
3472 let (state, cx) = window(&words(400), Container::Scrollable, cx);
3473 reveal(&state, "w399", cx);
3474 let top = scroll_top(&state, cx);
3475 assert!(top.offset_in_item > px(1000.), "{top:?}");
3476 reveal_at(&state, |text| text.len()..text.len(), cx);
3478 assert_unmoved(&state, top, cx);
3479 reveal_at(&state, |text| text.len() - 1..text.len(), cx);
3481 assert_unmoved(&state, top, cx);
3482
3483 let code = (0..200)
3484 .map(|ix| format!("line {ix}"))
3485 .collect::<Vec<_>>()
3486 .join("\n");
3487 state.update(cx, |state, cx| {
3488 state.set_text(&format!("```\n{code}\n```"), cx)
3489 });
3490 cx.run_until_parked();
3491 reveal(&state, "line 199", cx);
3492 let top = scroll_top(&state, cx);
3493 assert!(top.offset_in_item > px(1000.), "{top:?}");
3494 reveal_at(&state, |text| text.len()..text.len(), cx);
3495 assert_unmoved(&state, top, cx);
3496 }
3497
3498 #[gpui::test]
3499 fn the_end_of_a_block_above_reveals_its_last_line(cx: &mut TestAppContext) {
3500 let second = (0..400)
3501 .map(|ix| format!("v{ix}"))
3502 .collect::<Vec<_>>()
3503 .join(" ");
3504 let markdown = format!("{}\n\n{second}", words(400));
3505 let (state, cx) = window(&markdown, Container::Scrollable, cx);
3506 reveal(&state, "v399", cx);
3507 assert_eq!(scroll_top(&state, cx).item_ix, 1);
3508 reveal_at(
3510 &state,
3511 |text| {
3512 let end = text.find("w399").unwrap() + "w399".len();
3513 end..end
3514 },
3515 cx,
3516 );
3517 assert!(!is_pending(&state, cx));
3518 let top = scroll_top(&state, cx);
3519 assert_eq!(top.item_ix, 0, "{top:?}");
3520 assert!(top.offset_in_item > px(1000.), "{top:?}");
3521 }
3522
3523 #[gpui::test]
3524 fn an_empty_view_has_nothing_to_reveal(cx: &mut TestAppContext) {
3525 cx.update(crate::init);
3526 let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("", cx)));
3527 cx.run_until_parked();
3528 state.update(cx, |state, cx| {
3529 assert_eq!(state.reveal_range(0..0, cx), Ok(()));
3530 assert!(state.pending_reveal.is_none());
3531 });
3532 }
3533
3534 #[gpui::test]
3535 fn revealing_a_visible_block_does_not_scroll(cx: &mut TestAppContext) {
3536 let markdown = format!("{}\n\n<div>html</div>\n\n{}", words(400), words(400));
3537 let (state, cx) = window(&markdown, Container::Scrollable, cx);
3538 reveal(&state, "html", cx);
3539 state.update(cx, |state, _| state.list_state.scroll_by(px(30.)));
3541 draw(cx);
3542 let top = scroll_top(&state, cx);
3543 reveal(&state, "html", cx);
3544 assert_unmoved(&state, top, cx);
3545 }
3546
3547 #[gpui::test]
3548 fn a_line_of_an_inline_flow_counts_as_shown_once_scrolled_to(cx: &mut TestAppContext) {
3549 let markdown = (0..400)
3552 .map(|ix| {
3553 if ix % 10 == 0 {
3554 format!("`c{ix}`")
3555 } else {
3556 format!("w{ix}")
3557 }
3558 })
3559 .collect::<Vec<_>>()
3560 .join(" ");
3561 let (state, cx) = window(&markdown, Container::Scrollable, cx);
3562 reveal(&state, "c390", cx);
3563 assert!(!is_pending(&state, cx));
3564 assert!(scroll_top(&state, cx).offset_in_item > px(1000.));
3565 }
3566
3567 #[gpui::test]
3568 fn a_range_starting_on_a_line_break_reveals_the_next_line(cx: &mut TestAppContext) {
3569 let code = (0..200)
3570 .map(|ix| format!("line {ix}"))
3571 .collect::<Vec<_>>()
3572 .join("\n");
3573 let (state, cx) = window(&format!("```\n{code}\n```"), Container::Scrollable, cx);
3574 reveal(&state, "\nline 190", cx);
3575 assert!(!is_pending(&state, cx));
3576 let top = scroll_top(&state, cx);
3577 assert!(top.offset_in_item > px(1000.), "{top:?}");
3578 }
3579
3580 #[gpui::test]
3581 fn a_range_starting_on_a_line_break_in_an_inline_flow_reveals_the_next_line(
3582 cx: &mut TestAppContext,
3583 ) {
3584 let lines = (0..200)
3586 .map(|ix| format!("line {ix} `code`"))
3587 .collect::<Vec<_>>()
3588 .join("\\\n");
3589 let (state, cx) = window(&lines, Container::Scrollable, cx);
3590 reveal(&state, "\nline 190", cx);
3591 assert!(!is_pending(&state, cx));
3592 let top = scroll_top(&state, cx);
3593 assert!(top.offset_in_item > px(1000.), "{top:?}");
3594 }
3595
3596 #[gpui::test]
3597 fn a_reveal_is_dropped_when_its_text_before_it_changes(cx: &mut TestAppContext) {
3598 let (state, cx) = window(¶graphs(200), Container::Scrollable, cx);
3599 let target = "first words then the target";
3600 let markdown = format!("{target}\n\n{}", paragraphs(200));
3601 state.update(cx, |state, cx| state.set_text(&markdown, cx));
3602 cx.run_until_parked();
3603
3604 request(&state, "target", cx, |state, cx| {
3606 state.set_text(&markdown.replacen("target", "target and more", 1), cx);
3607 assert!(state.pending_reveal.is_some());
3608 });
3609 request(&state, "target", cx, |state, cx| {
3611 state.set_text(&markdown.replacen("words", "WORDS", 1), cx);
3612 assert!(state.pending_reveal.is_none());
3613 });
3614 }
3615
3616 #[gpui::test]
3617 fn a_clamped_view_does_not_reveal(cx: &mut TestAppContext) {
3618 let outer = ListState::new(2, ListAlignment::Top, px(1000.));
3619 let (state, cx) = window(&words(400), Container::Clamped(outer.clone()), cx);
3620 reveal(&state, "w390", cx);
3621 assert!(!is_pending(&state, cx));
3622 assert_eq!(outer.logical_scroll_top().item_ix, 0);
3623 }
3624
3625 #[gpui::test]
3626 fn text_outside_every_block_reveals_its_block(cx: &mut TestAppContext) {
3627 let markdown = format!("{}\n\n<div>html text</div>", paragraphs(100));
3628 let (state, cx) = window(&markdown, Container::Scrollable, cx);
3629 reveal(&state, "html text", cx);
3630 assert!(!is_pending(&state, cx));
3631 assert!(
3632 scroll_top(&state, cx).item_ix >= 90,
3633 "{:?}",
3634 scroll_top(&state, cx)
3635 );
3636 }
3637
3638 #[gpui::test]
3639 fn a_reveal_follows_its_text_past_an_edit_before_it(cx: &mut TestAppContext) {
3640 let (state, cx) = window(¶graphs(200), Container::Scrollable, cx);
3641 request(&state, "paragraph 150", cx, |state, cx| {
3643 state.set_text(&format!("inserted\n\n{}", paragraphs(200)), cx);
3644 assert!(state.pending_reveal.is_some());
3645 });
3646 for _ in 0..3 {
3647 draw(cx);
3648 }
3649 assert!(!is_pending(&state, cx));
3650 let top = scroll_top(&state, cx);
3651 assert!((141..=151).contains(&top.item_ix), "{top:?}");
3652
3653 request(&state, "paragraph 150", cx, |state, cx| {
3655 state.set_text(¶graphs(200).replace("paragraph 150", "changed"), cx);
3656 assert!(state.pending_reveal.is_none());
3657 });
3658 }
3659
3660 #[gpui::test]
3661 fn malformed_ranges_and_html_views_are_rejected(cx: &mut TestAppContext) {
3662 cx.update(crate::init);
3663 let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("first\n\nsecond", cx)));
3664 cx.run_until_parked();
3665 state.update(cx, |state, cx| {
3666 assert_eq!(
3667 state.reveal_range(std::ops::Range { start: 5, end: 3 }, cx),
3668 Err(RangeHighlightError::InvalidRange(0))
3669 );
3670 assert_eq!(
3671 state.reveal_range(0..100, cx),
3672 Err(RangeHighlightError::InvalidRange(0))
3673 );
3674 state.reveal_range(0..5, cx).unwrap();
3675 });
3676
3677 let html = cx.update(|cx| cx.new(|cx| TextViewState::html("<p>one</p>", cx)));
3678 cx.run_until_parked();
3679 html.update(cx, |html, cx| {
3680 assert_eq!(
3681 html.reveal_range(0..3, cx),
3682 Err(RangeHighlightError::Unsupported)
3683 );
3684 });
3685 }
3686 }
3687}