926 lines
28 KiB
Rust
926 lines
28 KiB
Rust
use std::cell::RefCell;
|
|
use std::rc::Rc;
|
|
|
|
use gpui::{
|
|
App, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement, MouseButton,
|
|
MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _, Render, Styled as _,
|
|
TestAppContext, VisualTestContext, Window, div, point, px, rems,
|
|
};
|
|
use gpui_native_editor::highlight::Role;
|
|
use gpui_native_editor::markdown::{
|
|
InlineItem, InlineSpan, InlineStyle, ListMarker, MarkdownBlock, MarkdownHeading,
|
|
MarkdownViewState, TextView, bind_text_view_keys,
|
|
};
|
|
|
|
fn items_text(items: &[InlineItem]) -> String {
|
|
items
|
|
.iter()
|
|
.filter_map(|item| match item {
|
|
InlineItem::Text(span) => Some(span.text.as_ref()),
|
|
InlineItem::Image { .. } => None,
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("")
|
|
}
|
|
|
|
fn spans_text(spans: &[InlineSpan]) -> String {
|
|
spans
|
|
.iter()
|
|
.map(|span| span.text.as_ref())
|
|
.collect::<Vec<_>>()
|
|
.join("")
|
|
}
|
|
|
|
fn paragraph_of(blocks: &[MarkdownBlock], index: usize) -> (&Vec<InlineItem>, usize, bool) {
|
|
match &blocks[index] {
|
|
MarkdownBlock::Paragraph {
|
|
items,
|
|
indent,
|
|
quote,
|
|
} => (items, *indent, *quote),
|
|
other => panic!("block {index} is {other:?}, not a paragraph"),
|
|
}
|
|
}
|
|
|
|
fn item_of(blocks: &[MarkdownBlock], index: usize) -> (ListMarker, String, usize, bool) {
|
|
match &blocks[index] {
|
|
MarkdownBlock::ListItem {
|
|
marker,
|
|
items,
|
|
indent,
|
|
quote,
|
|
} => (*marker, items_text(items), *indent, *quote),
|
|
other => panic!("block {index} is {other:?}, not a list item"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn inline_styles_survive_nesting_and_merge_runs() {
|
|
let blocks = parse_body("a **bold *both* b** `code` [link](https://example.com) done");
|
|
assert_eq!(blocks.len(), 1);
|
|
let (items, ..) = paragraph_of(&blocks, 0);
|
|
|
|
let styled: Vec<(String, &InlineStyle)> = items
|
|
.iter()
|
|
.filter_map(|item| match item {
|
|
InlineItem::Text(span) => Some((span.text.to_string(), &span.style)),
|
|
InlineItem::Image { .. } => None,
|
|
})
|
|
.collect();
|
|
|
|
assert!(styled.contains(&("bold ".to_string(), &InlineStyle::Strong)));
|
|
assert!(
|
|
styled.contains(&("both".to_string(), &InlineStyle::StrongEmphasis)),
|
|
"strong wrapping emphasis combines into one style"
|
|
);
|
|
assert!(styled.contains(&(" b".to_string(), &InlineStyle::Strong)));
|
|
assert!(styled.contains(&("code".to_string(), &InlineStyle::Code)));
|
|
assert!(styled.contains(&(
|
|
"link".to_string(),
|
|
&InlineStyle::Link {
|
|
destination: "https://example.com".into()
|
|
}
|
|
)));
|
|
assert!(styled.contains(&("a ".to_string(), &InlineStyle::Text)));
|
|
assert!(styled.contains(&(" done".to_string(), &InlineStyle::Text)));
|
|
}
|
|
|
|
#[test]
|
|
fn headings_headings_and_thematic_breaks_parse() {
|
|
let blocks = parse_body("# Title\n\ntext\n\n---\n\n###### small");
|
|
assert_eq!(blocks.len(), 4);
|
|
assert!(matches!(
|
|
&blocks[0],
|
|
MarkdownBlock::Heading {
|
|
level: MarkdownHeading::H1,
|
|
..
|
|
}
|
|
));
|
|
assert!(matches!(&blocks[2], MarkdownBlock::ThematicBreak { .. }));
|
|
assert!(matches!(
|
|
&blocks[3],
|
|
MarkdownBlock::Heading {
|
|
level: MarkdownHeading::H6,
|
|
..
|
|
}
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn lists_number_order_and_nest() {
|
|
let source = "- one\n- two\n 1. inner\n 2. more\n3. third\n";
|
|
let blocks = parse_body(source);
|
|
|
|
let markers: Vec<ListMarker> = blocks
|
|
.iter()
|
|
.filter_map(|block| match block {
|
|
MarkdownBlock::ListItem { marker, .. } => Some(*marker),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
assert_eq!(
|
|
markers,
|
|
vec![
|
|
ListMarker::Bullet,
|
|
ListMarker::Bullet,
|
|
ListMarker::Ordered(1),
|
|
ListMarker::Ordered(2),
|
|
ListMarker::Ordered(3),
|
|
],
|
|
"ordered numbering continues across nested lists"
|
|
);
|
|
|
|
let (_, _, outer_indent, _) = item_of(&blocks, 0);
|
|
assert_eq!(outer_indent, 0);
|
|
let (_, _, inner_indent, _) = item_of(&blocks, 2);
|
|
assert_eq!(inner_indent, 1, "nested items indent one level deeper");
|
|
}
|
|
|
|
#[test]
|
|
fn tight_items_stay_tight_but_block_content_flushes_before_them() {
|
|
let source = "- item text\n\n second paragraph\n- next\n";
|
|
let blocks = parse_body(source);
|
|
|
|
// The first paragraph of a loose item carries its marker row; the
|
|
// continuation flushes as a plain paragraph at the same indent.
|
|
let (marker, text, indent, _) = item_of(&blocks, 0);
|
|
assert_eq!(marker, ListMarker::Bullet);
|
|
assert_eq!(text, "item text");
|
|
assert_eq!(indent, 0);
|
|
|
|
let (items, indent, _) = paragraph_of(&blocks, 1);
|
|
assert_eq!(items_text(items), "second paragraph");
|
|
assert_eq!(
|
|
indent, 1,
|
|
"loose item continuations indent under the item's text"
|
|
);
|
|
|
|
let (marker, text, ..) = item_of(&blocks, 2);
|
|
assert_eq!(marker, ListMarker::Bullet);
|
|
assert_eq!(text, "next");
|
|
}
|
|
|
|
#[test]
|
|
fn tight_item_content_after_a_nested_block_stays_in_the_item() {
|
|
let blocks = parse_body(
|
|
"- doc
|
|
```rust
|
|
fn x() {}
|
|
```
|
|
after
|
|
",
|
|
);
|
|
assert_eq!(
|
|
blocks.len(),
|
|
3,
|
|
"marker row, code, and one continuation row"
|
|
);
|
|
|
|
let (marker, text, indent, _) = item_of(&blocks, 0);
|
|
assert_eq!(marker, ListMarker::Bullet);
|
|
assert_eq!(text, "doc");
|
|
assert_eq!(indent, 0);
|
|
assert!(matches!(
|
|
&blocks[1],
|
|
MarkdownBlock::Code {
|
|
indent: 1,
|
|
quote: false,
|
|
..
|
|
}
|
|
));
|
|
|
|
let (items, indent, _) = paragraph_of(&blocks, 2);
|
|
assert_eq!(items_text(items), "after");
|
|
assert_eq!(indent, 1, "the continuation stays under the item's text");
|
|
}
|
|
|
|
#[test]
|
|
fn task_markers_do_not_leak_into_following_items() {
|
|
let blocks = parse_body("- [ ] todo\n- plain\n1. [x] done\n2. next\n");
|
|
assert_eq!(item_of(&blocks, 0).0, ListMarker::Task(false));
|
|
assert_eq!(item_of(&blocks, 1).0, ListMarker::Bullet);
|
|
assert_eq!(item_of(&blocks, 2).0, ListMarker::Task(true));
|
|
assert_eq!(item_of(&blocks, 3).0, ListMarker::Ordered(2));
|
|
}
|
|
|
|
#[test]
|
|
fn quoted_text_after_an_item_flushes_the_item_first() {
|
|
let blocks = parse_body("- text\n > quoted\n");
|
|
assert_eq!(blocks.len(), 2);
|
|
assert_eq!(item_of(&blocks, 0).1, "text");
|
|
let (_, indent, quote) = paragraph_of(&blocks, 1);
|
|
assert!(quote, "the quoted paragraph carries the quote flag");
|
|
assert_eq!(indent, 1, "quote content stays under the item's text");
|
|
}
|
|
|
|
#[test]
|
|
fn a_rule_after_item_text_keeps_its_order() {
|
|
let blocks = parse_body("- text\n ***\n");
|
|
assert_eq!(blocks.len(), 2);
|
|
assert_eq!(item_of(&blocks, 0).1, "text");
|
|
assert!(matches!(
|
|
&blocks[1],
|
|
MarkdownBlock::ThematicBreak {
|
|
indent: 1,
|
|
quote: false
|
|
}
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn nested_code_and_tables_carry_indent_and_quote() {
|
|
let blocks =
|
|
parse_body("- fenced\n\n ```rust\n fn x() {}\n ```\n\n> | a |\n> |---|\n> | b |\n");
|
|
assert_eq!(blocks.len(), 3);
|
|
|
|
let (marker, text, indent, _) = item_of(&blocks, 0);
|
|
assert_eq!(marker, ListMarker::Bullet);
|
|
assert_eq!(text, "fenced");
|
|
assert_eq!(indent, 0);
|
|
|
|
assert!(matches!(
|
|
&blocks[1],
|
|
MarkdownBlock::Code {
|
|
indent: 1,
|
|
quote: false,
|
|
..
|
|
}
|
|
));
|
|
assert!(matches!(
|
|
&blocks[2],
|
|
MarkdownBlock::Table {
|
|
indent: 0,
|
|
quote: true,
|
|
..
|
|
}
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn quotes_mark_their_paragraphs_and_lists() {
|
|
let blocks = parse_body("> quoted\n>\n> - listed\n\nplain");
|
|
let (_, _, quote) = paragraph_of(&blocks, 0);
|
|
assert!(quote, "quoted paragraphs carry the quote flag");
|
|
assert!(item_of(&blocks, 1).3, "quoted list items carry it too");
|
|
assert!(!paragraph_of(&blocks, 2).2);
|
|
}
|
|
|
|
#[cfg(feature = "rust")]
|
|
#[test]
|
|
fn fenced_code_highlights_known_languages_and_keeps_the_info_string() {
|
|
let blocks = parse_body("```rust\nfn main() {}\n```\n");
|
|
assert_eq!(blocks.len(), 1);
|
|
|
|
let MarkdownBlock::Code {
|
|
language, lines, ..
|
|
} = &blocks[0]
|
|
else {
|
|
panic!("the first block is code");
|
|
};
|
|
assert_eq!(language.as_deref(), Some("rust"));
|
|
assert!(
|
|
lines
|
|
.lines
|
|
.iter()
|
|
.flat_map(|line| &line.spans)
|
|
.any(|span| span.role == Role::Keyword),
|
|
"rust fences highlight through the grammar"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn fenced_code_keeps_the_info_string_and_renders_unknown_languages_plain() {
|
|
let blocks = parse_body("```text\nplain\n```");
|
|
assert_eq!(blocks.len(), 1);
|
|
|
|
let MarkdownBlock::Code {
|
|
language, lines, ..
|
|
} = &blocks[0]
|
|
else {
|
|
panic!("the block is code");
|
|
};
|
|
assert_eq!(language.as_deref(), Some("text"));
|
|
assert!(
|
|
lines
|
|
.lines
|
|
.iter()
|
|
.flat_map(|line| &line.spans)
|
|
.all(|span| span.role == Role::Text),
|
|
"unknown languages render plain"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn tables_parse_header_first_with_column_positions() {
|
|
let blocks = parse_body("| a | b |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |\n");
|
|
let MarkdownBlock::Table { table, .. } = &blocks[0] else {
|
|
panic!("the block is a table");
|
|
};
|
|
|
|
assert_eq!(table.rows.len(), 3);
|
|
assert!(table.rows[0].header);
|
|
assert!(!table.rows[1].header);
|
|
|
|
let header: Vec<String> = table.rows[0]
|
|
.cells
|
|
.iter()
|
|
.map(|cell| spans_text(&cell.spans))
|
|
.collect();
|
|
assert_eq!(header, vec!["a", "b"]);
|
|
assert_eq!(spans_text(&table.rows[2].cells[1].spans), "4");
|
|
}
|
|
|
|
#[test]
|
|
fn images_render_inline_and_standalone_and_html_renders_nothing() {
|
|
// An image alone in its paragraph becomes a standalone image block.
|
|
let blocks = parse_body("");
|
|
assert_eq!(blocks.len(), 1);
|
|
let MarkdownBlock::Image { source, alt, .. } = &blocks[0] else {
|
|
panic!("the block is an image");
|
|
};
|
|
assert_eq!(source.as_ref(), "logo.png");
|
|
assert_eq!(alt.as_ref(), "the logo");
|
|
|
|
// Two consecutive images in one paragraph become two blocks.
|
|
let blocks = parse_body("\n");
|
|
assert_eq!(blocks.len(), 2);
|
|
assert_eq!(
|
|
blocks
|
|
.iter()
|
|
.filter(|block| matches!(block, MarkdownBlock::Image { .. }))
|
|
.count(),
|
|
2
|
|
);
|
|
|
|
// An image among text renders as an inline atom; the spaces around it
|
|
// stay in their runs, and its alt text is dropped.
|
|
let blocks = parse_body("see  now\n\n<div>hidden</div>\n");
|
|
assert_eq!(blocks.len(), 1, "the html block renders no block at all");
|
|
let (items, ..) = paragraph_of(&blocks, 0);
|
|
assert_eq!(items_text(items), "see now");
|
|
assert_eq!(
|
|
items
|
|
.iter()
|
|
.filter(|item| matches!(item, InlineItem::Image { .. }))
|
|
.count(),
|
|
1
|
|
);
|
|
|
|
// Images inside headings and table cells fall back to their alt text.
|
|
let blocks = parse_body("#  head\n\n|  |\n|---|\n| x |\n");
|
|
let MarkdownBlock::Heading { spans, .. } = &blocks[0] else {
|
|
panic!("the first block is a heading");
|
|
};
|
|
assert!(
|
|
spans_text(spans).contains("alt h"),
|
|
"the heading image renders as its alt text"
|
|
);
|
|
let MarkdownBlock::Table { table, .. } = &blocks[1] else {
|
|
panic!("the second block is a table");
|
|
};
|
|
let cell_text: String = table.rows[0].cells[0]
|
|
.spans
|
|
.iter()
|
|
.map(|span| span.text.as_ref())
|
|
.collect();
|
|
assert_eq!(cell_text, "cell");
|
|
}
|
|
|
|
#[test]
|
|
fn set_source_reparses_and_blocks_reset() {
|
|
let mut cx = TestAppContext::single();
|
|
cx.update(gpui_native_theme::Theme::init);
|
|
let state = cx.new(|_| MarkdownViewState::new("test-md", "# old\n"));
|
|
|
|
cx.update(|cx| {
|
|
state.update(cx, |state, cx| state.set_source("- a\n- b\n", cx));
|
|
});
|
|
|
|
let (source, count) = cx.update(|cx| {
|
|
let state = state.read(cx);
|
|
(state.source().to_string(), state.blocks().len())
|
|
});
|
|
assert_eq!(source, "- a\n- b\n");
|
|
assert_eq!(count, 2, "the block list reflects the new source");
|
|
}
|
|
|
|
struct Host {
|
|
view: Entity<MarkdownViewState>,
|
|
}
|
|
|
|
impl Render for Host {
|
|
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
|
div().size_full().child(self.view.clone())
|
|
}
|
|
}
|
|
|
|
fn view_window(source: &str) -> (TestAppContext, VisualTestContext, Entity<MarkdownViewState>) {
|
|
let mut cx = TestAppContext::single();
|
|
cx.update(gpui_native_theme::Theme::init);
|
|
let state = MarkdownViewState::new("test-md", source);
|
|
let state_cell: Rc<RefCell<Option<Entity<MarkdownViewState>>>> = Rc::new(RefCell::new(None));
|
|
let cell = state_cell.clone();
|
|
let window = cx.add_window(move |_, cx| {
|
|
let view = cx.new(|_| state);
|
|
cell.borrow_mut().replace(view.clone());
|
|
Host { view }
|
|
});
|
|
let state = state_cell.borrow().clone().expect("state captured");
|
|
let mut visual = VisualTestContext::from_window(window.into(), &cx);
|
|
visual.update(|window, cx| {
|
|
window.draw(cx).clear(cx);
|
|
});
|
|
(cx, visual, state)
|
|
}
|
|
|
|
/// The stable id string of the block at `block_index`. Leaks so the id can
|
|
/// feed `debug_bounds`, which requires `&'static str`.
|
|
fn block_id(state: &Entity<MarkdownViewState>, cx: &App, block_index: usize) -> &'static str {
|
|
Box::<str>::leak(
|
|
state
|
|
.read(cx)
|
|
.block_id(block_index)
|
|
.expect("the block has an id")
|
|
.to_string()
|
|
.into_boxed_str(),
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn blocks_render_virtualized_and_scroll_to_a_block() {
|
|
let mut source = String::new();
|
|
for paragraph in 0..200 {
|
|
source.push_str(&format!("paragraph {paragraph} with some body text\n\n"));
|
|
}
|
|
let (_cx, mut visual, state) = view_window(&source);
|
|
|
|
let first_id = _cx.update(|cx| block_id(&state, cx, 0));
|
|
let target_id = _cx.update(|cx| block_id(&state, cx, 150));
|
|
let last_id = _cx.update(|cx| block_id(&state, cx, 199));
|
|
|
|
assert!(
|
|
visual.debug_bounds(first_id).is_some(),
|
|
"the first block renders"
|
|
);
|
|
assert!(
|
|
visual.debug_bounds(target_id).is_none(),
|
|
"blocks past the viewport never render"
|
|
);
|
|
|
|
state.update_in(&mut visual, |state, _window, _cx| {
|
|
state.scroll_to_block(150)
|
|
});
|
|
visual.update(|window, cx| {
|
|
window.draw(cx).clear(cx);
|
|
});
|
|
|
|
assert!(
|
|
visual.debug_bounds(target_id).is_some(),
|
|
"scrolling brings the target block in"
|
|
);
|
|
assert!(
|
|
visual.debug_bounds(first_id).is_none(),
|
|
"the first block scrolled out"
|
|
);
|
|
assert!(
|
|
visual.debug_bounds(last_id).is_none(),
|
|
"blocks past the target stay unrendered"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn clicking_a_link_opens_its_destination() {
|
|
let (_cx, mut visual, state) = view_window("[click me](https://example.com/welcome)");
|
|
|
|
let first_id = _cx.update(|cx| block_id(&state, cx, 0));
|
|
let block = visual
|
|
.debug_bounds(first_id)
|
|
.expect("the paragraph renders");
|
|
|
|
visual.simulate_event(MouseDownEvent {
|
|
position: point(block.left() + px(4.), block.center().y),
|
|
button: MouseButton::Left,
|
|
..Default::default()
|
|
});
|
|
visual.simulate_event(MouseUpEvent {
|
|
position: point(block.left() + px(4.), block.center().y),
|
|
button: MouseButton::Left,
|
|
..Default::default()
|
|
});
|
|
|
|
assert_eq!(
|
|
visual.opened_url().as_deref(),
|
|
Some("https://example.com/welcome"),
|
|
"the link's destination opens through the platform"
|
|
);
|
|
}
|
|
|
|
/// Parses without an app context; the view reuses this path.
|
|
fn parse_body(source: &str) -> Vec<MarkdownBlock> {
|
|
let state = MarkdownViewState::new("parse-md", source);
|
|
state.blocks().to_vec()
|
|
}
|
|
/// Mirrors the gallery's "Large file" sample: five blocks per section.
|
|
fn large_markdown(sections: usize) -> String {
|
|
let mut source = String::new();
|
|
for section in 0..sections {
|
|
source.push_str(&format!("## Section {section}\n\n"));
|
|
source.push_str(&format!(
|
|
"Handler {section} retries stale sockets and logs every outcome.\n\n"
|
|
));
|
|
source.push_str(&format!(
|
|
"- [ ] Audit handler {section}\n- See [issue {section}](https://example.com/issues/{section})\n\n"
|
|
));
|
|
source.push_str("```rust\nfn handle() -> bool { true }\n```\n\n");
|
|
}
|
|
source
|
|
}
|
|
|
|
#[test]
|
|
fn a_large_document_swaps_and_renders_without_freezing() {
|
|
let source = large_markdown(1000);
|
|
let started = std::time::Instant::now();
|
|
|
|
let (_cx, mut visual, state) = view_window("# small\n");
|
|
state.update_in(&mut visual, |state, _window, cx| {
|
|
state.set_source(source, cx)
|
|
});
|
|
visual.update(|window, cx| {
|
|
window.draw(cx).clear(cx);
|
|
});
|
|
|
|
let elapsed = std::hint::black_box(started.elapsed());
|
|
assert!(
|
|
elapsed < std::time::Duration::from_secs(30),
|
|
"swapping to a large document took {elapsed:?}; the frame must not
|
|
measure every block"
|
|
);
|
|
|
|
let count = _cx.update(|cx| state.read(cx).blocks().len());
|
|
assert_eq!(count, 5000, "five blocks per section");
|
|
|
|
let first_id = _cx.update(|cx| block_id(&state, cx, 0));
|
|
let last_id = _cx.update(|cx| block_id(&state, cx, 4999));
|
|
assert!(
|
|
visual.debug_bounds(first_id).is_some(),
|
|
"the first block renders after the swap"
|
|
);
|
|
assert!(
|
|
visual.debug_bounds(last_id).is_none(),
|
|
"blocks past the viewport never render"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn block_ids_survive_prepending_and_edits_around_them() {
|
|
let mut cx = TestAppContext::single();
|
|
cx.update(gpui_native_theme::Theme::init);
|
|
let state = cx.new(|_| MarkdownViewState::new("feed", "first\n\nsecond\n"));
|
|
|
|
let (first, second) = cx.update(|cx| {
|
|
let state = state.read(cx);
|
|
(
|
|
state.block_id(0).expect("the first block has an id"),
|
|
state.block_id(1).expect("the second block has an id"),
|
|
)
|
|
});
|
|
|
|
cx.update(|cx| {
|
|
state.update(cx, |state, cx| {
|
|
state.set_source("new\n\nfirst\n\nsecond\n", cx)
|
|
});
|
|
});
|
|
|
|
cx.update(|cx| {
|
|
let state = state.read(cx);
|
|
assert_eq!(
|
|
state.block_id(1),
|
|
Some(first.clone()),
|
|
"prepending a block keeps the id of the old first block"
|
|
);
|
|
assert_eq!(
|
|
state.block_id(2),
|
|
Some(second.clone()),
|
|
"prepending a block keeps the id of the old second block"
|
|
);
|
|
});
|
|
|
|
// Editing a block's text changes only that block's id.
|
|
cx.update(|cx| {
|
|
state.update(cx, |state, cx| {
|
|
state.set_source("new\n\nfirst edited\n\nsecond\n", cx)
|
|
});
|
|
});
|
|
cx.update(|cx| {
|
|
let state = state.read(cx);
|
|
assert_eq!(
|
|
state.block_id(2),
|
|
Some(second),
|
|
"the untouched block keeps its id"
|
|
);
|
|
assert_ne!(
|
|
state.block_id(1),
|
|
Some(first),
|
|
"the edited block gets a new id"
|
|
);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn selection_spans_pieces_and_images_contribute_no_text() {
|
|
let mut cx = TestAppContext::single();
|
|
cx.update(gpui_native_theme::Theme::init);
|
|
let state = cx.new(|_| {
|
|
MarkdownViewState::new(
|
|
"sel",
|
|
"alpha\n\nbravo  charlie\n\n- delta\n- echo\n",
|
|
)
|
|
});
|
|
|
|
let selected = cx.update(|cx| {
|
|
state.update(cx, |view, cx| {
|
|
view.select_range(0, 30, cx);
|
|
view.selected_text()
|
|
})
|
|
});
|
|
|
|
assert_eq!(
|
|
selected.as_deref(),
|
|
Some("alpha\nbravo \n charlie\ndelta\nec"),
|
|
"pieces join with newlines and images contribute no text"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn selection_is_empty_past_the_document_end_and_clears_on_reparse() {
|
|
let mut cx = TestAppContext::single();
|
|
cx.update(gpui_native_theme::Theme::init);
|
|
let state = cx.new(|_| MarkdownViewState::new("sel", "only\n"));
|
|
|
|
let (overshoot, after_reparse) = cx.update(|cx| {
|
|
state.update(cx, |view, cx| {
|
|
view.select_range(0, 10_000, cx);
|
|
let overshoot = view.selected_text();
|
|
view.set_source("other\n", cx);
|
|
(overshoot, view.selected_text())
|
|
})
|
|
});
|
|
|
|
assert_eq!(
|
|
overshoot.as_deref(),
|
|
Some("only"),
|
|
"ranges clamp to the document"
|
|
);
|
|
assert_eq!(after_reparse, None, "reparsing clears the selection");
|
|
}
|
|
|
|
struct TextViewHost {
|
|
link_handler: Rc<RefCell<Vec<String>>>,
|
|
}
|
|
|
|
impl Render for TextViewHost {
|
|
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
|
let clicks = self.link_handler.clone();
|
|
div()
|
|
.debug_selector(|| "text-view".to_string())
|
|
.size_full()
|
|
.child(
|
|
TextView::markdown(
|
|
"text-view",
|
|
"[a very long link label that fills the line](https://example.com/welcome)",
|
|
)
|
|
.on_link_click(move |url, _window, _cx| {
|
|
clicks.borrow_mut().push(url.to_string());
|
|
}),
|
|
)
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn text_view_clicks_route_through_the_link_handler() {
|
|
let mut cx = TestAppContext::single();
|
|
cx.update(gpui_native_theme::Theme::init);
|
|
let clicks: Rc<RefCell<Vec<String>>> = Rc::new(RefCell::new(Vec::new()));
|
|
let clicks_cell = clicks.clone();
|
|
let window = cx.add_window(move |_, _| TextViewHost {
|
|
link_handler: clicks_cell,
|
|
});
|
|
let mut visual = VisualTestContext::from_window(window.into(), &cx);
|
|
visual.update(|window, cx| {
|
|
window.draw(cx).clear(cx);
|
|
});
|
|
|
|
let bounds = visual
|
|
.debug_bounds("text-view")
|
|
.expect("the text view renders");
|
|
let position = point(bounds.left() + px(4.), bounds.top() + px(10.));
|
|
visual.simulate_event(MouseDownEvent {
|
|
position,
|
|
button: MouseButton::Left,
|
|
..Default::default()
|
|
});
|
|
visual.simulate_event(MouseUpEvent {
|
|
position,
|
|
button: MouseButton::Left,
|
|
..Default::default()
|
|
});
|
|
|
|
assert_eq!(
|
|
clicks.borrow().as_slice(),
|
|
["https://example.com/welcome".to_string()],
|
|
"the click lands on the link and routes through the handler"
|
|
);
|
|
}
|
|
|
|
struct SelectionHost {
|
|
state: Entity<MarkdownViewState>,
|
|
}
|
|
|
|
impl Render for SelectionHost {
|
|
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
|
div().child(div().w(rems(30.)).child(TextView::new(&self.state)))
|
|
}
|
|
}
|
|
|
|
fn selection_window(
|
|
source: &str,
|
|
) -> (TestAppContext, VisualTestContext, Entity<MarkdownViewState>) {
|
|
let mut cx = TestAppContext::single();
|
|
cx.update(gpui_native_theme::Theme::init);
|
|
cx.update(bind_text_view_keys);
|
|
let state = MarkdownViewState::new("sel-view", source);
|
|
let state_cell: Rc<RefCell<Option<Entity<MarkdownViewState>>>> = Rc::new(RefCell::new(None));
|
|
let cell = state_cell.clone();
|
|
let window = cx.add_window(move |_, cx| {
|
|
let view = cx.new(|_| state);
|
|
cell.borrow_mut().replace(view.clone());
|
|
SelectionHost { state: view }
|
|
});
|
|
let state = state_cell.borrow().clone().expect("state captured");
|
|
let mut visual = VisualTestContext::from_window(window.into(), &cx);
|
|
visual.update(|window, cx| {
|
|
window.draw(cx).clear(cx);
|
|
});
|
|
(cx, visual, state)
|
|
}
|
|
|
|
/// Leaks a block id so it can feed `debug_bounds`, which needs `&'static str`.
|
|
fn leaked_block_id(
|
|
state: &Entity<MarkdownViewState>,
|
|
cx: &App,
|
|
block_index: usize,
|
|
) -> &'static str {
|
|
Box::<str>::leak(
|
|
state
|
|
.read(cx)
|
|
.block_id(block_index)
|
|
.expect("the block has an id")
|
|
.to_string()
|
|
.into_boxed_str(),
|
|
)
|
|
}
|
|
|
|
fn click(visual: &mut VisualTestContext, position: gpui::Point<gpui::Pixels>) {
|
|
visual.simulate_event(MouseDownEvent {
|
|
position,
|
|
button: MouseButton::Left,
|
|
..Default::default()
|
|
});
|
|
visual.simulate_event(MouseUpEvent {
|
|
position,
|
|
button: MouseButton::Left,
|
|
..Default::default()
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn dragging_across_paragraphs_selects_and_copies() {
|
|
let (cx, mut visual, state) = selection_window("alpha bravo\n\ncharlie delta\n");
|
|
|
|
let first = cx.update(|cx| leaked_block_id(&state, cx, 0));
|
|
let second = cx.update(|cx| leaked_block_id(&state, cx, 1));
|
|
|
|
let start = {
|
|
let bounds = visual.debug_bounds(first).expect("the first block renders");
|
|
gpui::point(bounds.left() + px(4.), bounds.top() + px(8.))
|
|
};
|
|
visual.simulate_event(MouseDownEvent {
|
|
position: start,
|
|
button: MouseButton::Left,
|
|
..Default::default()
|
|
});
|
|
let end = {
|
|
let bounds = visual
|
|
.debug_bounds(second)
|
|
.expect("the second block renders");
|
|
gpui::point(bounds.left() + px(40.), bounds.top() + px(8.))
|
|
};
|
|
visual.simulate_event(MouseMoveEvent {
|
|
position: end,
|
|
..Default::default()
|
|
});
|
|
visual.simulate_event(MouseUpEvent {
|
|
position: end,
|
|
button: MouseButton::Left,
|
|
..Default::default()
|
|
});
|
|
|
|
let selected = cx.update(|cx| state.read(cx).selected_text());
|
|
assert_eq!(
|
|
selected.as_deref(),
|
|
Some("alpha bravo\ncharli"),
|
|
"the drag selects from the press through the pointer"
|
|
);
|
|
|
|
visual.dispatch_action(gpui_native_editor::markdown::Copy);
|
|
let clipboard = cx.update(|cx| cx.read_from_clipboard().and_then(|item| item.text()));
|
|
assert_eq!(
|
|
clipboard.as_deref(),
|
|
selected.as_deref(),
|
|
"the copy action copies the selected text"
|
|
);
|
|
}
|
|
|
|
struct TaskHost {
|
|
toggles: Rc<RefCell<Vec<(usize, bool)>>>,
|
|
}
|
|
|
|
impl Render for TaskHost {
|
|
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
|
let toggles = self.toggles.clone();
|
|
div().size_full().child(
|
|
TextView::markdown("task-view", "- [ ] first\n- [x] second\n").on_task_toggle(
|
|
move |block, checked, _window, _cx| {
|
|
toggles.borrow_mut().push((block, checked));
|
|
},
|
|
),
|
|
)
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn clicking_a_task_checkbox_toggles_it_through_the_handler() {
|
|
let mut cx = TestAppContext::single();
|
|
cx.update(gpui_native_theme::Theme::init);
|
|
let toggles: Rc<RefCell<Vec<(usize, bool)>>> = Rc::new(RefCell::new(Vec::new()));
|
|
let toggles_cell = toggles.clone();
|
|
let window = cx.add_window(move |_, _| TaskHost {
|
|
toggles: toggles_cell,
|
|
});
|
|
let mut visual = VisualTestContext::from_window(window.into(), &cx);
|
|
visual.update(|window, cx| {
|
|
window.draw(cx).clear(cx);
|
|
});
|
|
|
|
let bounds = visual
|
|
.debug_bounds("task-toggle-0")
|
|
.expect("the task renders");
|
|
click(
|
|
&mut visual,
|
|
gpui::point(bounds.center().x, bounds.center().y),
|
|
);
|
|
|
|
assert_eq!(
|
|
toggles.borrow().as_slice(),
|
|
[(0, true)],
|
|
"checking the first task reports its block and the new state"
|
|
);
|
|
}
|
|
|
|
struct CodeHost;
|
|
|
|
impl Render for CodeHost {
|
|
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
|
div().size_full().child(TextView::markdown(
|
|
"code-host",
|
|
"```text\nfn main() {}\n```\n",
|
|
))
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_code_block_copy_button_copies_the_fenced_source() {
|
|
let mut cx = TestAppContext::single();
|
|
cx.update(gpui_native_theme::Theme::init);
|
|
let window = cx.add_window(|_, _| CodeHost);
|
|
let mut visual = VisualTestContext::from_window(window.into(), &cx);
|
|
visual.update(|window, cx| {
|
|
window.draw(cx).clear(cx);
|
|
});
|
|
|
|
let bounds = visual
|
|
.debug_bounds("copy-code")
|
|
.expect("the copy button renders");
|
|
click(
|
|
&mut visual,
|
|
gpui::point(bounds.center().x, bounds.center().y),
|
|
);
|
|
|
|
let clipboard = cx.update(|cx| cx.read_from_clipboard().and_then(|item| item.text()));
|
|
assert_eq!(
|
|
clipboard.as_deref(),
|
|
Some("fn main() {}"),
|
|
"the copy button copies the fenced source"
|
|
);
|
|
}
|