feat: add markdown view component
This commit is contained in:
Generated
+19
@@ -1912,8 +1912,10 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"gpui",
|
"gpui",
|
||||||
"gpui-native-core",
|
"gpui-native-core",
|
||||||
|
"gpui-native-icons",
|
||||||
"gpui-native-theme",
|
"gpui-native-theme",
|
||||||
"gpui-native-utils",
|
"gpui-native-utils",
|
||||||
|
"pulldown-cmark",
|
||||||
"similar",
|
"similar",
|
||||||
"tree-sitter",
|
"tree-sitter",
|
||||||
"tree-sitter-javascript",
|
"tree-sitter-javascript",
|
||||||
@@ -4235,6 +4237,17 @@ dependencies = [
|
|||||||
"syn 3.0.9",
|
"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]]
|
[[package]]
|
||||||
name = "pulp"
|
name = "pulp"
|
||||||
version = "0.22.3"
|
version = "0.22.3"
|
||||||
@@ -5978,6 +5991,12 @@ version = "0.1.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
|
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicase"
|
||||||
|
version = "2.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-bidi"
|
name = "unicode-bidi"
|
||||||
version = "0.3.18"
|
version = "0.3.18"
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ description = "Read-only code surfaces for gpui applications, one cargo feature
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
gpui = { workspace = true }
|
gpui = { workspace = true }
|
||||||
gpui-native-core = { workspace = true, optional = true }
|
gpui-native-core = { workspace = true, optional = true }
|
||||||
|
gpui-native-icons = { workspace = true }
|
||||||
gpui-native-theme = { workspace = true }
|
gpui-native-theme = { workspace = true }
|
||||||
gpui-native-utils = { workspace = true }
|
gpui-native-utils = { workspace = true }
|
||||||
tree-sitter = "0.25"
|
tree-sitter = "0.25"
|
||||||
similar = "2"
|
similar = "2"
|
||||||
|
pulldown-cmark = { version = "0.13", default-features = false, optional = true }
|
||||||
tree-sitter-rust = { version = "0.24", optional = true }
|
tree-sitter-rust = { version = "0.24", optional = true }
|
||||||
tree-sitter-typescript = { version = "0.23", optional = true }
|
tree-sitter-typescript = { version = "0.23", optional = true }
|
||||||
tree-sitter-javascript = { version = "0.23", optional = true }
|
tree-sitter-javascript = { version = "0.23", optional = true }
|
||||||
@@ -24,6 +26,7 @@ default = ["full"]
|
|||||||
full = [
|
full = [
|
||||||
"code_view",
|
"code_view",
|
||||||
"diff_view",
|
"diff_view",
|
||||||
|
"markdown_view",
|
||||||
"rust",
|
"rust",
|
||||||
"typescript",
|
"typescript",
|
||||||
"javascript",
|
"javascript",
|
||||||
@@ -35,6 +38,7 @@ code_view = [
|
|||||||
"gpui-native-core/scrollbar",
|
"gpui-native-core/scrollbar",
|
||||||
]
|
]
|
||||||
diff_view = ["code_view"]
|
diff_view = ["code_view"]
|
||||||
|
markdown_view = ["code_view", "dep:pulldown-cmark"]
|
||||||
rust = ["dep:tree-sitter-rust"]
|
rust = ["dep:tree-sitter-rust"]
|
||||||
typescript = ["dep:tree-sitter-typescript"]
|
typescript = ["dep:tree-sitter-typescript"]
|
||||||
javascript = ["dep:tree-sitter-javascript"]
|
javascript = ["dep:tree-sitter-javascript"]
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ pub mod code_view;
|
|||||||
#[cfg(feature = "diff_view")]
|
#[cfg(feature = "diff_view")]
|
||||||
pub mod diff_view;
|
pub mod diff_view;
|
||||||
|
|
||||||
|
#[cfg(feature = "markdown_view")]
|
||||||
|
pub mod markdown;
|
||||||
|
|
||||||
pub mod highlight;
|
pub mod highlight;
|
||||||
|
|
||||||
mod utils;
|
mod utils;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,11 @@
|
|||||||
//! Shared helpers for the editor's views: gutter cells, role colors, change
|
//! Shared helpers for the editor's views: gutter cells, role colors, code
|
||||||
//! washes and diff row building.
|
//! 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;
|
use std::ops::Range;
|
||||||
|
|
||||||
|
#[cfg(feature = "markdown_view")]
|
||||||
|
use gpui::StyledText;
|
||||||
use gpui::{App, HighlightStyle, Hsla, IntoElement, ParentElement as _, Styled as _, div};
|
use gpui::{App, HighlightStyle, Hsla, IntoElement, ParentElement as _, Styled as _, div};
|
||||||
use gpui_native_theme::{ActiveTheme as _, ColorTokens, Ramps};
|
use gpui_native_theme::{ActiveTheme as _, ColorTokens, Ramps};
|
||||||
#[cfg(feature = "diff_view")]
|
#[cfg(feature = "diff_view")]
|
||||||
@@ -14,6 +16,8 @@ use crate::diff_view::{DiffRow, DiffRowKind};
|
|||||||
#[cfg(feature = "diff_view")]
|
#[cfg(feature = "diff_view")]
|
||||||
use crate::highlight::HighlightedCode;
|
use crate::highlight::HighlightedCode;
|
||||||
use crate::highlight::Role;
|
use crate::highlight::Role;
|
||||||
|
#[cfg(feature = "markdown_view")]
|
||||||
|
use crate::highlight::{Language, Line};
|
||||||
|
|
||||||
/// Maps a highlight role to the theme's semantic palette.
|
/// Maps a highlight role to the theme's semantic palette.
|
||||||
pub(crate) fn role_color(role: Role, colors: ColorTokens, ramps: Ramps) -> Hsla {
|
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
|
..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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
+9
-9
@@ -9,15 +9,13 @@ is the first downstream consumer; its screens drive the order.
|
|||||||
|
|
||||||
## Next
|
## Next
|
||||||
|
|
||||||
- [ ] **Markdown view** (M16, `editor`) — only if a consumer confirms.
|
|
||||||
- [ ] API sketch
|
|
||||||
- [ ] Implementation
|
|
||||||
- [ ] Tests
|
|
||||||
- [ ] Gallery panel
|
|
||||||
- [ ] Review
|
|
||||||
|
|
||||||
## Queue — to follow
|
## 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
|
- [ ] **Editor follow-ups** — language detection by file path, meta line
|
||||||
overlay, per-view config overrides (line height, gutter visibility).
|
overlay, per-view config overrides (line height, gutter visibility).
|
||||||
- [ ] **Pickers** — Calendar / DatePicker, TimeField, ColorPicker (decide at
|
- [ ] **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
|
incremental line re-highlight
|
||||||
- [x] Diff view — unified rows via `similar`, highlighted with the code
|
- [x] Diff view — unified rows via `similar`, highlighted with the code
|
||||||
view's palette; change washes from the ramps, `scroll_to_first_change`
|
view's palette; change washes from the ramps, `scroll_to_first_change`
|
||||||
- [x] Diff view — unified rows via `similar`, highlighted with the code
|
- [x] Markdown view — `pulldown-cmark` blocks rendered as formatted text:
|
||||||
view's palette; change washes from the ramps, `scroll_to_first_change`
|
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`)
|
### layout (`crates/layout`)
|
||||||
|
|
||||||
|
|||||||
@@ -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 (``); 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.
|
||||||
@@ -43,6 +43,7 @@ use gpui_native_data::tree::{Tree, TreeItem};
|
|||||||
use gpui_native_editor::code_view::CodeViewState;
|
use gpui_native_editor::code_view::CodeViewState;
|
||||||
use gpui_native_editor::diff_view::{DiffRowKind, DiffViewState};
|
use gpui_native_editor::diff_view::{DiffRowKind, DiffViewState};
|
||||||
use gpui_native_editor::highlight::Language;
|
use gpui_native_editor::highlight::Language;
|
||||||
|
use gpui_native_editor::markdown::{MarkdownBlock, MarkdownViewState};
|
||||||
use gpui_native_icons::{IconAssets, IconRole, icon};
|
use gpui_native_icons::{IconAssets, IconRole, icon};
|
||||||
use gpui_native_layout::dock::{Dock, DockSidebar, DockState, PanelDescriptor, SplitDirection};
|
use gpui_native_layout::dock::{Dock, DockSidebar, DockState, PanelDescriptor, SplitDirection};
|
||||||
use gpui_native_layout::resize_handle::ResizeHandle;
|
use gpui_native_layout::resize_handle::ResizeHandle;
|
||||||
@@ -87,7 +88,11 @@ const CATALOG: &[(&str, &[(&str, &str)])] = &[
|
|||||||
),
|
),
|
||||||
(
|
(
|
||||||
"Editor",
|
"Editor",
|
||||||
&[("code-view", "Code view"), ("diff-view", "Diff view")],
|
&[
|
||||||
|
("code-view", "Code view"),
|
||||||
|
("diff-view", "Diff view"),
|
||||||
|
("markdown-view", "Markdown view"),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"Feedback",
|
"Feedback",
|
||||||
@@ -642,6 +647,12 @@ impl Gallery {
|
|||||||
}),
|
}),
|
||||||
cx,
|
cx,
|
||||||
);
|
);
|
||||||
|
dock.register_panel(
|
||||||
|
PanelDescriptor::new("markdown-view", "Markdown view", |_, cx| {
|
||||||
|
cx.new(MarkdownViewDemo::new).into()
|
||||||
|
}),
|
||||||
|
cx,
|
||||||
|
);
|
||||||
dock.register_panel(
|
dock.register_panel(
|
||||||
PanelDescriptor::new("sidebar", "Sidebar", |_, cx| {
|
PanelDescriptor::new("sidebar", "Sidebar", |_, cx| {
|
||||||
cx.new(|_| SidebarDemo { selected: None }).into()
|
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 {
|
struct SidebarDemo {
|
||||||
selected: Option<usize>,
|
selected: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user