text: Add TextView with Markdown and Simple HTML support. (#639)
This commit is contained in:
parent
6c39078e8a
commit
06f9178ec4
16 changed files with 3343 additions and 29 deletions
5
.github/workflows/ci.yml
vendored
5
.github/workflows/ci.yml
vendored
|
|
@ -19,7 +19,7 @@ jobs:
|
||||||
- name: Machete
|
- name: Machete
|
||||||
uses: bnjbvr/cargo-machete@main
|
uses: bnjbvr/cargo-machete@main
|
||||||
- name: Setup | Cache Cargo
|
- name: Setup | Cache Cargo
|
||||||
uses: actions/cache@v3.0.11
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.cargo/bin/
|
~/.cargo/bin/
|
||||||
|
|
@ -35,4 +35,5 @@ jobs:
|
||||||
cargo clippy -- --deny warnings
|
cargo clippy -- --deny warnings
|
||||||
typos
|
typos
|
||||||
- name: Build test
|
- name: Build test
|
||||||
run: cargo build
|
run: |
|
||||||
|
cargo test --all
|
||||||
|
|
|
||||||
1146
Cargo.lock
generated
1146
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -36,6 +36,7 @@ UI components for building fantastic desktop application by using [GPUI](https:/
|
||||||
- Sidebar
|
- Sidebar
|
||||||
- Breadcrumb
|
- Breadcrumb
|
||||||
- Badge
|
- Badge
|
||||||
|
- TextView (Markdown, Simple HTML) to native rendering.
|
||||||
|
|
||||||
## Showcase
|
## Showcase
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ chrono = "0.4"
|
||||||
fake = { version = "2.10.0", features = ["dummy"] }
|
fake = { version = "2.10.0", features = ["dummy"] }
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
gpui-component.workspace = true
|
gpui-component.workspace = true
|
||||||
|
reqwest_client = { git = "https://github.com/huacnlee/zed.git", branch = "webview" }
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
regex = "1"
|
regex = "1"
|
||||||
rust-embed.workspace = true
|
rust-embed.workspace = true
|
||||||
|
|
|
||||||
102
crates/story/examples/html.html
Normal file
102
crates/story/examples/html.html
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
<article>
|
||||||
|
<h1>A simple HTML document</h1>
|
||||||
|
<div>
|
||||||
|
Here is a test in div.
|
||||||
|
<p>
|
||||||
|
This is a paragraph inside a div element, have
|
||||||
|
<mention>@Mention Tag</mention>
|
||||||
|
<a href="https://google.com"
|
||||||
|
>Link with: <b>Bold <i>italic</i></b></a
|
||||||
|
>, <strong>bold</strong>, <em>italic</em>, and
|
||||||
|
<code>code</code> text.
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<p>This is second paragraph.</p>
|
||||||
|
</div>
|
||||||
|
A text after div.
|
||||||
|
<p>
|
||||||
|
这是一个中文演示段落,用于展示更多的
|
||||||
|
<a href="https://github.github.com/gfm/">Markdown GFM</a>
|
||||||
|
内容。これは日本語のデモ段落です。の多言語サポートを示すためのテキストが含まれています。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2>List</h2>
|
||||||
|
Example for Bulleted and Numbered List:
|
||||||
|
|
||||||
|
<h3>Numbered List</h3>
|
||||||
|
Text before the Numbered List.
|
||||||
|
<ol>
|
||||||
|
<li>
|
||||||
|
Numbered item 1
|
||||||
|
<ol>
|
||||||
|
<li>Sub item 1</li>
|
||||||
|
<li>Sub item 2</li>
|
||||||
|
</ol>
|
||||||
|
</li>
|
||||||
|
<li>Numbered item 2</li>
|
||||||
|
<li>Numbered item 3</li>
|
||||||
|
</ol>
|
||||||
|
Text after the Numbered List.
|
||||||
|
<h3>Bulleted List</h3>
|
||||||
|
Text before the Bulleted List.
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
Bullet 1
|
||||||
|
<ol>
|
||||||
|
<li>Sub Numbered 1</li>
|
||||||
|
<li>Sub Numbered 2</li>
|
||||||
|
</ol>
|
||||||
|
</li>
|
||||||
|
<li>Bullet 2</li>
|
||||||
|
</ul>
|
||||||
|
Text after the Bulleted List.
|
||||||
|
</div>
|
||||||
|
Text before the section.
|
||||||
|
<section>
|
||||||
|
<h2>Table</h2>
|
||||||
|
Text before the table.
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Head 1</th>
|
||||||
|
<th>Head 2</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Cell</strong> 1</td>
|
||||||
|
<td>Cell 2</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Cell 3</td>
|
||||||
|
<td>Cell 4</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
Text after the table.
|
||||||
|
</section>
|
||||||
|
Text after the section.
|
||||||
|
<section>
|
||||||
|
<h2>Images</h2>
|
||||||
|
<img
|
||||||
|
src="https://www.rust-lang.org/logos/rust-logo-blk.svg"
|
||||||
|
alt="Rust"
|
||||||
|
width="100"
|
||||||
|
height="100"
|
||||||
|
/>
|
||||||
|
Text before the image.
|
||||||
|
<img
|
||||||
|
src="https://www.rust-lang.org/logos/rust-logo-blk.svg"
|
||||||
|
alt="Rust"
|
||||||
|
width="100%"
|
||||||
|
/>
|
||||||
|
Text after the image.
|
||||||
|
<img
|
||||||
|
src="https://www.rust-lang.org/logos/rust-logo-blk.svg"
|
||||||
|
alt="Rust"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</article>
|
||||||
87
crates/story/examples/html.rs
Normal file
87
crates/story/examples/html.rs
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
use gpui::*;
|
||||||
|
use gpui_component::{input::TextInput, text::TextView, ActiveTheme as _};
|
||||||
|
use story::Assets;
|
||||||
|
|
||||||
|
pub struct Example {
|
||||||
|
text_input: Entity<TextInput>,
|
||||||
|
text_view: Entity<TextView>,
|
||||||
|
_subscribe: Subscription,
|
||||||
|
}
|
||||||
|
|
||||||
|
const EXAMPLE: &str = include_str!("./html.html");
|
||||||
|
|
||||||
|
impl Example {
|
||||||
|
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
|
let text_input = cx.new(|cx| {
|
||||||
|
TextInput::new(window, cx)
|
||||||
|
.multi_line()
|
||||||
|
.rows(50)
|
||||||
|
.placeholder("Input your HTML here...")
|
||||||
|
});
|
||||||
|
let text_view = cx.new(|cx| TextView::html(EXAMPLE, cx));
|
||||||
|
|
||||||
|
let _subscribe = cx.subscribe(
|
||||||
|
&text_input,
|
||||||
|
|this, _, _: &gpui_component::input::InputEvent, cx| {
|
||||||
|
let new_text = this.text_input.read(cx).text();
|
||||||
|
this.text_view.update(cx, |view, cx| {
|
||||||
|
view.set_text(new_text, cx);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
_ = text_input.update(cx, |input, cx| {
|
||||||
|
input.set_text(EXAMPLE, window, cx);
|
||||||
|
});
|
||||||
|
|
||||||
|
Self {
|
||||||
|
text_input,
|
||||||
|
text_view,
|
||||||
|
_subscribe,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view(window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||||
|
cx.new(|cx| Self::new(window, cx))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for Example {
|
||||||
|
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_row()
|
||||||
|
.h_full()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.id("source")
|
||||||
|
.h_full()
|
||||||
|
.w_1_2()
|
||||||
|
.border_r_1()
|
||||||
|
.border_color(cx.theme().border)
|
||||||
|
.flex_1()
|
||||||
|
.child(self.text_input.clone()),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.id("preview")
|
||||||
|
.h_full()
|
||||||
|
.w_1_2()
|
||||||
|
.p_5()
|
||||||
|
.flex_1()
|
||||||
|
.overflow_y_scroll()
|
||||||
|
.child(self.text_view.clone()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let app = Application::new().with_assets(Assets);
|
||||||
|
|
||||||
|
app.run(move |cx| {
|
||||||
|
story::init(cx);
|
||||||
|
cx.activate(true);
|
||||||
|
|
||||||
|
story::create_new_window("HTML Example", Example::view, cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
143
crates/story/examples/markdown.md
Normal file
143
crates/story/examples/markdown.md
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
# Hello, **World**!
|
||||||
|
|
||||||
|
This is first paragraph, there have **BOLD**, _italic_, and ~strikethrough~, `code` text.
|
||||||
|
|
||||||
|
> Blockquote: More complex nested inline style like **bold: _italic_**.
|
||||||
|
> This is second paragraph, it includes a block quote.
|
||||||
|
|
||||||
|
This is an additional demonstration paragraph in English demonstrating more content for [Markdown GFM](https://github.github.com/gfm/). It includes various stylistic elements and plain text.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
这是一个中文演示段落,用于展示更多的 [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).
|
||||||
|
|
||||||
|
### Images
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### HTML
|
||||||
|
|
||||||
|
#### Paragraph and Text
|
||||||
|
|
||||||
|
<div>
|
||||||
|
Here is a test in div.
|
||||||
|
<p>This is a paragraph inside a div element, have <a href="https://google.com">link</a>, <strong>bold</strong>, <em>italic</em>, and <code>code</code> text.</p>
|
||||||
|
<div>
|
||||||
|
<p>This is second paragraph.</p>
|
||||||
|
</div>
|
||||||
|
A text after div.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
#### List
|
||||||
|
|
||||||
|
<ol>
|
||||||
|
<li>Numbered item 1</li>
|
||||||
|
<li>Numbered item 2</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>Bullet 1</li>
|
||||||
|
<li>Bullet 2</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
#### Table
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<td>Head 1</td>
|
||||||
|
<td>Head 2</td>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Cell</strong> 1</td>
|
||||||
|
<td>Cell 2</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Cell 3</td>
|
||||||
|
<td>Cell 4</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
#### Image
|
||||||
|
|
||||||
|
<img src="https://www.rust-lang.org/logos/rust-logo-blk.svg" alt="Rust" width="100" height="100" />
|
||||||
|
<img src="https://www.rust-lang.org/logos/rust-logo-blk.svg" alt="Rust" width="100%" />
|
||||||
|
<img src="https://www.rust-lang.org/logos/rust-logo-blk.svg" alt="Rust" style="width:100%" />
|
||||||
|
|
||||||
|
### Table
|
||||||
|
|
||||||
|
| Header 1 | Header 2 | Header 3 | Header 4 |
|
||||||
|
| -------- | -------- | ------------------------------------ | -------- |
|
||||||
|
| Cell 0 | Cell 1 | This is a long cell with line break. | Cell 3 |
|
||||||
|
| Row 2 | Row 2 | Row 2<br>[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
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Click to expand</summary>
|
||||||
|
<div>
|
||||||
|
<p>This is a paragraph <a href="https://google.com">inside</a> a details element.</p>
|
||||||
|
<p>This is second paragraph.</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
### Math
|
||||||
|
|
||||||
|
This is an inline math $x^2 + y^2 = z^2$.
|
||||||
|
|
||||||
|
This is a block math:
|
||||||
|
|
||||||
|
$$
|
||||||
|
\begin{aligned}
|
||||||
|
x^2 + y^2 &= z^2 \\
|
||||||
|
x^3 + y^3 &= z^3
|
||||||
|
\end{aligned}
|
||||||
|
$$
|
||||||
|
|
||||||
|
This is final paragraph, it includes a code block and a list of items.
|
||||||
63
crates/story/examples/markdown.rs
Normal file
63
crates/story/examples/markdown.rs
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
use gpui::*;
|
||||||
|
use gpui_component::{text::TextView, ActiveTheme as _};
|
||||||
|
use story::Assets;
|
||||||
|
|
||||||
|
pub struct Example {
|
||||||
|
text_view: Entity<TextView>,
|
||||||
|
}
|
||||||
|
|
||||||
|
const EXAMPLE: &str = include_str!("./markdown.md");
|
||||||
|
|
||||||
|
impl Example {
|
||||||
|
pub fn new(_: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
|
let text_view = cx.new(|cx| TextView::markdown(EXAMPLE, cx));
|
||||||
|
|
||||||
|
Self { text_view }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view(window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||||
|
cx.new(|cx| Self::new(window, cx))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for Example {
|
||||||
|
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_row()
|
||||||
|
.h_full()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.id("source")
|
||||||
|
.h_full()
|
||||||
|
.w_1_2()
|
||||||
|
.border_r_1()
|
||||||
|
.border_color(cx.theme().border)
|
||||||
|
.flex_1()
|
||||||
|
.p_5()
|
||||||
|
.overflow_y_scroll()
|
||||||
|
.child(EXAMPLE),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.id("preview")
|
||||||
|
.h_full()
|
||||||
|
.w_1_2()
|
||||||
|
.p_5()
|
||||||
|
.flex_1()
|
||||||
|
.overflow_y_scroll()
|
||||||
|
.child(self.text_view.clone()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let app = Application::new().with_assets(Assets);
|
||||||
|
|
||||||
|
app.run(move |cx| {
|
||||||
|
story::init(cx);
|
||||||
|
cx.activate(true);
|
||||||
|
|
||||||
|
story::create_new_window("Markdown Example", Example::view, cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -201,6 +201,11 @@ pub fn init(cx: &mut App) {
|
||||||
dropdown_story::init(cx);
|
dropdown_story::init(cx);
|
||||||
popup_story::init(cx);
|
popup_story::init(cx);
|
||||||
|
|
||||||
|
let http_client = std::sync::Arc::new(
|
||||||
|
reqwest_client::ReqwestClient::user_agent("gpui-component/story").unwrap(),
|
||||||
|
);
|
||||||
|
cx.set_http_client(http_client);
|
||||||
|
|
||||||
register_panel(cx, PANEL_NAME, |_, _, info, window, cx| {
|
register_panel(cx, PANEL_NAME, |_, _, info, window, cx| {
|
||||||
let story_state = match info {
|
let story_state = match info {
|
||||||
PanelInfo::Panel(value) => StoryState::from_value(value.clone()),
|
PanelInfo::Panel(value) => StoryState::from_value(value.clone()),
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,12 @@ usvg = { version = "0.44.0", default-features = false, features = [
|
||||||
] }
|
] }
|
||||||
uuid = "1.10"
|
uuid = "1.10"
|
||||||
wry = "0.48.0"
|
wry = "0.48.0"
|
||||||
|
# Markdown Parser
|
||||||
|
markdown = "1.0.0-alpha.22"
|
||||||
|
# HTML Parser
|
||||||
|
html5ever = "0.27"
|
||||||
|
markup5ever_rcdom = "0.3.0"
|
||||||
|
minify-html = "0.15.0"
|
||||||
|
|
||||||
# Calendar
|
# Calendar
|
||||||
chrono = "0.4.38"
|
chrono = "0.4.38"
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ pub mod switch;
|
||||||
pub mod tab;
|
pub mod tab;
|
||||||
pub mod table;
|
pub mod table;
|
||||||
pub mod tag;
|
pub mod tag;
|
||||||
|
pub mod text;
|
||||||
pub mod theme;
|
pub mod theme;
|
||||||
pub mod tooltip;
|
pub mod tooltip;
|
||||||
pub mod webview;
|
pub mod webview;
|
||||||
|
|
|
||||||
583
crates/ui/src/text/element.rs
Normal file
583
crates/ui/src/text/element.rs
Normal file
|
|
@ -0,0 +1,583 @@
|
||||||
|
use std::ops::Range;
|
||||||
|
|
||||||
|
use gpui::{
|
||||||
|
div, img, prelude::FluentBuilder as _, px, relative, rems, App, DefiniteLength, ElementId,
|
||||||
|
FontStyle, FontWeight, Half, HighlightStyle, InteractiveElement as _, InteractiveText,
|
||||||
|
IntoElement, Length, ObjectFit, ParentElement, RenderOnce, SharedString, SharedUri, Styled,
|
||||||
|
StyledImage as _, StyledText, Window,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{h_flex, v_flex, ActiveTheme as _, Icon, IconName};
|
||||||
|
|
||||||
|
use super::utils::list_item_prefix;
|
||||||
|
|
||||||
|
#[allow(unused)]
|
||||||
|
#[derive(Debug, Default, Clone, PartialEq)]
|
||||||
|
pub struct LinkMark {
|
||||||
|
pub url: SharedString,
|
||||||
|
pub title: Option<SharedString>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Clone, PartialEq)]
|
||||||
|
pub struct InlineTextStyle {
|
||||||
|
pub bold: bool,
|
||||||
|
pub italic: bool,
|
||||||
|
pub strikethrough: bool,
|
||||||
|
pub code: bool,
|
||||||
|
pub link: Option<LinkMark>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Copy, Clone, PartialEq)]
|
||||||
|
pub struct Span {
|
||||||
|
pub start: usize,
|
||||||
|
pub end: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Span> for ElementId {
|
||||||
|
fn from(value: Span) -> Self {
|
||||||
|
ElementId::Name(format!("md-{}:{}", value.start, value.end).into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(unused)]
|
||||||
|
#[derive(Debug, Default, Clone)]
|
||||||
|
pub struct ImageNode {
|
||||||
|
pub url: SharedUri,
|
||||||
|
pub title: Option<SharedString>,
|
||||||
|
pub alt: Option<SharedString>,
|
||||||
|
pub width: Option<DefiniteLength>,
|
||||||
|
pub height: Option<DefiniteLength>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PartialEq for ImageNode {
|
||||||
|
fn eq(&self, other: &Self) -> bool {
|
||||||
|
self.url == other.url && self.title == other.title && self.alt == other.alt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Clone, PartialEq)]
|
||||||
|
pub struct TextNode {
|
||||||
|
/// The text content.
|
||||||
|
pub text: String,
|
||||||
|
/// The text styles, each tuple contains the range of the text and the style.
|
||||||
|
pub marks: Vec<(Range<usize>, InlineTextStyle)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, IntoElement)]
|
||||||
|
pub enum Paragraph {
|
||||||
|
Texts {
|
||||||
|
span: Option<Span>,
|
||||||
|
children: Vec<TextNode>,
|
||||||
|
},
|
||||||
|
Image {
|
||||||
|
span: Option<Span>,
|
||||||
|
image: ImageNode,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Paragraph {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::Texts {
|
||||||
|
span: None,
|
||||||
|
children: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<String> for Paragraph {
|
||||||
|
fn from(value: String) -> Self {
|
||||||
|
Self::Texts {
|
||||||
|
span: None,
|
||||||
|
children: vec![TextNode {
|
||||||
|
text: value.clone(),
|
||||||
|
marks: vec![],
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Clone, PartialEq)]
|
||||||
|
pub struct Table {
|
||||||
|
pub children: Vec<TableRow>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Clone, PartialEq)]
|
||||||
|
pub struct TableRow {
|
||||||
|
pub children: Vec<TableCell>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Clone, PartialEq)]
|
||||||
|
pub struct TableCell {
|
||||||
|
pub children: Paragraph,
|
||||||
|
pub width: Option<DefiniteLength>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Paragraph {
|
||||||
|
pub fn clear(&mut self) {
|
||||||
|
match self {
|
||||||
|
Self::Texts { children, .. } => children.clear(),
|
||||||
|
Self::Image { .. } => *self = Self::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_image(&self) -> bool {
|
||||||
|
matches!(self, Self::Image { .. })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_span(&mut self, span: Span) {
|
||||||
|
match self {
|
||||||
|
Self::Texts { span: s, .. } => *s = Some(span),
|
||||||
|
Self::Image { span: s, .. } => *s = Some(span),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push_str(&mut self, text: &str) {
|
||||||
|
if let Self::Texts { children, .. } = self {
|
||||||
|
children.push(TextNode {
|
||||||
|
text: text.to_string(),
|
||||||
|
marks: vec![(0..text.len(), InlineTextStyle::default())],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push(&mut self, text: TextNode) {
|
||||||
|
if let Self::Texts { children, .. } = self {
|
||||||
|
children.push(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_image(&mut self, image: ImageNode) {
|
||||||
|
*self = Self::Image { span: None, image };
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
match self {
|
||||||
|
Self::Texts { .. } => self.text_len() == 0,
|
||||||
|
Self::Image { .. } => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return length of children text.
|
||||||
|
pub fn text_len(&self) -> usize {
|
||||||
|
match self {
|
||||||
|
Self::Texts { children, .. } => {
|
||||||
|
let mut len = 0;
|
||||||
|
for text_node in children.iter() {
|
||||||
|
len = text_node.text.len().max(len);
|
||||||
|
}
|
||||||
|
len
|
||||||
|
}
|
||||||
|
Self::Image { .. } => 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(unused)]
|
||||||
|
#[derive(Debug, Clone, IntoElement, PartialEq)]
|
||||||
|
pub enum Node {
|
||||||
|
Root {
|
||||||
|
children: Vec<Node>,
|
||||||
|
},
|
||||||
|
Paragraph(Paragraph),
|
||||||
|
Heading {
|
||||||
|
level: u8,
|
||||||
|
children: Paragraph,
|
||||||
|
},
|
||||||
|
Blockquote(Paragraph),
|
||||||
|
List {
|
||||||
|
/// Only contains ListItem, others will be ignored
|
||||||
|
children: Vec<Node>,
|
||||||
|
ordered: bool,
|
||||||
|
},
|
||||||
|
ListItem {
|
||||||
|
children: Vec<Node>,
|
||||||
|
spread: bool,
|
||||||
|
/// Whether the list item is checked, if None, it's not a checkbox
|
||||||
|
checked: Option<bool>,
|
||||||
|
},
|
||||||
|
CodeBlock {
|
||||||
|
code: SharedString,
|
||||||
|
lang: Option<SharedString>,
|
||||||
|
},
|
||||||
|
Table(Table),
|
||||||
|
// <br>
|
||||||
|
Break,
|
||||||
|
Divider,
|
||||||
|
Ignore,
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Node {
|
||||||
|
fn is_ignore(&self) -> bool {
|
||||||
|
matches!(self, Self::Ignore)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_list_item(&self) -> bool {
|
||||||
|
matches!(self, Self::ListItem { .. })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Combine all children, omitting the empt parent nodes.
|
||||||
|
pub(super) fn compact(&self) -> Node {
|
||||||
|
match self {
|
||||||
|
Self::Root { children } => {
|
||||||
|
let children = children
|
||||||
|
.iter()
|
||||||
|
.map(|c| c.compact())
|
||||||
|
.filter(|c| !c.is_ignore())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if children.len() == 1 {
|
||||||
|
children.first().unwrap().compact()
|
||||||
|
} else {
|
||||||
|
self.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => self.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenderOnce for Paragraph {
|
||||||
|
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||||
|
match self {
|
||||||
|
Self::Texts { span, children } => {
|
||||||
|
let mut text = String::new();
|
||||||
|
let mut highlights: Vec<(Range<usize>, HighlightStyle)> = vec![];
|
||||||
|
let mut links: Vec<(Range<usize>, LinkMark)> = vec![];
|
||||||
|
let mut offset = 0;
|
||||||
|
|
||||||
|
for text_node in children.into_iter() {
|
||||||
|
let text_len = text_node.text.len();
|
||||||
|
text.push_str(&text_node.text);
|
||||||
|
|
||||||
|
let mut node_highlights = vec![];
|
||||||
|
for (range, style) in text_node.marks {
|
||||||
|
let inner_range = (offset + range.start)..(offset + range.end);
|
||||||
|
|
||||||
|
let mut highlight = HighlightStyle::default();
|
||||||
|
if style.bold {
|
||||||
|
highlight.font_weight = Some(FontWeight::BOLD);
|
||||||
|
}
|
||||||
|
if style.italic {
|
||||||
|
highlight.font_style = Some(FontStyle::Italic);
|
||||||
|
}
|
||||||
|
if style.strikethrough {
|
||||||
|
highlight.strikethrough = Some(gpui::StrikethroughStyle {
|
||||||
|
thickness: gpui::px(1.),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if style.code {
|
||||||
|
highlight.background_color = Some(cx.theme().accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(link_mark) = style.link {
|
||||||
|
highlight.color = Some(cx.theme().link);
|
||||||
|
highlight.underline = Some(gpui::UnderlineStyle {
|
||||||
|
thickness: gpui::px(1.),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
links.push((inner_range.clone(), link_mark));
|
||||||
|
}
|
||||||
|
|
||||||
|
node_highlights.push((inner_range, highlight));
|
||||||
|
}
|
||||||
|
|
||||||
|
highlights = gpui::combine_highlights(highlights, node_highlights).collect();
|
||||||
|
|
||||||
|
offset += text_len;
|
||||||
|
}
|
||||||
|
|
||||||
|
let text_style = window.text_style();
|
||||||
|
let element_id: ElementId = span.unwrap_or_default().into();
|
||||||
|
let styled_text = StyledText::new(text).with_highlights(&text_style, highlights);
|
||||||
|
let link_ranges = links
|
||||||
|
.iter()
|
||||||
|
.map(|(range, _)| range.clone())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
InteractiveText::new(element_id, styled_text)
|
||||||
|
.on_click(link_ranges, {
|
||||||
|
let links = links.clone();
|
||||||
|
move |ix, _, cx| {
|
||||||
|
if let Some((_, link)) = &links.get(ix) {
|
||||||
|
cx.open_url(&link.url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
Self::Image { image, .. } => img(image.url)
|
||||||
|
.object_fit(ObjectFit::Contain)
|
||||||
|
.max_w(relative(1.))
|
||||||
|
.when_some(image.width, |this, width| this.w(width))
|
||||||
|
.into_any_element(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct ListState {
|
||||||
|
todo: bool,
|
||||||
|
ordered: bool,
|
||||||
|
depth: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Node {
|
||||||
|
fn render_list_item(
|
||||||
|
item: Node,
|
||||||
|
ix: usize,
|
||||||
|
state: ListState,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut App,
|
||||||
|
) -> impl IntoElement {
|
||||||
|
match item {
|
||||||
|
Node::ListItem {
|
||||||
|
children,
|
||||||
|
spread,
|
||||||
|
checked,
|
||||||
|
} => v_flex()
|
||||||
|
.when(spread, |this| this.child(div()))
|
||||||
|
.children({
|
||||||
|
let mut items = Vec::with_capacity(children.len());
|
||||||
|
for child in children.into_iter() {
|
||||||
|
match &child {
|
||||||
|
Node::Paragraph(_) => {
|
||||||
|
items.push(
|
||||||
|
h_flex()
|
||||||
|
.items_center()
|
||||||
|
.when(!state.todo && checked.is_none(), |this| {
|
||||||
|
this.child(list_item_prefix(
|
||||||
|
ix,
|
||||||
|
state.ordered,
|
||||||
|
state.depth,
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.when_some(checked, |this, checked| {
|
||||||
|
this.child(
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.mr_1p5()
|
||||||
|
.size(rems(0.875))
|
||||||
|
.items_center()
|
||||||
|
.justify_center()
|
||||||
|
.rounded(cx.theme().radius.half())
|
||||||
|
.bg(cx.theme().primary)
|
||||||
|
.text_color(cx.theme().primary_foreground)
|
||||||
|
.when(checked, |this| {
|
||||||
|
this.child(
|
||||||
|
Icon::new(IconName::Check)
|
||||||
|
.size_2()
|
||||||
|
.text_xs(),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.child(child.render_node(
|
||||||
|
Some(ListState {
|
||||||
|
depth: state.depth + 1,
|
||||||
|
ordered: state.ordered,
|
||||||
|
todo: checked.is_some(),
|
||||||
|
}),
|
||||||
|
window,
|
||||||
|
cx,
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Node::List { .. } => {
|
||||||
|
items.push(div().ml(rems(1.)).child(child.render_node(
|
||||||
|
Some(ListState {
|
||||||
|
depth: state.depth + 1,
|
||||||
|
ordered: state.ordered,
|
||||||
|
todo: checked.is_some(),
|
||||||
|
}),
|
||||||
|
window,
|
||||||
|
cx,
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items
|
||||||
|
})
|
||||||
|
.into_any_element(),
|
||||||
|
_ => div().into_any_element(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_table(item: &Node, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||||
|
const DEFAULT_LENGTH: usize = 5;
|
||||||
|
const MAX_LENGTH: usize = 150;
|
||||||
|
let col_lens = match item {
|
||||||
|
Node::Table(table) => {
|
||||||
|
let mut col_lens = vec![];
|
||||||
|
for row in table.children.iter() {
|
||||||
|
for (ix, cell) in row.children.iter().enumerate() {
|
||||||
|
if col_lens.len() <= ix {
|
||||||
|
col_lens.push(DEFAULT_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
let len = cell.children.text_len();
|
||||||
|
if len > col_lens[ix] {
|
||||||
|
col_lens[ix] = len;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
col_lens
|
||||||
|
}
|
||||||
|
_ => vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
match item {
|
||||||
|
Node::Table(table) => div()
|
||||||
|
.id("table")
|
||||||
|
.mb(rems(1.))
|
||||||
|
.w_full()
|
||||||
|
.border_1()
|
||||||
|
.border_color(cx.theme().border)
|
||||||
|
.rounded(cx.theme().radius)
|
||||||
|
.children({
|
||||||
|
let mut rows = Vec::with_capacity(table.children.len());
|
||||||
|
for (row_ix, row) in table.children.iter().enumerate() {
|
||||||
|
rows.push(
|
||||||
|
div()
|
||||||
|
.id("row")
|
||||||
|
.w_full()
|
||||||
|
.when(row_ix < table.children.len() - 1, |this| this.border_b_1())
|
||||||
|
.border_color(cx.theme().border)
|
||||||
|
.flex()
|
||||||
|
.flex_row()
|
||||||
|
.children({
|
||||||
|
let mut cells = Vec::with_capacity(row.children.len());
|
||||||
|
for (ix, cell) in row.children.iter().enumerate() {
|
||||||
|
let len = col_lens
|
||||||
|
.get(ix)
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(MAX_LENGTH)
|
||||||
|
.min(MAX_LENGTH);
|
||||||
|
|
||||||
|
cells.push(
|
||||||
|
div()
|
||||||
|
.id("cell")
|
||||||
|
.w(Length::Definite(relative(len as f32)))
|
||||||
|
.px_2()
|
||||||
|
.py_1()
|
||||||
|
.truncate()
|
||||||
|
.child(cell.children.clone()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
cells
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
rows
|
||||||
|
})
|
||||||
|
.into_any_element(),
|
||||||
|
_ => div().into_any_element(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_node(
|
||||||
|
self,
|
||||||
|
list_state: Option<ListState>,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut App,
|
||||||
|
) -> impl IntoElement {
|
||||||
|
let in_list = list_state.is_some();
|
||||||
|
let mb = if in_list { rems(0.0) } else { rems(1.) };
|
||||||
|
|
||||||
|
match self {
|
||||||
|
Node::Root { children } => div().children(children).into_any_element(),
|
||||||
|
Node::Paragraph(paragraph) => div().mb(mb).child(paragraph).into_any_element(),
|
||||||
|
Node::Heading { level, children } => {
|
||||||
|
let (text_size, font_weight) = match level {
|
||||||
|
1 => (rems(2.), FontWeight::BOLD),
|
||||||
|
2 => (rems(1.5), FontWeight::SEMIBOLD),
|
||||||
|
3 => (rems(1.25), FontWeight::SEMIBOLD),
|
||||||
|
4 => (rems(1.125), FontWeight::SEMIBOLD),
|
||||||
|
5 => (rems(1.), FontWeight::SEMIBOLD),
|
||||||
|
6 => (rems(1.), FontWeight::MEDIUM),
|
||||||
|
_ => (rems(1.), FontWeight::NORMAL),
|
||||||
|
};
|
||||||
|
|
||||||
|
h_flex()
|
||||||
|
.mb(rems(0.5))
|
||||||
|
.whitespace_normal()
|
||||||
|
.text_size(text_size)
|
||||||
|
.font_weight(font_weight)
|
||||||
|
.child(children)
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
Node::Blockquote(children) => div()
|
||||||
|
.w_full()
|
||||||
|
.mb(mb)
|
||||||
|
.text_color(cx.theme().muted_foreground)
|
||||||
|
.border_l_3()
|
||||||
|
.border_color(cx.theme().secondary_active)
|
||||||
|
.px_4()
|
||||||
|
.py_1()
|
||||||
|
.child(children)
|
||||||
|
.into_any_element(),
|
||||||
|
Node::List { children, ordered } => v_flex()
|
||||||
|
.mb(mb)
|
||||||
|
.children({
|
||||||
|
let mut items = Vec::with_capacity(children.len());
|
||||||
|
let list_state = list_state.unwrap_or_default();
|
||||||
|
let mut ix = 0;
|
||||||
|
for item in children.into_iter() {
|
||||||
|
let is_item = item.is_list_item();
|
||||||
|
|
||||||
|
items.push(Self::render_list_item(
|
||||||
|
item,
|
||||||
|
ix,
|
||||||
|
ListState {
|
||||||
|
ordered,
|
||||||
|
todo: list_state.todo,
|
||||||
|
depth: list_state.depth,
|
||||||
|
},
|
||||||
|
window,
|
||||||
|
cx,
|
||||||
|
));
|
||||||
|
|
||||||
|
if is_item {
|
||||||
|
ix += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items
|
||||||
|
})
|
||||||
|
.into_any_element(),
|
||||||
|
Node::CodeBlock { code, .. } => div()
|
||||||
|
.mb(mb)
|
||||||
|
.rounded(cx.theme().radius)
|
||||||
|
.bg(cx.theme().secondary)
|
||||||
|
.p_3()
|
||||||
|
.text_size(rems(0.875))
|
||||||
|
.relative()
|
||||||
|
.child(code)
|
||||||
|
.into_any_element(),
|
||||||
|
Node::Table { .. } => Self::render_table(&self, window, cx).into_any_element(),
|
||||||
|
Node::Divider => div()
|
||||||
|
.bg(cx.theme().border)
|
||||||
|
.h(px(2.))
|
||||||
|
.mb(mb)
|
||||||
|
.into_any_element(),
|
||||||
|
Node::Break => div().into_any_element(),
|
||||||
|
Node::Ignore => div().into_any_element(),
|
||||||
|
_ => {
|
||||||
|
if cfg!(debug_assertions) {
|
||||||
|
eprintln!("Unknown implementation: {:?}", self);
|
||||||
|
}
|
||||||
|
|
||||||
|
div().into_any_element()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ref:
|
||||||
|
/// https://ui.shadcn.com/docs/components/typography
|
||||||
|
impl RenderOnce for Node {
|
||||||
|
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||||
|
self.render_node(None, window, cx)
|
||||||
|
}
|
||||||
|
}
|
||||||
726
crates/ui/src/text/html.rs
Normal file
726
crates/ui/src/text/html.rs
Normal file
|
|
@ -0,0 +1,726 @@
|
||||||
|
extern crate markup5ever_rcdom as rcdom;
|
||||||
|
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::ops::Range;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use gpui::{
|
||||||
|
div, px, relative, Context, DefiniteLength, IntoElement, ParentElement as _, Render,
|
||||||
|
SharedString,
|
||||||
|
};
|
||||||
|
use html5ever::tendril::TendrilSink;
|
||||||
|
use html5ever::{local_name, parse_document, LocalName, ParseOpts};
|
||||||
|
use markup5ever_rcdom::{Node, NodeData, RcDom};
|
||||||
|
|
||||||
|
use super::element::{
|
||||||
|
self, ImageNode, InlineTextStyle, LinkMark, Paragraph, Table, TableRow, TextNode,
|
||||||
|
};
|
||||||
|
|
||||||
|
const BLOCK_ELEMENTS: [&str; 33] = [
|
||||||
|
"html",
|
||||||
|
"body",
|
||||||
|
"head",
|
||||||
|
"address",
|
||||||
|
"article",
|
||||||
|
"aside",
|
||||||
|
"blockquote",
|
||||||
|
"details",
|
||||||
|
"summary",
|
||||||
|
"dialog",
|
||||||
|
"div",
|
||||||
|
"dl",
|
||||||
|
"fieldset",
|
||||||
|
"figcaption",
|
||||||
|
"figure",
|
||||||
|
"footer",
|
||||||
|
"form",
|
||||||
|
"h1",
|
||||||
|
"h2",
|
||||||
|
"h3",
|
||||||
|
"h4",
|
||||||
|
"h5",
|
||||||
|
"h6",
|
||||||
|
"header",
|
||||||
|
"hr",
|
||||||
|
"main",
|
||||||
|
"nav",
|
||||||
|
"ol",
|
||||||
|
"p",
|
||||||
|
"pre",
|
||||||
|
"section",
|
||||||
|
"table",
|
||||||
|
"ul",
|
||||||
|
];
|
||||||
|
|
||||||
|
pub(super) fn parse_html(source: &str) -> Result<element::Node, std::io::Error> {
|
||||||
|
let opts = ParseOpts {
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let bytes = minify_html::minify(source.as_bytes(), &minify_html::Cfg::default());
|
||||||
|
let mut cursor = std::io::Cursor::new(bytes);
|
||||||
|
// Ref
|
||||||
|
// https://github.com/servo/html5ever/blob/main/rcdom/examples/print-rcdom.rs
|
||||||
|
let dom = parse_document(RcDom::default(), opts)
|
||||||
|
.from_utf8()
|
||||||
|
.read_from(&mut cursor)?;
|
||||||
|
|
||||||
|
let mut paragraph = Paragraph::default();
|
||||||
|
// NOTE: The outer paragraph is not used.
|
||||||
|
let node: element::Node = parse_node(&dom.document, &mut paragraph);
|
||||||
|
let node = node.compact();
|
||||||
|
|
||||||
|
Ok(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct HtmlView {
|
||||||
|
text: SharedString,
|
||||||
|
parsed: bool,
|
||||||
|
node: Option<element::Node>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HtmlView {
|
||||||
|
pub fn new(raw: impl Into<SharedString>) -> Self {
|
||||||
|
Self {
|
||||||
|
text: raw.into(),
|
||||||
|
parsed: false,
|
||||||
|
node: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_text(&mut self, raw: impl Into<SharedString>, cx: &mut Context<Self>) {
|
||||||
|
self.text = raw.into();
|
||||||
|
self.parsed = false;
|
||||||
|
self.node = None;
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_if_needed(&mut self) {
|
||||||
|
if !self.parsed {
|
||||||
|
self.node = parse_html(&self.text).ok();
|
||||||
|
self.parsed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for HtmlView {
|
||||||
|
fn render(&mut self, _: &mut gpui::Window, _: &mut Context<'_, Self>) -> impl IntoElement {
|
||||||
|
self.parse_if_needed();
|
||||||
|
|
||||||
|
if let Some(node) = &self.node {
|
||||||
|
div().child(node.clone())
|
||||||
|
} else {
|
||||||
|
div()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn attr_value(attrs: &RefCell<Vec<html5ever::Attribute>>, name: LocalName) -> Option<String> {
|
||||||
|
attrs.borrow().iter().find_map(|attr| {
|
||||||
|
if attr.name.local == name {
|
||||||
|
Some(attr.value.to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get style properties to HashMap
|
||||||
|
/// TODO: Use cssparser to parse style attribute.
|
||||||
|
fn style_attrs(attrs: &RefCell<Vec<html5ever::Attribute>>) -> HashMap<String, String> {
|
||||||
|
let mut styles = HashMap::new();
|
||||||
|
let Some(css_text) = attr_value(attrs, local_name!("style")) else {
|
||||||
|
return styles;
|
||||||
|
};
|
||||||
|
|
||||||
|
for decl in css_text.split(';') {
|
||||||
|
for rule in decl.split(':') {
|
||||||
|
let mut parts = rule.splitn(2, ':');
|
||||||
|
if let (Some(key), Some(value)) = (parts.next(), parts.next()) {
|
||||||
|
styles.insert(
|
||||||
|
key.trim().to_lowercase().to_string(),
|
||||||
|
value.trim().to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
styles
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse length value from style attribute.
|
||||||
|
///
|
||||||
|
/// When is percentage, it will be converted to relative length.
|
||||||
|
/// Else, it will be converted to pixels.
|
||||||
|
fn value_to_length(value: &str) -> Option<DefiniteLength> {
|
||||||
|
if value.ends_with("px") {
|
||||||
|
value
|
||||||
|
.trim_end_matches("px")
|
||||||
|
.parse()
|
||||||
|
.ok()
|
||||||
|
.map(|v| px(v).into())
|
||||||
|
} else if value.ends_with("%") {
|
||||||
|
value
|
||||||
|
.trim_end_matches("%")
|
||||||
|
.parse::<f32>()
|
||||||
|
.ok()
|
||||||
|
.map(|v| relative(v / 100.))
|
||||||
|
} else {
|
||||||
|
value
|
||||||
|
.trim_end_matches("px")
|
||||||
|
.parse()
|
||||||
|
.ok()
|
||||||
|
.map(|v| px(v).into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get width, height from attributes or parse them from style attribute.
|
||||||
|
fn attr_width_height(
|
||||||
|
attrs: &RefCell<Vec<html5ever::Attribute>>,
|
||||||
|
) -> (Option<DefiniteLength>, Option<DefiniteLength>) {
|
||||||
|
let mut width = None;
|
||||||
|
let mut height = None;
|
||||||
|
|
||||||
|
if let Some(value) = attr_value(attrs, local_name!("width")) {
|
||||||
|
width = value_to_length(&value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(value) = attr_value(attrs, local_name!("height")) {
|
||||||
|
height = value_to_length(&value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if width.is_none() || height.is_none() {
|
||||||
|
let styles = style_attrs(attrs);
|
||||||
|
if width.is_none() {
|
||||||
|
width = styles.get("width").and_then(|v| value_to_length(&v));
|
||||||
|
}
|
||||||
|
if height.is_none() {
|
||||||
|
height = styles.get("height").and_then(|v| value_to_length(&v));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(width, height)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_table_row(table: &mut Table, node: &Rc<Node>) {
|
||||||
|
let mut row = TableRow::default();
|
||||||
|
let mut count = 0;
|
||||||
|
for child in node.children.borrow().iter() {
|
||||||
|
match child.data {
|
||||||
|
NodeData::Element {
|
||||||
|
ref name,
|
||||||
|
ref attrs,
|
||||||
|
..
|
||||||
|
} if name.local == local_name!("td") || name.local == local_name!("th") => {
|
||||||
|
if child.children.borrow().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
count += 1;
|
||||||
|
parse_table_cell(&mut row, child, attrs);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if count > 0 {
|
||||||
|
table.children.push(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_table_cell(
|
||||||
|
row: &mut element::TableRow,
|
||||||
|
node: &Rc<Node>,
|
||||||
|
attrs: &RefCell<Vec<html5ever::Attribute>>,
|
||||||
|
) {
|
||||||
|
let mut paragraph = Paragraph::default();
|
||||||
|
for child in node.children.borrow().iter() {
|
||||||
|
parse_paragraph(&mut paragraph, child);
|
||||||
|
}
|
||||||
|
let width = attr_width_height(attrs).0;
|
||||||
|
let table_cell = element::TableCell {
|
||||||
|
children: paragraph,
|
||||||
|
width,
|
||||||
|
};
|
||||||
|
row.children.push(table_cell);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trim text but leave at least one space.
|
||||||
|
///
|
||||||
|
/// - Before: " \r\n Hello world \t "
|
||||||
|
/// - After: " Hello world "
|
||||||
|
fn trim_text(text: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(text.len());
|
||||||
|
|
||||||
|
for (i, c) in text.chars().enumerate() {
|
||||||
|
if c.is_whitespace() {
|
||||||
|
if i > 0 && out.ends_with(' ') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_paragraph(
|
||||||
|
paragraph: &mut Paragraph,
|
||||||
|
node: &Rc<Node>,
|
||||||
|
) -> (String, Vec<(Range<usize>, InlineTextStyle)>) {
|
||||||
|
let mut text = String::new();
|
||||||
|
let mut marks = vec![];
|
||||||
|
|
||||||
|
/// Append new_text and new_marks to text and marks.
|
||||||
|
fn merge_child_text(
|
||||||
|
text: &mut String,
|
||||||
|
marks: &mut Vec<(Range<usize>, InlineTextStyle)>,
|
||||||
|
new_text: &str,
|
||||||
|
new_marks: &[(Range<usize>, InlineTextStyle)],
|
||||||
|
) {
|
||||||
|
let offset = text.len();
|
||||||
|
text.push_str(new_text);
|
||||||
|
for (range, style) in new_marks {
|
||||||
|
marks.push((range.start + offset..new_text.len() + offset, style.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match &node.data {
|
||||||
|
NodeData::Text { ref contents } => {
|
||||||
|
let part = trim_text(&contents.borrow());
|
||||||
|
text.push_str(&part);
|
||||||
|
paragraph.push_str(&text);
|
||||||
|
}
|
||||||
|
NodeData::Element { name, attrs, .. } => match name.local {
|
||||||
|
local_name!("em") | local_name!("i") => {
|
||||||
|
let mut child_paragraph = Paragraph::default();
|
||||||
|
for child in node.children.borrow().iter() {
|
||||||
|
let (child_text, child_marks) = parse_paragraph(&mut child_paragraph, &child);
|
||||||
|
merge_child_text(&mut text, &mut marks, &child_text, &child_marks);
|
||||||
|
}
|
||||||
|
marks.push((
|
||||||
|
0..text.len(),
|
||||||
|
InlineTextStyle {
|
||||||
|
italic: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
));
|
||||||
|
paragraph.push(element::TextNode {
|
||||||
|
text: text.clone(),
|
||||||
|
marks: marks.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
local_name!("strong") | local_name!("b") => {
|
||||||
|
let mut child_paragraph = Paragraph::default();
|
||||||
|
for child in node.children.borrow().iter() {
|
||||||
|
let (child_text, child_marks) = parse_paragraph(&mut child_paragraph, &child);
|
||||||
|
merge_child_text(&mut text, &mut marks, &child_text, &child_marks);
|
||||||
|
}
|
||||||
|
|
||||||
|
marks.push((
|
||||||
|
0..text.len(),
|
||||||
|
InlineTextStyle {
|
||||||
|
bold: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
));
|
||||||
|
paragraph.push(TextNode {
|
||||||
|
text: text.clone(),
|
||||||
|
marks: marks.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
local_name!("del") | local_name!("s") => {
|
||||||
|
let mut child_paragraph = Paragraph::default();
|
||||||
|
for child in node.children.borrow().iter() {
|
||||||
|
let (child_text, child_marks) = parse_paragraph(&mut child_paragraph, &child);
|
||||||
|
merge_child_text(&mut text, &mut marks, &child_text, &child_marks);
|
||||||
|
}
|
||||||
|
marks.push((
|
||||||
|
0..text.len(),
|
||||||
|
InlineTextStyle {
|
||||||
|
strikethrough: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
));
|
||||||
|
paragraph.push(TextNode {
|
||||||
|
text: text.clone(),
|
||||||
|
marks: marks.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
local_name!("code") => {
|
||||||
|
let mut child_paragraph = Paragraph::default();
|
||||||
|
for child in node.children.borrow().iter() {
|
||||||
|
let (child_text, child_marks) = parse_paragraph(&mut child_paragraph, &child);
|
||||||
|
merge_child_text(&mut text, &mut marks, &child_text, &child_marks);
|
||||||
|
}
|
||||||
|
marks.push((
|
||||||
|
0..text.len(),
|
||||||
|
InlineTextStyle {
|
||||||
|
code: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
));
|
||||||
|
paragraph.push(TextNode {
|
||||||
|
text: text.clone(),
|
||||||
|
marks: marks.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
local_name!("a") => {
|
||||||
|
let mut child_paragraph = Paragraph::default();
|
||||||
|
for child in node.children.borrow().iter() {
|
||||||
|
let (child_text, child_marks) = parse_paragraph(&mut child_paragraph, &child);
|
||||||
|
merge_child_text(&mut text, &mut marks, &child_text, &child_marks);
|
||||||
|
}
|
||||||
|
|
||||||
|
marks.push((
|
||||||
|
0..text.len(),
|
||||||
|
InlineTextStyle {
|
||||||
|
link: Some(LinkMark {
|
||||||
|
url: attr_value(&attrs, local_name!("href")).unwrap().into(),
|
||||||
|
title: attr_value(&attrs, local_name!("title")).map(Into::into),
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
));
|
||||||
|
paragraph.push(TextNode {
|
||||||
|
text: text.clone(),
|
||||||
|
marks: marks.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
local_name!("img") => {
|
||||||
|
let Some(src) = attr_value(attrs, local_name!("src")) else {
|
||||||
|
if cfg!(debug_assertions) {
|
||||||
|
eprintln!("[html] Image node missing src attribute");
|
||||||
|
}
|
||||||
|
return (text, marks);
|
||||||
|
};
|
||||||
|
|
||||||
|
let alt = attr_value(attrs, local_name!("alt"));
|
||||||
|
let title = attr_value(attrs, local_name!("title"));
|
||||||
|
let (width, height) = attr_width_height(attrs);
|
||||||
|
|
||||||
|
paragraph.set_image(ImageNode {
|
||||||
|
url: src.into(),
|
||||||
|
alt: alt.map(Into::into),
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
title: title.map(Into::into),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// All unknown tags to as text
|
||||||
|
let mut child_paragraph = Paragraph::default();
|
||||||
|
for child in node.children.borrow().iter() {
|
||||||
|
let (child_text, child_marks) = parse_paragraph(&mut child_paragraph, &child);
|
||||||
|
merge_child_text(&mut text, &mut marks, &child_text, &child_marks);
|
||||||
|
}
|
||||||
|
paragraph.push(element::TextNode {
|
||||||
|
text: text.clone(),
|
||||||
|
marks: marks.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_ => {
|
||||||
|
let mut child_paragraph = Paragraph::default();
|
||||||
|
for child in node.children.borrow().iter() {
|
||||||
|
let (child_text, child_marks) = parse_paragraph(&mut child_paragraph, &child);
|
||||||
|
merge_child_text(&mut text, &mut marks, &child_text, &child_marks);
|
||||||
|
}
|
||||||
|
paragraph.push(element::TextNode {
|
||||||
|
text: text.clone(),
|
||||||
|
marks: marks.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(text, marks)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_node(node: &Rc<Node>, paragraph: &mut Paragraph) -> element::Node {
|
||||||
|
match node.data {
|
||||||
|
NodeData::Text { ref contents } => {
|
||||||
|
let text = contents.borrow().trim_start().to_string();
|
||||||
|
if text.len() > 0 {
|
||||||
|
paragraph.push_str(&text);
|
||||||
|
}
|
||||||
|
|
||||||
|
element::Node::Ignore
|
||||||
|
}
|
||||||
|
NodeData::Element {
|
||||||
|
ref name,
|
||||||
|
ref attrs,
|
||||||
|
..
|
||||||
|
} => match name.local {
|
||||||
|
local_name!("br") => element::Node::Break,
|
||||||
|
local_name!("h1")
|
||||||
|
| local_name!("h2")
|
||||||
|
| local_name!("h3")
|
||||||
|
| local_name!("h4")
|
||||||
|
| local_name!("h5")
|
||||||
|
| local_name!("h6") => {
|
||||||
|
let mut children = vec![];
|
||||||
|
if !paragraph.is_empty() {
|
||||||
|
children.push(element::Node::Paragraph(paragraph.clone()));
|
||||||
|
paragraph.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
let level = name
|
||||||
|
.local
|
||||||
|
.chars()
|
||||||
|
.last()
|
||||||
|
.unwrap_or('6')
|
||||||
|
.to_digit(10)
|
||||||
|
.unwrap_or(6) as u8;
|
||||||
|
|
||||||
|
let mut paragraph = Paragraph::default();
|
||||||
|
for child in node.children.borrow().iter() {
|
||||||
|
parse_paragraph(&mut paragraph, child);
|
||||||
|
}
|
||||||
|
|
||||||
|
let heading = element::Node::Heading {
|
||||||
|
level,
|
||||||
|
children: paragraph,
|
||||||
|
};
|
||||||
|
if children.len() > 0 {
|
||||||
|
children.push(heading);
|
||||||
|
|
||||||
|
element::Node::Root { children }
|
||||||
|
} else {
|
||||||
|
heading
|
||||||
|
}
|
||||||
|
}
|
||||||
|
local_name!("img") => {
|
||||||
|
let mut children = vec![];
|
||||||
|
if !paragraph.is_empty() {
|
||||||
|
children.push(element::Node::Paragraph(paragraph.clone()));
|
||||||
|
paragraph.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(src) = attr_value(attrs, local_name!("src")) else {
|
||||||
|
if cfg!(debug_assertions) {
|
||||||
|
eprintln!("[html] Image node missing src attribute");
|
||||||
|
}
|
||||||
|
return element::Node::Ignore;
|
||||||
|
};
|
||||||
|
|
||||||
|
let alt = attr_value(&attrs, local_name!("alt"));
|
||||||
|
let title = attr_value(&attrs, local_name!("title"));
|
||||||
|
let (width, height) = attr_width_height(&attrs);
|
||||||
|
|
||||||
|
let image = Paragraph::Image {
|
||||||
|
span: None,
|
||||||
|
image: ImageNode {
|
||||||
|
url: src.into(),
|
||||||
|
title: title.map(Into::into),
|
||||||
|
alt: alt.map(Into::into),
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if children.len() > 0 {
|
||||||
|
children.push(element::Node::Paragraph(image));
|
||||||
|
element::Node::Root { children }
|
||||||
|
} else {
|
||||||
|
element::Node::Paragraph(image)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
local_name!("ul") | local_name!("ol") => {
|
||||||
|
let mut children = vec![];
|
||||||
|
if !paragraph.is_empty() {
|
||||||
|
children.push(element::Node::Paragraph(paragraph.clone()));
|
||||||
|
paragraph.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
let ordered = name.local == local_name!("ol");
|
||||||
|
|
||||||
|
let mut list_children = vec![];
|
||||||
|
for child in node.children.borrow().iter() {
|
||||||
|
let mut child_paragraph = Paragraph::default();
|
||||||
|
list_children.push(parse_node(child, &mut child_paragraph));
|
||||||
|
}
|
||||||
|
|
||||||
|
let list = element::Node::List {
|
||||||
|
children: list_children,
|
||||||
|
ordered,
|
||||||
|
};
|
||||||
|
if children.len() > 0 {
|
||||||
|
children.push(list);
|
||||||
|
element::Node::Root { children }
|
||||||
|
} else {
|
||||||
|
list
|
||||||
|
}
|
||||||
|
}
|
||||||
|
local_name!("li") => {
|
||||||
|
let mut children = vec![];
|
||||||
|
for child in node.children.borrow().iter() {
|
||||||
|
let mut child_paragraph = Paragraph::default();
|
||||||
|
children.push(parse_node(child, &mut child_paragraph));
|
||||||
|
if child_paragraph.text_len() > 0 {
|
||||||
|
children.push(element::Node::Paragraph(child_paragraph.clone()));
|
||||||
|
child_paragraph.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !paragraph.is_empty() {
|
||||||
|
children.push(element::Node::Paragraph(paragraph.clone()));
|
||||||
|
paragraph.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
element::Node::ListItem {
|
||||||
|
children,
|
||||||
|
spread: false,
|
||||||
|
checked: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
local_name!("table") => {
|
||||||
|
let mut children = vec![];
|
||||||
|
if !paragraph.is_empty() {
|
||||||
|
children.push(element::Node::Paragraph(paragraph.clone()));
|
||||||
|
paragraph.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut table = Table::default();
|
||||||
|
for child in node.children.borrow().iter() {
|
||||||
|
match child.data {
|
||||||
|
NodeData::Element { ref name, .. }
|
||||||
|
if name.local == local_name!("tbody")
|
||||||
|
|| name.local == local_name!("thead") =>
|
||||||
|
{
|
||||||
|
for sub_child in child.children.borrow().iter() {
|
||||||
|
parse_table_row(&mut table, &sub_child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
parse_table_row(&mut table, &child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let table = element::Node::Table(table);
|
||||||
|
if children.len() > 0 {
|
||||||
|
children.push(table);
|
||||||
|
element::Node::Root { children }
|
||||||
|
} else {
|
||||||
|
table
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
if BLOCK_ELEMENTS.contains(&name.local.trim()) {
|
||||||
|
let mut children: Vec<element::Node> = vec![];
|
||||||
|
|
||||||
|
// Case:
|
||||||
|
//
|
||||||
|
// Hello <p>Inner text of block element</p> World
|
||||||
|
|
||||||
|
// Insert before text as a node -- The "Hello"
|
||||||
|
if !paragraph.is_empty() {
|
||||||
|
children.push(element::Node::Paragraph(paragraph.clone()));
|
||||||
|
paragraph.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inner of the block element -- The "Inner text of block element"
|
||||||
|
for child in node.children.borrow().iter() {
|
||||||
|
children.push(parse_node(child, paragraph));
|
||||||
|
}
|
||||||
|
|
||||||
|
// if !paragraph.is_empty() {
|
||||||
|
// children.push(element::Node::Paragraph(paragraph.clone()));
|
||||||
|
// paragraph.clear();
|
||||||
|
// }
|
||||||
|
|
||||||
|
if children.is_empty() {
|
||||||
|
element::Node::Ignore
|
||||||
|
} else {
|
||||||
|
element::Node::Root { children }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Others to as Inline
|
||||||
|
parse_paragraph(paragraph, node);
|
||||||
|
|
||||||
|
if paragraph.is_image() {
|
||||||
|
let image = paragraph.clone();
|
||||||
|
paragraph.clear();
|
||||||
|
element::Node::Paragraph(image)
|
||||||
|
} else {
|
||||||
|
element::Node::Ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
NodeData::Document => {
|
||||||
|
let mut children = vec![];
|
||||||
|
for child in node.children.borrow().iter() {
|
||||||
|
children.push(parse_node(child, paragraph));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !paragraph.is_empty() {
|
||||||
|
children.push(element::Node::Paragraph(paragraph.clone()));
|
||||||
|
paragraph.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
element::Node::Root { children }
|
||||||
|
}
|
||||||
|
NodeData::Doctype { .. } => element::Node::Ignore,
|
||||||
|
NodeData::Comment { .. } => element::Node::Ignore,
|
||||||
|
NodeData::ProcessingInstruction { .. } => element::Node::Ignore,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use gpui::{px, relative};
|
||||||
|
|
||||||
|
use crate::text::element::{Node, Paragraph};
|
||||||
|
|
||||||
|
use super::trim_text;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_trim_text() {
|
||||||
|
assert_eq!(trim_text(" \n\tHello world \t\r "), " Hello world ",);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_value_to_length() {
|
||||||
|
assert_eq!(super::value_to_length("100px"), Some(px(100.).into()));
|
||||||
|
assert_eq!(super::value_to_length("100%"), Some(relative(1.)));
|
||||||
|
assert_eq!(super::value_to_length("56%"), Some(relative(0.56)));
|
||||||
|
assert_eq!(super::value_to_length("240"), Some(px(240.).into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_image() {
|
||||||
|
let html = r#"<img src="https://example.com/image.png" alt="Example" width="100" height="200" title="Example Image" />"#;
|
||||||
|
let node = super::parse_html(html).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
node,
|
||||||
|
Node::Paragraph(Paragraph::Image {
|
||||||
|
span: None,
|
||||||
|
image: super::ImageNode {
|
||||||
|
url: "https://example.com/image.png".to_string().into(),
|
||||||
|
alt: Some("Example".to_string().into()),
|
||||||
|
width: Some(px(100.).into()),
|
||||||
|
height: Some(px(200.).into()),
|
||||||
|
title: Some("Example Image".to_string().into())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
let html = r#"<img src="https://example.com/image.png" alt="Example" style="width: 80%" title="Example Image" />"#;
|
||||||
|
let node = super::parse_html(html).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
node,
|
||||||
|
Node::Paragraph(Paragraph::Image {
|
||||||
|
span: None,
|
||||||
|
image: super::ImageNode {
|
||||||
|
url: "https://example.com/image.png".to_string().into(),
|
||||||
|
alt: Some("Example".to_string().into()),
|
||||||
|
width: Some(relative(0.8)),
|
||||||
|
height: None,
|
||||||
|
title: Some("Example Image".to_string().into())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
395
crates/ui/src/text/markdown.rs
Normal file
395
crates/ui/src/text/markdown.rs
Normal file
|
|
@ -0,0 +1,395 @@
|
||||||
|
use gpui::{
|
||||||
|
div, prelude::FluentBuilder as _, Context, IntoElement, ParentElement, Render, SharedString,
|
||||||
|
Styled, Window,
|
||||||
|
};
|
||||||
|
use markdown::{
|
||||||
|
mdast::{self, Node},
|
||||||
|
ParseOptions,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::v_flex;
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
element::{self, ImageNode, InlineTextStyle, LinkMark, Paragraph, Span, Table, TableRow},
|
||||||
|
html::parse_html,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Markdown GFM renderer
|
||||||
|
///
|
||||||
|
/// 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.
|
||||||
|
pub(super) struct MarkdownView {
|
||||||
|
text: SharedString,
|
||||||
|
parsed: bool,
|
||||||
|
root: Option<Result<element::Node, markdown::message::Message>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MarkdownView {
|
||||||
|
pub(super) fn new(raw: impl Into<SharedString>) -> Self {
|
||||||
|
Self {
|
||||||
|
text: raw.into(),
|
||||||
|
parsed: false,
|
||||||
|
root: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the source of the markdown view.
|
||||||
|
pub(crate) fn set_text(&mut self, raw: impl Into<SharedString>, cx: &mut Context<Self>) {
|
||||||
|
self.text = raw.into();
|
||||||
|
self.parsed = false;
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_if_needed(&mut self) {
|
||||||
|
if self.parsed {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.root = Some(markdown::to_mdast(&self.text, &ParseOptions::gfm()).map(|n| n.into()));
|
||||||
|
self.parsed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for MarkdownView {
|
||||||
|
fn render(&mut self, _: &mut Window, _: &mut gpui::Context<'_, Self>) -> impl IntoElement {
|
||||||
|
self.parse_if_needed();
|
||||||
|
|
||||||
|
let Some(root) = self.root.clone() else {
|
||||||
|
return div();
|
||||||
|
};
|
||||||
|
|
||||||
|
div().map(|this| match root {
|
||||||
|
Ok(node) => this.child(node),
|
||||||
|
Err(err) => this.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_1()
|
||||||
|
.child("Error parsing markdown")
|
||||||
|
.child(err.to_string()),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_table_row(table: &mut Table, node: &mdast::TableRow) {
|
||||||
|
let mut row = TableRow::default();
|
||||||
|
node.children.iter().for_each(|c| {
|
||||||
|
match c {
|
||||||
|
Node::TableCell(cell) => {
|
||||||
|
parse_table_cell(&mut row, cell);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
table.children.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_table_cell(row: &mut element::TableRow, node: &mdast::TableCell) {
|
||||||
|
let mut paragraph = Paragraph::default();
|
||||||
|
node.children.iter().for_each(|c| {
|
||||||
|
parse_paragraph(&mut paragraph, c);
|
||||||
|
});
|
||||||
|
let table_cell = element::TableCell {
|
||||||
|
children: paragraph,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
row.children.push(table_cell);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_paragraph(paragraph: &mut Paragraph, node: &mdast::Node) -> String {
|
||||||
|
let span = node.position().map(|pos| Span {
|
||||||
|
start: pos.start.offset,
|
||||||
|
end: pos.end.offset,
|
||||||
|
});
|
||||||
|
if let Some(span) = span {
|
||||||
|
paragraph.set_span(span);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut text = String::new();
|
||||||
|
|
||||||
|
match node {
|
||||||
|
Node::Paragraph(val) => {
|
||||||
|
val.children.iter().for_each(|c| {
|
||||||
|
text.push_str(&parse_paragraph(paragraph, c));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Node::Text(val) => {
|
||||||
|
text = val.value.clone();
|
||||||
|
paragraph.push_str(&val.value)
|
||||||
|
}
|
||||||
|
Node::Emphasis(val) => {
|
||||||
|
let mut child_paragraph = Paragraph::default();
|
||||||
|
for child in val.children.iter() {
|
||||||
|
text.push_str(&parse_paragraph(&mut child_paragraph, &child));
|
||||||
|
}
|
||||||
|
paragraph.push(element::TextNode {
|
||||||
|
text: text.clone(),
|
||||||
|
marks: vec![(
|
||||||
|
0..text.len(),
|
||||||
|
InlineTextStyle {
|
||||||
|
italic: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Node::Strong(val) => {
|
||||||
|
let mut child_paragraph = Paragraph::default();
|
||||||
|
for child in val.children.iter() {
|
||||||
|
text.push_str(&parse_paragraph(&mut child_paragraph, &child));
|
||||||
|
}
|
||||||
|
paragraph.push(element::TextNode {
|
||||||
|
text: text.clone(),
|
||||||
|
marks: vec![(
|
||||||
|
0..text.len(),
|
||||||
|
InlineTextStyle {
|
||||||
|
bold: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Node::Delete(val) => {
|
||||||
|
let mut child_paragraph = Paragraph::default();
|
||||||
|
for child in val.children.iter() {
|
||||||
|
text.push_str(&parse_paragraph(&mut child_paragraph, &child));
|
||||||
|
}
|
||||||
|
paragraph.push(element::TextNode {
|
||||||
|
text: text.clone(),
|
||||||
|
marks: vec![(
|
||||||
|
0..text.len(),
|
||||||
|
InlineTextStyle {
|
||||||
|
strikethrough: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Node::InlineCode(val) => {
|
||||||
|
text = val.value.clone();
|
||||||
|
paragraph.push(element::TextNode {
|
||||||
|
text: text.clone(),
|
||||||
|
marks: vec![(
|
||||||
|
0..text.len(),
|
||||||
|
InlineTextStyle {
|
||||||
|
code: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Node::Link(val) => {
|
||||||
|
let mut child_paragraph = Paragraph::default();
|
||||||
|
for child in val.children.iter() {
|
||||||
|
text.push_str(&parse_paragraph(&mut child_paragraph, &child));
|
||||||
|
}
|
||||||
|
paragraph.push(element::TextNode {
|
||||||
|
text: text.clone(),
|
||||||
|
marks: vec![(
|
||||||
|
0..text.len(),
|
||||||
|
InlineTextStyle {
|
||||||
|
link: Some(LinkMark {
|
||||||
|
url: val.url.clone().into(),
|
||||||
|
title: val.title.clone().map(|s| s.into()),
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Node::Image(raw) => {
|
||||||
|
paragraph.set_image(ImageNode {
|
||||||
|
url: raw.url.clone().into(),
|
||||||
|
title: raw.title.clone().map(|t| t.into()),
|
||||||
|
alt: Some(raw.alt.clone().into()),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Node::InlineMath(raw) => {
|
||||||
|
text = raw.value.clone();
|
||||||
|
paragraph.push(element::TextNode {
|
||||||
|
text: text.clone(),
|
||||||
|
marks: vec![(
|
||||||
|
0..text.len(),
|
||||||
|
InlineTextStyle {
|
||||||
|
code: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Node::MdxTextExpression(raw) => {
|
||||||
|
text = raw.value.clone();
|
||||||
|
paragraph.push(element::TextNode {
|
||||||
|
text: text.clone(),
|
||||||
|
marks: vec![(0..text.len(), InlineTextStyle::default())],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Node::Html(val) => match parse_html(&val.value) {
|
||||||
|
Ok(el) => {
|
||||||
|
if el == element::Node::Break {
|
||||||
|
text.push_str("\n");
|
||||||
|
} else {
|
||||||
|
if cfg!(debug_assertions) {
|
||||||
|
eprintln!("[markdown] unsupported inline html tag: {:#?}", el);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
if cfg!(debug_assertions) {
|
||||||
|
eprintln!("[markdown] error parsing html: {:#?}", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
text.push_str(&val.value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_ => {
|
||||||
|
if cfg!(debug_assertions) {
|
||||||
|
eprintln!("[markdown] unsupported inline node: {:#?}", node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
text
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<mdast::Node> for element::Node {
|
||||||
|
fn from(value: Node) -> Self {
|
||||||
|
match value {
|
||||||
|
Node::Root(val) => {
|
||||||
|
let children = val.children.into_iter().map(|c| c.into()).collect();
|
||||||
|
element::Node::Root { children }
|
||||||
|
}
|
||||||
|
Node::Paragraph(val) => {
|
||||||
|
let mut paragraph = Paragraph::default();
|
||||||
|
val.children.iter().for_each(|c| {
|
||||||
|
parse_paragraph(&mut paragraph, c);
|
||||||
|
});
|
||||||
|
|
||||||
|
element::Node::Paragraph(paragraph)
|
||||||
|
}
|
||||||
|
Node::Blockquote(val) => {
|
||||||
|
let mut paragraph = Paragraph::default();
|
||||||
|
val.children.iter().for_each(|c| {
|
||||||
|
parse_paragraph(&mut paragraph, c);
|
||||||
|
});
|
||||||
|
|
||||||
|
element::Node::Blockquote(paragraph)
|
||||||
|
}
|
||||||
|
Node::List(list) => {
|
||||||
|
let children = list.children.into_iter().map(|c| c.into()).collect();
|
||||||
|
element::Node::List {
|
||||||
|
ordered: list.ordered,
|
||||||
|
children,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Node::ListItem(val) => {
|
||||||
|
let children = val.children.into_iter().map(|c| c.into()).collect();
|
||||||
|
element::Node::ListItem {
|
||||||
|
children,
|
||||||
|
spread: val.spread,
|
||||||
|
checked: val.checked,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Node::Break(_) => element::Node::Break,
|
||||||
|
Node::Code(raw) => element::Node::CodeBlock {
|
||||||
|
code: raw.value.into(),
|
||||||
|
lang: raw.lang.map(|s| s.into()),
|
||||||
|
},
|
||||||
|
Node::Heading(val) => {
|
||||||
|
let mut paragraph = Paragraph::default();
|
||||||
|
val.children.iter().for_each(|c| {
|
||||||
|
parse_paragraph(&mut paragraph, c);
|
||||||
|
});
|
||||||
|
|
||||||
|
element::Node::Heading {
|
||||||
|
level: val.depth,
|
||||||
|
children: paragraph,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Node::Math(val) => element::Node::CodeBlock {
|
||||||
|
code: val.value.into(),
|
||||||
|
lang: Some("math".into()),
|
||||||
|
},
|
||||||
|
Node::Html(val) => match parse_html(&val.value) {
|
||||||
|
Ok(el) => el,
|
||||||
|
Err(err) => {
|
||||||
|
if cfg!(debug_assertions) {
|
||||||
|
eprintln!("[markdown] error parsing html: {:#?}", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
element::Node::Paragraph(val.value.into())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Node::MdxFlowExpression(val) => element::Node::CodeBlock {
|
||||||
|
code: val.value.into(),
|
||||||
|
lang: Some("mdx".into()),
|
||||||
|
},
|
||||||
|
Node::Yaml(val) => element::Node::CodeBlock {
|
||||||
|
code: val.value.into(),
|
||||||
|
lang: Some("yaml".into()),
|
||||||
|
},
|
||||||
|
Node::Toml(val) => element::Node::CodeBlock {
|
||||||
|
code: val.value.into(),
|
||||||
|
lang: Some("toml".into()),
|
||||||
|
},
|
||||||
|
Node::MdxJsxTextElement(val) => {
|
||||||
|
println!("MdxJsxTextElement: {:#?}", val);
|
||||||
|
let mut paragraph = Paragraph::default();
|
||||||
|
val.children.iter().for_each(|c| {
|
||||||
|
parse_paragraph(&mut paragraph, c);
|
||||||
|
});
|
||||||
|
element::Node::Paragraph(paragraph)
|
||||||
|
}
|
||||||
|
Node::MdxJsxFlowElement(val) => {
|
||||||
|
println!("MdxJsxFlowElement: {:#?}", val);
|
||||||
|
let mut paragraph = Paragraph::default();
|
||||||
|
val.children.iter().for_each(|c| {
|
||||||
|
parse_paragraph(&mut paragraph, c);
|
||||||
|
});
|
||||||
|
element::Node::Paragraph(paragraph)
|
||||||
|
}
|
||||||
|
Node::ThematicBreak(_) => element::Node::Divider,
|
||||||
|
Node::Table(val) => {
|
||||||
|
let mut table = Table::default();
|
||||||
|
val.children.iter().for_each(|c| {
|
||||||
|
if let Node::TableRow(row) = c {
|
||||||
|
parse_table_row(&mut table, row);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
element::Node::Table(table)
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
if cfg!(debug_assertions) {
|
||||||
|
eprintln!("[markdown] unsupported node: {:#?}", value);
|
||||||
|
}
|
||||||
|
element::Node::Unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::MarkdownView;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse() {
|
||||||
|
let source = include_str!("../../../story/examples/markdown.md");
|
||||||
|
let mut renderer = MarkdownView::new(source);
|
||||||
|
renderer.parse_if_needed();
|
||||||
|
// println!("{:#?}", renderer.root);
|
||||||
|
}
|
||||||
|
}
|
||||||
47
crates/ui/src/text/mod.rs
Normal file
47
crates/ui/src/text/mod.rs
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
use gpui::{App, AppContext, Entity, IntoElement, Render, SharedString};
|
||||||
|
use html::HtmlView;
|
||||||
|
use markdown::MarkdownView;
|
||||||
|
|
||||||
|
mod element;
|
||||||
|
mod html;
|
||||||
|
mod markdown;
|
||||||
|
mod utils;
|
||||||
|
|
||||||
|
#[allow(private_interfaces)]
|
||||||
|
pub enum TextView {
|
||||||
|
Markdown(Entity<MarkdownView>),
|
||||||
|
Html(Entity<HtmlView>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TextView {
|
||||||
|
/// Create a new markdown text view.
|
||||||
|
pub fn markdown(raw: impl Into<SharedString>, cx: &mut App) -> Self {
|
||||||
|
Self::Markdown(cx.new(|_| MarkdownView::new(raw)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new html text view.
|
||||||
|
pub fn html(raw: impl Into<SharedString>, cx: &mut App) -> Self {
|
||||||
|
Self::Html(cx.new(|_| HtmlView::new(raw)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the source text of the text view.
|
||||||
|
pub fn set_text(&mut self, raw: impl Into<SharedString>, cx: &mut App) {
|
||||||
|
match self {
|
||||||
|
Self::Markdown(view) => view.update(cx, |this, cx| this.set_text(raw, cx)),
|
||||||
|
Self::Html(view) => view.update(cx, |this, cx| this.set_text(raw, cx)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for TextView {
|
||||||
|
fn render(
|
||||||
|
&mut self,
|
||||||
|
_: &mut gpui::Window,
|
||||||
|
_: &mut gpui::Context<'_, Self>,
|
||||||
|
) -> impl IntoElement {
|
||||||
|
match self {
|
||||||
|
Self::Markdown(view) => view.clone().into_any_element(),
|
||||||
|
Self::Html(view) => view.clone().into_any_element(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
61
crates/ui/src/text/utils.rs
Normal file
61
crates/ui/src/text/utils.rs
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
const NUMBERED_PREFIXES_1: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||||
|
const NUMBERED_PREFIXES_2: &str = "abcdefghijklmnopqrstuvwxyz";
|
||||||
|
|
||||||
|
const BULLETS: [&str; 5] = ["▪", "•", "◦", "‣", "⁃"];
|
||||||
|
|
||||||
|
/// Returns the prefix for a list item.
|
||||||
|
pub fn list_item_prefix(ix: usize, ordered: bool, depth: usize) -> String {
|
||||||
|
if ordered {
|
||||||
|
if depth == 0 {
|
||||||
|
return format!("{}. ", ix + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if depth == 1 {
|
||||||
|
return format!(
|
||||||
|
"{}. ",
|
||||||
|
NUMBERED_PREFIXES_1
|
||||||
|
.chars()
|
||||||
|
.nth(ix % NUMBERED_PREFIXES_1.len())
|
||||||
|
.unwrap()
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return format!(
|
||||||
|
"{}. ",
|
||||||
|
NUMBERED_PREFIXES_2
|
||||||
|
.chars()
|
||||||
|
.nth(ix % NUMBERED_PREFIXES_2.len())
|
||||||
|
.unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let depth = depth.min(BULLETS.len() - 1);
|
||||||
|
let bullet = BULLETS[depth];
|
||||||
|
return format!("{} ", bullet);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::text::utils::list_item_prefix;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_list_item_prefix() {
|
||||||
|
assert_eq!(list_item_prefix(0, true, 0), "1. ");
|
||||||
|
assert_eq!(list_item_prefix(1, true, 0), "2. ");
|
||||||
|
assert_eq!(list_item_prefix(2, true, 0), "3. ");
|
||||||
|
assert_eq!(list_item_prefix(10, true, 0), "11. ");
|
||||||
|
assert_eq!(list_item_prefix(0, true, 1), "A. ");
|
||||||
|
assert_eq!(list_item_prefix(1, true, 1), "B. ");
|
||||||
|
assert_eq!(list_item_prefix(2, true, 1), "C. ");
|
||||||
|
assert_eq!(list_item_prefix(0, true, 2), "a. ");
|
||||||
|
assert_eq!(list_item_prefix(1, true, 2), "b. ");
|
||||||
|
assert_eq!(list_item_prefix(6, true, 2), "g. ");
|
||||||
|
assert_eq!(list_item_prefix(0, true, 1), "A. ");
|
||||||
|
assert_eq!(list_item_prefix(0, true, 2), "a. ");
|
||||||
|
assert_eq!(list_item_prefix(0, false, 0), "▪ ");
|
||||||
|
assert_eq!(list_item_prefix(0, false, 1), "• ");
|
||||||
|
assert_eq!(list_item_prefix(0, false, 2), "◦ ");
|
||||||
|
assert_eq!(list_item_prefix(0, false, 3), "‣ ");
|
||||||
|
assert_eq!(list_item_prefix(0, false, 4), "⁃ ");
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue