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