feat: add markdown view component
This commit is contained in:
@@ -0,0 +1,449 @@
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
AppContext as _, Context, Entity, IntoElement, MouseButton, MouseDownEvent, MouseUpEvent,
|
||||
ParentElement as _, Render, Styled as _, TestAppContext, VisualTestContext, Window, div, point,
|
||||
px,
|
||||
};
|
||||
use gpui_native_editor::highlight::Role;
|
||||
use gpui_native_editor::markdown::{
|
||||
InlineSpan, InlineStyle, ListMarker, MarkdownBlock, MarkdownHeading, MarkdownViewState,
|
||||
};
|
||||
|
||||
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<InlineSpan>, usize, bool) {
|
||||
match &blocks[index] {
|
||||
MarkdownBlock::Paragraph {
|
||||
spans,
|
||||
indent,
|
||||
quote,
|
||||
} => (spans, *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,
|
||||
spans,
|
||||
indent,
|
||||
quote,
|
||||
} => (*marker, spans_text(spans), *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 (spans, ..) = paragraph_of(&blocks, 0);
|
||||
|
||||
let styled: Vec<(String, &InlineStyle)> = spans
|
||||
.iter()
|
||||
.map(|span| (span.text.to_string(), &span.style))
|
||||
.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 (spans, indent, _) = paragraph_of(&blocks, 1);
|
||||
assert_eq!(spans_text(spans), "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 (spans, indent, _) = paragraph_of(&blocks, 2);
|
||||
assert_eq!(spans_text(spans), "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 task_markers_carry_their_checked_state() {
|
||||
let blocks = parse_body("- [ ] todo\n- [x] done\n");
|
||||
assert_eq!(item_of(&blocks, 0).0, ListMarker::Task(false));
|
||||
assert_eq!(item_of(&blocks, 1).0, ListMarker::Task(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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fenced_code_highlights_known_languages_and_keeps_the_info_string() {
|
||||
let blocks = parse_body("```rust\nfn main() {}\n```\n\n```text\nplain\n```");
|
||||
assert_eq!(blocks.len(), 2);
|
||||
|
||||
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"
|
||||
);
|
||||
|
||||
let MarkdownBlock::Code {
|
||||
language, lines, ..
|
||||
} = &blocks[1]
|
||||
else {
|
||||
panic!("the second 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!(
|
||||
table.rows[0]
|
||||
.cells
|
||||
.iter()
|
||||
.map(|cell| cell.column)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![1, 2]
|
||||
);
|
||||
assert_eq!(spans_text(&table.rows[2].cells[1].spans), "4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn images_render_their_alt_text_and_html_renders_nothing() {
|
||||
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 (spans, ..) = paragraph_of(&blocks, 0);
|
||||
assert_eq!(spans_text(spans), "see the logo now");
|
||||
}
|
||||
|
||||
#[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)
|
||||
}
|
||||
|
||||
#[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);
|
||||
|
||||
assert!(
|
||||
visual.debug_bounds("test-md-block-0").is_some(),
|
||||
"the first block renders"
|
||||
);
|
||||
assert!(
|
||||
visual.debug_bounds("test-md-block-150").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("test-md-block-150").is_some(),
|
||||
"scrolling brings the target block in"
|
||||
);
|
||||
assert!(
|
||||
visual.debug_bounds("test-md-block-0").is_none(),
|
||||
"the first block scrolled out"
|
||||
);
|
||||
assert!(
|
||||
visual.debug_bounds("test-md-block-199").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 block = visual
|
||||
.debug_bounds("test-md-block-0")
|
||||
.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()
|
||||
}
|
||||
Reference in New Issue
Block a user