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