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