feat: add markdown text view

This commit is contained in:
2026-11-10 18:14:10 +07:00
parent 7c061edfba
commit a6b6cfd5a4
5 changed files with 1660 additions and 261 deletions
+3 -1
View File
@@ -40,7 +40,9 @@ code_view = [
]
diff_view = ["code_view"]
markdown_view = [
"code_view",
"dep:gpui-native-core",
"gpui-native-core/button",
"gpui-native-core/scrollbar",
"dep:pulldown-cmark",
"dep:gpui-native-icons",
]
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -1,24 +1,24 @@
//! Shared helpers for the editor's views: gutter cells, role colors and run
//! styles, code line text, language mapping and change washes.
#[cfg(feature = "markdown_view")]
#[cfg(any(feature = "code_view", feature = "markdown_view"))]
use std::ops::Range;
#[cfg(feature = "markdown_view")]
use crate::highlight::{Language, Line};
#[cfg(feature = "markdown_view")]
use gpui::StyledText;
#[cfg(feature = "code_view")]
#[cfg(any(feature = "code_view", feature = "markdown_view"))]
use gpui::{App, HighlightStyle, Hsla, IntoElement, ParentElement as _, Styled as _, div};
#[cfg(feature = "code_view")]
#[cfg(any(feature = "code_view", feature = "markdown_view"))]
use gpui_native_theme::{ActiveTheme as _, ColorTokens, Ramps};
#[cfg(feature = "diff_view")]
use gpui_native_theme::{WASH_ALPHA, WASH_ALPHA_GUTTER, WASH_ALPHA_STRONG};
#[cfg(feature = "code_view")]
#[cfg(any(feature = "code_view", feature = "markdown_view"))]
use crate::highlight::Role;
#[cfg(feature = "code_view")]
#[cfg(any(feature = "code_view", feature = "markdown_view"))]
impl Role {
/// Maps the role to the theme's semantic palette.
pub(crate) fn color(self, colors: ColorTokens, ramps: Ramps) -> Hsla {
+446 -23
View File
@@ -2,15 +2,27 @@ use std::cell::RefCell;
use std::rc::Rc;
use gpui::{
App, AppContext as _, Context, Entity, IntoElement, MouseButton, MouseDownEvent, MouseUpEvent,
ParentElement as _, Render, Styled as _, TestAppContext, VisualTestContext, Window, div, point,
px,
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::{
InlineSpan, InlineStyle, ListMarker, MarkdownBlock, MarkdownHeading, MarkdownViewState,
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()
@@ -19,13 +31,13 @@ fn spans_text(spans: &[InlineSpan]) -> String {
.join("")
}
fn paragraph_of(blocks: &[MarkdownBlock], index: usize) -> (&Vec<InlineSpan>, usize, bool) {
fn paragraph_of(blocks: &[MarkdownBlock], index: usize) -> (&Vec<InlineItem>, usize, bool) {
match &blocks[index] {
MarkdownBlock::Paragraph {
spans,
items,
indent,
quote,
} => (spans, *indent, *quote),
} => (items, *indent, *quote),
other => panic!("block {index} is {other:?}, not a paragraph"),
}
}
@@ -34,10 +46,10 @@ fn item_of(blocks: &[MarkdownBlock], index: usize) -> (ListMarker, String, usize
match &blocks[index] {
MarkdownBlock::ListItem {
marker,
spans,
items,
indent,
quote,
} => (*marker, spans_text(spans), *indent, *quote),
} => (*marker, items_text(items), *indent, *quote),
other => panic!("block {index} is {other:?}, not a list item"),
}
}
@@ -46,11 +58,14 @@ fn item_of(blocks: &[MarkdownBlock], index: usize) -> (ListMarker, String, usize
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 (items, ..) = paragraph_of(&blocks, 0);
let styled: Vec<(String, &InlineStyle)> = spans
let styled: Vec<(String, &InlineStyle)> = items
.iter()
.map(|span| (span.text.to_string(), &span.style))
.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)));
@@ -133,8 +148,8 @@ fn tight_items_stay_tight_but_block_content_flushes_before_them() {
assert_eq!(text, "item text");
assert_eq!(indent, 0);
let (spans, indent, _) = paragraph_of(&blocks, 1);
assert_eq!(spans_text(spans), "second paragraph");
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"
@@ -174,8 +189,8 @@ fn tight_item_content_after_a_nested_block_stays_in_the_item() {
}
));
let (spans, indent, _) = paragraph_of(&blocks, 2);
assert_eq!(spans_text(spans), "after");
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");
}
@@ -250,10 +265,11 @@ fn quotes_mark_their_paragraphs_and_lists() {
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\n```text\nplain\n```");
assert_eq!(blocks.len(), 2);
let blocks = parse_body("```rust\nfn main() {}\n```\n");
assert_eq!(blocks.len(), 1);
let MarkdownBlock::Code {
language, lines, ..
@@ -270,12 +286,18 @@ fn fenced_code_highlights_known_languages_and_keeps_the_info_string() {
.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[1]
} = &blocks[0]
else {
panic!("the second block is code");
panic!("the block is code");
};
assert_eq!(language.as_deref(), Some("text"));
assert!(
@@ -309,11 +331,59 @@ fn tables_parse_header_first_with_column_positions() {
}
#[test]
fn images_render_their_alt_text_and_html_renders_nothing() {
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("![the logo](logo.png)");
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("![a](one.png)\n![b](two.png)");
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 ![the logo](logo.png) 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");
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("# ![alt h](h.png) head\n\n| ![cell](c.png) |\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]
@@ -500,3 +570,356 @@ fn a_large_document_swaps_and_renders_without_freezing() {
"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 ![e](u.png) 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"
);
}