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 if parsed_update.revision != state.revision {
183 return;
184 }
185 if parsed_update.baseline_ack {
186 debug_assert!(parsed_update.full_parse);
187 return;
188 }
189
190 match parsed_update.result {
191 Ok(content) => {
192 let append =
193 parsed_update.selection_compatible && !parsed_update.full_parse;
194 if append && state.full_update_revision <= state.committed_revision
195 {
196 state.splice_appended_blocks(&content.document);
197 }
198 state.reconcile_range_highlights(
199 &content.document,
200 parsed_update.revision,
201 append,
202 );
203 state.stream_fade.record(
204 &state.parsed_content.document,
205 &content.document,
206 Instant::now(),
207 );
208 state.parsed_content = content;
209 state.parsed_error = None;
210 state.compatible_layout_update = parsed_update.selection_compatible;
211 if parsed_update.full_parse {
212 state.invalidate_measured_heights();
213 }
214 }
215 Err(err) => {
216 state.stream_fade.discard_pending();
217 state.parsed_error = Some(err);
218 }
219 }
220 if !parsed_update.selection_compatible && !state.is_selecting {
224 state.reset_selection_and_adapter(cx);
225 }
226 cx.notify();
227 });
228 }
229 }
230 });
231
232 let _parse_task = cx.background_spawn(UpdateFuture::new(format, rx, tx_result));
233
234 let mut this = Self {
235 entity_id: cx.entity_id(),
236 focus_handle,
237 bounds: Bounds::default(),
238 multi_click_selection: None,
239 selected_text_override: None,
240 select_all: false,
241 selectable: false,
242 selection_format: SelectionFormat::default(),
243 scrollable: false,
244 max_lines: None,
245 line_spans: Arc::default(),
246 clamped: false,
247 list_state: ListState::new(0, gpui::ListAlignment::Top, px(1000.)).measure_all(),
252 text_view_style: Arc::default(),
253 code_block_actions: None,
254 code_block_highlighter: None,
255 table_actions: None,
256 link_click_handler: None,
257 image_source: None,
258 markdown_extensions: Arc::default(),
259 is_selecting: false,
260 preserve_inline_selection: false,
261 auto_scroll: AutoScroll::default(),
262 selection_adapter,
263 parsed_content: Default::default(),
264 stream_fade: StreamFadeTracker::default(),
265 fade_tick: None,
266 format,
267 parsed_error: None,
268 text: text.to_string(),
269 element_text: None,
270 revision: 0,
271 committed_revision: 0,
272 full_update_revision: 0,
273 rendered_index: Arc::default(),
274 range_highlights: None,
275 pending_reveal: None,
276 selection_revision: 0,
277 compatible_layout_update: false,
278 layout_text_style: None,
279 tx,
280 _parse_task,
281 _receive_task,
282 };
283 this.increment_update(&text, false, cx);
284 this
285 }
286
287 pub(crate) fn source(&self) -> SharedString {
289 self.parsed_content.document.source.clone()
290 }
291
292 pub fn selectable(mut self, selectable: bool) -> Self {
294 self.selectable = selectable;
295 self
296 }
297
298 pub fn set_selectable(&mut self, selectable: bool, cx: &mut Context<Self>) {
300 self.selectable = selectable;
301 cx.notify();
302 }
303
304 pub fn selection_format(mut self, selection_format: SelectionFormat) -> Self {
306 self.selection_format = selection_format;
307 self
308 }
309
310 pub fn set_selection_format(
312 &mut self,
313 selection_format: SelectionFormat,
314 cx: &mut Context<Self>,
315 ) {
316 self.selection_format = selection_format;
317 cx.notify();
318 }
319
320 pub fn scrollable(mut self, scrollable: bool) -> Self {
322 self.scrollable = scrollable;
323 self
324 }
325
326 pub fn set_scrollable(&mut self, scrollable: bool, cx: &mut Context<Self>) {
328 if !scrollable {
329 self.reset_selection_and_adapter(cx);
330 }
331 self.scrollable = scrollable;
332 cx.notify();
333 }
334
335 pub fn is_clamped(&self) -> bool {
338 self.clamped
339 }
340
341 pub fn set_text(&mut self, text: &str, cx: &mut Context<Self>) {
347 if self.text.as_str() == text {
348 return;
349 }
350 if self.stream_fade.is_enabled() {
351 if text.starts_with(self.text.as_str()) {
352 self.stream_fade.note_extend(self.text.len());
353 } else {
354 self.stream_fade.note_replace();
355 }
356 }
357
358 self.text.clear();
359 self.text.push_str(text);
360 self.parsed_error = None;
361 self.increment_update(text, false, cx);
362 }
363
364 pub(super) fn set_element_text(&mut self, text: &SharedString, cx: &mut Context<Self>) {
369 let same_string = self.element_text.as_ref().is_some_and(|current| {
370 current.as_ptr() == text.as_ptr() && current.len() == text.len()
371 });
372 if same_string && self.text.len() == text.len() {
373 return;
374 }
375 self.element_text = Some(text.clone());
376 self.set_text(text, cx);
377 }
378
379 pub fn push_str(&mut self, new_text: &str, cx: &mut Context<Self>) {
381 if new_text.is_empty() {
382 return;
383 }
384 self.stream_fade.note_extend(self.text.len());
385 self.text.push_str(new_text);
386 self.increment_update(new_text, true, cx);
387 }
388
389 pub fn motion(mut self, motion: TextViewMotion) -> Self {
391 self.set_motion(motion);
392 self
393 }
394
395 pub fn set_motion(&mut self, motion: TextViewMotion) {
397 self.stream_fade.set_motion(motion);
398 }
399
400 pub(crate) fn set_markdown_extensions(
401 &mut self,
402 markdown_extensions: Arc<MarkdownExtensions>,
403 cx: &mut Context<Self>,
404 ) {
405 if self.markdown_extensions.revision() == markdown_extensions.revision() {
406 return;
407 }
408
409 let parser_configuration_changed = !self
410 .markdown_extensions
411 .has_same_parser_configuration(&markdown_extensions);
412 self.markdown_extensions = markdown_extensions;
413 if parser_configuration_changed && self.format == TextViewFormat::Markdown {
414 let text = self.text.clone();
415 self.increment_update(&text, false, cx);
416 }
417 }
418
419 pub fn selected_text(&self) -> String {
421 self.selected_text_in(None)
422 }
423
424 pub fn selected_source_range(&self) -> Option<Range<usize>> {
435 if self.format != TextViewFormat::Markdown {
436 return None;
437 }
438 if self.select_all {
439 return Some(0..self.source().len());
440 }
441 self.parsed_content.document.selected_source_range()
442 }
443
444 fn effective_format(&self) -> SelectionFormat {
454 match self.format {
455 TextViewFormat::Markdown => self.selection_format,
456 TextViewFormat::Html => SelectionFormat::Plain,
457 }
458 }
459
460 pub(super) fn selected_text_in(&self, blocks: Option<RangeInclusive<usize>>) -> String {
467 let format = self.effective_format();
468
469 if self.select_all {
470 if format == SelectionFormat::Source {
471 return self.source().to_string();
472 }
473
474 return self.parsed_content.document.text();
475 }
476
477 if format != SelectionFormat::Source
482 && let Some(text) = &self.selected_text_override
483 {
484 return text.clone();
485 }
486
487 self.parsed_content.document.selected_text(format, blocks)
488 }
489
490 fn invalidate_measured_heights(&self) {
506 let count = self.list_state.item_count();
507 if count > 0 {
508 self.list_state.remeasure_items(0..count);
509 }
510 }
511
512 pub fn invalidate_inline_layout(&mut self, cx: &mut Context<Self>) {
518 self.preserve_inline_selection = true;
519 self.compatible_layout_update = true;
520 self.invalidate_measured_heights();
521 cx.notify();
522 }
523
524 fn increment_update(&mut self, text: &str, append: bool, cx: &mut Context<Self>) {
525 self.revision += 1;
526 if !append {
527 self.full_update_revision = self.revision;
528 self.selection_revision = self.selection_revision.wrapping_add(1);
529 }
530 let parse_synchronously = !append && text.len() <= MAX_SYNC_FULL_REPLACE_BYTES;
531 let update_options = UpdateOptions {
532 revision: self.revision,
533 append,
534 mode: if append {
535 ParseMode::Compatible
536 } else if parse_synchronously {
537 ParseMode::BaselineAck
538 } else {
539 ParseMode::Replace
540 },
541 pending_text: text.to_string(),
542 markdown_extensions: self.markdown_extensions.clone(),
543 };
544
545 if parse_synchronously {
549 match parse_content(self.format, ParsedContent::default(), &update_options) {
550 Ok(content) => {
551 self.reconcile_range_highlights(&content.document, self.revision, false);
552 self.stream_fade.record(
553 &self.parsed_content.document,
554 &content.document,
555 Instant::now(),
556 );
557 self.parsed_content = content;
558 self.parsed_error = None;
559 self.invalidate_measured_heights();
560 if !self.is_selecting {
561 self.reset_selection_and_adapter(cx);
562 }
563 }
564 Err(err) => {
565 self.stream_fade.discard_pending();
566 self.parsed_error = Some(err);
567 }
568 }
569 _ = self.tx.try_send(update_options);
573 cx.notify();
574 return;
575 }
576
577 _ = self.tx.try_send(update_options);
578 }
579
580 pub fn rendered_text(&self) -> RenderedText {
585 RenderedText::new(
586 self.entity_id,
587 self.committed_revision,
588 self.parsed_content.document.clone(),
589 self.rendered_index.clone(),
590 )
591 }
592
593 pub fn set_range_highlights(
607 &mut self,
608 highlights: impl IntoIterator<Item = RangeHighlight>,
609 cx: &mut Context<Self>,
610 ) -> Result<(), RangeHighlightError> {
611 if self.format != TextViewFormat::Markdown {
612 return Err(RangeHighlightError::Unsupported);
613 }
614 let text = self.rendered_text();
615 self.range_highlights = RangeHighlightFrame::new(&text, highlights)?.map(Arc::new);
616 cx.notify();
617 Ok(())
618 }
619
620 pub fn reveal_range(
643 &mut self,
644 range: Range<usize>,
645 cx: &mut Context<Self>,
646 ) -> Result<(), RangeHighlightError> {
647 if self.format != TextViewFormat::Markdown {
648 return Err(RangeHighlightError::Unsupported);
649 }
650 let text = self.rendered_text();
651 if text.is_empty() && range == (0..0) {
652 self.pending_reveal = None;
654 return Ok(());
655 }
656 let now = cx.background_executor().now();
657 self.pending_reveal = Some(
658 PendingReveal::new(&text, &range, now).ok_or(RangeHighlightError::InvalidRange(0))?,
659 );
660 cx.notify();
661 Ok(())
662 }
663
664 pub fn clear_range_highlights(&mut self, cx: &mut Context<Self>) {
666 if self.range_highlights.take().is_some() {
667 cx.notify();
668 }
669 }
670
671 fn reconcile_range_highlights(&mut self, new: &ParsedDocument, revision: usize, append: bool) {
679 let tail_only = append && self.full_update_revision <= self.committed_revision;
680 self.committed_revision = revision;
681 self.rendered_index = Arc::default();
682 if self.range_highlights.is_some() || self.pending_reveal.is_some() {
683 let remap = LeafRemap::new(&self.parsed_content.document, new, tail_only);
684 self.range_highlights = self
685 .range_highlights
686 .take()
687 .and_then(|highlights| highlights.remap(&remap))
688 .map(Arc::new);
689 self.pending_reveal = self
690 .pending_reveal
691 .take()
692 .and_then(|reveal| reveal.remap(&remap));
693 }
694 }
695
696 fn splice_appended_blocks(&self, new: &ParsedDocument) {
706 let old = &self.parsed_content.document.blocks;
707 let (old_count, new_count) = (old.len(), new.blocks.len());
708 if old_count == 0 || new_count <= old_count || self.list_state.item_count() != old_count {
709 return;
710 }
711
712 let unchanged = old
715 .iter()
716 .zip(new.blocks.iter())
717 .take(old_count - 1)
718 .take_while(|(old_block, new_block)| {
719 old_block
720 .span()
721 .is_some_and(|span| new_block.span() == Some(span))
722 })
723 .count();
724 self.list_state.remeasure_items(unchanged..old_count);
728 self.list_state
729 .splice(old_count..old_count, new_count - old_count);
730 }
731
732 pub(super) fn update_bounds(&mut self, bounds: Bounds<Pixels>, _cx: &mut App) {
734 self.bounds = bounds;
735 }
736
737 pub(super) fn block_ix_at(&self, content_y: Pixels) -> Option<usize> {
745 if !self.scrollable {
746 return None;
747 }
748
749 let origin = self.bounds.origin.y + self.scroll_offset().y;
750 let count = self.list_state.item_count();
751 let mut ix = self.list_state.logical_scroll_top().item_ix;
752 while ix < count {
753 let bounds = self.list_state.bounds_for_item(ix)?;
754 if content_y < bounds.bottom() - origin {
755 return Some(ix);
756 }
757 ix += 1;
758 }
759
760 count.checked_sub(1)
761 }
762
763 #[doc(hidden)]
764 pub fn bounds(&self) -> Bounds<Pixels> {
765 self.bounds
766 }
767
768 #[doc(hidden)]
769 pub fn list_state(&self) -> &ListState {
770 &self.list_state
771 }
772
773 #[doc(hidden)]
774 pub fn is_selecting(&self) -> bool {
775 self.is_selecting
776 }
777
778 #[doc(hidden)]
779 pub fn focus_handle(&self) -> &FocusHandle {
780 &self.focus_handle
781 }
782
783 pub(super) fn has_view_selection(&self) -> bool {
786 self.select_all
787 || self.multi_click_selection.is_some()
788 || self.selected_text_override.is_some()
789 }
790
791 pub(super) fn stop_auto_scroll(&mut self) {
792 self.auto_scroll.stop();
793 }
794
795 pub(super) fn reset_selection(&mut self) {
796 self.preserve_inline_selection = false;
797 self.multi_click_selection = None;
798 self.selected_text_override = None;
799 self.select_all = false;
800 self.is_selecting = false;
801 self.auto_scroll.stop();
802 self.parsed_content.document.clear_selection();
806 }
807
808 fn reset_selection_and_adapter(&mut self, cx: &mut App) {
809 self.reset_selection();
810 self.selection_adapter.set_local_selection(false, cx);
811 }
812
813 pub fn clear_selection(&mut self, cx: &mut Context<Self>) {
815 self.reset_selection_and_adapter(cx);
816 cx.notify();
817 }
818
819 pub(super) fn scroll_offset(&self) -> Point<Pixels> {
820 if self.scrollable {
821 self.list_state.scroll_px_offset_for_scrollbar()
822 } else {
823 Point::default()
824 }
825 }
826
827 pub fn select_all(&mut self, cx: &mut Context<Self>) {
829 self.multi_click_selection = None;
830 self.selected_text_override = None;
831 self.select_all = true;
832 self.is_selecting = false;
833 self.auto_scroll.stop();
834 self.selection_adapter.set_local_selection(true, cx);
835 cx.notify();
836 }
837
838 pub(crate) fn set_multi_click_selection(
839 &mut self,
840 pos: Point<Pixels>,
841 kind: TextViewMultiClickKind,
842 selected_text: String,
843 cx: &mut App,
844 ) {
845 self.preserve_inline_selection = false;
846 let scroll_offset = self.scroll_offset();
847 let pos = pos - self.bounds.origin - scroll_offset;
848 self.multi_click_selection = Some(TextViewMultiClickSelection {
849 pos,
850 kind,
851 line_bounds: None,
852 });
853 self.selected_text_override = Some(selected_text);
854 self.select_all = false;
855 self.is_selecting = false;
856 self.auto_scroll.stop();
857 self.selection_adapter.set_local_selection(true, cx);
858 }
859
860 pub(crate) fn set_multi_click_line(&mut self, bounds: Bounds<Pixels>, cx: &mut App) {
861 self.set_multi_click_selection(
862 bounds.center(),
863 TextViewMultiClickKind::Line,
864 String::new(),
865 cx,
866 );
867 self.selected_text_override = None;
868 let offset = self.bounds.origin + self.scroll_offset();
869 if let Some(selection) = self.multi_click_selection.as_mut() {
870 selection.line_bounds = Some(Bounds::new(bounds.origin - offset, bounds.size));
871 }
872 }
873
874 pub(super) fn set_auto_scroll(&mut self, delta: Option<Pixels>, cx: &mut Context<Self>) {
875 self.auto_scroll.set(delta, cx, |delta, state, cx| {
876 state.list_state.scroll_by(delta);
877 cx.notify();
878 });
879 }
880
881 pub(crate) fn selection_points(&self, cx: &App) -> Option<(Point<Pixels>, Point<Pixels>)> {
888 if !self.selectable {
889 return None;
890 }
891 self.selection_adapter.selection_points(cx)
892 }
893
894 pub(crate) fn has_selection(&self, cx: &App) -> bool {
895 self.has_view_selection() || self.selection_points(cx).is_some()
896 }
897
898 pub(super) fn on_action_select_all(
899 &mut self,
900 _: &SelectAll,
901 _: &mut Window,
902 cx: &mut Context<Self>,
903 ) {
904 if !self.selectable {
905 cx.propagate();
906 return;
907 }
908
909 self.select_all(cx);
910 }
911
912 pub(crate) fn is_selectable(&self) -> bool {
913 self.selectable
914 }
915
916 pub(crate) fn is_all_selected(&self) -> bool {
917 self.select_all
918 }
919
920 pub(crate) fn multi_click_selection(&self) -> Option<TextViewMultiClickSelection> {
921 let scroll_offset = self.scroll_offset();
922 self.multi_click_selection.map(|selection| {
923 let pos = selection.pos + scroll_offset + self.bounds.origin;
924 let line_bounds = selection.line_bounds.map(|bounds| {
925 Bounds::new(
926 bounds.origin + scroll_offset + self.bounds.origin,
927 bounds.size,
928 )
929 });
930 TextViewMultiClickSelection {
931 pos,
932 line_bounds,
933 ..selection
934 }
935 })
936 }
937}
938
939#[derive(Clone, Copy, Debug, PartialEq)]
940pub(crate) struct TextViewMultiClickSelection {
941 pub(crate) pos: Point<Pixels>,
942 pub(crate) kind: TextViewMultiClickKind,
943 pub(crate) line_bounds: Option<Bounds<Pixels>>,
944}
945
946#[derive(Clone, Copy, Debug, PartialEq, Eq)]
947pub(crate) enum TextViewMultiClickKind {
948 Word,
949 Paragraph,
950 Line,
951}
952
953impl TextViewState {
954 fn reveal_frame(
958 &mut self,
959 now: Instant,
960 window: &mut Window,
961 ) -> (Option<RevealRequest>, Option<usize>) {
962 if self.max_lines.is_some()
963 || self
964 .pending_reveal
965 .as_ref()
966 .is_some_and(|reveal| reveal.is_expired(now))
967 {
968 self.pending_reveal = None;
969 }
970 let Some(pending) = &self.pending_reveal else {
971 return (None, None);
972 };
973
974 let block_ix = pending.block_ix(&self.parsed_content.document);
975 let block_off_screen = |ix: usize| {
976 let viewport = self.list_state.viewport_bounds();
977 self.list_state.bounds_for_item(ix).is_none_or(|item| {
978 item.bottom() <= viewport.top() || item.top() >= viewport.bottom()
979 })
980 };
981 let reveal_block = block_ix.filter(|ix| {
982 self.scrollable
983 && (pending.is_block() || !pending.was_laid_out())
984 && block_off_screen(*ix)
985 });
986 let request = pending.request();
987 if pending.is_block() {
988 self.pending_reveal = None;
990 } else {
991 window.request_animation_frame();
992 }
993 (request, reveal_block)
994 }
995}
996
997impl Render for TextViewState {
998 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
999 let typography = (window.text_style(), window.rem_size());
1000 if self
1001 .layout_text_style
1002 .as_ref()
1003 .is_some_and(|previous| previous != &typography)
1004 {
1005 self.preserve_inline_selection = true;
1009 self.compatible_layout_update = true;
1010 self.invalidate_measured_heights();
1011 }
1012 self.layout_text_style = Some(typography);
1013 let state = cx.entity();
1014 let stream_fade = self.stream_fade.frame(Instant::now(), cx.reduce_motion());
1015 if stream_fade.is_some() && self.fade_tick.is_none() {
1016 self.fade_tick = Some(cx.spawn(async move |this, cx| {
1017 cx.background_executor().timer(STREAM_FADE_TICK).await;
1018 _ = this.update(cx, |state, cx| {
1019 state.fade_tick = None;
1020 cx.notify();
1021 });
1022 }));
1023 }
1024 let (reveal, reveal_block) = self.reveal_frame(cx.background_executor().now(), window);
1025 let node_cx = NodeContext {
1027 offset: self.parsed_content.node_cx.offset,
1028 link_refs: self.parsed_content.node_cx.link_refs.clone(),
1029 style: self.text_view_style.clone(),
1030 code_block_actions: self.code_block_actions.clone(),
1031 code_block_highlighter: self.code_block_highlighter.clone(),
1032 table_actions: self.table_actions.clone(),
1033 link_click_handler: self.link_click_handler.clone(),
1034 image_source: self.image_source.clone(),
1035 markdown_extensions: self.markdown_extensions.clone(),
1036 stream_fade,
1037 range_highlights: self.range_highlights.clone(),
1038 reveal,
1039 };
1040
1041 let content = v_flex()
1042 .w_full()
1043 .when(self.max_lines.is_none(), |this| this.h_full())
1046 .map(|this| match &self.parsed_error {
1047 None => this.child(self.parsed_content.document.render_root(
1048 if self.scrollable {
1049 Some(self.list_state.clone())
1050 } else {
1051 None
1052 },
1053 &node_cx,
1054 window,
1055 cx,
1056 )),
1057 Some(err) => this.child(
1058 v_flex()
1059 .gap_1()
1060 .child("Failed to parse content")
1061 .child(err.to_string()),
1062 ),
1063 })
1064 .on_prepaint(move |bounds, window, cx| {
1065 let (
1066 size_changed,
1067 selection_involves_view,
1068 has_selection_snapshot,
1069 is_selecting,
1070 compatible_layout_update,
1071 ) = {
1072 let state = state.read(cx);
1073 (
1074 state.bounds().size != bounds.size,
1075 state.selection_adapter.is_part_of_window_selection(cx),
1076 state.selection_adapter.has_selection_snapshot(cx),
1077 state.is_selecting,
1078 state.compatible_layout_update,
1079 )
1080 };
1081 let mut revision_changed = false;
1082 state.update(cx, |state, cx| {
1083 revision_changed = state
1084 .selection_adapter
1085 .update_layout_revision(state.selection_revision, state.is_selecting);
1086 state.update_bounds(bounds, cx);
1087 state.compatible_layout_update = false;
1088 });
1089 if !is_selecting
1090 && ((size_changed && selection_involves_view && !compatible_layout_update)
1091 || (revision_changed && has_selection_snapshot))
1092 {
1093 TextSelection::clear(window, cx);
1094 }
1095 });
1096 if let Some(block_ix) = reveal_block {
1099 self.list_state.scroll_to_reveal_item(block_ix);
1100 }
1101 content
1102 }
1103}
1104
1105#[derive(Clone, PartialEq, Default)]
1106pub(crate) struct ParsedContent {
1107 pub(crate) document: ParsedDocument,
1108 pub(crate) node_cx: node::NodeContext,
1109}
1110
1111struct UpdateFuture {
1112 format: TextViewFormat,
1113 content: ParsedContent,
1114 rx: Pin<Box<Receiver<UpdateOptions>>>,
1115 tx_result: Sender<ParsedUpdate>,
1116}
1117
1118impl UpdateFuture {
1119 fn new(
1120 format: TextViewFormat,
1121 rx: Receiver<UpdateOptions>,
1122 tx_result: Sender<ParsedUpdate>,
1123 ) -> Self {
1124 Self {
1125 format,
1126 content: Default::default(),
1127 rx: Box::pin(rx),
1128 tx_result,
1129 }
1130 }
1131}
1132
1133impl Future for UpdateFuture {
1134 type Output = ();
1135
1136 fn poll(mut self: Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> Poll<Self::Output> {
1137 loop {
1138 match self.rx.as_mut().poll_next(cx) {
1139 Poll::Ready(Some(mut options)) => {
1140 let hit_coalesce_budget =
1141 merge_pending_options(&mut options, self.rx.as_ref().get_ref());
1142
1143 let res = parse_content(self.format, self.content.clone(), &options);
1144 if let Ok(content) = &res {
1145 self.content = content.clone();
1146 }
1147 _ = self.tx_result.try_send(ParsedUpdate {
1148 revision: options.revision,
1149 full_parse: !options.append,
1150 selection_compatible: options.mode == ParseMode::Compatible,
1151 baseline_ack: options.mode == ParseMode::BaselineAck,
1152 result: res,
1153 });
1154 if hit_coalesce_budget {
1155 cx.waker().wake_by_ref();
1156 return Poll::Pending;
1157 }
1158 continue;
1159 }
1160 Poll::Ready(None) => return Poll::Ready(()),
1161 Poll::Pending => return Poll::Pending,
1162 }
1163 }
1164 }
1165}
1166
1167#[derive(Clone)]
1168struct UpdateOptions {
1169 revision: usize,
1170 pending_text: String,
1171 append: bool,
1172 mode: ParseMode,
1173 markdown_extensions: Arc<MarkdownExtensions>,
1174}
1175
1176impl UpdateOptions {
1177 fn merge(&mut self, next: UpdateOptions) {
1178 if next.append {
1179 self.pending_text.push_str(&next.pending_text);
1180 self.revision = next.revision;
1181 if self.mode != ParseMode::Replace {
1182 self.mode = ParseMode::Compatible;
1183 }
1184 } else {
1185 *self = next;
1186 }
1187 }
1188}
1189
1190struct ParsedUpdate {
1191 revision: usize,
1192 full_parse: bool,
1193 selection_compatible: bool,
1194 baseline_ack: bool,
1195 result: Result<ParsedContent, SharedString>,
1196}
1197
1198#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1199enum ParseMode {
1200 BaselineAck,
1201 Replace,
1202 Compatible,
1203}
1204
1205fn merge_pending_options(options: &mut UpdateOptions, rx: &Receiver<UpdateOptions>) -> bool {
1206 let mut update_count = 1;
1207
1208 while update_count < MAX_COALESCED_UPDATES_PER_PARSE {
1209 match rx.try_recv() {
1210 Ok(next_options) => {
1211 options.merge(next_options);
1212 update_count += 1;
1213 }
1214 Err(_) => return false,
1215 }
1216 }
1217
1218 true
1219}
1220
1221fn parse_content(
1222 format: TextViewFormat,
1223 mut content: ParsedContent,
1224 options: &UpdateOptions,
1225) -> Result<ParsedContent, SharedString> {
1226 let mut node_cx = NodeContext {
1227 markdown_extensions: options.markdown_extensions.clone(),
1228 ..NodeContext::default()
1229 };
1230
1231 let last_span = options
1237 .append
1238 .then(|| {
1239 content
1240 .document
1241 .blocks
1242 .last()
1243 .and_then(|block| block.span())
1244 })
1245 .flatten();
1246
1247 let mut source = String::new();
1248 if let Some(span) = last_span {
1249 Arc::make_mut(&mut content.document.blocks).pop();
1250 node_cx.offset = span.start;
1251 source.push_str(&content.document.source[span.start..]);
1252 source.push_str(&options.pending_text);
1253 } else {
1254 if options.append {
1255 node_cx.offset = content.document.source.len();
1256 }
1257 source.push_str(&options.pending_text);
1258 }
1259
1260 let new_document = match format {
1261 TextViewFormat::Markdown => format::markdown::parse(&source, &mut node_cx),
1262 TextViewFormat::Html => format::html::parse(&source, &mut node_cx),
1263 }?;
1264
1265 if options.append {
1266 content.document.source =
1267 format!("{}{}", content.document.source, options.pending_text).into();
1268 Arc::make_mut(&mut content.document.blocks)
1269 .extend(Arc::unwrap_or_clone(new_document.blocks));
1270 } else {
1271 content.document = new_document;
1272 }
1273
1274 Ok(content)
1275}
1276
1277#[cfg(test)]
1278mod tests {
1279 use super::*;
1280 use crate::text::{MarkdownNode, node::BlockNode};
1281 use gpui::TestAppContext;
1282
1283 mod stream_fade {
1284 use std::{cell::Cell, ops::Range, rc::Rc, time::Duration};
1285
1286 use gpui::{Entity, TestAppContext};
1287
1288 use super::super::*;
1289 use crate::{motion::Easing, text::stream_fade::TextLeafKey};
1290
1291 const FADE: Duration = Duration::from_secs(10);
1294
1295 fn fading_state(markdown: &str, cx: &mut TestAppContext) -> Entity<TextViewState> {
1296 cx.update(crate::init);
1297 let state = cx.update(|cx| {
1298 cx.new(|cx| {
1299 TextViewState::markdown(markdown, cx).motion(
1300 TextViewMotion::default()
1301 .with_stream_fade(FADE)
1302 .with_stream_fade_easing(Easing::Linear),
1303 )
1304 })
1305 });
1306 cx.run_until_parked();
1307 state
1308 }
1309
1310 fn fades(
1313 state: &Entity<TextViewState>,
1314 key: TextLeafKey,
1315 cx: &mut TestAppContext,
1316 ) -> Option<Vec<Range<usize>>> {
1317 state.update(cx, |state, _| {
1318 let frame = state.stream_fade.frame(Instant::now(), false)?;
1319 let fades = frame.fades(key)?;
1320 assert!(
1321 fades.iter().all(|(_, fade_out)| *fade_out > 0.9),
1322 "a fade sampled right after it starts is still transparent: {fades:?}"
1323 );
1324 Some(fades.iter().map(|(range, _)| range.clone()).collect())
1325 })
1326 }
1327
1328 #[gpui::test]
1329 fn push_str_fades_only_the_appended_text(cx: &mut TestAppContext) {
1330 let state = fading_state("hello", cx);
1331 state.update(cx, |state, cx| state.push_str(" world", cx));
1332 cx.run_until_parked();
1333
1334 assert_eq!(fades(&state, TextLeafKey::block(0), cx), Some(vec![5..11]));
1335
1336 state.update(cx, |state, _| {
1337 let later = Instant::now() + FADE + Duration::from_secs(1);
1338 assert!(state.stream_fade.frame(later, false).is_none());
1339 assert!(state.stream_fade.frame(Instant::now(), false).is_none());
1340 });
1341 }
1342
1343 #[gpui::test]
1344 fn set_text_extending_the_text_fades_like_push_str(cx: &mut TestAppContext) {
1345 let state = fading_state("hello", cx);
1346 state.update(cx, |state, cx| state.set_text("hello world", cx));
1347 cx.run_until_parked();
1348
1349 assert_eq!(fades(&state, TextLeafKey::block(0), cx), Some(vec![5..11]));
1350 }
1351
1352 #[gpui::test]
1353 fn set_text_replacing_the_text_shows_it_at_once(cx: &mut TestAppContext) {
1354 let state = fading_state("hello", cx);
1355 state.update(cx, |state, cx| state.push_str(" world", cx));
1356 cx.run_until_parked();
1357 assert!(fades(&state, TextLeafKey::block(0), cx).is_some());
1358
1359 state.update(cx, |state, cx| state.set_text("other", cx));
1360 cx.run_until_parked();
1361
1362 assert_eq!(fades(&state, TextLeafKey::block(0), cx), None);
1363 }
1364
1365 #[gpui::test]
1366 fn completed_markup_refades_from_the_divergence(cx: &mut TestAppContext) {
1367 let state = fading_state("text ", cx);
1370 state.update(cx, |state, cx| state.push_str("**bo", cx));
1371 cx.run_until_parked();
1372 assert_eq!(fades(&state, TextLeafKey::block(0), cx), Some(vec![4..9]));
1373
1374 state.update(cx, |state, cx| state.push_str("ld**", cx));
1377 cx.run_until_parked();
1378 assert_eq!(
1379 fades(&state, TextLeafKey::block(0), cx),
1380 Some(vec![4..5, 5..9])
1381 );
1382 }
1383
1384 #[gpui::test]
1385 fn without_stagger_an_update_fades_as_one_chunk(cx: &mut TestAppContext) {
1386 let state = fading_state("hello", cx);
1387 state.update(cx, |state, cx| state.push_str(" one two three", cx));
1388 cx.run_until_parked();
1389
1390 assert_eq!(fades(&state, TextLeafKey::block(0), cx), Some(vec![5..19]));
1391 }
1392
1393 #[gpui::test]
1394 fn words_of_one_update_start_one_after_another(cx: &mut TestAppContext) {
1395 let stagger = Duration::from_millis(100);
1396 let state = fading_state("hello", cx);
1397 state.update(cx, |state, _| {
1398 state.set_motion(
1399 TextViewMotion::default()
1400 .with_stream_fade(FADE)
1401 .with_stream_fade_stagger(stagger)
1402 .with_stream_fade_easing(Easing::Linear),
1403 )
1404 });
1405 state.update(cx, |state, cx| state.push_str(" one two three", cx));
1406 cx.run_until_parked();
1407
1408 state.update(cx, |state, _| {
1409 let frame = state
1410 .stream_fade
1411 .frame(Instant::now() + stagger * 3, false)
1412 .expect("words still fading");
1413 let fades = frame.fades(TextLeafKey::block(0)).expect("paragraph fades");
1414 let ranges: Vec<_> = fades.iter().map(|(range, _)| range.clone()).collect();
1415 assert_eq!(ranges, vec![5..10, 10..14, 14..19]);
1416 assert!(
1418 fades[0].1 < fades[1].1 && fades[1].1 < fades[2].1,
1419 "{fades:?}"
1420 );
1421 });
1422 }
1423
1424 #[gpui::test]
1425 fn a_new_paragraph_fades_as_a_whole(cx: &mut TestAppContext) {
1426 let state = fading_state("first", cx);
1427 state.update(cx, |state, cx| state.push_str("\n\nsecond", cx));
1428 cx.run_until_parked();
1429
1430 assert_eq!(fades(&state, TextLeafKey::block(0), cx), None);
1431 assert_eq!(fades(&state, TextLeafKey::block(7), cx), Some(vec![0..6]));
1432 }
1433
1434 #[gpui::test]
1435 fn code_block_text_fades_by_block(cx: &mut TestAppContext) {
1436 let state = fading_state("```rs\nlet", cx);
1437 state.update(cx, |state, cx| state.push_str(" x", cx));
1438 cx.run_until_parked();
1439
1440 assert_eq!(fades(&state, TextLeafKey::block(0), cx), Some(vec![3..5]));
1441 }
1442
1443 #[gpui::test]
1444 fn table_cells_fade_by_ordinal(cx: &mut TestAppContext) {
1445 let state = fading_state("| a | b |\n|---|---|\n| c | d", cx);
1446 state.update(cx, |state, cx| state.push_str("e |", cx));
1447 cx.run_until_parked();
1448
1449 assert_eq!(fades(&state, TextLeafKey::table_cell(0, 2), cx), None);
1450 assert_eq!(
1451 fades(&state, TextLeafKey::table_cell(0, 3), cx),
1452 Some(vec![1..2])
1453 );
1454 }
1455
1456 #[gpui::test]
1457 fn zero_duration_records_nothing(cx: &mut TestAppContext) {
1458 cx.update(crate::init);
1459 let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("hello", cx)));
1460 cx.run_until_parked();
1461 state.update(cx, |state, cx| state.push_str(" world", cx));
1462 cx.run_until_parked();
1463
1464 state.update(cx, |state, _| {
1465 assert!(state.stream_fade.frame(Instant::now(), false).is_none());
1466 });
1467 }
1468
1469 #[gpui::test]
1470 fn reduced_motion_drops_the_fade(cx: &mut TestAppContext) {
1471 let state = fading_state("hello", cx);
1472 state.update(cx, |state, cx| state.push_str(" world", cx));
1473 cx.run_until_parked();
1474
1475 state.update(cx, |state, _| {
1476 assert!(state.stream_fade.frame(Instant::now(), true).is_none());
1477 assert!(state.stream_fade.frame(Instant::now(), false).is_none());
1478 });
1479 }
1480
1481 #[gpui::test]
1482 fn a_fade_repaints_on_a_timer_until_nothing_fades(cx: &mut TestAppContext) {
1483 struct FadeRoot(Entity<TextViewState>);
1484 impl Render for FadeRoot {
1485 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1486 crate::text::TextView::new(&self.0)
1487 }
1488 }
1489
1490 let state = fading_state("hello", cx);
1491 let (_, cx) = cx.add_window_view(|_, _| FadeRoot(state.clone()));
1492 let notifies = Rc::new(Cell::new(0));
1493 let _subscription = cx.update(|_, cx| {
1494 let notifies = notifies.clone();
1495 cx.observe(&state, move |_, _| notifies.set(notifies.get() + 1))
1496 });
1497
1498 state.update(cx, |state, cx| state.push_str(" world", cx));
1499 cx.run_until_parked();
1500 cx.update(|window, cx| window.draw(cx).clear(cx));
1501 let before = notifies.get();
1502
1503 cx.executor().advance_clock(STREAM_FADE_TICK / 2);
1504 cx.run_until_parked();
1505 assert_eq!(notifies.get(), before);
1506
1507 for tick in 1..=3 {
1509 cx.executor().advance_clock(STREAM_FADE_TICK);
1510 cx.run_until_parked();
1511 assert_eq!(notifies.get(), before + tick);
1512 }
1513
1514 state.update(cx, |state, cx| state.set_text("other", cx));
1516 cx.run_until_parked();
1517 cx.executor().advance_clock(STREAM_FADE_TICK);
1518 cx.run_until_parked();
1519 let after = notifies.get();
1520 cx.executor().advance_clock(STREAM_FADE_TICK * 10);
1521 cx.run_until_parked();
1522 assert_eq!(notifies.get(), after);
1523 assert!(state.read_with(cx, |state, _| state.fade_tick.is_none()));
1524 }
1525 }
1526
1527 #[gpui::test]
1528 fn small_full_replace_parses_before_background_executor_runs(cx: &mut TestAppContext) {
1529 cx.update(crate::init);
1530 let markdown = "# ready";
1531 let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown(markdown, cx)));
1532
1533 state.read_with(cx, |state, _| {
1534 assert_eq!(state.source().as_str(), markdown);
1535 assert_eq!(state.parsed_content.document.blocks.len(), 1);
1536 });
1537 }
1538
1539 #[gpui::test]
1540 fn large_markdown_and_html_full_replacements_wait_for_background_executor(
1541 cx: &mut TestAppContext,
1542 ) {
1543 cx.update(crate::init);
1544 let markdown = "# x\n\n".repeat(MAX_SYNC_FULL_REPLACE_BYTES / 5 + 1);
1545 let html = format!("<p>{}</p>", "x".repeat(MAX_SYNC_FULL_REPLACE_BYTES + 1));
1546 assert!(markdown.len() > MAX_SYNC_FULL_REPLACE_BYTES);
1547 assert!(html.len() > MAX_SYNC_FULL_REPLACE_BYTES);
1548
1549 let (markdown_state, html_state) = cx.update(|cx| {
1550 (
1551 cx.new(|cx| TextViewState::markdown(&markdown, cx)),
1552 cx.new(|cx| TextViewState::html(&html, cx)),
1553 )
1554 });
1555
1556 markdown_state.read_with(cx, |state, _| {
1557 assert_eq!(state.text.as_str(), markdown.as_str());
1558 assert!(state.source().as_str().is_empty());
1559 assert!(state.parsed_content.document.blocks.is_empty());
1560 });
1561 html_state.read_with(cx, |state, _| {
1562 assert_eq!(state.text.as_str(), html.as_str());
1563 assert!(state.source().as_str().is_empty());
1564 assert!(state.parsed_content.document.blocks.is_empty());
1565 });
1566
1567 cx.run_until_parked();
1568
1569 markdown_state.read_with(cx, |state, _| {
1570 assert_eq!(state.source().as_str(), markdown.as_str());
1571 assert!(!state.parsed_content.document.blocks.is_empty());
1572 });
1573 html_state.read_with(cx, |state, _| {
1574 assert_eq!(state.source().as_str(), html.as_str());
1575 assert!(!state.parsed_content.document.blocks.is_empty());
1576 });
1577 }
1578
1579 #[gpui::test]
1580 fn inline_source_ranges_follow_streamed_tail_reparsing(cx: &mut TestAppContext) {
1581 cx.update(crate::init);
1582 let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("中文 $a$\n\n尾 $x", cx)));
1583 state.update(cx, |state, cx| {
1584 let extensions = MarkdownExtensions::default().plugin(
1585 crate::text::markdown_ext::TestInlinePlugin::new("test").parse_with(|node, _| {
1586 let markdown::mdast::Node::InlineMath(math) = node else {
1587 return None;
1588 };
1589 Some(super::super::MarkdownNode::new("formula", ()).text(math.value.clone()))
1590 }),
1591 );
1592 state.set_markdown_extensions(Arc::new(extensions), cx);
1593 });
1594 cx.run_until_parked();
1595 state.update(cx, |state, cx| state.push_str("^2$ 后", cx));
1596 cx.run_until_parked();
1597 state.read_with(cx, |state, _| {
1598 let source = "中文 $a$\n\n尾 $x^2$ 后";
1599 assert_eq!(state.source().as_str(), source);
1600 let objects: Vec<_> = state
1601 .parsed_content
1602 .document
1603 .blocks
1604 .iter()
1605 .flat_map(|block| {
1606 let super::super::node::BlockNode::Paragraph(paragraph) = block else {
1607 panic!()
1608 };
1609 paragraph
1610 .children
1611 .iter()
1612 .filter_map(|node| node.custom.as_ref())
1613 })
1614 .collect();
1615 assert_eq!(objects.len(), 2);
1616 for (object, markdown) in objects.iter().zip(["$a$", "$x^2$"]) {
1617 let start = source.find(markdown).unwrap();
1618 assert_eq!(object.source_range(), Some(start..start + markdown.len()));
1619 assert_eq!(object.as_markdown(), markdown);
1620 }
1621 });
1622 }
1623
1624 #[gpui::test]
1625 fn async_full_replace_then_push_str_preserves_complete_source(cx: &mut TestAppContext) {
1626 cx.update(crate::init);
1627 let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("old", cx)));
1628 cx.run_until_parked();
1629
1630 let replacement = "x".repeat(MAX_SYNC_FULL_REPLACE_BYTES + 1);
1631 let expected = format!("{replacement} tail");
1632 state.update(cx, |state, cx| {
1633 state.set_text(&replacement, cx);
1634 state.push_str(" tail", cx);
1635 });
1636 cx.run_until_parked();
1637
1638 state.read_with(cx, |state, _| {
1639 assert_eq!(state.text.as_str(), expected.as_str());
1640 assert_eq!(state.source().as_str(), expected.as_str());
1641 });
1642 }
1643
1644 #[gpui::test]
1645 fn html_push_str_keeps_earlier_blocks(cx: &mut TestAppContext) {
1646 cx.update(crate::init);
1647 let state = cx.update(|cx| cx.new(|cx| TextViewState::html("<p>first</p>", cx)));
1648 cx.run_until_parked();
1649
1650 state.update(cx, |state, cx| {
1651 state.push_str("<p>second</p>", cx);
1652 });
1653 cx.run_until_parked();
1654
1655 state.read_with(cx, |state, _| {
1656 assert_eq!(state.source().as_str(), "<p>first</p><p>second</p>");
1657 let text = state
1658 .parsed_content
1659 .document
1660 .blocks
1661 .iter()
1662 .map(|block| block.text())
1663 .collect::<String>();
1664 assert!(text.contains("first"), "lost the first block: {text:?}");
1665 assert!(text.contains("second"), "lost the appended block: {text:?}");
1666 });
1667 }
1668
1669 #[gpui::test]
1670 fn element_text_of_the_same_string_is_not_compared_again(cx: &mut TestAppContext) {
1671 cx.update(crate::init);
1672 let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("", cx)));
1673 let revision = |cx: &mut TestAppContext| state.read_with(cx, |state, _| state.revision);
1674 let text = SharedString::from("hello");
1675
1676 state.update(cx, |state, cx| state.set_element_text(&text, cx));
1677 let parsed = revision(cx);
1678 assert!(parsed > 0);
1679
1680 state.update(cx, |state, cx| state.set_element_text(&text, cx));
1683 assert_eq!(revision(cx), parsed);
1684 let equal = SharedString::from("hello".to_string());
1685 state.update(cx, |state, cx| state.set_element_text(&equal, cx));
1686 assert_eq!(revision(cx), parsed);
1687
1688 state.update(cx, |state, cx| {
1690 state.push_str(" world", cx);
1691 state.set_element_text(&equal, cx);
1692 });
1693 state.read_with(cx, |state, _| assert_eq!(state.text.as_str(), "hello"));
1694 }
1695
1696 #[gpui::test]
1697 fn set_text_then_push_str_appends_to_replaced_content(cx: &mut TestAppContext) {
1698 cx.update(crate::init);
1699 let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("old", cx)));
1700 cx.run_until_parked();
1701
1702 state.update(cx, |state, cx| {
1703 state.set_text("", cx);
1704 state.push_str("new", cx);
1705 state.push_str(" text", cx);
1706 });
1707 cx.run_until_parked();
1708
1709 state.read_with(cx, |state, _| {
1710 assert_eq!(state.text.as_str(), "new text");
1711 assert_eq!(state.source().as_str(), "new text");
1712 });
1713
1714 state.update(cx, |state, cx| {
1715 state.set_text("", cx);
1716 });
1717 cx.run_until_parked();
1718
1719 state.read_with(cx, |state, _| {
1720 assert_eq!(state.text.as_str(), "");
1721 assert_eq!(state.source().as_str(), "");
1722 });
1723 }
1724
1725 #[gpui::test]
1726 fn full_parse_coalesced_with_append_preserves_new_select_all(cx: &mut TestAppContext) {
1727 cx.update(crate::init);
1728 let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("old", cx)));
1729 cx.run_until_parked();
1730
1731 state.update(cx, |state, cx| {
1732 state.set_text("new", cx);
1733 state.push_str(" text", cx);
1734 state.select_all(cx);
1735 });
1736 cx.run_until_parked();
1737
1738 state.read_with(cx, |state, _| {
1739 assert!(state.select_all);
1740 assert_eq!(state.selected_text().trim(), "new text");
1741 });
1742 }
1743
1744 #[test]
1745 fn update_options_merge_keeps_latest_full_text() {
1746 let mut options = UpdateOptions {
1747 revision: 1,
1748 pending_text: "old".to_string(),
1749 append: true,
1750 mode: ParseMode::Compatible,
1751 markdown_extensions: Arc::default(),
1752 };
1753
1754 options.merge(UpdateOptions {
1755 revision: 2,
1756 pending_text: "new".to_string(),
1757 append: false,
1758 mode: ParseMode::BaselineAck,
1759 markdown_extensions: Arc::default(),
1760 });
1761 options.merge(UpdateOptions {
1762 revision: 3,
1763 pending_text: " text".to_string(),
1764 append: true,
1765 mode: ParseMode::Compatible,
1766 markdown_extensions: Arc::default(),
1767 });
1768
1769 assert_eq!(options.revision, 3);
1770 assert_eq!(options.pending_text, "new text");
1771 assert!(!options.append);
1772 }
1773
1774 #[test]
1775 fn append_merged_into_async_replace_remains_a_replacement() {
1776 let mut options = UpdateOptions {
1777 revision: 1,
1778 pending_text: "new".to_string(),
1779 append: false,
1780 mode: ParseMode::Replace,
1781 markdown_extensions: Arc::default(),
1782 };
1783
1784 options.merge(UpdateOptions {
1785 revision: 2,
1786 pending_text: " text".to_string(),
1787 append: true,
1788 mode: ParseMode::Compatible,
1789 markdown_extensions: Arc::default(),
1790 });
1791
1792 assert_eq!(options.revision, 2);
1793 assert_eq!(options.pending_text, "new text");
1794 assert!(!options.append);
1795 assert_eq!(options.mode, ParseMode::Replace);
1796 }
1797
1798 #[test]
1799 fn update_future_yields_before_coalescing_all_queued_updates() {
1800 let (tx, rx) = unbounded::<UpdateOptions>();
1801 let (tx_result, rx_result) = unbounded::<ParsedUpdate>();
1802 let total_updates = 128;
1803
1804 for revision in 1..=total_updates {
1805 tx.try_send(UpdateOptions {
1806 revision,
1807 pending_text: format!("{revision}\n"),
1808 append: revision != 1,
1809 mode: if revision == 1 {
1810 ParseMode::BaselineAck
1811 } else {
1812 ParseMode::Compatible
1813 },
1814 markdown_extensions: Arc::default(),
1815 })
1816 .unwrap();
1817 }
1818
1819 let mut future = Box::pin(UpdateFuture::new(TextViewFormat::Markdown, rx, tx_result));
1820 let waker = futures::task::noop_waker();
1821 let mut task_cx = std::task::Context::from_waker(&waker);
1822
1823 assert!(matches!(
1824 std::future::Future::poll(future.as_mut(), &mut task_cx),
1825 Poll::Pending
1826 ));
1827 let parsed_update = rx_result.try_recv().expect("parse result");
1828
1829 assert!(
1830 parsed_update.revision < total_updates,
1831 "single poll coalesced every queued update through revision {}",
1832 parsed_update.revision
1833 );
1834
1835 assert!(matches!(
1836 std::future::Future::poll(future.as_mut(), &mut task_cx),
1837 Poll::Pending
1838 ));
1839 let parsed_update = rx_result.try_recv().expect("next parse result");
1840 assert_eq!(parsed_update.revision, total_updates);
1841 }
1842
1843 #[gpui::test]
1844 fn select_all_returns_rendered_text(cx: &mut TestAppContext) {
1845 cx.update(crate::init);
1846 let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("**quick** value", cx)));
1847 cx.run_until_parked();
1848
1849 state.update(cx, |state, cx| {
1850 state.select_all(cx);
1851 });
1852
1853 state.read_with(cx, |state, _| {
1854 assert!(state.has_view_selection());
1855 assert_eq!(state.selected_text().trim(), "quick value");
1856 });
1857
1858 state.update(cx, |state, cx| {
1859 state.clear_selection(cx);
1860 });
1861
1862 state.read_with(cx, |state, _| {
1863 assert!(!state.has_view_selection());
1864 assert_eq!(state.selected_text(), "");
1865 });
1866 }
1867
1868 #[gpui::test]
1869 fn select_all_in_source_format_returns_source(cx: &mut TestAppContext) {
1870 cx.update(crate::init);
1871 let markdown = "**quick** value";
1872 let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown(markdown, cx)));
1873 cx.run_until_parked();
1874
1875 state.update(cx, |state, cx| state.select_all(cx));
1876
1877 state.read_with(cx, |state, _| {
1879 assert_eq!(state.selected_text().trim(), "quick value");
1880 });
1881
1882 state.update(cx, |state, cx| {
1883 state.set_selection_format(SelectionFormat::Source, cx)
1884 });
1885
1886 state.read_with(cx, |state, _| {
1888 assert_eq!(state.selected_text().trim(), markdown);
1889 });
1890 }
1891
1892 #[gpui::test]
1893 fn selected_source_range_returns_full_markdown_source_for_select_all(cx: &mut TestAppContext) {
1894 cx.update(crate::init);
1895 let markdown = "**quick** value";
1896 let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown(markdown, cx)));
1897 cx.run_until_parked();
1898
1899 state.update(cx, |state, cx| state.select_all(cx));
1900
1901 state.read_with(cx, |state, _| {
1902 assert_eq!(state.selected_source_range(), Some(0..markdown.len()));
1903 });
1904 }
1905
1906 #[gpui::test]
1907 fn selected_source_range_returns_none_for_html(cx: &mut TestAppContext) {
1908 cx.update(crate::init);
1909 let state = cx.update(|cx| cx.new(|cx| TextViewState::html("<b>quick</b>", cx)));
1910 cx.run_until_parked();
1911
1912 state.update(cx, |state, cx| state.select_all(cx));
1913
1914 state.read_with(cx, |state, _| {
1915 assert_eq!(state.selected_source_range(), None);
1916 });
1917 }
1918
1919 #[test]
1920 fn selected_source_range_keeps_global_offsets_after_incremental_tail_parse() {
1921 let options = UpdateOptions {
1922 revision: 1,
1923 pending_text: "first\n\nsecond".to_string(),
1924 append: false,
1925 mode: ParseMode::Replace,
1926 markdown_extensions: Arc::default(),
1927 };
1928 let content = parse_content(TextViewFormat::Markdown, ParsedContent::default(), &options)
1929 .expect("initial parse");
1930 let content = parse_content(
1931 TextViewFormat::Markdown,
1932 content,
1933 &UpdateOptions {
1934 revision: 2,
1935 pending_text: "\n\n**écho**".to_string(),
1936 append: true,
1937 mode: ParseMode::Compatible,
1938 markdown_extensions: Arc::default(),
1939 },
1940 )
1941 .expect("incremental parse");
1942 let BlockNode::Paragraph(paragraph) = &content.document.blocks[2] else {
1943 panic!("expected appended paragraph");
1944 };
1945 let mut state = paragraph.state.lock().unwrap();
1946 state.set_text(paragraph.text().into());
1947 state.selection = Some((0.."écho".len()).into());
1948 drop(state);
1949
1950 assert_eq!(content.document.selected_source_range(), Some(17..22));
1951 }
1952
1953 #[test]
1954 fn streamed_ordered_list_continuation_preserves_start() {
1955 let initial = parse_content(
1956 TextViewFormat::Markdown,
1957 ParsedContent::default(),
1958 &UpdateOptions {
1959 revision: 1,
1960 pending_text: "3. three".to_string(),
1961 append: false,
1962 mode: ParseMode::Replace,
1963 markdown_extensions: Arc::default(),
1964 },
1965 )
1966 .expect("initial list parse");
1967 let continued = parse_content(
1968 TextViewFormat::Markdown,
1969 initial,
1970 &UpdateOptions {
1971 revision: 2,
1972 pending_text: "\n4. four".to_string(),
1973 append: true,
1974 mode: ParseMode::Compatible,
1975 markdown_extensions: Arc::default(),
1976 },
1977 )
1978 .expect("streamed list continuation parse");
1979
1980 let BlockNode::List {
1981 start, children, ..
1982 } = &continued.document.blocks[0]
1983 else {
1984 panic!("expected streamed ordered list");
1985 };
1986 assert_eq!(*start, Some(3));
1987 assert_eq!(children.len(), 2);
1988 }
1989
1990 #[gpui::test]
1991 fn parser_revision_reparses_same_name_inline_configuration(cx: &mut TestAppContext) {
1992 cx.update(crate::init);
1993 let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("@member", cx)));
1994 for (revision, label) in [(1, "Alice"), (2, "Bob")] {
1995 let extensions = MarkdownExtensions::default()
1996 .parser_revision(revision)
1997 .plugin(
1998 crate::text::markdown_ext::TestInlinePlugin::new("test").parse_with(
1999 move |node, _| {
2000 matches!(node, markdown::mdast::Node::Text(_))
2001 .then(|| MarkdownNode::new("mention", label).text(label))
2002 },
2003 ),
2004 );
2005 state.update(cx, |state, cx| {
2006 state.set_markdown_extensions(Arc::new(extensions), cx)
2007 });
2008 cx.run_until_parked();
2009 state.read_with(cx, |state, _| {
2010 let node::BlockNode::Paragraph(paragraph) =
2011 &state.parsed_content.document.blocks[0]
2012 else {
2013 panic!()
2014 };
2015 assert_eq!(
2016 paragraph.children[0].custom.as_ref().unwrap().as_text(),
2017 label
2018 );
2019 });
2020 }
2021 }
2022
2023 #[gpui::test]
2024 fn set_markdown_extensions_reparses_existing_text(cx: &mut TestAppContext) {
2025 cx.update(crate::init);
2026 let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("$TSLA.US", cx)));
2027 cx.run_until_parked();
2028
2029 let extensions = MarkdownExtensions::default().block_parser(|node, cx| {
2030 let markdown::mdast::Node::Paragraph(paragraph) = node else {
2031 return None;
2032 };
2033 let [markdown::mdast::Node::Text(text)] = paragraph.children.as_slice() else {
2034 return None;
2035 };
2036 let symbol = text.value.strip_prefix('$')?.to_string();
2037 let node_text = format!("${symbol}");
2038
2039 Some(
2040 MarkdownNode::new("ticker", symbol)
2041 .text(node_text)
2042 .markdown(cx.node_source(node).unwrap_or_default()),
2043 )
2044 });
2045
2046 state.update(cx, |state, cx| {
2047 state.set_markdown_extensions(Arc::new(extensions), cx);
2048 });
2049 cx.run_until_parked();
2050
2051 state.read_with(cx, |state, _| {
2052 let node::BlockNode::Custom(node) = &state.parsed_content.document.blocks[0] else {
2053 panic!("expected custom markdown node");
2054 };
2055 assert_eq!(node.name(), "ticker");
2056 assert_eq!(node.data::<String>().map(String::as_str), Some("TSLA.US"));
2057 });
2058 }
2059
2060 mod range_highlights {
2061 use std::ops::Range;
2062
2063 use gpui::{Entity, TestAppContext};
2064
2065 use super::super::*;
2066 use crate::text::stream_fade::TextLeafKey;
2067
2068 fn state(markdown: &str, cx: &mut TestAppContext) -> Entity<TextViewState> {
2069 cx.update(crate::init);
2070 let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown(markdown, cx)));
2071 cx.run_until_parked();
2072 state
2073 }
2074
2075 fn highlight(range: Range<usize>) -> RangeHighlight {
2076 RangeHighlight::new(range, gpui::hsla(0.15, 1., 0.5, 0.4))
2077 }
2078
2079 fn set(
2081 state: &Entity<TextViewState>,
2082 ranges: impl IntoIterator<Item = Range<usize>>,
2083 cx: &mut TestAppContext,
2084 ) -> Result<(), RangeHighlightError> {
2085 state.update(cx, |state, cx| {
2086 state.set_range_highlights(ranges.into_iter().map(highlight), cx)
2087 })
2088 }
2089
2090 fn painted(
2092 state: &Entity<TextViewState>,
2093 key: TextLeafKey,
2094 cx: &mut TestAppContext,
2095 ) -> Vec<Range<usize>> {
2096 state.read_with(cx, |state, _| {
2097 state.range_highlights.as_ref().map_or(Vec::new(), |frame| {
2098 frame
2099 .backgrounds(key)
2100 .iter()
2101 .map(|(range, _)| range.clone())
2102 .collect()
2103 })
2104 })
2105 }
2106
2107 fn has_highlights(state: &Entity<TextViewState>, cx: &mut TestAppContext) -> bool {
2108 state.read_with(cx, |state, _| state.range_highlights.is_some())
2109 }
2110
2111 #[gpui::test]
2112 fn rendered_text_is_the_plain_copy_text(cx: &mut TestAppContext) {
2113 let state = state(
2114 "# Title\n\nhello **world** \\*\n\n- item\n\n| a | b |\n|---|---|\n| c | d |\n\n```\nlet x\n```",
2115 cx,
2116 );
2117 state.read_with(cx, |state, _| {
2118 assert_eq!(
2119 state.rendered_text().as_str(),
2120 "Title\nhello world *\nitem\na b\nc d\n\nlet x\n"
2121 );
2122 });
2123 }
2124
2125 #[gpui::test]
2126 fn a_match_across_marks_paints_in_its_paragraph(cx: &mut TestAppContext) {
2127 let state = state("hello **world**", cx);
2128 set(&state, [0.."hello world".len()], cx).unwrap();
2129 assert_eq!(painted(&state, TextLeafKey::block(0), cx), [0..11]);
2130 }
2131
2132 #[gpui::test]
2133 fn repeated_text_maps_to_the_occurrence_addressed(cx: &mut TestAppContext) {
2134 let state = state("foo\n\nfoo", cx);
2135 let second = state.read_with(cx, |state, _| {
2136 state.rendered_text().as_str().rfind("foo").unwrap()
2137 });
2138 set(&state, [second..second + 3], cx).unwrap();
2139 assert!(painted(&state, TextLeafKey::block(0), cx).is_empty());
2140 assert_eq!(painted(&state, TextLeafKey::block(5), cx), [0..3]);
2141 }
2142
2143 #[gpui::test]
2144 fn a_range_across_blocks_skips_the_separator(cx: &mut TestAppContext) {
2145 let state = state("ab\n\ncd", cx);
2146 set(&state, [1..4], cx).unwrap();
2148 assert_eq!(painted(&state, TextLeafKey::block(0), cx), [1..2]);
2149 assert_eq!(painted(&state, TextLeafKey::block(4), cx), [0..1]);
2150 }
2151
2152 #[gpui::test]
2153 fn table_cells_and_code_blocks_are_leaves(cx: &mut TestAppContext) {
2154 let state = state("| a | b |\n|---|---|\n| c | d |\n\n```\nlet x\n```", cx);
2155 set(&state, [0..3, 6..7, 9..12], cx).unwrap();
2157 assert_eq!(painted(&state, TextLeafKey::table_cell(0, 0), cx), [0..1]);
2158 assert_eq!(painted(&state, TextLeafKey::table_cell(0, 1), cx), [0..1]);
2159 assert_eq!(painted(&state, TextLeafKey::table_cell(0, 3), cx), [0..1]);
2160 let code_start = "| a | b |\n|---|---|\n| c | d |\n\n".len();
2161 assert_eq!(painted(&state, TextLeafKey::block(code_start), cx), [0..3]);
2162 }
2163
2164 #[gpui::test]
2165 fn invalid_ranges_reject_the_whole_set(cx: &mut TestAppContext) {
2166 let state = state("中文\n\nab", cx);
2167 set(&state, [0.."中".len()], cx).unwrap();
2169 let invalid = [Range { start: 6, end: 3 }, 0..1, 0..100];
2170 for range in invalid {
2171 assert_eq!(
2172 set(&state, [0..3, range.clone()], cx),
2173 Err(RangeHighlightError::InvalidRange(1)),
2174 "{range:?}"
2175 );
2176 }
2177 assert_eq!(painted(&state, TextLeafKey::block(0), cx), [0..3]);
2179 }
2180
2181 #[gpui::test]
2182 fn text_outside_every_block_is_left_unpainted(cx: &mut TestAppContext) {
2183 let source = "foo one\n\n<div>foo two</div>\n\n| foo | x |\n|---|---|\n\nfoo three";
2184 let state = state(source, cx);
2185 let text = state.read_with(cx, |state, _| state.rendered_text().as_str().to_string());
2186 let ranges = text
2190 .match_indices("foo")
2191 .chain(text.match_indices(' '))
2192 .map(|(start, found)| start..start + found.len())
2193 .chain([3..3, 7..8])
2194 .collect::<Vec<_>>();
2195 set(&state, ranges, cx).unwrap();
2196 assert_eq!(painted(&state, TextLeafKey::block(0), cx), [0..3, 3..4]);
2197 let table = source.find("| foo").unwrap();
2198 assert_eq!(
2199 painted(&state, TextLeafKey::table_cell(table, 0), cx),
2200 [0..3]
2201 );
2202 let last = source.find("foo three").unwrap();
2203 assert_eq!(painted(&state, TextLeafKey::block(last), cx), [0..3, 3..4]);
2204 }
2205
2206 #[gpui::test]
2207 fn a_later_highlight_paints_over_an_earlier_one(cx: &mut TestAppContext) {
2208 let state = state("abcdef", cx);
2209 set(&state, [0..6, 2..3], cx).unwrap();
2210 assert_eq!(painted(&state, TextLeafKey::block(0), cx), [0..6, 2..3]);
2211 }
2212
2213 #[gpui::test]
2214 fn html_text_is_unsupported(cx: &mut TestAppContext) {
2215 let html = cx.update(|cx| cx.new(|cx| TextViewState::html("<p>one</p>", cx)));
2216 cx.run_until_parked();
2217 html.update(cx, |html, cx| {
2218 assert_eq!(
2219 html.set_range_highlights([highlight(0..3)], cx),
2220 Err(RangeHighlightError::Unsupported)
2221 );
2222 });
2223 }
2224
2225 #[gpui::test]
2226 fn inline_objects_are_skipped(cx: &mut TestAppContext) {
2227 let state = state("x $a$ y", cx);
2228 state.update(cx, |state, cx| {
2229 let extensions = MarkdownExtensions::default().plugin(
2230 crate::text::markdown_ext::TestInlinePlugin::new("test").parse_with(
2231 |node, _| {
2232 let markdown::mdast::Node::InlineMath(math) = node else {
2233 return None;
2234 };
2235 Some(
2236 crate::text::MarkdownNode::new("formula", ())
2237 .text(math.value.clone()),
2238 )
2239 },
2240 ),
2241 );
2242 state.set_markdown_extensions(Arc::new(extensions), cx);
2243 });
2244 cx.run_until_parked();
2245 set(&state, [2..3], cx).unwrap();
2247 assert!(painted(&state, TextLeafKey::block(0), cx).is_empty());
2248 set(&state, [0..5], cx).unwrap();
2249 assert_eq!(painted(&state, TextLeafKey::block(0), cx), [0..2, 3..5]);
2250 }
2251
2252 #[gpui::test]
2253 fn push_str_keeps_earlier_blocks_and_clips_the_changed_tail(cx: &mut TestAppContext) {
2254 let state = state("first\n\na **b", cx);
2255 set(&state, [0..5, 6..11], cx).unwrap();
2257
2258 state.update(cx, |state, cx| state.push_str("c**", cx));
2260 cx.run_until_parked();
2261
2262 assert_eq!(painted(&state, TextLeafKey::block(0), cx), [0..5]);
2263 assert_eq!(painted(&state, TextLeafKey::block(7), cx), [0..2]);
2264 }
2265
2266 #[gpui::test]
2267 fn push_str_keeps_a_block_whose_text_is_unchanged(cx: &mut TestAppContext) {
2268 let state = state("first\n\nsecond", cx);
2269 set(&state, [6..12], cx).unwrap();
2270 state.update(cx, |state, cx| state.push_str("\n===", cx));
2273 cx.run_until_parked();
2274 assert_eq!(painted(&state, TextLeafKey::block(7), cx), [0..6]);
2275 }
2276
2277 #[gpui::test]
2278 fn push_str_drops_highlights_of_a_leaf_that_is_gone(cx: &mut TestAppContext) {
2279 let state = state("first\n\n| a |", cx);
2280 set(&state, [0..5, 6..11], cx).unwrap();
2281 state.update(cx, |state, cx| state.push_str("\n|---|", cx));
2284 cx.run_until_parked();
2285 assert_eq!(painted(&state, TextLeafKey::block(0), cx), [0..5]);
2286 assert!(painted(&state, TextLeafKey::block(7), cx).is_empty());
2287 assert!(painted(&state, TextLeafKey::table_cell(7, 0), cx).is_empty());
2288 }
2289
2290 #[gpui::test]
2291 fn replacing_text_drops_highlights_only_where_it_changed(cx: &mut TestAppContext) {
2292 let state = state("first\n\nsecond", cx);
2293 set(&state, [0..5, 6..12], cx).unwrap();
2294 state.update(cx, |state, cx| state.set_text("first\n\nchanged", cx));
2295 cx.run_until_parked();
2296 assert_eq!(painted(&state, TextLeafKey::block(0), cx), [0..5]);
2297 assert!(painted(&state, TextLeafKey::block(7), cx).is_empty());
2298
2299 let large = "x".repeat(MAX_SYNC_FULL_REPLACE_BYTES + 1);
2300 state.update(cx, |state, cx| state.set_text(&large, cx));
2301 cx.run_until_parked();
2302 assert!(!has_highlights(&state, cx));
2303 }
2304
2305 #[gpui::test]
2306 fn a_highlight_follows_its_block_past_an_earlier_edit(cx: &mut TestAppContext) {
2307 let state = state("foo\n\nbar\n\nbar", cx);
2308 set(&state, [4..7], cx).unwrap();
2310 state.update(cx, |state, cx| state.set_text("bar\n\nbar", cx));
2312 cx.run_until_parked();
2313 assert_eq!(painted(&state, TextLeafKey::block(0), cx), [0..3]);
2314 assert!(painted(&state, TextLeafKey::block(5), cx).is_empty());
2315
2316 let state = self::state("ERROR a\n\nERROR b\n\nERROR c", cx);
2317 set(&state, [8..15], cx).unwrap();
2318 state.update(cx, |state, cx| state.set_text("ERROR b\n\nERROR c", cx));
2319 cx.run_until_parked();
2320 assert_eq!(painted(&state, TextLeafKey::block(0), cx), [0..7]);
2321 assert!(painted(&state, TextLeafKey::block(9), cx).is_empty());
2322 }
2323
2324 #[gpui::test]
2325 fn appending_a_copy_of_the_last_block_keeps_the_highlight_on_it(cx: &mut TestAppContext) {
2326 let state = state("a\n\nfoo", cx);
2327 set(&state, [2..5], cx).unwrap();
2329 state.update(cx, |state, cx| state.set_text("a\n\nfoo\n\nfoo", cx));
2330 cx.run_until_parked();
2331 assert_eq!(painted(&state, TextLeafKey::block(3), cx), [0..3]);
2332 assert!(painted(&state, TextLeafKey::block(8), cx).is_empty());
2333 }
2334
2335 #[gpui::test]
2336 fn deleting_a_table_row_drops_highlights_in_the_rows_it_moves(cx: &mut TestAppContext) {
2337 let table = "| a | b |\n|---|---|\n| x | 1 |\n| x | 2 |";
2338 let without_middle_row = "| a | b |\n|---|---|\n| x | 2 |";
2339 for ranges in [vec![8..9, 10..11], vec![4..5]] {
2342 let state = state(table, cx);
2343 set(&state, [0..1].into_iter().chain(ranges), cx).unwrap();
2344 state.update(cx, |state, cx| state.set_text(without_middle_row, cx));
2345 cx.run_until_parked();
2346 assert_eq!(painted(&state, TextLeafKey::table_cell(0, 0), cx), [0..1]);
2347 for cell in 1..4 {
2348 assert!(
2349 painted(&state, TextLeafKey::table_cell(0, cell), cx).is_empty(),
2350 "cell {cell}"
2351 );
2352 }
2353 }
2354 }
2355
2356 #[gpui::test]
2357 fn streaming_table_rows_keeps_the_highlights_of_earlier_rows(cx: &mut TestAppContext) {
2358 let state = state("| a | b |\n|---|---|\n| x | 1 |", cx);
2359 set(&state, [0..1, 4..5], cx).unwrap();
2361 state.update(cx, |state, cx| {
2362 state.set_text("| a | b |\n|---|---|\n| x | 1 |\n| y | 2 |", cx)
2363 });
2364 cx.run_until_parked();
2365 assert_eq!(painted(&state, TextLeafKey::table_cell(0, 0), cx), [0..1]);
2366 assert_eq!(painted(&state, TextLeafKey::table_cell(0, 2), cx), [0..1]);
2367 }
2368
2369 #[gpui::test]
2370 fn an_edit_in_an_earlier_block_keeps_later_highlights(cx: &mut TestAppContext) {
2371 let state = state("one\n\ntwo", cx);
2372 set(&state, [0..3, 4..7], cx).unwrap();
2373 state.update(cx, |state, cx| state.set_text("one!\n\ntwo", cx));
2374 cx.run_until_parked();
2375 assert_eq!(painted(&state, TextLeafKey::block(0), cx), [0..3]);
2376 assert_eq!(painted(&state, TextLeafKey::block(6), cx), [0..3]);
2377 }
2378
2379 #[gpui::test]
2380 fn an_append_after_a_full_update_compares_every_block(cx: &mut TestAppContext) {
2381 let state = state("first\n\nsecond", cx);
2382 set(&state, [0..5], cx).unwrap();
2383 let options = UpdateOptions {
2386 revision: 0,
2387 pending_text: "FIRST\n\nsecond".to_string(),
2388 append: false,
2389 mode: ParseMode::Replace,
2390 markdown_extensions: Arc::default(),
2391 };
2392 let new = parse_content(TextViewFormat::Markdown, ParsedContent::default(), &options)
2393 .unwrap()
2394 .document;
2395 state.update(cx, |state, _| {
2396 state.full_update_revision = state.committed_revision + 1;
2397 let revision = state.committed_revision + 2;
2398 state.reconcile_range_highlights(&new, revision, true);
2399 });
2400 assert!(!has_highlights(&state, cx));
2401 }
2402
2403 #[gpui::test]
2404 fn streaming_through_set_text_keeps_highlights(cx: &mut TestAppContext) {
2405 let state = state("first\n\nsec", cx);
2406 set(&state, [0..5, 6..9], cx).unwrap();
2408 state.update(cx, |state, cx| state.set_text("first\n\nsecond", cx));
2409 cx.run_until_parked();
2410 assert_eq!(painted(&state, TextLeafKey::block(0), cx), [0..5]);
2411 assert_eq!(painted(&state, TextLeafKey::block(7), cx), [0..3]);
2412 }
2413
2414 #[gpui::test]
2415 fn a_full_parse_merged_with_an_append_compares_every_block(cx: &mut TestAppContext) {
2416 let state = state("", cx);
2417 state.update(cx, |state, cx| {
2421 state.set_text("[foo] and some text\n\nmore", cx);
2422 let text = state.rendered_text();
2423 let some = text.as_str().find("some").unwrap();
2424 state
2425 .set_range_highlights([highlight(some..some + 4)], cx)
2426 .unwrap();
2427 state.push_str("\n\n[foo]: https://example.com", cx);
2428 });
2429 cx.run_until_parked();
2430 state.read_with(cx, |state, _| {
2431 assert!(state.rendered_text().as_str().starts_with("foo and some"));
2432 });
2433 assert!(painted(&state, TextLeafKey::block(0), cx).is_empty());
2434 }
2435
2436 #[gpui::test]
2437 fn a_full_update_before_an_append_drops_replaced_highlights(cx: &mut TestAppContext) {
2438 let state = state("first", cx);
2439 set(&state, [0..5], cx).unwrap();
2440 let large = "x".repeat(MAX_SYNC_FULL_REPLACE_BYTES + 1);
2441 state.update(cx, |state, cx| {
2442 state.set_text(&large, cx);
2443 state.push_str(" tail", cx);
2444 });
2445 cx.run_until_parked();
2446 assert!(!has_highlights(&state, cx));
2447 }
2448
2449 #[gpui::test]
2450 fn reparsing_unchanged_text_keeps_highlights(cx: &mut TestAppContext) {
2451 let state = state("first", cx);
2452 set(&state, [0..5], cx).unwrap();
2453 state.update(cx, |state, cx| {
2454 state.set_markdown_extensions(
2455 Arc::new(MarkdownExtensions::default().parser_revision(1)),
2456 cx,
2457 )
2458 });
2459 cx.run_until_parked();
2460 assert_eq!(painted(&state, TextLeafKey::block(0), cx), [0..5]);
2461 }
2462
2463 pub(super) struct Root {
2464 pub(super) state: Entity<TextViewState>,
2465 }
2466
2467 impl Render for Root {
2468 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2469 gpui::div()
2470 .w(px(120.))
2471 .child(crate::text::TextView::new(&self.state).selectable(true))
2472 }
2473 }
2474
2475 #[gpui::test]
2476 fn highlights_paint_across_inline_code_tables_and_code_blocks(cx: &mut TestAppContext) {
2477 cx.update(crate::init);
2478 let markdown = "wrapping text with `inline code` and a [link](https://x.y) \
2479 that wraps\n\n| a | b |\n|---|---|\n| c | d |\n\n```\nlet x = 1;\n```";
2480 let (root, cx) = cx.add_window_view(|_, cx| Root {
2481 state: cx.new(|cx| TextViewState::markdown(markdown, cx)),
2482 });
2483 cx.run_until_parked();
2484 let state = root.read_with(cx, |root, _| root.state.clone());
2485 let text = state.read_with(cx, |state, _| state.rendered_text().as_str().to_string());
2486 let code = text.find("code").unwrap();
2487 let len = text.len();
2488 let ranges = [code..code + 2]
2490 .into_iter()
2491 .chain(text.char_indices().map(|(ix, c)| ix..ix + c.len_utf8()))
2492 .chain([0..len])
2493 .filter(|range| !text[range.clone()].trim().is_empty());
2494 state.update(cx, |state, cx| {
2495 state
2496 .set_range_highlights(ranges.map(highlight), cx)
2497 .unwrap();
2498 });
2499 cx.update(|window, cx| window.draw(cx).clear(cx));
2500 assert_eq!(
2501 painted(&state, TextLeafKey::block(0), cx)[0],
2502 code..code + 2
2503 );
2504 }
2505
2506 #[gpui::test]
2507 fn clear_range_highlights_removes_them(cx: &mut TestAppContext) {
2508 let state = state("first", cx);
2509 set(&state, [0..5], cx).unwrap();
2510 state.update(cx, |state, cx| state.clear_range_highlights(cx));
2511 assert!(!has_highlights(&state, cx));
2512 }
2513 }
2514
2515 mod reveal_range {
2516 use gpui::{
2517 Entity, InteractiveElement as _, ListAlignment, ListState, ScrollHandle,
2518 StatefulInteractiveElement as _, TestAppContext, VisualTestContext, div, list,
2519 };
2520
2521 use super::super::*;
2522 use crate::text::TextView;
2523
2524 #[derive(Clone)]
2526 enum Container {
2527 Scrollable,
2529 List(ListState),
2531 Div(ScrollHandle),
2534 Clamped(ListState),
2536 Fixed,
2538 }
2539
2540 struct Root {
2541 state: Entity<TextViewState>,
2542 container: Container,
2543 }
2544
2545 impl Render for Root {
2546 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2547 let state = self.state.clone();
2548 let frame = div().w(px(200.)).h(px(100.));
2549 let row = |list_state: &ListState, clamp: bool| {
2550 let state = state.clone();
2551 list(list_state.clone(), move |ix, _, _| match ix {
2552 0 => div().h(px(40.)).into_any_element(),
2553 _ => TextView::new(&state)
2554 .when(clamp, |view| view.max_lines(2))
2555 .into_any_element(),
2556 })
2557 .size_full()
2558 };
2559 match &self.container {
2560 Container::Scrollable => frame
2561 .child(TextView::new(&state).scrollable(true))
2562 .into_any_element(),
2563 Container::List(list_state) => {
2564 frame.child(row(list_state, false)).into_any_element()
2565 }
2566 Container::Clamped(list_state) => {
2567 frame.child(row(list_state, true)).into_any_element()
2568 }
2569 Container::Div(handle) => {
2570 let scroll = handle.clone();
2571 frame
2572 .child(
2573 div()
2574 .id("scroll")
2575 .size_full()
2576 .overflow_y_scroll()
2577 .track_scroll(handle)
2578 .child(TextView::new(&state).on_reveal(move |line, _, _| {
2579 let viewport = scroll.bounds();
2580 let mut offset = scroll.offset();
2581 if line.bottom() > viewport.bottom() {
2582 offset.y -= line.bottom() - viewport.bottom();
2583 } else if line.top() < viewport.top() {
2584 offset.y += viewport.top() - line.top();
2585 }
2586 scroll.set_offset(offset);
2587 })),
2588 )
2589 .into_any_element()
2590 }
2591 Container::Fixed => frame.child(TextView::new(&state)).into_any_element(),
2592 }
2593 }
2594 }
2595
2596 fn window<'a>(
2597 markdown: &str,
2598 container: Container,
2599 cx: &'a mut TestAppContext,
2600 ) -> (Entity<TextViewState>, &'a mut VisualTestContext) {
2601 cx.update(crate::init);
2602 let (root, cx) = cx.add_window_view(|_, cx| Root {
2603 state: cx.new(|cx| TextViewState::markdown(markdown, cx)),
2604 container,
2605 });
2606 cx.run_until_parked();
2607 draw(cx);
2608 let state = root.read_with(cx, |root, _| root.state.clone());
2609 (state, cx)
2610 }
2611
2612 fn draw(cx: &mut VisualTestContext) {
2613 cx.update(|window, cx| window.draw(cx).clear(cx));
2614 }
2615
2616 fn request(
2619 state: &Entity<TextViewState>,
2620 needle: &str,
2621 cx: &mut VisualTestContext,
2622 then: impl FnOnce(&mut TextViewState, &mut Context<TextViewState>),
2623 ) {
2624 state.update(cx, |state, cx| {
2625 let text = state.rendered_text();
2626 let start = text.as_str().find(needle).unwrap();
2627 state.reveal_range(start..start + needle.len(), cx).unwrap();
2628 then(state, cx);
2629 });
2630 }
2631
2632 fn reveal(state: &Entity<TextViewState>, needle: &str, cx: &mut VisualTestContext) {
2634 request(state, needle, cx, |_, _| {});
2635 for _ in 0..3 {
2636 draw(cx);
2637 }
2638 }
2639
2640 fn is_pending(state: &Entity<TextViewState>, cx: &mut VisualTestContext) -> bool {
2641 state.read_with(cx, |state, _| state.pending_reveal.is_some())
2642 }
2643
2644 fn scroll_top(
2645 state: &Entity<TextViewState>,
2646 cx: &mut VisualTestContext,
2647 ) -> gpui::ListOffset {
2648 state.read_with(cx, |state, _| state.list_state.logical_scroll_top())
2649 }
2650
2651 fn paragraphs(count: usize) -> String {
2652 (0..count)
2653 .map(|ix| format!("paragraph {ix}"))
2654 .collect::<Vec<_>>()
2655 .join("\n\n")
2656 }
2657
2658 fn words(count: usize) -> String {
2659 (0..count)
2660 .map(|ix| format!("w{ix}"))
2661 .collect::<Vec<_>>()
2662 .join(" ")
2663 }
2664
2665 #[gpui::test]
2666 fn a_scrollable_view_scrolls_to_an_offscreen_block(cx: &mut TestAppContext) {
2667 let (state, cx) = window(¶graphs(200), Container::Scrollable, cx);
2668 reveal(&state, "paragraph 150", cx);
2669 assert!(!is_pending(&state, cx));
2670 let top = scroll_top(&state, cx);
2671 assert!((140..=150).contains(&top.item_ix), "{top:?}");
2672
2673 reveal(&state, "paragraph 3", cx);
2674 assert!(!is_pending(&state, cx));
2675 assert!(scroll_top(&state, cx).item_ix <= 3);
2676 }
2677
2678 #[gpui::test]
2679 fn an_append_adding_blocks_keeps_the_scroll_position(cx: &mut TestAppContext) {
2680 let (state, cx) = window(¶graphs(200), Container::Scrollable, cx);
2681 reveal(&state, "paragraph 100", cx);
2682 let top = scroll_top(&state, cx);
2683 assert!(top.item_ix > 0, "{top:?}");
2684
2685 state.update(cx, |state, cx| state.push_str("\n\nparagraph 200", cx));
2686 cx.run_until_parked();
2687 draw(cx);
2688
2689 state.read_with(cx, |state, _| {
2690 assert_eq!(state.list_state.item_count(), 201);
2691 });
2692 assert_unmoved(&state, top, cx);
2693 }
2694
2695 #[gpui::test]
2696 fn a_scrollable_view_scrolls_to_a_line_inside_a_long_paragraph(cx: &mut TestAppContext) {
2697 let (state, cx) = window(&words(400), Container::Scrollable, cx);
2698 reveal(&state, "w390", cx);
2699 assert!(!is_pending(&state, cx));
2700 let top = scroll_top(&state, cx);
2701 assert_eq!(top.item_ix, 0);
2702 assert!(top.offset_in_item > px(100.), "{top:?}");
2703 }
2704
2705 #[gpui::test]
2706 fn revealing_a_visible_line_does_not_scroll(cx: &mut TestAppContext) {
2707 let (state, cx) = window(&words(400), Container::Scrollable, cx);
2708 reveal(&state, "w200", cx);
2709 let top = scroll_top(&state, cx);
2710 assert!(top.offset_in_item > px(0.), "{top:?}");
2711 for word in ["w199", "w198", "w197", "w196"] {
2712 reveal(&state, word, cx);
2713 assert!(!is_pending(&state, cx));
2714 let now = scroll_top(&state, cx);
2715 assert_eq!(
2716 (now.item_ix, now.offset_in_item),
2717 (top.item_ix, top.offset_in_item),
2718 "{word}"
2719 );
2720 }
2721 }
2722
2723 #[gpui::test]
2724 fn an_enclosing_list_scrolls_to_a_line_of_a_fit_content_view(cx: &mut TestAppContext) {
2725 let outer = ListState::new(2, ListAlignment::Top, px(1000.));
2726 let (state, cx) = window(&words(400), Container::List(outer.clone()), cx);
2727 reveal(&state, "w390", cx);
2728 assert!(!is_pending(&state, cx));
2729 let top = outer.logical_scroll_top();
2730 assert_eq!(top.item_ix, 1, "{top:?}");
2731 assert!(top.offset_in_item > px(100.), "{top:?}");
2732 }
2733
2734 #[gpui::test]
2735 fn on_reveal_scrolls_a_container_that_ignores_scroll_requests(cx: &mut TestAppContext) {
2736 let handle = ScrollHandle::new();
2737 let (state, cx) = window(&words(400), Container::Div(handle.clone()), cx);
2738 reveal(&state, "w390", cx);
2739 assert!(!is_pending(&state, cx));
2740 assert!(handle.offset().y < px(-100.), "{:?}", handle.offset());
2741 }
2742
2743 #[gpui::test]
2744 fn a_reveal_that_cannot_be_shown_gives_up(cx: &mut TestAppContext) {
2745 let (state, cx) = window(&words(4000), Container::Fixed, cx);
2746 reveal(&state, "w3990", cx);
2747 assert!(is_pending(&state, cx));
2748 for _ in 0..10 {
2749 draw(cx);
2750 }
2751 assert!(!is_pending(&state, cx));
2752 }
2753
2754 #[gpui::test]
2755 fn a_reveal_not_carried_out_in_time_is_dropped(cx: &mut TestAppContext) {
2756 let (state, cx) = window(¶graphs(200), Container::Scrollable, cx);
2757 request(&state, "paragraph 150", cx, |_, cx| {
2758 cx.background_executor()
2759 .advance_clock(std::time::Duration::from_secs(2))
2760 });
2761 draw(cx);
2762 assert!(!is_pending(&state, cx));
2763 assert_eq!(scroll_top(&state, cx).item_ix, 0);
2764 }
2765
2766 #[gpui::test]
2767 fn an_empty_range_reveals_its_line(cx: &mut TestAppContext) {
2768 let (state, cx) = window(&words(400), Container::Scrollable, cx);
2769 reveal(&state, "w390", cx);
2770 let top = scroll_top(&state, cx);
2771 state.update(cx, |state, cx| {
2773 let text = state.rendered_text();
2774 let start = text.as_str().find("w391").unwrap();
2775 state.reveal_range(start..start, cx).unwrap();
2776 });
2777 for _ in 0..3 {
2778 draw(cx);
2779 }
2780 assert!(!is_pending(&state, cx));
2781 let now = scroll_top(&state, cx);
2782 assert_eq!(
2783 (now.item_ix, now.offset_in_item),
2784 (top.item_ix, top.offset_in_item)
2785 );
2786 }
2787
2788 #[gpui::test]
2789 fn a_position_after_the_last_character_reveals_its_line(cx: &mut TestAppContext) {
2790 let (state, cx) = window(&words(400), Container::Scrollable, cx);
2791 reveal(&state, "w399", cx);
2792 let top = scroll_top(&state, cx);
2793 assert!(top.offset_in_item > px(1000.), "{top:?}");
2794 state.update(cx, |state, cx| {
2796 let text = state.rendered_text();
2797 let end = text.as_str().find("w399").unwrap() + "w399".len();
2798 state.reveal_range(end..end, cx).unwrap();
2799 });
2800 for _ in 0..3 {
2801 draw(cx);
2802 }
2803 assert!(!is_pending(&state, cx));
2804 let now = scroll_top(&state, cx);
2805 assert_eq!(
2806 (now.item_ix, now.offset_in_item),
2807 (top.item_ix, top.offset_in_item)
2808 );
2809 }
2810
2811 fn reveal_at(
2813 state: &Entity<TextViewState>,
2814 range: impl FnOnce(&str) -> std::ops::Range<usize>,
2815 cx: &mut VisualTestContext,
2816 ) {
2817 state.update(cx, |state, cx| {
2818 let text = state.rendered_text();
2819 let range = range(text.as_str());
2820 state.reveal_range(range, cx).unwrap();
2821 });
2822 for _ in 0..3 {
2823 draw(cx);
2824 }
2825 }
2826
2827 fn assert_unmoved(
2828 state: &Entity<TextViewState>,
2829 top: gpui::ListOffset,
2830 cx: &mut VisualTestContext,
2831 ) {
2832 assert!(!is_pending(state, cx));
2833 let now = scroll_top(state, cx);
2834 assert_eq!(
2835 (now.item_ix, now.offset_in_item),
2836 (top.item_ix, top.offset_in_item)
2837 );
2838 }
2839
2840 #[gpui::test]
2841 fn the_end_of_the_text_and_its_separators_reveal_the_last_line(cx: &mut TestAppContext) {
2842 let (state, cx) = window(&words(400), Container::Scrollable, cx);
2843 reveal(&state, "w399", cx);
2844 let top = scroll_top(&state, cx);
2845 assert!(top.offset_in_item > px(1000.), "{top:?}");
2846 reveal_at(&state, |text| text.len()..text.len(), cx);
2848 assert_unmoved(&state, top, cx);
2849 reveal_at(&state, |text| text.len() - 1..text.len(), cx);
2851 assert_unmoved(&state, top, cx);
2852
2853 let code = (0..200)
2854 .map(|ix| format!("line {ix}"))
2855 .collect::<Vec<_>>()
2856 .join("\n");
2857 state.update(cx, |state, cx| {
2858 state.set_text(&format!("```\n{code}\n```"), cx)
2859 });
2860 cx.run_until_parked();
2861 reveal(&state, "line 199", cx);
2862 let top = scroll_top(&state, cx);
2863 assert!(top.offset_in_item > px(1000.), "{top:?}");
2864 reveal_at(&state, |text| text.len()..text.len(), cx);
2865 assert_unmoved(&state, top, cx);
2866 }
2867
2868 #[gpui::test]
2869 fn the_end_of_a_block_above_reveals_its_last_line(cx: &mut TestAppContext) {
2870 let second = (0..400)
2871 .map(|ix| format!("v{ix}"))
2872 .collect::<Vec<_>>()
2873 .join(" ");
2874 let markdown = format!("{}\n\n{second}", words(400));
2875 let (state, cx) = window(&markdown, Container::Scrollable, cx);
2876 reveal(&state, "v399", cx);
2877 assert_eq!(scroll_top(&state, cx).item_ix, 1);
2878 reveal_at(
2880 &state,
2881 |text| {
2882 let end = text.find("w399").unwrap() + "w399".len();
2883 end..end
2884 },
2885 cx,
2886 );
2887 assert!(!is_pending(&state, cx));
2888 let top = scroll_top(&state, cx);
2889 assert_eq!(top.item_ix, 0, "{top:?}");
2890 assert!(top.offset_in_item > px(1000.), "{top:?}");
2891 }
2892
2893 #[gpui::test]
2894 fn an_empty_view_has_nothing_to_reveal(cx: &mut TestAppContext) {
2895 cx.update(crate::init);
2896 let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("", cx)));
2897 cx.run_until_parked();
2898 state.update(cx, |state, cx| {
2899 assert_eq!(state.reveal_range(0..0, cx), Ok(()));
2900 assert!(state.pending_reveal.is_none());
2901 });
2902 }
2903
2904 #[gpui::test]
2905 fn revealing_a_visible_block_does_not_scroll(cx: &mut TestAppContext) {
2906 let markdown = format!("{}\n\n<div>html</div>\n\n{}", words(400), words(400));
2907 let (state, cx) = window(&markdown, Container::Scrollable, cx);
2908 reveal(&state, "html", cx);
2909 state.update(cx, |state, _| state.list_state.scroll_by(px(30.)));
2911 draw(cx);
2912 let top = scroll_top(&state, cx);
2913 reveal(&state, "html", cx);
2914 assert_unmoved(&state, top, cx);
2915 }
2916
2917 #[gpui::test]
2918 fn a_line_of_an_inline_flow_counts_as_shown_once_scrolled_to(cx: &mut TestAppContext) {
2919 let markdown = (0..400)
2922 .map(|ix| {
2923 if ix % 10 == 0 {
2924 format!("`c{ix}`")
2925 } else {
2926 format!("w{ix}")
2927 }
2928 })
2929 .collect::<Vec<_>>()
2930 .join(" ");
2931 let (state, cx) = window(&markdown, Container::Scrollable, cx);
2932 reveal(&state, "c390", cx);
2933 assert!(!is_pending(&state, cx));
2934 assert!(scroll_top(&state, cx).offset_in_item > px(1000.));
2935 }
2936
2937 #[gpui::test]
2938 fn a_range_starting_on_a_line_break_reveals_the_next_line(cx: &mut TestAppContext) {
2939 let code = (0..200)
2940 .map(|ix| format!("line {ix}"))
2941 .collect::<Vec<_>>()
2942 .join("\n");
2943 let (state, cx) = window(&format!("```\n{code}\n```"), Container::Scrollable, cx);
2944 reveal(&state, "\nline 190", cx);
2945 assert!(!is_pending(&state, cx));
2946 let top = scroll_top(&state, cx);
2947 assert!(top.offset_in_item > px(1000.), "{top:?}");
2948 }
2949
2950 #[gpui::test]
2951 fn a_range_starting_on_a_line_break_in_an_inline_flow_reveals_the_next_line(
2952 cx: &mut TestAppContext,
2953 ) {
2954 let lines = (0..200)
2956 .map(|ix| format!("line {ix} `code`"))
2957 .collect::<Vec<_>>()
2958 .join("\\\n");
2959 let (state, cx) = window(&lines, Container::Scrollable, cx);
2960 reveal(&state, "\nline 190", cx);
2961 assert!(!is_pending(&state, cx));
2962 let top = scroll_top(&state, cx);
2963 assert!(top.offset_in_item > px(1000.), "{top:?}");
2964 }
2965
2966 #[gpui::test]
2967 fn a_reveal_is_dropped_when_its_text_before_it_changes(cx: &mut TestAppContext) {
2968 let (state, cx) = window(¶graphs(200), Container::Scrollable, cx);
2969 let target = "first words then the target";
2970 let markdown = format!("{target}\n\n{}", paragraphs(200));
2971 state.update(cx, |state, cx| state.set_text(&markdown, cx));
2972 cx.run_until_parked();
2973
2974 request(&state, "target", cx, |state, cx| {
2976 state.set_text(&markdown.replacen("target", "target and more", 1), cx);
2977 assert!(state.pending_reveal.is_some());
2978 });
2979 request(&state, "target", cx, |state, cx| {
2981 state.set_text(&markdown.replacen("words", "WORDS", 1), cx);
2982 assert!(state.pending_reveal.is_none());
2983 });
2984 }
2985
2986 #[gpui::test]
2987 fn a_clamped_view_does_not_reveal(cx: &mut TestAppContext) {
2988 let outer = ListState::new(2, ListAlignment::Top, px(1000.));
2989 let (state, cx) = window(&words(400), Container::Clamped(outer.clone()), cx);
2990 reveal(&state, "w390", cx);
2991 assert!(!is_pending(&state, cx));
2992 assert_eq!(outer.logical_scroll_top().item_ix, 0);
2993 }
2994
2995 #[gpui::test]
2996 fn text_outside_every_block_reveals_its_block(cx: &mut TestAppContext) {
2997 let markdown = format!("{}\n\n<div>html text</div>", paragraphs(100));
2998 let (state, cx) = window(&markdown, Container::Scrollable, cx);
2999 reveal(&state, "html text", cx);
3000 assert!(!is_pending(&state, cx));
3001 assert!(
3002 scroll_top(&state, cx).item_ix >= 90,
3003 "{:?}",
3004 scroll_top(&state, cx)
3005 );
3006 }
3007
3008 #[gpui::test]
3009 fn a_reveal_follows_its_text_past_an_edit_before_it(cx: &mut TestAppContext) {
3010 let (state, cx) = window(¶graphs(200), Container::Scrollable, cx);
3011 request(&state, "paragraph 150", cx, |state, cx| {
3013 state.set_text(&format!("inserted\n\n{}", paragraphs(200)), cx);
3014 assert!(state.pending_reveal.is_some());
3015 });
3016 for _ in 0..3 {
3017 draw(cx);
3018 }
3019 assert!(!is_pending(&state, cx));
3020 let top = scroll_top(&state, cx);
3021 assert!((141..=151).contains(&top.item_ix), "{top:?}");
3022
3023 request(&state, "paragraph 150", cx, |state, cx| {
3025 state.set_text(¶graphs(200).replace("paragraph 150", "changed"), cx);
3026 assert!(state.pending_reveal.is_none());
3027 });
3028 }
3029
3030 #[gpui::test]
3031 fn malformed_ranges_and_html_views_are_rejected(cx: &mut TestAppContext) {
3032 cx.update(crate::init);
3033 let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("first\n\nsecond", cx)));
3034 cx.run_until_parked();
3035 state.update(cx, |state, cx| {
3036 assert_eq!(
3037 state.reveal_range(std::ops::Range { start: 5, end: 3 }, cx),
3038 Err(RangeHighlightError::InvalidRange(0))
3039 );
3040 assert_eq!(
3041 state.reveal_range(0..100, cx),
3042 Err(RangeHighlightError::InvalidRange(0))
3043 );
3044 state.reveal_range(0..5, cx).unwrap();
3045 });
3046
3047 let html = cx.update(|cx| cx.new(|cx| TextViewState::html("<p>one</p>", cx)));
3048 cx.run_until_parked();
3049 html.update(cx, |html, cx| {
3050 assert_eq!(
3051 html.reveal_range(0..3, cx),
3052 Err(RangeHighlightError::Unsupported)
3053 );
3054 });
3055 }
3056 }
3057}