use futures::Stream as _;
use std::{
ops::RangeInclusive,
pin::Pin,
sync::{Arc, Mutex},
task::Poll,
};
use gpui::{
App, AppContext as _, Bounds, Context, 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,
TableActionsFn, TextViewStyle,
document::ParsedDocument,
format,
node::{self, NodeContext},
selection_adapter::TextViewSelectionAdapter,
},
v_flex,
};
const CONTEXT: &'static str = "TextView";
const MAX_COALESCED_UPDATES_PER_PARSE: usize = 64;
const MAX_SYNC_FULL_REPLACE_BYTES: usize = 4 * 1024;
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 {
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: 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) link_click_handler: Option<std::sync::Arc<LinkClickHandlerFn>>,
pub(super) markdown_extensions: Arc<MarkdownExtensions>,
pub(super) is_selecting: 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,
format: TextViewFormat,
text: String,
revision: usize,
pub(super) selection_revision: usize,
compatible_layout_update: bool,
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) => {
state.parsed_content = content;
state.parsed_error = None;
state.compatible_layout_update = parsed_update.selection_compatible;
}
Err(err) => {
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 {
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: TextViewStyle::default(),
code_block_actions: None,
code_block_highlighter: None,
table_actions: None,
link_click_handler: None,
markdown_extensions: Arc::default(),
is_selecting: false,
auto_scroll: AutoScroll::default(),
selection_adapter,
parsed_content: Default::default(),
format,
parsed_error: None,
text: text.to_string(),
revision: 0,
selection_revision: 0,
compatible_layout_update: false,
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;
}
self.text.clear();
self.text.push_str(text);
self.parsed_error = None;
self.increment_update(text, false, cx);
}
pub fn push_str(&mut self, new_text: &str, cx: &mut Context<Self>) {
if new_text.is_empty() {
return;
}
self.text.push_str(new_text);
self.increment_update(new_text, true, cx);
}
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)
}
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 increment_update(&mut self, text: &str, append: bool, cx: &mut Context<Self>) {
self.revision += 1;
if !append {
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.parsed_content = content;
self.parsed_error = None;
if !self.is_selecting {
self.reset_selection_and_adapter(cx);
}
}
Err(err) => {
self.parsed_error = Some(err);
}
}
_ = self.tx.try_send(update_options);
cx.notify();
return;
}
_ = self.tx.try_send(update_options);
}
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.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,
) {
let scroll_offset = self.scroll_offset();
let pos = pos - self.bounds.origin - scroll_offset;
self.multi_click_selection = Some(TextViewMultiClickSelection { pos, kind });
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(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;
TextViewMultiClickSelection { pos, ..selection }
})
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct TextViewMultiClickSelection {
pub(crate) pos: Point<Pixels>,
pub(crate) kind: TextViewMultiClickKind,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum TextViewMultiClickKind {
Word,
Paragraph,
}
impl Render for TextViewState {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let state = cx.entity();
let document = self.parsed_content.document.clone();
let mut node_cx = self.parsed_content.node_cx.clone();
node_cx.code_block_actions = self.code_block_actions.clone();
node_cx.code_block_highlighter = self.code_block_highlighter.clone();
node_cx.table_actions = self.table_actions.clone();
node_cx.link_click_handler = self.link_click_handler.clone();
node_cx.markdown_extensions = self.markdown_extensions.clone();
node_cx.style = self.text_view_style.clone();
v_flex()
.w_full()
.when(self.max_lines.is_none(), |this| this.h_full())
.map(|this| match &mut self.parsed_error {
None => this.child(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);
}
})
}
}
#[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;
use gpui::TestAppContext;
#[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 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 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 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"));
});
}
}