use futures::Stream as _;
#[cfg(not(target_family = "wasm"))]
use std::time::Instant;
use std::{
ops::{Range, RangeInclusive},
pin::Pin,
sync::{Arc, Mutex, OnceLock},
task::Poll,
time::Duration,
};
#[cfg(target_family = "wasm")]
use web_time::Instant;
use gpui::{
App, AppContext as _, Bounds, Context, EntityId, FocusHandle, IntoElement, KeyBinding,
ListState, ParentElement as _, Pixels, Point, Render, SharedString, Styled as _, Task, Window,
prelude::FluentBuilder as _, px,
};
use crate::{
AutoScroll, ElementExt, TextSelection,
async_util::{Receiver, Sender, unbounded},
input::{self, SelectAll},
text::{
CodeBlockActionsFn, CodeBlockHighlighterFn, LinkClickHandlerFn, MarkdownExtensions,
RangeHighlight, RangeHighlightError, RenderedText, TableActionsFn, TextViewStyle,
document::ParsedDocument,
format,
node::{self, NodeContext},
range_highlight::{
LeafRemap, PendingReveal, RangeHighlightFrame, RenderedIndex, RevealRequest,
},
selection_adapter::TextViewSelectionAdapter,
stream_fade::{StreamFadeTracker, TextViewMotion},
},
v_flex,
};
const CONTEXT: &'static str = "TextView";
const MAX_COALESCED_UPDATES_PER_PARSE: usize = 64;
const MAX_SYNC_FULL_REPLACE_BYTES: usize = 4 * 1024;
const STREAM_FADE_TICK: Duration = Duration::from_millis(33);
pub(crate) fn init(cx: &mut App) {
cx.bind_keys(vec![
#[cfg(target_os = "macos")]
KeyBinding::new("cmd-c", input::Copy, Some(CONTEXT)),
#[cfg(not(target_os = "macos"))]
KeyBinding::new("ctrl-c", input::Copy, Some(CONTEXT)),
#[cfg(target_os = "macos")]
KeyBinding::new("cmd-a", input::SelectAll, Some(CONTEXT)),
#[cfg(not(target_os = "macos"))]
KeyBinding::new("ctrl-a", input::SelectAll, Some(CONTEXT)),
]);
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub(super) enum TextViewFormat {
Markdown,
Html,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum SelectionFormat {
#[default]
Plain,
Source,
}
#[derive(Clone, Copy)]
pub(super) struct LineSpan {
pub(super) top: Pixels,
pub(super) bottom: Pixels,
pub(super) line_height: Pixels,
}
pub struct TextViewState {
entity_id: EntityId,
pub(super) focus_handle: FocusHandle,
pub(super) list_state: ListState,
bounds: Bounds<Pixels>,
pub(super) selectable: bool,
pub(super) selection_format: SelectionFormat,
pub(super) scrollable: bool,
pub(super) max_lines: Option<usize>,
pub(super) line_spans: Arc<Mutex<Vec<LineSpan>>>,
pub(super) clamped: bool,
pub(super) text_view_style: Arc<TextViewStyle>,
pub(super) code_block_actions: Option<std::sync::Arc<CodeBlockActionsFn>>,
pub(super) code_block_highlighter: Option<std::sync::Arc<CodeBlockHighlighterFn>>,
pub(super) table_actions: Option<std::sync::Arc<TableActionsFn>>,
pub(super) image_source: Option<std::sync::Arc<super::text_view::ImageSourceFn>>,
pub(super) link_click_handler: Option<std::sync::Arc<LinkClickHandlerFn>>,
pub(super) markdown_extensions: Arc<MarkdownExtensions>,
pub(super) is_selecting: bool,
pub(super) preserve_inline_selection: bool,
multi_click_selection: Option<TextViewMultiClickSelection>,
selected_text_override: Option<String>,
select_all: bool,
pub(super) auto_scroll: AutoScroll,
pub(super) selection_adapter: TextViewSelectionAdapter,
pub(super) parsed_content: ParsedContent,
pub(super) stream_fade: StreamFadeTracker,
fade_tick: Option<Task<()>>,
format: TextViewFormat,
text: String,
element_text: Option<SharedString>,
revision: usize,
committed_revision: usize,
full_update_revision: usize,
rendered_index: Arc<OnceLock<RenderedIndex>>,
range_highlights: Option<Arc<RangeHighlightFrame>>,
pub(super) pending_reveal: Option<PendingReveal>,
pub(super) selection_revision: usize,
compatible_layout_update: bool,
layout_text_style: Option<(gpui::TextStyle, Pixels)>,
parsed_error: Option<SharedString>,
tx: Sender<UpdateOptions>,
_parse_task: Task<()>,
_receive_task: Task<()>,
}
impl TextViewState {
pub fn markdown(text: &str, cx: &mut Context<Self>) -> Self {
Self::new(TextViewFormat::Markdown, text, cx)
}
pub fn html(text: &str, cx: &mut Context<Self>) -> Self {
Self::new(TextViewFormat::Html, text, cx)
}
fn new(format: TextViewFormat, text: &str, cx: &mut Context<Self>) -> Self {
let focus_handle = cx.focus_handle();
let selection_adapter = TextViewSelectionAdapter::new(cx.entity().downgrade(), cx);
let (tx, rx) = unbounded::<UpdateOptions>();
let (tx_result, rx_result) = unbounded::<ParsedUpdate>();
let _receive_task = cx.spawn({
async move |weak_self, cx| {
while let Ok(parsed_update) = rx_result.recv().await {
_ = weak_self.update(cx, |state, cx| {
if parsed_update.revision != state.revision {
return;
}
if parsed_update.baseline_ack {
debug_assert!(parsed_update.full_parse);
return;
}
match parsed_update.result {
Ok(content) => {
let append =
parsed_update.selection_compatible && !parsed_update.full_parse;
if append && state.full_update_revision <= state.committed_revision
{
state.splice_appended_blocks(&content.document);
}
state.reconcile_range_highlights(
&content.document,
parsed_update.revision,
append,
);
state.stream_fade.record(
&state.parsed_content.document,
&content.document,
Instant::now(),
);
state.parsed_content = content;
state.parsed_error = None;
state.compatible_layout_update = parsed_update.selection_compatible;
if parsed_update.full_parse {
state.invalidate_measured_heights();
}
}
Err(err) => {
state.stream_fade.discard_pending();
state.parsed_error = Some(err);
}
}
if !parsed_update.selection_compatible && !state.is_selecting {
state.reset_selection_and_adapter(cx);
}
cx.notify();
});
}
}
});
let _parse_task = cx.background_spawn(UpdateFuture::new(format, rx, tx_result));
let mut this = Self {
entity_id: cx.entity_id(),
focus_handle,
bounds: Bounds::default(),
multi_click_selection: None,
selected_text_override: None,
select_all: false,
selectable: false,
selection_format: SelectionFormat::default(),
scrollable: false,
max_lines: None,
line_spans: Arc::default(),
clamped: false,
list_state: ListState::new(0, gpui::ListAlignment::Top, px(1000.)).measure_all(),
text_view_style: Arc::default(),
code_block_actions: None,
code_block_highlighter: None,
table_actions: None,
link_click_handler: None,
image_source: None,
markdown_extensions: Arc::default(),
is_selecting: false,
preserve_inline_selection: false,
auto_scroll: AutoScroll::default(),
selection_adapter,
parsed_content: Default::default(),
stream_fade: StreamFadeTracker::default(),
fade_tick: None,
format,
parsed_error: None,
text: text.to_string(),
element_text: None,
revision: 0,
committed_revision: 0,
full_update_revision: 0,
rendered_index: Arc::default(),
range_highlights: None,
pending_reveal: None,
selection_revision: 0,
compatible_layout_update: false,
layout_text_style: None,
tx,
_parse_task,
_receive_task,
};
this.increment_update(&text, false, cx);
this
}
pub(crate) fn source(&self) -> SharedString {
self.parsed_content.document.source.clone()
}
pub fn selectable(mut self, selectable: bool) -> Self {
self.selectable = selectable;
self
}
pub fn set_selectable(&mut self, selectable: bool, cx: &mut Context<Self>) {
self.selectable = selectable;
cx.notify();
}
pub fn selection_format(mut self, selection_format: SelectionFormat) -> Self {
self.selection_format = selection_format;
self
}
pub fn set_selection_format(
&mut self,
selection_format: SelectionFormat,
cx: &mut Context<Self>,
) {
self.selection_format = selection_format;
cx.notify();
}
pub fn scrollable(mut self, scrollable: bool) -> Self {
self.scrollable = scrollable;
self
}
pub fn set_scrollable(&mut self, scrollable: bool, cx: &mut Context<Self>) {
if !scrollable {
self.reset_selection_and_adapter(cx);
}
self.scrollable = scrollable;
cx.notify();
}
pub fn is_clamped(&self) -> bool {
self.clamped
}
pub fn set_text(&mut self, text: &str, cx: &mut Context<Self>) {
if self.text.as_str() == text {
return;
}
if self.stream_fade.is_enabled() {
if text.starts_with(self.text.as_str()) {
self.stream_fade.note_extend(self.text.len());
} else {
self.stream_fade.note_replace();
}
}
self.text.clear();
self.text.push_str(text);
self.parsed_error = None;
self.increment_update(text, false, cx);
}
pub(super) fn set_element_text(&mut self, text: &SharedString, cx: &mut Context<Self>) {
let same_string = self.element_text.as_ref().is_some_and(|current| {
current.as_ptr() == text.as_ptr() && current.len() == text.len()
});
if same_string && self.text.len() == text.len() {
return;
}
self.element_text = Some(text.clone());
self.set_text(text, cx);
}
pub fn push_str(&mut self, new_text: &str, cx: &mut Context<Self>) {
if new_text.is_empty() {
return;
}
self.stream_fade.note_extend(self.text.len());
self.text.push_str(new_text);
self.increment_update(new_text, true, cx);
}
pub fn motion(mut self, motion: TextViewMotion) -> Self {
self.set_motion(motion);
self
}
pub fn set_motion(&mut self, motion: TextViewMotion) {
self.stream_fade.set_motion(motion);
}
pub(crate) fn set_markdown_extensions(
&mut self,
markdown_extensions: Arc<MarkdownExtensions>,
cx: &mut Context<Self>,
) {
if self.markdown_extensions.revision() == markdown_extensions.revision() {
return;
}
let parser_configuration_changed = !self
.markdown_extensions
.has_same_parser_configuration(&markdown_extensions);
self.markdown_extensions = markdown_extensions;
if parser_configuration_changed && self.format == TextViewFormat::Markdown {
let text = self.text.clone();
self.increment_update(&text, false, cx);
}
}
pub fn selected_text(&self) -> String {
self.selected_text_in(None)
}
pub fn selected_source_range(&self) -> Option<Range<usize>> {
if self.format != TextViewFormat::Markdown {
return None;
}
if self.select_all {
return Some(0..self.source().len());
}
self.parsed_content.document.selected_source_range()
}
fn effective_format(&self) -> SelectionFormat {
match self.format {
TextViewFormat::Markdown => self.selection_format,
TextViewFormat::Html => SelectionFormat::Plain,
}
}
pub(super) fn selected_text_in(&self, blocks: Option<RangeInclusive<usize>>) -> String {
let format = self.effective_format();
if self.select_all {
if format == SelectionFormat::Source {
return self.source().to_string();
}
return self.parsed_content.document.text();
}
if format != SelectionFormat::Source
&& let Some(text) = &self.selected_text_override
{
return text.clone();
}
self.parsed_content.document.selected_text(format, blocks)
}
fn invalidate_measured_heights(&self) {
let count = self.list_state.item_count();
if count > 0 {
self.list_state.remeasure_items(0..count);
}
}
pub fn invalidate_inline_layout(&mut self, cx: &mut Context<Self>) {
self.preserve_inline_selection = true;
self.compatible_layout_update = true;
self.invalidate_measured_heights();
cx.notify();
}
fn increment_update(&mut self, text: &str, append: bool, cx: &mut Context<Self>) {
self.revision += 1;
if !append {
self.full_update_revision = self.revision;
self.selection_revision = self.selection_revision.wrapping_add(1);
}
let parse_synchronously = !append && text.len() <= MAX_SYNC_FULL_REPLACE_BYTES;
let update_options = UpdateOptions {
revision: self.revision,
append,
mode: if append {
ParseMode::Compatible
} else if parse_synchronously {
ParseMode::BaselineAck
} else {
ParseMode::Replace
},
pending_text: text.to_string(),
markdown_extensions: self.markdown_extensions.clone(),
};
if parse_synchronously {
match parse_content(self.format, ParsedContent::default(), &update_options) {
Ok(content) => {
self.reconcile_range_highlights(&content.document, self.revision, false);
self.stream_fade.record(
&self.parsed_content.document,
&content.document,
Instant::now(),
);
self.parsed_content = content;
self.parsed_error = None;
self.invalidate_measured_heights();
if !self.is_selecting {
self.reset_selection_and_adapter(cx);
}
}
Err(err) => {
self.stream_fade.discard_pending();
self.parsed_error = Some(err);
}
}
_ = self.tx.try_send(update_options);
cx.notify();
return;
}
_ = self.tx.try_send(update_options);
}
pub fn rendered_text(&self) -> RenderedText {
RenderedText::new(
self.entity_id,
self.committed_revision,
self.parsed_content.document.clone(),
self.rendered_index.clone(),
)
}
pub fn set_range_highlights(
&mut self,
highlights: impl IntoIterator<Item = RangeHighlight>,
cx: &mut Context<Self>,
) -> Result<(), RangeHighlightError> {
if self.format != TextViewFormat::Markdown {
return Err(RangeHighlightError::Unsupported);
}
let text = self.rendered_text();
self.range_highlights = RangeHighlightFrame::new(&text, highlights)?.map(Arc::new);
cx.notify();
Ok(())
}
pub fn reveal_range(
&mut self,
range: Range<usize>,
cx: &mut Context<Self>,
) -> Result<(), RangeHighlightError> {
if self.format != TextViewFormat::Markdown {
return Err(RangeHighlightError::Unsupported);
}
let text = self.rendered_text();
if text.is_empty() && range == (0..0) {
self.pending_reveal = None;
return Ok(());
}
let now = cx.background_executor().now();
self.pending_reveal = Some(
PendingReveal::new(&text, &range, now).ok_or(RangeHighlightError::InvalidRange(0))?,
);
cx.notify();
Ok(())
}
pub fn clear_range_highlights(&mut self, cx: &mut Context<Self>) {
if self.range_highlights.take().is_some() {
cx.notify();
}
}
fn reconcile_range_highlights(&mut self, new: &ParsedDocument, revision: usize, append: bool) {
let tail_only = append && self.full_update_revision <= self.committed_revision;
self.committed_revision = revision;
self.rendered_index = Arc::default();
if self.range_highlights.is_some() || self.pending_reveal.is_some() {
let remap = LeafRemap::new(&self.parsed_content.document, new, tail_only);
self.range_highlights = self
.range_highlights
.take()
.and_then(|highlights| highlights.remap(&remap))
.map(Arc::new);
self.pending_reveal = self
.pending_reveal
.take()
.and_then(|reveal| reveal.remap(&remap));
}
}
fn splice_appended_blocks(&self, new: &ParsedDocument) {
let old = &self.parsed_content.document.blocks;
let (old_count, new_count) = (old.len(), new.blocks.len());
if old_count == 0 || new_count <= old_count || self.list_state.item_count() != old_count {
return;
}
let unchanged = old
.iter()
.zip(new.blocks.iter())
.take(old_count - 1)
.take_while(|(old_block, new_block)| {
old_block
.span()
.is_some_and(|span| new_block.span() == Some(span))
})
.count();
self.list_state.remeasure_items(unchanged..old_count);
self.list_state
.splice(old_count..old_count, new_count - old_count);
}
pub(super) fn update_bounds(&mut self, bounds: Bounds<Pixels>, _cx: &mut App) {
self.bounds = bounds;
}
pub(super) fn block_ix_at(&self, content_y: Pixels) -> Option<usize> {
if !self.scrollable {
return None;
}
let origin = self.bounds.origin.y + self.scroll_offset().y;
let count = self.list_state.item_count();
let mut ix = self.list_state.logical_scroll_top().item_ix;
while ix < count {
let bounds = self.list_state.bounds_for_item(ix)?;
if content_y < bounds.bottom() - origin {
return Some(ix);
}
ix += 1;
}
count.checked_sub(1)
}
#[doc(hidden)]
pub fn bounds(&self) -> Bounds<Pixels> {
self.bounds
}
#[doc(hidden)]
pub fn list_state(&self) -> &ListState {
&self.list_state
}
#[doc(hidden)]
pub fn is_selecting(&self) -> bool {
self.is_selecting
}
#[doc(hidden)]
pub fn focus_handle(&self) -> &FocusHandle {
&self.focus_handle
}
pub(super) fn has_view_selection(&self) -> bool {
self.select_all
|| self.multi_click_selection.is_some()
|| self.selected_text_override.is_some()
}
pub(super) fn stop_auto_scroll(&mut self) {
self.auto_scroll.stop();
}
pub(super) fn reset_selection(&mut self) {
self.preserve_inline_selection = false;
self.multi_click_selection = None;
self.selected_text_override = None;
self.select_all = false;
self.is_selecting = false;
self.auto_scroll.stop();
self.parsed_content.document.clear_selection();
}
fn reset_selection_and_adapter(&mut self, cx: &mut App) {
self.reset_selection();
self.selection_adapter.set_local_selection(false, cx);
}
pub fn clear_selection(&mut self, cx: &mut Context<Self>) {
self.reset_selection_and_adapter(cx);
cx.notify();
}
pub(super) fn scroll_offset(&self) -> Point<Pixels> {
if self.scrollable {
self.list_state.scroll_px_offset_for_scrollbar()
} else {
Point::default()
}
}
pub fn select_all(&mut self, cx: &mut Context<Self>) {
self.multi_click_selection = None;
self.selected_text_override = None;
self.select_all = true;
self.is_selecting = false;
self.auto_scroll.stop();
self.selection_adapter.set_local_selection(true, cx);
cx.notify();
}
pub(crate) fn set_multi_click_selection(
&mut self,
pos: Point<Pixels>,
kind: TextViewMultiClickKind,
selected_text: String,
cx: &mut App,
) {
self.preserve_inline_selection = false;
let scroll_offset = self.scroll_offset();
let pos = pos - self.bounds.origin - scroll_offset;
self.multi_click_selection = Some(TextViewMultiClickSelection {
pos,
kind,
line_bounds: None,
});
self.selected_text_override = Some(selected_text);
self.select_all = false;
self.is_selecting = false;
self.auto_scroll.stop();
self.selection_adapter.set_local_selection(true, cx);
}
pub(crate) fn set_multi_click_line(&mut self, bounds: Bounds<Pixels>, cx: &mut App) {
self.set_multi_click_selection(
bounds.center(),
TextViewMultiClickKind::Line,
String::new(),
cx,
);
self.selected_text_override = None;
let offset = self.bounds.origin + self.scroll_offset();
if let Some(selection) = self.multi_click_selection.as_mut() {
selection.line_bounds = Some(Bounds::new(bounds.origin - offset, bounds.size));
}
}
pub(super) fn set_auto_scroll(&mut self, delta: Option<Pixels>, cx: &mut Context<Self>) {
self.auto_scroll.set(delta, cx, |delta, state, cx| {
state.list_state.scroll_by(delta);
cx.notify();
});
}
pub(crate) fn selection_points(&self, cx: &App) -> Option<(Point<Pixels>, Point<Pixels>)> {
if !self.selectable {
return None;
}
self.selection_adapter.selection_points(cx)
}
pub(crate) fn has_selection(&self, cx: &App) -> bool {
self.has_view_selection() || self.selection_points(cx).is_some()
}
pub(super) fn on_action_select_all(
&mut self,
_: &SelectAll,
_: &mut Window,
cx: &mut Context<Self>,
) {
if !self.selectable {
cx.propagate();
return;
}
self.select_all(cx);
}
pub(crate) fn is_selectable(&self) -> bool {
self.selectable
}
pub(crate) fn is_all_selected(&self) -> bool {
self.select_all
}
pub(crate) fn multi_click_selection(&self) -> Option<TextViewMultiClickSelection> {
let scroll_offset = self.scroll_offset();
self.multi_click_selection.map(|selection| {
let pos = selection.pos + scroll_offset + self.bounds.origin;
let line_bounds = selection.line_bounds.map(|bounds| {
Bounds::new(
bounds.origin + scroll_offset + self.bounds.origin,
bounds.size,
)
});
TextViewMultiClickSelection {
pos,
line_bounds,
..selection
}
})
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct TextViewMultiClickSelection {
pub(crate) pos: Point<Pixels>,
pub(crate) kind: TextViewMultiClickKind,
pub(crate) line_bounds: Option<Bounds<Pixels>>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum TextViewMultiClickKind {
Word,
Paragraph,
Line,
}
impl TextViewState {
fn reveal_frame(
&mut self,
now: Instant,
window: &mut Window,
) -> (Option<RevealRequest>, Option<usize>) {
if self.max_lines.is_some()
|| self
.pending_reveal
.as_ref()
.is_some_and(|reveal| reveal.is_expired(now))
{
self.pending_reveal = None;
}
let Some(pending) = &self.pending_reveal else {
return (None, None);
};
let block_ix = pending.block_ix(&self.parsed_content.document);
let block_off_screen = |ix: usize| {
let viewport = self.list_state.viewport_bounds();
self.list_state.bounds_for_item(ix).is_none_or(|item| {
item.bottom() <= viewport.top() || item.top() >= viewport.bottom()
})
};
let reveal_block = block_ix.filter(|ix| {
self.scrollable
&& (pending.is_block() || !pending.was_laid_out())
&& block_off_screen(*ix)
});
let request = pending.request();
if pending.is_block() {
self.pending_reveal = None;
} else {
window.request_animation_frame();
}
(request, reveal_block)
}
}
impl Render for TextViewState {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let typography = (window.text_style(), window.rem_size());
if self
.layout_text_style
.as_ref()
.is_some_and(|previous| previous != &typography)
{
self.preserve_inline_selection = true;
self.compatible_layout_update = true;
self.invalidate_measured_heights();
}
self.layout_text_style = Some(typography);
let state = cx.entity();
let stream_fade = self.stream_fade.frame(Instant::now(), cx.reduce_motion());
if stream_fade.is_some() && self.fade_tick.is_none() {
self.fade_tick = Some(cx.spawn(async move |this, cx| {
cx.background_executor().timer(STREAM_FADE_TICK).await;
_ = this.update(cx, |state, cx| {
state.fade_tick = None;
cx.notify();
});
}));
}
let (reveal, reveal_block) = self.reveal_frame(cx.background_executor().now(), window);
let node_cx = NodeContext {
offset: self.parsed_content.node_cx.offset,
link_refs: self.parsed_content.node_cx.link_refs.clone(),
style: self.text_view_style.clone(),
code_block_actions: self.code_block_actions.clone(),
code_block_highlighter: self.code_block_highlighter.clone(),
table_actions: self.table_actions.clone(),
link_click_handler: self.link_click_handler.clone(),
image_source: self.image_source.clone(),
markdown_extensions: self.markdown_extensions.clone(),
stream_fade,
range_highlights: self.range_highlights.clone(),
reveal,
};
let content = v_flex()
.w_full()
.when(self.max_lines.is_none(), |this| this.h_full())
.map(|this| match &self.parsed_error {
None => this.child(self.parsed_content.document.render_root(
if self.scrollable {
Some(self.list_state.clone())
} else {
None
},
&node_cx,
window,
cx,
)),
Some(err) => this.child(
v_flex()
.gap_1()
.child("Failed to parse content")
.child(err.to_string()),
),
})
.on_prepaint(move |bounds, window, cx| {
let (
size_changed,
selection_involves_view,
has_selection_snapshot,
is_selecting,
compatible_layout_update,
) = {
let state = state.read(cx);
(
state.bounds().size != bounds.size,
state.selection_adapter.is_part_of_window_selection(cx),
state.selection_adapter.has_selection_snapshot(cx),
state.is_selecting,
state.compatible_layout_update,
)
};
let mut revision_changed = false;
state.update(cx, |state, cx| {
revision_changed = state
.selection_adapter
.update_layout_revision(state.selection_revision, state.is_selecting);
state.update_bounds(bounds, cx);
state.compatible_layout_update = false;
});
if !is_selecting
&& ((size_changed && selection_involves_view && !compatible_layout_update)
|| (revision_changed && has_selection_snapshot))
{
TextSelection::clear(window, cx);
}
});
if let Some(block_ix) = reveal_block {
self.list_state.scroll_to_reveal_item(block_ix);
}
content
}
}
#[derive(Clone, PartialEq, Default)]
pub(crate) struct ParsedContent {
pub(crate) document: ParsedDocument,
pub(crate) node_cx: node::NodeContext,
}
struct UpdateFuture {
format: TextViewFormat,
content: ParsedContent,
rx: Pin<Box<Receiver<UpdateOptions>>>,
tx_result: Sender<ParsedUpdate>,
}
impl UpdateFuture {
fn new(
format: TextViewFormat,
rx: Receiver<UpdateOptions>,
tx_result: Sender<ParsedUpdate>,
) -> Self {
Self {
format,
content: Default::default(),
rx: Box::pin(rx),
tx_result,
}
}
}
impl Future for UpdateFuture {
type Output = ();
fn poll(mut self: Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> Poll<Self::Output> {
loop {
match self.rx.as_mut().poll_next(cx) {
Poll::Ready(Some(mut options)) => {
let hit_coalesce_budget =
merge_pending_options(&mut options, self.rx.as_ref().get_ref());
let res = parse_content(self.format, self.content.clone(), &options);
if let Ok(content) = &res {
self.content = content.clone();
}
_ = self.tx_result.try_send(ParsedUpdate {
revision: options.revision,
full_parse: !options.append,
selection_compatible: options.mode == ParseMode::Compatible,
baseline_ack: options.mode == ParseMode::BaselineAck,
result: res,
});
if hit_coalesce_budget {
cx.waker().wake_by_ref();
return Poll::Pending;
}
continue;
}
Poll::Ready(None) => return Poll::Ready(()),
Poll::Pending => return Poll::Pending,
}
}
}
}
#[derive(Clone)]
struct UpdateOptions {
revision: usize,
pending_text: String,
append: bool,
mode: ParseMode,
markdown_extensions: Arc<MarkdownExtensions>,
}
impl UpdateOptions {
fn merge(&mut self, next: UpdateOptions) {
if next.append {
self.pending_text.push_str(&next.pending_text);
self.revision = next.revision;
if self.mode != ParseMode::Replace {
self.mode = ParseMode::Compatible;
}
} else {
*self = next;
}
}
}
struct ParsedUpdate {
revision: usize,
full_parse: bool,
selection_compatible: bool,
baseline_ack: bool,
result: Result<ParsedContent, SharedString>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ParseMode {
BaselineAck,
Replace,
Compatible,
}
fn merge_pending_options(options: &mut UpdateOptions, rx: &Receiver<UpdateOptions>) -> bool {
let mut update_count = 1;
while update_count < MAX_COALESCED_UPDATES_PER_PARSE {
match rx.try_recv() {
Ok(next_options) => {
options.merge(next_options);
update_count += 1;
}
Err(_) => return false,
}
}
true
}
fn parse_content(
format: TextViewFormat,
mut content: ParsedContent,
options: &UpdateOptions,
) -> Result<ParsedContent, SharedString> {
let mut node_cx = NodeContext {
markdown_extensions: options.markdown_extensions.clone(),
..NodeContext::default()
};
let last_span = options
.append
.then(|| {
content
.document
.blocks
.last()
.and_then(|block| block.span())
})
.flatten();
let mut source = String::new();
if let Some(span) = last_span {
Arc::make_mut(&mut content.document.blocks).pop();
node_cx.offset = span.start;
source.push_str(&content.document.source[span.start..]);
source.push_str(&options.pending_text);
} else {
if options.append {
node_cx.offset = content.document.source.len();
}
source.push_str(&options.pending_text);
}
let new_document = match format {
TextViewFormat::Markdown => format::markdown::parse(&source, &mut node_cx),
TextViewFormat::Html => format::html::parse(&source, &mut node_cx),
}?;
if options.append {
content.document.source =
format!("{}{}", content.document.source, options.pending_text).into();
Arc::make_mut(&mut content.document.blocks)
.extend(Arc::unwrap_or_clone(new_document.blocks));
} else {
content.document = new_document;
}
Ok(content)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::text::{MarkdownNode, node::BlockNode};
use gpui::TestAppContext;
mod stream_fade {
use std::{cell::Cell, ops::Range, rc::Rc, time::Duration};
use gpui::{Entity, TestAppContext};
use super::super::*;
use crate::{motion::Easing, text::stream_fade::TextLeafKey};
const FADE: Duration = Duration::from_secs(10);
fn fading_state(markdown: &str, cx: &mut TestAppContext) -> Entity<TextViewState> {
cx.update(crate::init);
let state = cx.update(|cx| {
cx.new(|cx| {
TextViewState::markdown(markdown, cx).motion(
TextViewMotion::default()
.with_stream_fade(FADE)
.with_stream_fade_easing(Easing::Linear),
)
})
});
cx.run_until_parked();
state
}
fn fades(
state: &Entity<TextViewState>,
key: TextLeafKey,
cx: &mut TestAppContext,
) -> Option<Vec<Range<usize>>> {
state.update(cx, |state, _| {
let frame = state.stream_fade.frame(Instant::now(), false)?;
let fades = frame.fades(key)?;
assert!(
fades.iter().all(|(_, fade_out)| *fade_out > 0.9),
"a fade sampled right after it starts is still transparent: {fades:?}"
);
Some(fades.iter().map(|(range, _)| range.clone()).collect())
})
}
#[gpui::test]
fn push_str_fades_only_the_appended_text(cx: &mut TestAppContext) {
let state = fading_state("hello", cx);
state.update(cx, |state, cx| state.push_str(" world", cx));
cx.run_until_parked();
assert_eq!(fades(&state, TextLeafKey::block(0), cx), Some(vec![5..11]));
state.update(cx, |state, _| {
let later = Instant::now() + FADE + Duration::from_secs(1);
assert!(state.stream_fade.frame(later, false).is_none());
assert!(state.stream_fade.frame(Instant::now(), false).is_none());
});
}
#[gpui::test]
fn set_text_extending_the_text_fades_like_push_str(cx: &mut TestAppContext) {
let state = fading_state("hello", cx);
state.update(cx, |state, cx| state.set_text("hello world", cx));
cx.run_until_parked();
assert_eq!(fades(&state, TextLeafKey::block(0), cx), Some(vec![5..11]));
}
#[gpui::test]
fn set_text_replacing_the_text_shows_it_at_once(cx: &mut TestAppContext) {
let state = fading_state("hello", cx);
state.update(cx, |state, cx| state.push_str(" world", cx));
cx.run_until_parked();
assert!(fades(&state, TextLeafKey::block(0), cx).is_some());
state.update(cx, |state, cx| state.set_text("other", cx));
cx.run_until_parked();
assert_eq!(fades(&state, TextLeafKey::block(0), cx), None);
}
#[gpui::test]
fn completed_markup_refades_from_the_divergence(cx: &mut TestAppContext) {
let state = fading_state("text ", cx);
state.update(cx, |state, cx| state.push_str("**bo", cx));
cx.run_until_parked();
assert_eq!(fades(&state, TextLeafKey::block(0), cx), Some(vec![4..9]));
state.update(cx, |state, cx| state.push_str("ld**", cx));
cx.run_until_parked();
assert_eq!(
fades(&state, TextLeafKey::block(0), cx),
Some(vec![4..5, 5..9])
);
}
#[gpui::test]
fn without_stagger_an_update_fades_as_one_chunk(cx: &mut TestAppContext) {
let state = fading_state("hello", cx);
state.update(cx, |state, cx| state.push_str(" one two three", cx));
cx.run_until_parked();
assert_eq!(fades(&state, TextLeafKey::block(0), cx), Some(vec![5..19]));
}
#[gpui::test]
fn words_of_one_update_start_one_after_another(cx: &mut TestAppContext) {
let stagger = Duration::from_millis(100);
let state = fading_state("hello", cx);
state.update(cx, |state, _| {
state.set_motion(
TextViewMotion::default()
.with_stream_fade(FADE)
.with_stream_fade_stagger(stagger)
.with_stream_fade_easing(Easing::Linear),
)
});
state.update(cx, |state, cx| state.push_str(" one two three", cx));
cx.run_until_parked();
state.update(cx, |state, _| {
let frame = state
.stream_fade
.frame(Instant::now() + stagger * 3, false)
.expect("words still fading");
let fades = frame.fades(TextLeafKey::block(0)).expect("paragraph fades");
let ranges: Vec<_> = fades.iter().map(|(range, _)| range.clone()).collect();
assert_eq!(ranges, vec![5..10, 10..14, 14..19]);
assert!(
fades[0].1 < fades[1].1 && fades[1].1 < fades[2].1,
"{fades:?}"
);
});
}
#[gpui::test]
fn a_new_paragraph_fades_as_a_whole(cx: &mut TestAppContext) {
let state = fading_state("first", cx);
state.update(cx, |state, cx| state.push_str("\n\nsecond", cx));
cx.run_until_parked();
assert_eq!(fades(&state, TextLeafKey::block(0), cx), None);
assert_eq!(fades(&state, TextLeafKey::block(7), cx), Some(vec![0..6]));
}
#[gpui::test]
fn code_block_text_fades_by_block(cx: &mut TestAppContext) {
let state = fading_state("```rs\nlet", cx);
state.update(cx, |state, cx| state.push_str(" x", cx));
cx.run_until_parked();
assert_eq!(fades(&state, TextLeafKey::block(0), cx), Some(vec![3..5]));
}
#[gpui::test]
fn table_cells_fade_by_ordinal(cx: &mut TestAppContext) {
let state = fading_state("| a | b |\n|---|---|\n| c | d", cx);
state.update(cx, |state, cx| state.push_str("e |", cx));
cx.run_until_parked();
assert_eq!(fades(&state, TextLeafKey::table_cell(0, 2), cx), None);
assert_eq!(
fades(&state, TextLeafKey::table_cell(0, 3), cx),
Some(vec![1..2])
);
}
#[gpui::test]
fn zero_duration_records_nothing(cx: &mut TestAppContext) {
cx.update(crate::init);
let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("hello", cx)));
cx.run_until_parked();
state.update(cx, |state, cx| state.push_str(" world", cx));
cx.run_until_parked();
state.update(cx, |state, _| {
assert!(state.stream_fade.frame(Instant::now(), false).is_none());
});
}
#[gpui::test]
fn reduced_motion_drops_the_fade(cx: &mut TestAppContext) {
let state = fading_state("hello", cx);
state.update(cx, |state, cx| state.push_str(" world", cx));
cx.run_until_parked();
state.update(cx, |state, _| {
assert!(state.stream_fade.frame(Instant::now(), true).is_none());
assert!(state.stream_fade.frame(Instant::now(), false).is_none());
});
}
#[gpui::test]
fn a_fade_repaints_on_a_timer_until_nothing_fades(cx: &mut TestAppContext) {
struct FadeRoot(Entity<TextViewState>);
impl Render for FadeRoot {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
crate::text::TextView::new(&self.0)
}
}
let state = fading_state("hello", cx);
let (_, cx) = cx.add_window_view(|_, _| FadeRoot(state.clone()));
let notifies = Rc::new(Cell::new(0));
let _subscription = cx.update(|_, cx| {
let notifies = notifies.clone();
cx.observe(&state, move |_, _| notifies.set(notifies.get() + 1))
});
state.update(cx, |state, cx| state.push_str(" world", cx));
cx.run_until_parked();
cx.update(|window, cx| window.draw(cx).clear(cx));
let before = notifies.get();
cx.executor().advance_clock(STREAM_FADE_TICK / 2);
cx.run_until_parked();
assert_eq!(notifies.get(), before);
for tick in 1..=3 {
cx.executor().advance_clock(STREAM_FADE_TICK);
cx.run_until_parked();
assert_eq!(notifies.get(), before + tick);
}
state.update(cx, |state, cx| state.set_text("other", cx));
cx.run_until_parked();
cx.executor().advance_clock(STREAM_FADE_TICK);
cx.run_until_parked();
let after = notifies.get();
cx.executor().advance_clock(STREAM_FADE_TICK * 10);
cx.run_until_parked();
assert_eq!(notifies.get(), after);
assert!(state.read_with(cx, |state, _| state.fade_tick.is_none()));
}
}
#[gpui::test]
fn small_full_replace_parses_before_background_executor_runs(cx: &mut TestAppContext) {
cx.update(crate::init);
let markdown = "# ready";
let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown(markdown, cx)));
state.read_with(cx, |state, _| {
assert_eq!(state.source().as_str(), markdown);
assert_eq!(state.parsed_content.document.blocks.len(), 1);
});
}
#[gpui::test]
fn large_markdown_and_html_full_replacements_wait_for_background_executor(
cx: &mut TestAppContext,
) {
cx.update(crate::init);
let markdown = "# x\n\n".repeat(MAX_SYNC_FULL_REPLACE_BYTES / 5 + 1);
let html = format!("<p>{}</p>", "x".repeat(MAX_SYNC_FULL_REPLACE_BYTES + 1));
assert!(markdown.len() > MAX_SYNC_FULL_REPLACE_BYTES);
assert!(html.len() > MAX_SYNC_FULL_REPLACE_BYTES);
let (markdown_state, html_state) = cx.update(|cx| {
(
cx.new(|cx| TextViewState::markdown(&markdown, cx)),
cx.new(|cx| TextViewState::html(&html, cx)),
)
});
markdown_state.read_with(cx, |state, _| {
assert_eq!(state.text.as_str(), markdown.as_str());
assert!(state.source().as_str().is_empty());
assert!(state.parsed_content.document.blocks.is_empty());
});
html_state.read_with(cx, |state, _| {
assert_eq!(state.text.as_str(), html.as_str());
assert!(state.source().as_str().is_empty());
assert!(state.parsed_content.document.blocks.is_empty());
});
cx.run_until_parked();
markdown_state.read_with(cx, |state, _| {
assert_eq!(state.source().as_str(), markdown.as_str());
assert!(!state.parsed_content.document.blocks.is_empty());
});
html_state.read_with(cx, |state, _| {
assert_eq!(state.source().as_str(), html.as_str());
assert!(!state.parsed_content.document.blocks.is_empty());
});
}
#[gpui::test]
fn inline_source_ranges_follow_streamed_tail_reparsing(cx: &mut TestAppContext) {
cx.update(crate::init);
let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("中文 $a$\n\n尾 $x", cx)));
state.update(cx, |state, cx| {
let extensions = MarkdownExtensions::default().plugin(
crate::text::markdown_ext::TestInlinePlugin::new("test").parse_with(|node, _| {
let markdown::mdast::Node::InlineMath(math) = node else {
return None;
};
Some(super::super::MarkdownNode::new("formula", ()).text(math.value.clone()))
}),
);
state.set_markdown_extensions(Arc::new(extensions), cx);
});
cx.run_until_parked();
state.update(cx, |state, cx| state.push_str("^2$ 后", cx));
cx.run_until_parked();
state.read_with(cx, |state, _| {
let source = "中文 $a$\n\n尾 $x^2$ 后";
assert_eq!(state.source().as_str(), source);
let objects: Vec<_> = state
.parsed_content
.document
.blocks
.iter()
.flat_map(|block| {
let super::super::node::BlockNode::Paragraph(paragraph) = block else {
panic!()
};
paragraph
.children
.iter()
.filter_map(|node| node.custom.as_ref())
})
.collect();
assert_eq!(objects.len(), 2);
for (object, markdown) in objects.iter().zip(["$a$", "$x^2$"]) {
let start = source.find(markdown).unwrap();
assert_eq!(object.source_range(), Some(start..start + markdown.len()));
assert_eq!(object.as_markdown(), markdown);
}
});
}
#[gpui::test]
fn async_full_replace_then_push_str_preserves_complete_source(cx: &mut TestAppContext) {
cx.update(crate::init);
let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("old", cx)));
cx.run_until_parked();
let replacement = "x".repeat(MAX_SYNC_FULL_REPLACE_BYTES + 1);
let expected = format!("{replacement} tail");
state.update(cx, |state, cx| {
state.set_text(&replacement, cx);
state.push_str(" tail", cx);
});
cx.run_until_parked();
state.read_with(cx, |state, _| {
assert_eq!(state.text.as_str(), expected.as_str());
assert_eq!(state.source().as_str(), expected.as_str());
});
}
#[gpui::test]
fn html_push_str_keeps_earlier_blocks(cx: &mut TestAppContext) {
cx.update(crate::init);
let state = cx.update(|cx| cx.new(|cx| TextViewState::html("<p>first</p>", cx)));
cx.run_until_parked();
state.update(cx, |state, cx| {
state.push_str("<p>second</p>", cx);
});
cx.run_until_parked();
state.read_with(cx, |state, _| {
assert_eq!(state.source().as_str(), "<p>first</p><p>second</p>");
let text = state
.parsed_content
.document
.blocks
.iter()
.map(|block| block.text())
.collect::<String>();
assert!(text.contains("first"), "lost the first block: {text:?}");
assert!(text.contains("second"), "lost the appended block: {text:?}");
});
}
#[gpui::test]
fn element_text_of_the_same_string_is_not_compared_again(cx: &mut TestAppContext) {
cx.update(crate::init);
let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("", cx)));
let revision = |cx: &mut TestAppContext| state.read_with(cx, |state, _| state.revision);
let text = SharedString::from("hello");
state.update(cx, |state, cx| state.set_element_text(&text, cx));
let parsed = revision(cx);
assert!(parsed > 0);
state.update(cx, |state, cx| state.set_element_text(&text, cx));
assert_eq!(revision(cx), parsed);
let equal = SharedString::from("hello".to_string());
state.update(cx, |state, cx| state.set_element_text(&equal, cx));
assert_eq!(revision(cx), parsed);
state.update(cx, |state, cx| {
state.push_str(" world", cx);
state.set_element_text(&equal, cx);
});
state.read_with(cx, |state, _| assert_eq!(state.text.as_str(), "hello"));
}
#[gpui::test]
fn set_text_then_push_str_appends_to_replaced_content(cx: &mut TestAppContext) {
cx.update(crate::init);
let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("old", cx)));
cx.run_until_parked();
state.update(cx, |state, cx| {
state.set_text("", cx);
state.push_str("new", cx);
state.push_str(" text", cx);
});
cx.run_until_parked();
state.read_with(cx, |state, _| {
assert_eq!(state.text.as_str(), "new text");
assert_eq!(state.source().as_str(), "new text");
});
state.update(cx, |state, cx| {
state.set_text("", cx);
});
cx.run_until_parked();
state.read_with(cx, |state, _| {
assert_eq!(state.text.as_str(), "");
assert_eq!(state.source().as_str(), "");
});
}
#[gpui::test]
fn full_parse_coalesced_with_append_preserves_new_select_all(cx: &mut TestAppContext) {
cx.update(crate::init);
let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("old", cx)));
cx.run_until_parked();
state.update(cx, |state, cx| {
state.set_text("new", cx);
state.push_str(" text", cx);
state.select_all(cx);
});
cx.run_until_parked();
state.read_with(cx, |state, _| {
assert!(state.select_all);
assert_eq!(state.selected_text().trim(), "new text");
});
}
#[test]
fn update_options_merge_keeps_latest_full_text() {
let mut options = UpdateOptions {
revision: 1,
pending_text: "old".to_string(),
append: true,
mode: ParseMode::Compatible,
markdown_extensions: Arc::default(),
};
options.merge(UpdateOptions {
revision: 2,
pending_text: "new".to_string(),
append: false,
mode: ParseMode::BaselineAck,
markdown_extensions: Arc::default(),
});
options.merge(UpdateOptions {
revision: 3,
pending_text: " text".to_string(),
append: true,
mode: ParseMode::Compatible,
markdown_extensions: Arc::default(),
});
assert_eq!(options.revision, 3);
assert_eq!(options.pending_text, "new text");
assert!(!options.append);
}
#[test]
fn append_merged_into_async_replace_remains_a_replacement() {
let mut options = UpdateOptions {
revision: 1,
pending_text: "new".to_string(),
append: false,
mode: ParseMode::Replace,
markdown_extensions: Arc::default(),
};
options.merge(UpdateOptions {
revision: 2,
pending_text: " text".to_string(),
append: true,
mode: ParseMode::Compatible,
markdown_extensions: Arc::default(),
});
assert_eq!(options.revision, 2);
assert_eq!(options.pending_text, "new text");
assert!(!options.append);
assert_eq!(options.mode, ParseMode::Replace);
}
#[test]
fn update_future_yields_before_coalescing_all_queued_updates() {
let (tx, rx) = unbounded::<UpdateOptions>();
let (tx_result, rx_result) = unbounded::<ParsedUpdate>();
let total_updates = 128;
for revision in 1..=total_updates {
tx.try_send(UpdateOptions {
revision,
pending_text: format!("{revision}\n"),
append: revision != 1,
mode: if revision == 1 {
ParseMode::BaselineAck
} else {
ParseMode::Compatible
},
markdown_extensions: Arc::default(),
})
.unwrap();
}
let mut future = Box::pin(UpdateFuture::new(TextViewFormat::Markdown, rx, tx_result));
let waker = futures::task::noop_waker();
let mut task_cx = std::task::Context::from_waker(&waker);
assert!(matches!(
std::future::Future::poll(future.as_mut(), &mut task_cx),
Poll::Pending
));
let parsed_update = rx_result.try_recv().expect("parse result");
assert!(
parsed_update.revision < total_updates,
"single poll coalesced every queued update through revision {}",
parsed_update.revision
);
assert!(matches!(
std::future::Future::poll(future.as_mut(), &mut task_cx),
Poll::Pending
));
let parsed_update = rx_result.try_recv().expect("next parse result");
assert_eq!(parsed_update.revision, total_updates);
}
#[gpui::test]
fn select_all_returns_rendered_text(cx: &mut TestAppContext) {
cx.update(crate::init);
let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("**quick** value", cx)));
cx.run_until_parked();
state.update(cx, |state, cx| {
state.select_all(cx);
});
state.read_with(cx, |state, _| {
assert!(state.has_view_selection());
assert_eq!(state.selected_text().trim(), "quick value");
});
state.update(cx, |state, cx| {
state.clear_selection(cx);
});
state.read_with(cx, |state, _| {
assert!(!state.has_view_selection());
assert_eq!(state.selected_text(), "");
});
}
#[gpui::test]
fn select_all_in_source_format_returns_source(cx: &mut TestAppContext) {
cx.update(crate::init);
let markdown = "**quick** value";
let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown(markdown, cx)));
cx.run_until_parked();
state.update(cx, |state, cx| state.select_all(cx));
state.read_with(cx, |state, _| {
assert_eq!(state.selected_text().trim(), "quick value");
});
state.update(cx, |state, cx| {
state.set_selection_format(SelectionFormat::Source, cx)
});
state.read_with(cx, |state, _| {
assert_eq!(state.selected_text().trim(), markdown);
});
}
#[gpui::test]
fn selected_source_range_returns_full_markdown_source_for_select_all(cx: &mut TestAppContext) {
cx.update(crate::init);
let markdown = "**quick** value";
let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown(markdown, cx)));
cx.run_until_parked();
state.update(cx, |state, cx| state.select_all(cx));
state.read_with(cx, |state, _| {
assert_eq!(state.selected_source_range(), Some(0..markdown.len()));
});
}
#[gpui::test]
fn selected_source_range_returns_none_for_html(cx: &mut TestAppContext) {
cx.update(crate::init);
let state = cx.update(|cx| cx.new(|cx| TextViewState::html("<b>quick</b>", cx)));
cx.run_until_parked();
state.update(cx, |state, cx| state.select_all(cx));
state.read_with(cx, |state, _| {
assert_eq!(state.selected_source_range(), None);
});
}
#[test]
fn selected_source_range_keeps_global_offsets_after_incremental_tail_parse() {
let options = UpdateOptions {
revision: 1,
pending_text: "first\n\nsecond".to_string(),
append: false,
mode: ParseMode::Replace,
markdown_extensions: Arc::default(),
};
let content = parse_content(TextViewFormat::Markdown, ParsedContent::default(), &options)
.expect("initial parse");
let content = parse_content(
TextViewFormat::Markdown,
content,
&UpdateOptions {
revision: 2,
pending_text: "\n\n**écho**".to_string(),
append: true,
mode: ParseMode::Compatible,
markdown_extensions: Arc::default(),
},
)
.expect("incremental parse");
let BlockNode::Paragraph(paragraph) = &content.document.blocks[2] else {
panic!("expected appended paragraph");
};
let mut state = paragraph.state.lock().unwrap();
state.set_text(paragraph.text().into());
state.selection = Some((0.."écho".len()).into());
drop(state);
assert_eq!(content.document.selected_source_range(), Some(17..22));
}
#[test]
fn streamed_ordered_list_continuation_preserves_start() {
let initial = parse_content(
TextViewFormat::Markdown,
ParsedContent::default(),
&UpdateOptions {
revision: 1,
pending_text: "3. three".to_string(),
append: false,
mode: ParseMode::Replace,
markdown_extensions: Arc::default(),
},
)
.expect("initial list parse");
let continued = parse_content(
TextViewFormat::Markdown,
initial,
&UpdateOptions {
revision: 2,
pending_text: "\n4. four".to_string(),
append: true,
mode: ParseMode::Compatible,
markdown_extensions: Arc::default(),
},
)
.expect("streamed list continuation parse");
let BlockNode::List {
start, children, ..
} = &continued.document.blocks[0]
else {
panic!("expected streamed ordered list");
};
assert_eq!(*start, Some(3));
assert_eq!(children.len(), 2);
}
#[gpui::test]
fn parser_revision_reparses_same_name_inline_configuration(cx: &mut TestAppContext) {
cx.update(crate::init);
let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("@member", cx)));
for (revision, label) in [(1, "Alice"), (2, "Bob")] {
let extensions = MarkdownExtensions::default()
.parser_revision(revision)
.plugin(
crate::text::markdown_ext::TestInlinePlugin::new("test").parse_with(
move |node, _| {
matches!(node, markdown::mdast::Node::Text(_))
.then(|| MarkdownNode::new("mention", label).text(label))
},
),
);
state.update(cx, |state, cx| {
state.set_markdown_extensions(Arc::new(extensions), cx)
});
cx.run_until_parked();
state.read_with(cx, |state, _| {
let node::BlockNode::Paragraph(paragraph) =
&state.parsed_content.document.blocks[0]
else {
panic!()
};
assert_eq!(
paragraph.children[0].custom.as_ref().unwrap().as_text(),
label
);
});
}
}
#[gpui::test]
fn set_markdown_extensions_reparses_existing_text(cx: &mut TestAppContext) {
cx.update(crate::init);
let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("$TSLA.US", cx)));
cx.run_until_parked();
let extensions = MarkdownExtensions::default().block_parser(|node, cx| {
let markdown::mdast::Node::Paragraph(paragraph) = node else {
return None;
};
let [markdown::mdast::Node::Text(text)] = paragraph.children.as_slice() else {
return None;
};
let symbol = text.value.strip_prefix('$')?.to_string();
let node_text = format!("${symbol}");
Some(
MarkdownNode::new("ticker", symbol)
.text(node_text)
.markdown(cx.node_source(node).unwrap_or_default()),
)
});
state.update(cx, |state, cx| {
state.set_markdown_extensions(Arc::new(extensions), cx);
});
cx.run_until_parked();
state.read_with(cx, |state, _| {
let node::BlockNode::Custom(node) = &state.parsed_content.document.blocks[0] else {
panic!("expected custom markdown node");
};
assert_eq!(node.name(), "ticker");
assert_eq!(node.data::<String>().map(String::as_str), Some("TSLA.US"));
});
}
mod range_highlights {
use std::ops::Range;
use gpui::{Entity, TestAppContext};
use super::super::*;
use crate::text::stream_fade::TextLeafKey;
fn state(markdown: &str, cx: &mut TestAppContext) -> Entity<TextViewState> {
cx.update(crate::init);
let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown(markdown, cx)));
cx.run_until_parked();
state
}
fn highlight(range: Range<usize>) -> RangeHighlight {
RangeHighlight::new(range, gpui::hsla(0.15, 1., 0.5, 0.4))
}
fn set(
state: &Entity<TextViewState>,
ranges: impl IntoIterator<Item = Range<usize>>,
cx: &mut TestAppContext,
) -> Result<(), RangeHighlightError> {
state.update(cx, |state, cx| {
state.set_range_highlights(ranges.into_iter().map(highlight), cx)
})
}
fn painted(
state: &Entity<TextViewState>,
key: TextLeafKey,
cx: &mut TestAppContext,
) -> Vec<Range<usize>> {
state.read_with(cx, |state, _| {
state.range_highlights.as_ref().map_or(Vec::new(), |frame| {
frame
.backgrounds(key)
.iter()
.map(|(range, _)| range.clone())
.collect()
})
})
}
fn has_highlights(state: &Entity<TextViewState>, cx: &mut TestAppContext) -> bool {
state.read_with(cx, |state, _| state.range_highlights.is_some())
}
#[gpui::test]
fn rendered_text_is_the_plain_copy_text(cx: &mut TestAppContext) {
let state = state(
"# Title\n\nhello **world** \\*\n\n- item\n\n| a | b |\n|---|---|\n| c | d |\n\n```\nlet x\n```",
cx,
);
state.read_with(cx, |state, _| {
assert_eq!(
state.rendered_text().as_str(),
"Title\nhello world *\nitem\na b\nc d\n\nlet x\n"
);
});
}
#[gpui::test]
fn a_match_across_marks_paints_in_its_paragraph(cx: &mut TestAppContext) {
let state = state("hello **world**", cx);
set(&state, [0.."hello world".len()], cx).unwrap();
assert_eq!(painted(&state, TextLeafKey::block(0), cx), [0..11]);
}
#[gpui::test]
fn repeated_text_maps_to_the_occurrence_addressed(cx: &mut TestAppContext) {
let state = state("foo\n\nfoo", cx);
let second = state.read_with(cx, |state, _| {
state.rendered_text().as_str().rfind("foo").unwrap()
});
set(&state, [second..second + 3], cx).unwrap();
assert!(painted(&state, TextLeafKey::block(0), cx).is_empty());
assert_eq!(painted(&state, TextLeafKey::block(5), cx), [0..3]);
}
#[gpui::test]
fn a_range_across_blocks_skips_the_separator(cx: &mut TestAppContext) {
let state = state("ab\n\ncd", cx);
set(&state, [1..4], cx).unwrap();
assert_eq!(painted(&state, TextLeafKey::block(0), cx), [1..2]);
assert_eq!(painted(&state, TextLeafKey::block(4), cx), [0..1]);
}
#[gpui::test]
fn table_cells_and_code_blocks_are_leaves(cx: &mut TestAppContext) {
let state = state("| a | b |\n|---|---|\n| c | d |\n\n```\nlet x\n```", cx);
set(&state, [0..3, 6..7, 9..12], cx).unwrap();
assert_eq!(painted(&state, TextLeafKey::table_cell(0, 0), cx), [0..1]);
assert_eq!(painted(&state, TextLeafKey::table_cell(0, 1), cx), [0..1]);
assert_eq!(painted(&state, TextLeafKey::table_cell(0, 3), cx), [0..1]);
let code_start = "| a | b |\n|---|---|\n| c | d |\n\n".len();
assert_eq!(painted(&state, TextLeafKey::block(code_start), cx), [0..3]);
}
#[gpui::test]
fn invalid_ranges_reject_the_whole_set(cx: &mut TestAppContext) {
let state = state("中文\n\nab", cx);
set(&state, [0.."中".len()], cx).unwrap();
let invalid = [Range { start: 6, end: 3 }, 0..1, 0..100];
for range in invalid {
assert_eq!(
set(&state, [0..3, range.clone()], cx),
Err(RangeHighlightError::InvalidRange(1)),
"{range:?}"
);
}
assert_eq!(painted(&state, TextLeafKey::block(0), cx), [0..3]);
}
#[gpui::test]
fn text_outside_every_block_is_left_unpainted(cx: &mut TestAppContext) {
let source = "foo one\n\n<div>foo two</div>\n\n| foo | x |\n|---|---|\n\nfoo three";
let state = state(source, cx);
let text = state.read_with(cx, |state, _| state.rendered_text().as_str().to_string());
let ranges = text
.match_indices("foo")
.chain(text.match_indices(' '))
.map(|(start, found)| start..start + found.len())
.chain([3..3, 7..8])
.collect::<Vec<_>>();
set(&state, ranges, cx).unwrap();
assert_eq!(painted(&state, TextLeafKey::block(0), cx), [0..3, 3..4]);
let table = source.find("| foo").unwrap();
assert_eq!(
painted(&state, TextLeafKey::table_cell(table, 0), cx),
[0..3]
);
let last = source.find("foo three").unwrap();
assert_eq!(painted(&state, TextLeafKey::block(last), cx), [0..3, 3..4]);
}
#[gpui::test]
fn a_later_highlight_paints_over_an_earlier_one(cx: &mut TestAppContext) {
let state = state("abcdef", cx);
set(&state, [0..6, 2..3], cx).unwrap();
assert_eq!(painted(&state, TextLeafKey::block(0), cx), [0..6, 2..3]);
}
#[gpui::test]
fn html_text_is_unsupported(cx: &mut TestAppContext) {
let html = cx.update(|cx| cx.new(|cx| TextViewState::html("<p>one</p>", cx)));
cx.run_until_parked();
html.update(cx, |html, cx| {
assert_eq!(
html.set_range_highlights([highlight(0..3)], cx),
Err(RangeHighlightError::Unsupported)
);
});
}
#[gpui::test]
fn inline_objects_are_skipped(cx: &mut TestAppContext) {
let state = state("x $a$ y", cx);
state.update(cx, |state, cx| {
let extensions = MarkdownExtensions::default().plugin(
crate::text::markdown_ext::TestInlinePlugin::new("test").parse_with(
|node, _| {
let markdown::mdast::Node::InlineMath(math) = node else {
return None;
};
Some(
crate::text::MarkdownNode::new("formula", ())
.text(math.value.clone()),
)
},
),
);
state.set_markdown_extensions(Arc::new(extensions), cx);
});
cx.run_until_parked();
set(&state, [2..3], cx).unwrap();
assert!(painted(&state, TextLeafKey::block(0), cx).is_empty());
set(&state, [0..5], cx).unwrap();
assert_eq!(painted(&state, TextLeafKey::block(0), cx), [0..2, 3..5]);
}
#[gpui::test]
fn push_str_keeps_earlier_blocks_and_clips_the_changed_tail(cx: &mut TestAppContext) {
let state = state("first\n\na **b", cx);
set(&state, [0..5, 6..11], cx).unwrap();
state.update(cx, |state, cx| state.push_str("c**", cx));
cx.run_until_parked();
assert_eq!(painted(&state, TextLeafKey::block(0), cx), [0..5]);
assert_eq!(painted(&state, TextLeafKey::block(7), cx), [0..2]);
}
#[gpui::test]
fn push_str_keeps_a_block_whose_text_is_unchanged(cx: &mut TestAppContext) {
let state = state("first\n\nsecond", cx);
set(&state, [6..12], cx).unwrap();
state.update(cx, |state, cx| state.push_str("\n===", cx));
cx.run_until_parked();
assert_eq!(painted(&state, TextLeafKey::block(7), cx), [0..6]);
}
#[gpui::test]
fn push_str_drops_highlights_of_a_leaf_that_is_gone(cx: &mut TestAppContext) {
let state = state("first\n\n| a |", cx);
set(&state, [0..5, 6..11], cx).unwrap();
state.update(cx, |state, cx| state.push_str("\n|---|", cx));
cx.run_until_parked();
assert_eq!(painted(&state, TextLeafKey::block(0), cx), [0..5]);
assert!(painted(&state, TextLeafKey::block(7), cx).is_empty());
assert!(painted(&state, TextLeafKey::table_cell(7, 0), cx).is_empty());
}
#[gpui::test]
fn replacing_text_drops_highlights_only_where_it_changed(cx: &mut TestAppContext) {
let state = state("first\n\nsecond", cx);
set(&state, [0..5, 6..12], cx).unwrap();
state.update(cx, |state, cx| state.set_text("first\n\nchanged", cx));
cx.run_until_parked();
assert_eq!(painted(&state, TextLeafKey::block(0), cx), [0..5]);
assert!(painted(&state, TextLeafKey::block(7), cx).is_empty());
let large = "x".repeat(MAX_SYNC_FULL_REPLACE_BYTES + 1);
state.update(cx, |state, cx| state.set_text(&large, cx));
cx.run_until_parked();
assert!(!has_highlights(&state, cx));
}
#[gpui::test]
fn a_highlight_follows_its_block_past_an_earlier_edit(cx: &mut TestAppContext) {
let state = state("foo\n\nbar\n\nbar", cx);
set(&state, [4..7], cx).unwrap();
state.update(cx, |state, cx| state.set_text("bar\n\nbar", cx));
cx.run_until_parked();
assert_eq!(painted(&state, TextLeafKey::block(0), cx), [0..3]);
assert!(painted(&state, TextLeafKey::block(5), cx).is_empty());
let state = self::state("ERROR a\n\nERROR b\n\nERROR c", cx);
set(&state, [8..15], cx).unwrap();
state.update(cx, |state, cx| state.set_text("ERROR b\n\nERROR c", cx));
cx.run_until_parked();
assert_eq!(painted(&state, TextLeafKey::block(0), cx), [0..7]);
assert!(painted(&state, TextLeafKey::block(9), cx).is_empty());
}
#[gpui::test]
fn appending_a_copy_of_the_last_block_keeps_the_highlight_on_it(cx: &mut TestAppContext) {
let state = state("a\n\nfoo", cx);
set(&state, [2..5], cx).unwrap();
state.update(cx, |state, cx| state.set_text("a\n\nfoo\n\nfoo", cx));
cx.run_until_parked();
assert_eq!(painted(&state, TextLeafKey::block(3), cx), [0..3]);
assert!(painted(&state, TextLeafKey::block(8), cx).is_empty());
}
#[gpui::test]
fn deleting_a_table_row_drops_highlights_in_the_rows_it_moves(cx: &mut TestAppContext) {
let table = "| a | b |\n|---|---|\n| x | 1 |\n| x | 2 |";
let without_middle_row = "| a | b |\n|---|---|\n| x | 2 |";
for ranges in [vec![8..9, 10..11], vec![4..5]] {
let state = state(table, cx);
set(&state, [0..1].into_iter().chain(ranges), cx).unwrap();
state.update(cx, |state, cx| state.set_text(without_middle_row, cx));
cx.run_until_parked();
assert_eq!(painted(&state, TextLeafKey::table_cell(0, 0), cx), [0..1]);
for cell in 1..4 {
assert!(
painted(&state, TextLeafKey::table_cell(0, cell), cx).is_empty(),
"cell {cell}"
);
}
}
}
#[gpui::test]
fn streaming_table_rows_keeps_the_highlights_of_earlier_rows(cx: &mut TestAppContext) {
let state = state("| a | b |\n|---|---|\n| x | 1 |", cx);
set(&state, [0..1, 4..5], cx).unwrap();
state.update(cx, |state, cx| {
state.set_text("| a | b |\n|---|---|\n| x | 1 |\n| y | 2 |", cx)
});
cx.run_until_parked();
assert_eq!(painted(&state, TextLeafKey::table_cell(0, 0), cx), [0..1]);
assert_eq!(painted(&state, TextLeafKey::table_cell(0, 2), cx), [0..1]);
}
#[gpui::test]
fn an_edit_in_an_earlier_block_keeps_later_highlights(cx: &mut TestAppContext) {
let state = state("one\n\ntwo", cx);
set(&state, [0..3, 4..7], cx).unwrap();
state.update(cx, |state, cx| state.set_text("one!\n\ntwo", cx));
cx.run_until_parked();
assert_eq!(painted(&state, TextLeafKey::block(0), cx), [0..3]);
assert_eq!(painted(&state, TextLeafKey::block(6), cx), [0..3]);
}
#[gpui::test]
fn an_append_after_a_full_update_compares_every_block(cx: &mut TestAppContext) {
let state = state("first\n\nsecond", cx);
set(&state, [0..5], cx).unwrap();
let options = UpdateOptions {
revision: 0,
pending_text: "FIRST\n\nsecond".to_string(),
append: false,
mode: ParseMode::Replace,
markdown_extensions: Arc::default(),
};
let new = parse_content(TextViewFormat::Markdown, ParsedContent::default(), &options)
.unwrap()
.document;
state.update(cx, |state, _| {
state.full_update_revision = state.committed_revision + 1;
let revision = state.committed_revision + 2;
state.reconcile_range_highlights(&new, revision, true);
});
assert!(!has_highlights(&state, cx));
}
#[gpui::test]
fn streaming_through_set_text_keeps_highlights(cx: &mut TestAppContext) {
let state = state("first\n\nsec", cx);
set(&state, [0..5, 6..9], cx).unwrap();
state.update(cx, |state, cx| state.set_text("first\n\nsecond", cx));
cx.run_until_parked();
assert_eq!(painted(&state, TextLeafKey::block(0), cx), [0..5]);
assert_eq!(painted(&state, TextLeafKey::block(7), cx), [0..3]);
}
#[gpui::test]
fn a_full_parse_merged_with_an_append_compares_every_block(cx: &mut TestAppContext) {
let state = state("", cx);
state.update(cx, |state, cx| {
state.set_text("[foo] and some text\n\nmore", cx);
let text = state.rendered_text();
let some = text.as_str().find("some").unwrap();
state
.set_range_highlights([highlight(some..some + 4)], cx)
.unwrap();
state.push_str("\n\n[foo]: https://example.com", cx);
});
cx.run_until_parked();
state.read_with(cx, |state, _| {
assert!(state.rendered_text().as_str().starts_with("foo and some"));
});
assert!(painted(&state, TextLeafKey::block(0), cx).is_empty());
}
#[gpui::test]
fn a_full_update_before_an_append_drops_replaced_highlights(cx: &mut TestAppContext) {
let state = state("first", cx);
set(&state, [0..5], cx).unwrap();
let large = "x".repeat(MAX_SYNC_FULL_REPLACE_BYTES + 1);
state.update(cx, |state, cx| {
state.set_text(&large, cx);
state.push_str(" tail", cx);
});
cx.run_until_parked();
assert!(!has_highlights(&state, cx));
}
#[gpui::test]
fn reparsing_unchanged_text_keeps_highlights(cx: &mut TestAppContext) {
let state = state("first", cx);
set(&state, [0..5], cx).unwrap();
state.update(cx, |state, cx| {
state.set_markdown_extensions(
Arc::new(MarkdownExtensions::default().parser_revision(1)),
cx,
)
});
cx.run_until_parked();
assert_eq!(painted(&state, TextLeafKey::block(0), cx), [0..5]);
}
pub(super) struct Root {
pub(super) state: Entity<TextViewState>,
}
impl Render for Root {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
gpui::div()
.w(px(120.))
.child(crate::text::TextView::new(&self.state).selectable(true))
}
}
#[gpui::test]
fn highlights_paint_across_inline_code_tables_and_code_blocks(cx: &mut TestAppContext) {
cx.update(crate::init);
let markdown = "wrapping text with `inline code` and a [link](https://x.y) \
that wraps\n\n| a | b |\n|---|---|\n| c | d |\n\n```\nlet x = 1;\n```";
let (root, cx) = cx.add_window_view(|_, cx| Root {
state: cx.new(|cx| TextViewState::markdown(markdown, cx)),
});
cx.run_until_parked();
let state = root.read_with(cx, |root, _| root.state.clone());
let text = state.read_with(cx, |state, _| state.rendered_text().as_str().to_string());
let code = text.find("code").unwrap();
let len = text.len();
let ranges = [code..code + 2]
.into_iter()
.chain(text.char_indices().map(|(ix, c)| ix..ix + c.len_utf8()))
.chain([0..len])
.filter(|range| !text[range.clone()].trim().is_empty());
state.update(cx, |state, cx| {
state
.set_range_highlights(ranges.map(highlight), cx)
.unwrap();
});
cx.update(|window, cx| window.draw(cx).clear(cx));
assert_eq!(
painted(&state, TextLeafKey::block(0), cx)[0],
code..code + 2
);
}
#[gpui::test]
fn clear_range_highlights_removes_them(cx: &mut TestAppContext) {
let state = state("first", cx);
set(&state, [0..5], cx).unwrap();
state.update(cx, |state, cx| state.clear_range_highlights(cx));
assert!(!has_highlights(&state, cx));
}
}
mod reveal_range {
use gpui::{
Entity, InteractiveElement as _, ListAlignment, ListState, ScrollHandle,
StatefulInteractiveElement as _, TestAppContext, VisualTestContext, div, list,
};
use super::super::*;
use crate::text::TextView;
#[derive(Clone)]
enum Container {
Scrollable,
List(ListState),
Div(ScrollHandle),
Clamped(ListState),
Fixed,
}
struct Root {
state: Entity<TextViewState>,
container: Container,
}
impl Render for Root {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
let state = self.state.clone();
let frame = div().w(px(200.)).h(px(100.));
let row = |list_state: &ListState, clamp: bool| {
let state = state.clone();
list(list_state.clone(), move |ix, _, _| match ix {
0 => div().h(px(40.)).into_any_element(),
_ => TextView::new(&state)
.when(clamp, |view| view.max_lines(2))
.into_any_element(),
})
.size_full()
};
match &self.container {
Container::Scrollable => frame
.child(TextView::new(&state).scrollable(true))
.into_any_element(),
Container::List(list_state) => {
frame.child(row(list_state, false)).into_any_element()
}
Container::Clamped(list_state) => {
frame.child(row(list_state, true)).into_any_element()
}
Container::Div(handle) => {
let scroll = handle.clone();
frame
.child(
div()
.id("scroll")
.size_full()
.overflow_y_scroll()
.track_scroll(handle)
.child(TextView::new(&state).on_reveal(move |line, _, _| {
let viewport = scroll.bounds();
let mut offset = scroll.offset();
if line.bottom() > viewport.bottom() {
offset.y -= line.bottom() - viewport.bottom();
} else if line.top() < viewport.top() {
offset.y += viewport.top() - line.top();
}
scroll.set_offset(offset);
})),
)
.into_any_element()
}
Container::Fixed => frame.child(TextView::new(&state)).into_any_element(),
}
}
}
fn window<'a>(
markdown: &str,
container: Container,
cx: &'a mut TestAppContext,
) -> (Entity<TextViewState>, &'a mut VisualTestContext) {
cx.update(crate::init);
let (root, cx) = cx.add_window_view(|_, cx| Root {
state: cx.new(|cx| TextViewState::markdown(markdown, cx)),
container,
});
cx.run_until_parked();
draw(cx);
let state = root.read_with(cx, |root, _| root.state.clone());
(state, cx)
}
fn draw(cx: &mut VisualTestContext) {
cx.update(|window, cx| window.draw(cx).clear(cx));
}
fn request(
state: &Entity<TextViewState>,
needle: &str,
cx: &mut VisualTestContext,
then: impl FnOnce(&mut TextViewState, &mut Context<TextViewState>),
) {
state.update(cx, |state, cx| {
let text = state.rendered_text();
let start = text.as_str().find(needle).unwrap();
state.reveal_range(start..start + needle.len(), cx).unwrap();
then(state, cx);
});
}
fn reveal(state: &Entity<TextViewState>, needle: &str, cx: &mut VisualTestContext) {
request(state, needle, cx, |_, _| {});
for _ in 0..3 {
draw(cx);
}
}
fn is_pending(state: &Entity<TextViewState>, cx: &mut VisualTestContext) -> bool {
state.read_with(cx, |state, _| state.pending_reveal.is_some())
}
fn scroll_top(
state: &Entity<TextViewState>,
cx: &mut VisualTestContext,
) -> gpui::ListOffset {
state.read_with(cx, |state, _| state.list_state.logical_scroll_top())
}
fn paragraphs(count: usize) -> String {
(0..count)
.map(|ix| format!("paragraph {ix}"))
.collect::<Vec<_>>()
.join("\n\n")
}
fn words(count: usize) -> String {
(0..count)
.map(|ix| format!("w{ix}"))
.collect::<Vec<_>>()
.join(" ")
}
#[gpui::test]
fn a_scrollable_view_scrolls_to_an_offscreen_block(cx: &mut TestAppContext) {
let (state, cx) = window(¶graphs(200), Container::Scrollable, cx);
reveal(&state, "paragraph 150", cx);
assert!(!is_pending(&state, cx));
let top = scroll_top(&state, cx);
assert!((140..=150).contains(&top.item_ix), "{top:?}");
reveal(&state, "paragraph 3", cx);
assert!(!is_pending(&state, cx));
assert!(scroll_top(&state, cx).item_ix <= 3);
}
#[gpui::test]
fn an_append_adding_blocks_keeps_the_scroll_position(cx: &mut TestAppContext) {
let (state, cx) = window(¶graphs(200), Container::Scrollable, cx);
reveal(&state, "paragraph 100", cx);
let top = scroll_top(&state, cx);
assert!(top.item_ix > 0, "{top:?}");
state.update(cx, |state, cx| state.push_str("\n\nparagraph 200", cx));
cx.run_until_parked();
draw(cx);
state.read_with(cx, |state, _| {
assert_eq!(state.list_state.item_count(), 201);
});
assert_unmoved(&state, top, cx);
}
#[gpui::test]
fn a_scrollable_view_scrolls_to_a_line_inside_a_long_paragraph(cx: &mut TestAppContext) {
let (state, cx) = window(&words(400), Container::Scrollable, cx);
reveal(&state, "w390", cx);
assert!(!is_pending(&state, cx));
let top = scroll_top(&state, cx);
assert_eq!(top.item_ix, 0);
assert!(top.offset_in_item > px(100.), "{top:?}");
}
#[gpui::test]
fn revealing_a_visible_line_does_not_scroll(cx: &mut TestAppContext) {
let (state, cx) = window(&words(400), Container::Scrollable, cx);
reveal(&state, "w200", cx);
let top = scroll_top(&state, cx);
assert!(top.offset_in_item > px(0.), "{top:?}");
for word in ["w199", "w198", "w197", "w196"] {
reveal(&state, word, cx);
assert!(!is_pending(&state, cx));
let now = scroll_top(&state, cx);
assert_eq!(
(now.item_ix, now.offset_in_item),
(top.item_ix, top.offset_in_item),
"{word}"
);
}
}
#[gpui::test]
fn an_enclosing_list_scrolls_to_a_line_of_a_fit_content_view(cx: &mut TestAppContext) {
let outer = ListState::new(2, ListAlignment::Top, px(1000.));
let (state, cx) = window(&words(400), Container::List(outer.clone()), cx);
reveal(&state, "w390", cx);
assert!(!is_pending(&state, cx));
let top = outer.logical_scroll_top();
assert_eq!(top.item_ix, 1, "{top:?}");
assert!(top.offset_in_item > px(100.), "{top:?}");
}
#[gpui::test]
fn on_reveal_scrolls_a_container_that_ignores_scroll_requests(cx: &mut TestAppContext) {
let handle = ScrollHandle::new();
let (state, cx) = window(&words(400), Container::Div(handle.clone()), cx);
reveal(&state, "w390", cx);
assert!(!is_pending(&state, cx));
assert!(handle.offset().y < px(-100.), "{:?}", handle.offset());
}
#[gpui::test]
fn a_reveal_that_cannot_be_shown_gives_up(cx: &mut TestAppContext) {
let (state, cx) = window(&words(4000), Container::Fixed, cx);
reveal(&state, "w3990", cx);
assert!(is_pending(&state, cx));
for _ in 0..10 {
draw(cx);
}
assert!(!is_pending(&state, cx));
}
#[gpui::test]
fn a_reveal_not_carried_out_in_time_is_dropped(cx: &mut TestAppContext) {
let (state, cx) = window(¶graphs(200), Container::Scrollable, cx);
request(&state, "paragraph 150", cx, |_, cx| {
cx.background_executor()
.advance_clock(std::time::Duration::from_secs(2))
});
draw(cx);
assert!(!is_pending(&state, cx));
assert_eq!(scroll_top(&state, cx).item_ix, 0);
}
#[gpui::test]
fn an_empty_range_reveals_its_line(cx: &mut TestAppContext) {
let (state, cx) = window(&words(400), Container::Scrollable, cx);
reveal(&state, "w390", cx);
let top = scroll_top(&state, cx);
state.update(cx, |state, cx| {
let text = state.rendered_text();
let start = text.as_str().find("w391").unwrap();
state.reveal_range(start..start, cx).unwrap();
});
for _ in 0..3 {
draw(cx);
}
assert!(!is_pending(&state, cx));
let now = scroll_top(&state, cx);
assert_eq!(
(now.item_ix, now.offset_in_item),
(top.item_ix, top.offset_in_item)
);
}
#[gpui::test]
fn a_position_after_the_last_character_reveals_its_line(cx: &mut TestAppContext) {
let (state, cx) = window(&words(400), Container::Scrollable, cx);
reveal(&state, "w399", cx);
let top = scroll_top(&state, cx);
assert!(top.offset_in_item > px(1000.), "{top:?}");
state.update(cx, |state, cx| {
let text = state.rendered_text();
let end = text.as_str().find("w399").unwrap() + "w399".len();
state.reveal_range(end..end, cx).unwrap();
});
for _ in 0..3 {
draw(cx);
}
assert!(!is_pending(&state, cx));
let now = scroll_top(&state, cx);
assert_eq!(
(now.item_ix, now.offset_in_item),
(top.item_ix, top.offset_in_item)
);
}
fn reveal_at(
state: &Entity<TextViewState>,
range: impl FnOnce(&str) -> std::ops::Range<usize>,
cx: &mut VisualTestContext,
) {
state.update(cx, |state, cx| {
let text = state.rendered_text();
let range = range(text.as_str());
state.reveal_range(range, cx).unwrap();
});
for _ in 0..3 {
draw(cx);
}
}
fn assert_unmoved(
state: &Entity<TextViewState>,
top: gpui::ListOffset,
cx: &mut VisualTestContext,
) {
assert!(!is_pending(state, cx));
let now = scroll_top(state, cx);
assert_eq!(
(now.item_ix, now.offset_in_item),
(top.item_ix, top.offset_in_item)
);
}
#[gpui::test]
fn the_end_of_the_text_and_its_separators_reveal_the_last_line(cx: &mut TestAppContext) {
let (state, cx) = window(&words(400), Container::Scrollable, cx);
reveal(&state, "w399", cx);
let top = scroll_top(&state, cx);
assert!(top.offset_in_item > px(1000.), "{top:?}");
reveal_at(&state, |text| text.len()..text.len(), cx);
assert_unmoved(&state, top, cx);
reveal_at(&state, |text| text.len() - 1..text.len(), cx);
assert_unmoved(&state, top, cx);
let code = (0..200)
.map(|ix| format!("line {ix}"))
.collect::<Vec<_>>()
.join("\n");
state.update(cx, |state, cx| {
state.set_text(&format!("```\n{code}\n```"), cx)
});
cx.run_until_parked();
reveal(&state, "line 199", cx);
let top = scroll_top(&state, cx);
assert!(top.offset_in_item > px(1000.), "{top:?}");
reveal_at(&state, |text| text.len()..text.len(), cx);
assert_unmoved(&state, top, cx);
}
#[gpui::test]
fn the_end_of_a_block_above_reveals_its_last_line(cx: &mut TestAppContext) {
let second = (0..400)
.map(|ix| format!("v{ix}"))
.collect::<Vec<_>>()
.join(" ");
let markdown = format!("{}\n\n{second}", words(400));
let (state, cx) = window(&markdown, Container::Scrollable, cx);
reveal(&state, "v399", cx);
assert_eq!(scroll_top(&state, cx).item_ix, 1);
reveal_at(
&state,
|text| {
let end = text.find("w399").unwrap() + "w399".len();
end..end
},
cx,
);
assert!(!is_pending(&state, cx));
let top = scroll_top(&state, cx);
assert_eq!(top.item_ix, 0, "{top:?}");
assert!(top.offset_in_item > px(1000.), "{top:?}");
}
#[gpui::test]
fn an_empty_view_has_nothing_to_reveal(cx: &mut TestAppContext) {
cx.update(crate::init);
let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("", cx)));
cx.run_until_parked();
state.update(cx, |state, cx| {
assert_eq!(state.reveal_range(0..0, cx), Ok(()));
assert!(state.pending_reveal.is_none());
});
}
#[gpui::test]
fn revealing_a_visible_block_does_not_scroll(cx: &mut TestAppContext) {
let markdown = format!("{}\n\n<div>html</div>\n\n{}", words(400), words(400));
let (state, cx) = window(&markdown, Container::Scrollable, cx);
reveal(&state, "html", cx);
state.update(cx, |state, _| state.list_state.scroll_by(px(30.)));
draw(cx);
let top = scroll_top(&state, cx);
reveal(&state, "html", cx);
assert_unmoved(&state, top, cx);
}
#[gpui::test]
fn a_line_of_an_inline_flow_counts_as_shown_once_scrolled_to(cx: &mut TestAppContext) {
let markdown = (0..400)
.map(|ix| {
if ix % 10 == 0 {
format!("`c{ix}`")
} else {
format!("w{ix}")
}
})
.collect::<Vec<_>>()
.join(" ");
let (state, cx) = window(&markdown, Container::Scrollable, cx);
reveal(&state, "c390", cx);
assert!(!is_pending(&state, cx));
assert!(scroll_top(&state, cx).offset_in_item > px(1000.));
}
#[gpui::test]
fn a_range_starting_on_a_line_break_reveals_the_next_line(cx: &mut TestAppContext) {
let code = (0..200)
.map(|ix| format!("line {ix}"))
.collect::<Vec<_>>()
.join("\n");
let (state, cx) = window(&format!("```\n{code}\n```"), Container::Scrollable, cx);
reveal(&state, "\nline 190", cx);
assert!(!is_pending(&state, cx));
let top = scroll_top(&state, cx);
assert!(top.offset_in_item > px(1000.), "{top:?}");
}
#[gpui::test]
fn a_range_starting_on_a_line_break_in_an_inline_flow_reveals_the_next_line(
cx: &mut TestAppContext,
) {
let lines = (0..200)
.map(|ix| format!("line {ix} `code`"))
.collect::<Vec<_>>()
.join("\\\n");
let (state, cx) = window(&lines, Container::Scrollable, cx);
reveal(&state, "\nline 190", cx);
assert!(!is_pending(&state, cx));
let top = scroll_top(&state, cx);
assert!(top.offset_in_item > px(1000.), "{top:?}");
}
#[gpui::test]
fn a_reveal_is_dropped_when_its_text_before_it_changes(cx: &mut TestAppContext) {
let (state, cx) = window(¶graphs(200), Container::Scrollable, cx);
let target = "first words then the target";
let markdown = format!("{target}\n\n{}", paragraphs(200));
state.update(cx, |state, cx| state.set_text(&markdown, cx));
cx.run_until_parked();
request(&state, "target", cx, |state, cx| {
state.set_text(&markdown.replacen("target", "target and more", 1), cx);
assert!(state.pending_reveal.is_some());
});
request(&state, "target", cx, |state, cx| {
state.set_text(&markdown.replacen("words", "WORDS", 1), cx);
assert!(state.pending_reveal.is_none());
});
}
#[gpui::test]
fn a_clamped_view_does_not_reveal(cx: &mut TestAppContext) {
let outer = ListState::new(2, ListAlignment::Top, px(1000.));
let (state, cx) = window(&words(400), Container::Clamped(outer.clone()), cx);
reveal(&state, "w390", cx);
assert!(!is_pending(&state, cx));
assert_eq!(outer.logical_scroll_top().item_ix, 0);
}
#[gpui::test]
fn text_outside_every_block_reveals_its_block(cx: &mut TestAppContext) {
let markdown = format!("{}\n\n<div>html text</div>", paragraphs(100));
let (state, cx) = window(&markdown, Container::Scrollable, cx);
reveal(&state, "html text", cx);
assert!(!is_pending(&state, cx));
assert!(
scroll_top(&state, cx).item_ix >= 90,
"{:?}",
scroll_top(&state, cx)
);
}
#[gpui::test]
fn a_reveal_follows_its_text_past_an_edit_before_it(cx: &mut TestAppContext) {
let (state, cx) = window(¶graphs(200), Container::Scrollable, cx);
request(&state, "paragraph 150", cx, |state, cx| {
state.set_text(&format!("inserted\n\n{}", paragraphs(200)), cx);
assert!(state.pending_reveal.is_some());
});
for _ in 0..3 {
draw(cx);
}
assert!(!is_pending(&state, cx));
let top = scroll_top(&state, cx);
assert!((141..=151).contains(&top.item_ix), "{top:?}");
request(&state, "paragraph 150", cx, |state, cx| {
state.set_text(¶graphs(200).replace("paragraph 150", "changed"), cx);
assert!(state.pending_reveal.is_none());
});
}
#[gpui::test]
fn malformed_ranges_and_html_views_are_rejected(cx: &mut TestAppContext) {
cx.update(crate::init);
let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("first\n\nsecond", cx)));
cx.run_until_parked();
state.update(cx, |state, cx| {
assert_eq!(
state.reveal_range(std::ops::Range { start: 5, end: 3 }, cx),
Err(RangeHighlightError::InvalidRange(0))
);
assert_eq!(
state.reveal_range(0..100, cx),
Err(RangeHighlightError::InvalidRange(0))
);
state.reveal_range(0..5, cx).unwrap();
});
let html = cx.update(|cx| cx.new(|cx| TextViewState::html("<p>one</p>", cx)));
cx.run_until_parked();
html.update(cx, |html, cx| {
assert_eq!(
html.reveal_range(0..3, cx),
Err(RangeHighlightError::Unsupported)
);
});
}
}
}