diff --git a/Cargo.lock b/Cargo.lock
index 775efe39..db6eefc3 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -527,7 +527,7 @@ dependencies = [
"bitflags 2.6.0",
"cexpr",
"clang-sys",
- "itertools 0.11.0",
+ "itertools 0.13.0",
"log",
"prettyplease",
"proc-macro2",
@@ -2546,6 +2546,7 @@ dependencies = [
"gpui",
"html5ever 0.27.0",
"image",
+ "indoc",
"itertools 0.13.0",
"markdown",
"markup5ever_rcdom",
@@ -3025,6 +3026,12 @@ dependencies = [
"serde",
]
+[[package]]
+name = "indoc"
+version = "2.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b248f5224d1d606005e02c97f5aa4e88eeb230488bcc03bc9ca4d7991399f2b5"
+
[[package]]
name = "inout"
version = "0.1.3"
@@ -3915,7 +3922,7 @@ version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af1844ef2428cc3e1cb900be36181049ef3d3193c63e43026cfe202983b27a56"
dependencies = [
- "proc-macro-crate 1.3.1",
+ "proc-macro-crate 3.2.0",
"proc-macro2",
"quote",
"syn 2.0.96",
diff --git a/crates/story/examples/html.html b/crates/story/examples/html.html
index c636cca9..7452b52a 100644
--- a/crates/story/examples/html.html
+++ b/crates/story/examples/html.html
@@ -72,8 +72,7 @@
- | Cell 1 |
- Cell 2 |
+ This Cell have 2 span |
| Cell 3 |
@@ -94,21 +93,18 @@
(A Tesla Model X on display at the June 2024 Shanghai new energy
vehicle show. Image credit: CnEVPost)
- SVG
Text before the image.
Text after the image.
diff --git a/crates/story/examples/markdown.md b/crates/story/examples/markdown.md
index 7f8239b9..fcce51b8 100644
--- a/crates/story/examples/markdown.md
+++ b/crates/story/examples/markdown.md
@@ -9,10 +9,10 @@ This is an additional demonstration paragraph in English demonstrating more cont

----
-
这是一个中文演示段落,用于展示更多的 [Markdown GFM](https://github.github.com/gfm/) 内容。您可以在此尝试使用使用**粗体**、*斜体*和`代码`等样式。これは日本語のデモ段落です。Markdown の多言語サポートを示すためのテキストが含まれています。例えば、、**ボールド**、_イタリック_、および`コード`のスタイルなどを試すことができます。
+---
+
## Heading for [Links](https://www.google.com)
Here is a link to [Google](https://www.google.com), and another to [Rust](https://www.rust-lang.org).
@@ -25,6 +25,50 @@ Here is a link to [Google](https://www.google.com), and another to [Rust](https:

+### Table
+
+| Header 1 | Centered | Header 3 | Align Right |
+| -------- | :------: | ------------------------------------ | ----------: |
+| Cell 0 | Cell 1 | This is a long cell with line break. | Cell 3 |
+| Row 2 | Row 2 | Row 2
[Link](https://github.com) | Row 2 |
+| Row 3 | **Bold** | Row 3 | Row 3 |
+
+#### Lists
+
+##### Bulleted List
+
+- Bullet 1
+- Bullet 2
+ - Bullet 2.1
+ - Bullet 2.1.1
+ - Bullet 2.1.1.1
+ - Bullet 2.1.2
+ - Bullet 2.2
+- Bullet 3
+
+##### Numbered List
+
+1. Numbered item 1
+ 1. Numbered item 1.1
+ 1. Numbered item 1.1.1
+ 1. Numbered item 1.2
+2. Numbered item 2
+3. Numbered item 3
+
+##### To-Do List
+
+- [x] Task 1
+- [ ] Task 2
+- [ ] Task 3
+
+#### Heading for Code
+
+```rust
+fn main() {
+ println!("Hello, World!");
+}
+```
+
### HTML
#### Paragraph and Text
@@ -77,50 +121,6 @@ Here is a link to [Google](https://www.google.com), and another to [Rust](https:
-### Table
-
-| Header 1 | Centered | Header 3 | Align Right |
-| -------- | :------: | ------------------------------------ | ----------: |
-| Cell 0 | Cell 1 | This is a long cell with line break. | Cell 3 |
-| Row 2 | Row 2 | Row 2
[Link](https://github.com) | Row 2 |
-| Row 3 | **Bold** | Row 3 | Row 3 |
-
-#### Lists
-
-##### Bulleted List
-
-- Bullet 1
-- Bullet 2
- - Bullet 2.1
- - Bullet 2.1.1
- - Bullet 2.1.1.1
- - Bullet 2.1.2
- - Bullet 2.2
-- Bullet 3
-
-##### Numbered List
-
-1. Numbered item 1
- 1. Numbered item 1.1
- 1. Numbered item 1.1.1
- 1. Numbered item 1.2
-2. Numbered item 2
-3. Numbered item 3
-
-##### To-Do List
-
-- [x] Task 1
-- [ ] Task 2
-- [ ] Task 3
-
-#### Heading for Code
-
-```rust
-fn main() {
- println!("Hello, World!");
-}
-```
-
## Unsupported
### HTML
diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml
index a45fefa8..7f1c5397 100644
--- a/crates/ui/Cargo.toml
+++ b/crates/ui/Cargo.toml
@@ -23,8 +23,8 @@ once_cell = "1.19.0"
paste = "1"
regex = "1"
resvg = { version = "0.44.0", default-features = false, features = [
- "system-fonts",
- "text",
+ "system-fonts",
+ "text",
] }
rust-embed.workspace = true
rust-i18n = "3"
@@ -34,20 +34,22 @@ smallvec = "1.13.2"
smol = "1"
unicode-segmentation = "1.12.0"
usvg = { version = "0.44.0", default-features = false, features = [
- "system-fonts",
- "text",
+ "system-fonts",
+ "text",
] }
uuid = "1.10"
wry = { version = "0.48.0", optional = true }
# Markdown Parser
markdown = "1.0.0-alpha.22"
# HTML Parser
-html5ever = "0.27"
+html5ever = "0.27"
markup5ever_rcdom = "0.3.0"
minify-html = "0.15.0"
-
# Calendar
chrono = "0.4.38"
+[dev-dependencies]
+indoc = "2"
+
[lints]
workspace = true
diff --git a/crates/ui/src/text/element.rs b/crates/ui/src/text/element.rs
index 88573f4d..8abbed2a 100644
--- a/crates/ui/src/text/element.rs
+++ b/crates/ui/src/text/element.rs
@@ -229,22 +229,27 @@ pub enum Node {
lang: Option,
},
Table(Table),
- //
- Break,
+ Break {
+ html: bool,
+ },
Divider,
Ignore,
Unknown,
}
impl Node {
- fn is_ignore(&self) -> bool {
+ pub(super) fn is_ignore(&self) -> bool {
matches!(self, Self::Ignore)
}
- fn is_list_item(&self) -> bool {
+ pub(super) fn is_list_item(&self) -> bool {
matches!(self, Self::ListItem { .. })
}
+ pub(super) fn is_break(&self) -> bool {
+ matches!(self, Self::Break { .. })
+ }
+
/// Combine all children, omitting the empt parent nodes.
pub(super) fn compact(&self) -> Node {
match self {
@@ -276,7 +281,14 @@ impl RenderOnce for Paragraph {
for text_node in children.into_iter() {
let text_len = text_node.text.len();
- text.push_str(&text_node.text);
+ let part = if text.len() == 0 {
+ // trim start for first text
+ text_node.text.trim_start()
+ } else {
+ text_node.text.as_str()
+ };
+
+ text.push_str(part);
let mut node_highlights = vec![];
for (range, style) in text_node.marks {
@@ -581,7 +593,6 @@ impl Node {
.border_l_3()
.border_color(cx.theme().secondary_active)
.px_4()
- .py_1()
.child(children)
.into_any_element(),
Node::List { children, ordered } => v_flex()
@@ -628,7 +639,7 @@ impl Node {
.h(px(2.))
.mb(mb)
.into_any_element(),
- Node::Break => div().into_any_element(),
+ Node::Break { .. } => div().into_any_element(),
Node::Ignore => div().into_any_element(),
_ => {
if cfg!(debug_assertions) {
@@ -640,3 +651,165 @@ impl Node {
}
}
}
+
+impl Paragraph {
+ fn to_markdown(&self) -> String {
+ let mut text = match self {
+ Paragraph::Texts { children, .. } => children
+ .iter()
+ .map(|text_node| {
+ let mut text = text_node.text.clone();
+ for (range, style) in &text_node.marks {
+ if style.bold {
+ text = format!("**{}**", &text_node.text[range.clone()]);
+ }
+ if style.italic {
+ text = format!("*{}*", &text_node.text[range.clone()]);
+ }
+ if style.strikethrough {
+ text = format!("~~{}~~", &text_node.text[range.clone()]);
+ }
+ if style.code {
+ text = format!("`{}`", &text_node.text[range.clone()]);
+ }
+ if let Some(link) = &style.link {
+ text = format!("[{}]({})", &text_node.text[range.clone()], link.url);
+ }
+ }
+ text
+ })
+ .collect::>()
+ .join(""),
+ Paragraph::Image { image, .. } => {
+ let alt = image.alt.clone().unwrap_or_default();
+ let title = image
+ .title
+ .clone()
+ .map_or(String::new(), |t| format!(" \"{}\"", t));
+ format!("", alt, image.url, title)
+ }
+ };
+
+ text.push_str("\n\n");
+ text
+ }
+}
+
+impl Node {
+ /// Converts the node to markdown format.
+ ///
+ /// This is used to generate markdown for test.
+ #[allow(dead_code)]
+ pub(crate) fn to_markdown(&self) -> String {
+ match self {
+ Node::Root { children } => children
+ .iter()
+ .map(|child| child.to_markdown())
+ .collect::>()
+ .join("\n\n"),
+ Node::Paragraph(paragraph) => paragraph.to_markdown(),
+ Node::Heading { level, children } => {
+ let hashes = "#".repeat(*level as usize);
+ format!("{} {}", hashes, children.to_markdown())
+ }
+ Node::Blockquote(paragraph) => {
+ let content = paragraph.to_markdown();
+ content
+ .lines()
+ .map(|line| format!("> {}", line))
+ .collect::>()
+ .join("\n")
+ }
+ Node::List { children, ordered } => children
+ .iter()
+ .enumerate()
+ .map(|(i, child)| {
+ let prefix = if *ordered {
+ format!("{}. ", i + 1)
+ } else {
+ "- ".to_string()
+ };
+ format!("{}{}", prefix, child.to_markdown())
+ })
+ .collect::>()
+ .join("\n"),
+ Node::ListItem {
+ children, checked, ..
+ } => {
+ let checkbox = if let Some(checked) = checked {
+ if *checked {
+ "[x] "
+ } else {
+ "[ ] "
+ }
+ } else {
+ ""
+ };
+ format!(
+ "{}{}",
+ checkbox,
+ children
+ .iter()
+ .map(|child| child.to_markdown())
+ .collect::>()
+ .join("\n")
+ )
+ }
+ Node::CodeBlock { code, lang } => {
+ format!("```{}\n{}\n```", lang.clone().unwrap_or_default(), code)
+ }
+ Node::Table(table) => {
+ let header = table
+ .children
+ .first()
+ .map(|row| {
+ row.children
+ .iter()
+ .map(|cell| cell.children.to_markdown())
+ .collect::>()
+ .join(" | ")
+ })
+ .unwrap_or_default();
+ let alignments = table
+ .column_aligns
+ .iter()
+ .map(|align| {
+ match align {
+ TableColumnAlign::Left => ":--",
+ TableColumnAlign::Center => ":-:",
+ TableColumnAlign::Right => "--:",
+ }
+ .to_string()
+ })
+ .collect::>()
+ .join(" | ");
+ let rows = table
+ .children
+ .iter()
+ .skip(1)
+ .map(|row| {
+ row.children
+ .iter()
+ .map(|cell| cell.children.to_markdown())
+ .collect::>()
+ .join(" | ")
+ })
+ .collect::>()
+ .join("\n");
+ format!("{}\n{}\n{}", header, alignments, rows)
+ }
+ Node::Break { html } => {
+ if *html {
+ "
".to_string()
+ } else {
+ "\n".to_string()
+ }
+ }
+ Node::Divider => "---".to_string(),
+ Node::Ignore => "".to_string(),
+ Node::Unknown => "".to_string(),
+ }
+ .trim()
+ .to_string()
+ }
+}
diff --git a/crates/ui/src/text/html.rs b/crates/ui/src/text/html.rs
index c43be79a..bd97eabc 100644
--- a/crates/ui/src/text/html.rs
+++ b/crates/ui/src/text/html.rs
@@ -62,7 +62,7 @@ pub(super) fn parse_html(source: &str) -> Result {
..Default::default()
};
- let bytes = minify_html::minify(source.as_bytes(), &minify_html::Cfg::default());
+ let bytes = cleanup_html(&source);
let mut cursor = std::io::Cursor::new(bytes);
// Ref
// https://github.com/servo/html5ever/blob/main/rcdom/examples/print-rcdom.rs
@@ -79,6 +79,12 @@ pub(super) fn parse_html(source: &str) -> Result {
Ok(node)
}
+fn cleanup_html(source: &str) -> Vec {
+ let mut cfg = minify_html::Cfg::default();
+ cfg.keep_closing_tags = true;
+ minify_html::minify(source.as_bytes(), &cfg)
+}
+
#[derive(Clone)]
pub(super) struct HtmlElement {
id: ElementId,
@@ -334,6 +340,7 @@ fn parse_table_cell(
///
/// - Before: " \r\n Hello world \t "
/// - After: " Hello world "
+#[allow(dead_code)]
fn trim_text(text: &str) -> String {
let mut out = String::with_capacity(text.len());
@@ -372,7 +379,7 @@ fn parse_paragraph(
match &node.data {
NodeData::Text { ref contents } => {
- let part = trim_text(&contents.borrow());
+ let part = &contents.borrow();
text.push_str(&part);
paragraph.push_str(&text);
}
@@ -524,7 +531,7 @@ fn parse_paragraph(
fn parse_node(node: &Rc, paragraph: &mut Paragraph) -> element::Node {
match node.data {
NodeData::Text { ref contents } => {
- let text = contents.borrow().trim_start().to_string();
+ let text = contents.borrow().to_string();
if text.len() > 0 {
paragraph.push_str(&text);
}
@@ -536,7 +543,7 @@ fn parse_node(node: &Rc, paragraph: &mut Paragraph) -> element::Node {
ref attrs,
..
} => match name.local {
- local_name!("br") => element::Node::Break,
+ local_name!("br") => element::Node::Break { html: true },
local_name!("h1")
| local_name!("h2")
| local_name!("h3")
@@ -760,11 +767,74 @@ mod tests {
use super::trim_text;
+ #[test]
+ fn test_cleanup_html() {
+ let html = r#"
+ and
+ code
+ text
+
"#;
+ let cleaned = super::cleanup_html(html);
+ assert_eq!(
+ String::from_utf8(cleaned).unwrap(),
+ "and code text
"
+ );
+
+ let html = r#"
+ and
+ code italic
+ text
+
"#;
+ let cleaned = super::cleanup_html(html);
+ assert_eq!(
+ String::from_utf8(cleaned).unwrap(),
+ "and code italic text
"
+ );
+ }
+
#[test]
fn test_trim_text() {
assert_eq!(trim_text(" \n\tHello world \t\r "), " Hello world ",);
}
+ #[test]
+ fn test_keep_spaces() {
+ let html = r#"and code text
"#;
+ let node = super::parse_html(html).unwrap();
+ assert_eq!(node.to_markdown(), "and `code` text");
+
+ let html = r#"
+
+
+ and
+ code italic
+ text
+
+
+
+
+
+
+ "#;
+ let node = super::parse_html(html).unwrap();
+ assert_eq!(
+ node.to_markdown(),
+ indoc::indoc! {r#"
+ and * code italic * text
+
+ 
+
+ - Item 1
+ - Item 2
+ "#}
+ .trim()
+ );
+ }
+
#[test]
fn test_value_to_length() {
assert_eq!(super::value_to_length("100px"), Some(px(100.).into()));
diff --git a/crates/ui/src/text/markdown.rs b/crates/ui/src/text/markdown.rs
index 984db6b0..9cecb398 100644
--- a/crates/ui/src/text/markdown.rs
+++ b/crates/ui/src/text/markdown.rs
@@ -20,17 +20,7 @@ use super::{
/// This is design goal is to be able to most common Markdown (GFM) features
/// to let us to display rich text in our application.
///
-/// The goal:
-///
-/// - For used to help message.
-/// - For used to display like about page.
-/// - Some general style customization (Like base text size, line-height...).
-///
-/// Not in goal:
-///
-/// - As a markdown editor.
-/// - Add custom markdown syntax.
-/// - Complex styles cumstomization.
+/// See also [`super::TextView`]
#[derive(Clone)]
pub(super) struct MarkdownElement {
id: ElementId,
@@ -75,11 +65,7 @@ impl MarkdownState {
}
self.raw = new_text;
- self.root = Some(
- markdown::to_mdast(&self.raw, &ParseOptions::gfm())
- .map(|n| n.into())
- .map_err(|e| e.to_string().into()),
- );
+ self.root = Some(parse_markdown(&self.raw));
}
}
@@ -156,6 +142,13 @@ impl Element for MarkdownElement {
}
}
+/// Parse Markdown into a tree of nodes.
+fn parse_markdown(raw: &str) -> Result {
+ markdown::to_mdast(&raw, &ParseOptions::gfm())
+ .map(|n| n.into())
+ .map_err(|e| e.to_string().into())
+}
+
fn parse_table_row(table: &mut Table, node: &mdast::TableRow) {
let mut row = TableRow::default();
node.children.iter().for_each(|c| {
@@ -312,8 +305,12 @@ fn parse_paragraph(paragraph: &mut Paragraph, node: &mdast::Node) -> String {
}
Node::Html(val) => match parse_html(&val.value) {
Ok(el) => {
- if el == element::Node::Break {
- text.push_str("\n");
+ if el.is_break() {
+ text = "\n".to_owned();
+ paragraph.push(element::TextNode {
+ text: text.clone(),
+ marks: vec![(0..text.len(), InlineTextStyle::default())],
+ });
} else {
if cfg!(debug_assertions) {
eprintln!("[markdown] unsupported inline html tag: {:#?}", el);
@@ -376,7 +373,7 @@ impl From for element::Node {
checked: val.checked,
}
}
- Node::Break(_) => element::Node::Break,
+ Node::Break(_) => element::Node::Break { html: false },
Node::Code(raw) => element::Node::CodeBlock {
code: raw.value.into(),
lang: raw.lang.map(|s| s.into()),
@@ -460,3 +457,18 @@ impl From for element::Node {
}
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::parse_markdown;
+
+ #[test]
+ fn test_parse_br() {
+ let raw = "Row 1
Row 2
[Link](https://github.com)";
+ let node = parse_markdown(&raw).unwrap();
+ assert_eq!(
+ node.to_markdown(),
+ "Row 1\nRow 2\n[Link](https://github.com)"
+ );
+ }
+}
diff --git a/crates/ui/src/text/text_view.rs b/crates/ui/src/text/text_view.rs
index d841f6e4..4b413629 100644
--- a/crates/ui/src/text/text_view.rs
+++ b/crates/ui/src/text/text_view.rs
@@ -3,6 +3,21 @@ use gpui::{rems, App, ElementId, IntoElement, Rems, RenderOnce, SharedString, Wi
use super::{html::HtmlElement, markdown::MarkdownElement};
/// A text view that can render Markdown or HTML.
+///
+/// ## Goals
+///
+/// - Provide a rich text rendering component for such as Markdown or HTML,
+/// used to display rich text in GPUI application (e.g., Help messages, Release notes)
+/// - Support Markdown GFM and HTML (Simple HTML like Safari Reader Mode) for showing most common used markups.
+/// - Support Heading, Paragraph, Bold, Italic, StrikeThrough, Code, Link, Image, Blockquote, List, Table, HorizontalRule, CodeBlock ...
+///
+/// ## Not Goals
+///
+/// - Customization of the complex style (some simple styles will be supported)
+/// - As a Markdown editor or viewer (If you want to like this, you must fork your version).
+/// - As a HTML viewer, we not support CSS, we only support basic HTML tags for used to as a content reader.
+///
+/// See also [`MarkdownElement`], [`HtmlElement`]
#[allow(private_interfaces)]
#[derive(IntoElement, Clone)]
pub enum TextView {