feat: add markdown view component

This commit is contained in:
2026-11-06 17:04:56 +07:00
parent e8e1cb39e0
commit 310cad32be
9 changed files with 1957 additions and 13 deletions
Generated
+19
View File
@@ -1912,8 +1912,10 @@ version = "0.1.0"
dependencies = [
"gpui",
"gpui-native-core",
"gpui-native-icons",
"gpui-native-theme",
"gpui-native-utils",
"pulldown-cmark",
"similar",
"tree-sitter",
"tree-sitter-javascript",
@@ -4235,6 +4237,17 @@ dependencies = [
"syn 3.0.9",
]
[[package]]
name = "pulldown-cmark"
version = "0.13.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3db2be46835f013dd3f4fa7f5f7402c537787b54ebb6fe2b94bbdd7e2669bb7b"
dependencies = [
"bitflags 2.13.2",
"memchr",
"unicase",
]
[[package]]
name = "pulp"
version = "0.22.3"
@@ -5978,6 +5991,12 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
[[package]]
name = "unicase"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f"
[[package]]
name = "unicode-bidi"
version = "0.3.18"
+4
View File
@@ -7,10 +7,12 @@ description = "Read-only code surfaces for gpui applications, one cargo feature
[dependencies]
gpui = { workspace = true }
gpui-native-core = { workspace = true, optional = true }
gpui-native-icons = { workspace = true }
gpui-native-theme = { workspace = true }
gpui-native-utils = { workspace = true }
tree-sitter = "0.25"
similar = "2"
pulldown-cmark = { version = "0.13", default-features = false, optional = true }
tree-sitter-rust = { version = "0.24", optional = true }
tree-sitter-typescript = { version = "0.23", optional = true }
tree-sitter-javascript = { version = "0.23", optional = true }
@@ -24,6 +26,7 @@ default = ["full"]
full = [
"code_view",
"diff_view",
"markdown_view",
"rust",
"typescript",
"javascript",
@@ -35,6 +38,7 @@ code_view = [
"gpui-native-core/scrollbar",
]
diff_view = ["code_view"]
markdown_view = ["code_view", "dep:pulldown-cmark"]
rust = ["dep:tree-sitter-rust"]
typescript = ["dep:tree-sitter-typescript"]
javascript = ["dep:tree-sitter-javascript"]
+3
View File
@@ -8,6 +8,9 @@ pub mod code_view;
#[cfg(feature = "diff_view")]
pub mod diff_view;
#[cfg(feature = "markdown_view")]
pub mod markdown;
pub mod highlight;
mod utils;
File diff suppressed because it is too large Load Diff
+42 -3
View File
@@ -1,9 +1,11 @@
//! Shared helpers for the editor's views: gutter cells, role colors, change
//! washes and diff row building.
//! Shared helpers for the editor's views: gutter cells, role colors, code
//! line text, language mapping, change washes and diff row building.
#[cfg(feature = "diff_view")]
#[cfg(any(feature = "diff_view", feature = "markdown_view"))]
use std::ops::Range;
#[cfg(feature = "markdown_view")]
use gpui::StyledText;
use gpui::{App, HighlightStyle, Hsla, IntoElement, ParentElement as _, Styled as _, div};
use gpui_native_theme::{ActiveTheme as _, ColorTokens, Ramps};
#[cfg(feature = "diff_view")]
@@ -14,6 +16,8 @@ use crate::diff_view::{DiffRow, DiffRowKind};
#[cfg(feature = "diff_view")]
use crate::highlight::HighlightedCode;
use crate::highlight::Role;
#[cfg(feature = "markdown_view")]
use crate::highlight::{Language, Line};
/// Maps a highlight role to the theme's semantic palette.
pub(crate) fn role_color(role: Role, colors: ColorTokens, ramps: Ramps) -> Hsla {
@@ -136,3 +140,38 @@ pub(crate) fn wash_gutter(colors: &ColorTokens) -> Hsla {
..colors.muted
}
}
/// One highlighted code line as styled text, roles resolved from the theme.
#[cfg(feature = "markdown_view")]
pub(crate) fn code_line(line: &Line, colors: ColorTokens, ramps: Ramps) -> StyledText {
let mut text = String::new();
let mut highlights: Vec<(Range<usize>, HighlightStyle)> = Vec::new();
for span in &line.spans {
let start = text.len();
text.push_str(&span.text);
if span.role != Role::Text {
highlights.push((
start..text.len(),
HighlightStyle {
color: Some(role_color(span.role, colors, ramps)),
..Default::default()
},
));
}
}
StyledText::new(text).with_highlights(highlights)
}
/// Maps a fence info string to a bundled grammar.
#[cfg(feature = "markdown_view")]
pub(crate) fn grammar_language(info: &str) -> Option<Language> {
match info.to_ascii_lowercase().as_str() {
"rust" | "rs" => Some(Language::Rust),
"typescript" | "ts" => Some(Language::TypeScript),
"javascript" | "js" => Some(Language::JavaScript),
"python" | "py" => Some(Language::Python),
_ => None,
}
}
+449
View File
@@ -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 ![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");
}
#[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()
}
+9 -9
View File
@@ -9,15 +9,13 @@ is the first downstream consumer; its screens drive the order.
## Next
- [ ] **Markdown view** (M16, `editor`) — only if a consumer confirms.
- [ ] API sketch
- [ ] Implementation
- [ ] Tests
- [ ] Gallery panel
- [ ] Review
## Queue — to follow
- [ ] **Markdown view follow-ups** — streaming `append` with a coalesced
background parse, text selection with copy, per-block a11y roles,
GFM alerts + table column alignment, per-fence highlight reuse,
`soft_break_as_hard_break`; ranked notes in
[markdown-view-comparison.md](markdown-view-comparison.md).
- [ ] **Editor follow-ups** — language detection by file path, meta line
overlay, per-view config overrides (line height, gutter visibility).
- [ ] **Pickers** — Calendar / DatePicker, TimeField, ColorPicker (decide at
@@ -76,8 +74,10 @@ is the first downstream consumer; its screens drive the order.
incremental line re-highlight
- [x] Diff view — unified rows via `similar`, highlighted with the code
view's palette; change washes from the ramps, `scroll_to_first_change`
- [x] Diff view — unified rows via `similar`, highlighted with the code
view's palette; change washes from the ramps, `scroll_to_first_change`
- [x] Markdown view — `pulldown-cmark` blocks rendered as formatted text:
headings, emphasis runs, ordered/task lists, quotes, fenced code
highlighted with the code view's palette, tables, clickable links;
virtualized variable-height rows over gpui's `list`
### layout (`crates/layout`)
+136
View File
@@ -0,0 +1,136 @@
# Markdown view — comparison with bezel and gpuikit
Peer read for API ideas only; nothing ported, nothing depended on
([AGENTS.md](../AGENTS.md)). Assessed 2026-11 against `main` of
[bezel](https://github.com/crabtalk/bezel) (`crates/markdown`) and
[gpuikit](https://github.com/iamnbutler/gpuikit) (`src/markdown`). Both move
fast and bezel's APIs are explicitly still evolving, so details may have
shifted when this is revisited. Queue entry:
[CHECKLIST.md](CHECKLIST.md) — "Markdown view follow-ups".
## Positioning — three different products
| | gpui-native-kit (ours) | bezel | gpuikit |
|---|---|---|---|
| Ambition | Read-only document viewer | Editable Notion-style document engine, markdown as wire form | Streaming reader built for LLM/chat output |
| Core model | `Vec<MarkdownBlock>` IR | `Doc` = flat block list + indent; `Text` = string + marks over byte ranges | Pulldown events kept with source ranges, rendered straight to elements |
| Editing | None (Input covers fields) | Full: splice/shortcut edits, caret, selection, parse↔serialize fixed point | None |
| gpui coupling | One crate, feature-gated | Pure core + `gpui` feature; builds for wasm | All gpui |
## Dimension by dimension
**Parsing.** All three use pulldown-cmark. gpuikit's option set is the most
disciplined: GFM + footnotes on, every option left off documented with what
turning it on would silently break (`~x~` becoming subscript instead of
strikethrough, wikilinks, math), and exhaustive `Start`/`End` matches with no
wildcard arm, so a pulldown upgrade is a compile error instead of silently
dropped styling. We match on tables/strikethrough/tasklists but skip footnotes
and GFM alerts, and use `_ => {}` wildcard arms in two places.
**Block model.** bezel is the outlier: flat blocks with a one-level-deeper
indent invariant; marks as ordered spans (`**_x_**` differs from `_**x**_`,
which run flags cannot express); block-level Bookmark/Mention rich links;
image width in the title slot (`![alt|480](url)`); GFM alert quotes
(`[!NOTE]`); GitHub-style heading slugs; a parse↔serialize fixed-point
guarantee so an edit/save cycle never drifts. Neither viewer attempts
round-tripping — fine for us, and the reason bezel's parse layer is its own
crate.
**Streaming (gpuikit's headline).** `append()` for LLM deltas: parse on the
background executor, previous events keep rendering (never blanks), deltas
arriving mid-parse coalesce into one follow-up parse, first parse synchronous
so the document is never empty on frame one. Two extra streaming details: a
`stitch` preprocessing pass closes unterminated inline syntax (`**bold`)
before parsing so text does not flicker between plain and styled mid-stream,
and an unclosed fence is drawn plain until its closer arrives — a streaming
fence is a new cache key per delta, so highlighting it costs a full pass per
frame. Their open-fence byte scan errs only toward "closed", the harmless
direction. We parse synchronously in `set_source` and re-highlight every fence
on each parse.
**Code highlighting.** tree-sitter for bezel, syntect for gpuikit
(feature-gated, with a highlight cache and theme selection), tree-sitter with
per-language cargo features for us. Ours shares one grammar stack with the
code view — one dependency set across both surfaces — but re-highlights every
block on every parse where gpuikit caches settled blocks and suspends the open
one.
**Layout and performance.** The one place we are clearly ahead: neither
library virtualizes. gpuikit builds one flex column of every block in the
document each frame (confirmed in its `render_events`); bezel renders blocks
directly (not assessed in depth). We window over blocks like the code and diff
views do. The trade: an all-in-one-column layout makes text selection
tractable, which our windowing makes harder.
**Text selection.** Both ship it, we do not. gpuikit's is the deeper
implementation: a document-wide selection of `(run, byte offset)` positions, a
per-frame run registry, `append` preserves the selection while `set_source`
drops it, `selected_text()` for the copy binding. bezel's rides on its
editable model. This is our biggest functional gap against both.
**Accessibility.** gpuikit is far ahead: `Role::Document` on the root, per-run
roles (heading with level, paragraph, list item, blockquote, code), and
element ids stable across frames because assistive technology reads a changed
node id as a replaced element. Tested through a recorded-run harness. We ship
one optional `aria_label` on the root. bezel's a11y not assessed.
**Tables.** gpuikit honors GFM column alignment, stripes body rows, borders
per column. We parse cell order but dropped `Alignment` — cheap to add since
pulldown hands it over.
**Images.** gpuikit renders real image elements with alt text; bezel supports
captions and widths. We render alt text only (documented M16 scope).
**Styling philosophy.** gpuikit: a per-instance `MarkdownStyle` struct with
per-key theme fallbacks, plus knobs like `soft_break_as_hard_break` (LLM
output treats single newlines as breaks). bezel: nothing passed at all — style
flows through the environment, `Brand`/`Tint` rotate one palette. Ours is
closest to bezel's philosophy: everything resolves from the native system
theme, zero per-instance color knobs, which is the kit's stated goal. We have
no soft-break knob yet.
**Lists.** All three solved the same loose/tight and multi-block-item
problems. gpuikit's mechanism is elegant: the item's first block takes the
marker (`Shown`), later blocks reserve the marker's width but draw it
`Hidden`, so ordinals are not burned and continuation text aligns exactly with
the first block's text column. Ours reaches the same semantics (marker on the
first row, continuations as deeper paragraphs) with approximate alignment
(indent step 1.5rem vs marker column 1.75rem).
## Ideas worth stealing
Ranked by value to downstream consumers, signed first. API ideas only —
implementation stays ours.
1. Streaming `append` + coalesced background parse (gpuikit). For an LLM
client this is the headline feature; our synchronous `set_source` blocks
the UI thread on large documents.
2. Text selection with copy (both). The pattern exists in the code view; a
wrapped-text version is real work but the biggest functional gap.
3. Per-block a11y roles + stable ids (gpuikit). Medium effort, high
correctness value; the recorded-run test technique is reusable as an idea.
4. Incremental highlight caching (gpuikit). Cheap version for us: reuse
per-block tree-sitter trees across parses — `highlight_with_tree` already
takes `old_tree` — instead of re-parsing every fence per `set_source`.
5. GFM alerts + table column alignment (both). Nearly free; pulldown emits
`BlockQuoteKind` and `Alignment` today and we ignore both.
6. `soft_break_as_hard_break` (gpuikit). One option, real payoff for agent
output.
7. Exhaustive match, no wildcard arms (gpuikit). Turns pulldown upgrades into
compile errors.
8. Heading anchors/slugs (bezel). Useful when a consumer wants in-document
navigation.
9. Per-view typography overrides (gpuikit's `MarkdownStyle`). Conflicts with
the theme-global discipline; the checklist's editor follow-ups already
gesture at per-view config — decide deliberately, not by accident.
## Deliberate differences
- Virtualization is ours alone and is the right call for a kit whose gallery
ships 5,000-line perf tests.
- Native theme resolution (platform sources → tokens) instead of per-instance
style structs is the kit's identity, not a gap.
- No editing or serialization. bezel's editor-grade model is a different
product; if a consumer ever wants an editable markdown surface, bezel's
flat-block model is the architecture study to make first, and its pure-core
split is the cleanest layering of the three.
+167 -1
View File
@@ -43,6 +43,7 @@ use gpui_native_data::tree::{Tree, TreeItem};
use gpui_native_editor::code_view::CodeViewState;
use gpui_native_editor::diff_view::{DiffRowKind, DiffViewState};
use gpui_native_editor::highlight::Language;
use gpui_native_editor::markdown::{MarkdownBlock, MarkdownViewState};
use gpui_native_icons::{IconAssets, IconRole, icon};
use gpui_native_layout::dock::{Dock, DockSidebar, DockState, PanelDescriptor, SplitDirection};
use gpui_native_layout::resize_handle::ResizeHandle;
@@ -87,7 +88,11 @@ const CATALOG: &[(&str, &[(&str, &str)])] = &[
),
(
"Editor",
&[("code-view", "Code view"), ("diff-view", "Diff view")],
&[
("code-view", "Code view"),
("diff-view", "Diff view"),
("markdown-view", "Markdown view"),
],
),
(
"Feedback",
@@ -642,6 +647,12 @@ impl Gallery {
}),
cx,
);
dock.register_panel(
PanelDescriptor::new("markdown-view", "Markdown view", |_, cx| {
cx.new(MarkdownViewDemo::new).into()
}),
cx,
);
dock.register_panel(
PanelDescriptor::new("sidebar", "Sidebar", |_, cx| {
cx.new(|_| SidebarDemo { selected: None }).into()
@@ -3102,6 +3113,161 @@ impl Render for DiffViewDemo {
}
}
/// The markdown view demo's sample document.
const MARKDOWN_SAMPLE: &str = r###"# Release notes
Everything shipped this week, with **bold claims**, *quiet fixes*, and ~~regressions~~.
## Highlights
- The relay pool now [retries automatically](https://example.com/relay) on stale sockets
- Inline `code spans` and keyboard chords read fine
### Checklist
- [x] Ship the diff view
- [x] Ship the markdown view
- [ ] Wire it into signed
- [ ] Profile the large file
1. Cut the branch
2. Run the gallery
1. Open this panel
2. Scroll the large file
> Notes from the field: **measure first**, then optimize.
```rust
fn bind_all(&self) -> Result<(), String> {
for port in &self.ports {
if port > &1024 {
continue;
}
return Err(format!("port {} is privileged", port));
}
Ok(())
}
```
| Area | Status | Owner |
|------|--------|-------|
| Editor | shipping | reya |
| Layout | staged | team |
---
That is the news.
"###;
/// The section count of the large markdown sample.
const LARGE_MARKDOWN_SECTIONS: usize = 1000;
/// Generates a long markdown document, so scrolling the view can be
/// exercised on realistic content.
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
}
struct MarkdownViewDemo {
view: Entity<MarkdownViewState>,
sample: MarkdownSample,
large_source: SharedString,
_subscription: gpui::Subscription,
}
/// The markdown view demo's sample choice.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
enum MarkdownSample {
#[default]
Guide,
/// A generated document with thousands of blocks, to test scroll
/// performance.
Large,
}
impl MarkdownViewDemo {
fn new(cx: &mut Context<Self>) -> Self {
let view = cx.new(|_| MarkdownViewState::new("markdown-view-demo", MARKDOWN_SAMPLE));
// The panel is a cached view in the dock: it re-renders only when
// the markdown state it renders is notified.
let subscription = cx.observe(&view, |_, _, cx| cx.notify());
Self {
view,
sample: MarkdownSample::Guide,
large_source: large_markdown(LARGE_MARKDOWN_SECTIONS).into(),
_subscription: subscription,
}
}
}
impl Render for MarkdownViewDemo {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let colors = cx.theme().colors;
let radius = cx.theme().radius;
let view = cx.entity();
let blocks = self.view.read(cx).blocks();
let block_count = blocks.len();
let heading_count = blocks
.iter()
.filter(|block| matches!(block, MarkdownBlock::Heading { .. }))
.count();
ScrollArea::new("markdown-view-demo").size_full().p_4().child(
v_flex()
.gap_3()
.child(demo_section(
"document",
colors,
Segmented::new("markdown-view-demo-sample", self.sample)
.options(vec![
(MarkdownSample::Guide, "Release notes"),
(MarkdownSample::Large, "Large file"),
])
.on_change(move |next, _, cx| {
view.update(cx, |demo, cx| {
demo.sample = next;
let source = match next {
MarkdownSample::Guide => MARKDOWN_SAMPLE.into(),
MarkdownSample::Large => demo.large_source.clone(),
};
demo.view.update(cx, |view, cx| view.set_source(source, cx));
});
}),
))
.child(demo_section(
"rendered, virtualized, links clickable",
colors,
div()
.h(px(480.))
.rounded(radius.control)
.border_1()
.border_color(colors.border)
.overflow_hidden()
.child(self.view.clone()),
))
.child(
div()
.text_xs()
.text_color(colors.muted_foreground)
.child(format!(
"{block_count} blocks, {heading_count} headings \u{2014} scroll to test performance"
)),
),
)
}
}
struct SidebarDemo {
selected: Option<usize>,
}