logiguard fork: GPUI xdg-activation keyboard-focus serial fix
Some checks failed
Update All Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Triage Project Sync (#84) / Sync triage project (push) Has been cancelled
release_nightly / notify_on_failure (push) Has been cancelled
release_nightly / check_style (push) Has been cancelled
release_nightly / run_tests_windows (push) Has been cancelled
release_nightly / clippy_windows (push) Has been cancelled
release_nightly / bundle_linux_aarch64 (push) Has been cancelled
release_nightly / bundle_linux_x86_64 (push) Has been cancelled
release_nightly / bundle_mac_aarch64 (push) Has been cancelled
release_nightly / bundle_mac_x86_64 (push) Has been cancelled
release_nightly / bundle_windows_aarch64 (push) Has been cancelled
release_nightly / bundle_windows_x86_64 (push) Has been cancelled
release_nightly / build_nix_linux_x86_64 (push) Has been cancelled
release_nightly / build_nix_mac_aarch64 (push) Has been cancelled
release_nightly / update_nightly_tag (push) Has been cancelled
Hotfix Review Monitor / check-hotfix-reviews (push) Has been cancelled
Stale PR Review Reminder / check-stale-prs (push) Has been cancelled
Update Weekly Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Bump collab-staging Tag / update-collab-staging-tag (push) Has been cancelled
compliance_check / scheduled_compliance_check (push) Has been cancelled
Some checks failed
Update All Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Triage Project Sync (#84) / Sync triage project (push) Has been cancelled
release_nightly / notify_on_failure (push) Has been cancelled
release_nightly / check_style (push) Has been cancelled
release_nightly / run_tests_windows (push) Has been cancelled
release_nightly / clippy_windows (push) Has been cancelled
release_nightly / bundle_linux_aarch64 (push) Has been cancelled
release_nightly / bundle_linux_x86_64 (push) Has been cancelled
release_nightly / bundle_mac_aarch64 (push) Has been cancelled
release_nightly / bundle_mac_x86_64 (push) Has been cancelled
release_nightly / bundle_windows_aarch64 (push) Has been cancelled
release_nightly / bundle_windows_x86_64 (push) Has been cancelled
release_nightly / build_nix_linux_x86_64 (push) Has been cancelled
release_nightly / build_nix_mac_aarch64 (push) Has been cancelled
release_nightly / update_nightly_tag (push) Has been cancelled
Hotfix Review Monitor / check-hotfix-reviews (push) Has been cancelled
Stale PR Review Reminder / check-stale-prs (push) Has been cancelled
Update Weekly Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Bump collab-staging Tag / update-collab-staging-tag (push) Has been cancelled
compliance_check / scheduled_compliance_check (push) Has been cancelled
Single-commit orphan branch: full zed-industries/zed @ 8c74db0 source tree with a 3-file patch applied (no upstream history). Patch (crates/gpui_linux/src/linux/wayland/): - serial.rs: add SerialKind::KeyboardEnter - client.rs: store wl_keyboard.enter serial; add latest_serial_of() - window.rs: activate() uses keyboard-enter serial (Mutter focus gate) Mutter honors window activation only when the token carries the keyboard- focus serial from wl_keyboard.enter; GPUI used a stale mouse-press serial. See docs/tray-window-focus-wayland.md in logiguard. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
52
crates/markdown/Cargo.toml
Normal file
52
crates/markdown/Cargo.toml
Normal file
@@ -0,0 +1,52 @@
|
||||
[package]
|
||||
name = "markdown"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[lib]
|
||||
path = "src/markdown.rs"
|
||||
doctest = false
|
||||
|
||||
[features]
|
||||
test-support = [
|
||||
"gpui/test-support",
|
||||
"util/test-support"
|
||||
]
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
base64.workspace = true
|
||||
collections.workspace = true
|
||||
futures.workspace = true
|
||||
gpui.workspace = true
|
||||
html5ever.workspace = true
|
||||
language.workspace = true
|
||||
linkify.workspace = true
|
||||
log.workspace = true
|
||||
markup5ever_rcdom.workspace = true
|
||||
mermaid-rs-renderer.workspace = true
|
||||
pulldown-cmark.workspace = true
|
||||
settings.workspace = true
|
||||
stacksafe.workspace = true
|
||||
sum_tree.workspace = true
|
||||
theme.workspace = true
|
||||
theme_settings.workspace = true
|
||||
ui.workspace = true
|
||||
util.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
assets.workspace = true
|
||||
env_logger.workspace = true
|
||||
fs = {workspace = true, features = ["test-support"]}
|
||||
gpui = { workspace = true, features = ["test-support"] }
|
||||
gpui_platform.workspace = true
|
||||
language = { workspace = true, features = ["test-support"] }
|
||||
languages = { workspace = true, features = ["load-grammars"] }
|
||||
node_runtime.workspace = true
|
||||
settings = { workspace = true, features = ["test-support"] }
|
||||
util = { workspace = true, features = ["test-support"] }
|
||||
1
crates/markdown/LICENSE-GPL
Symbolic link
1
crates/markdown/LICENSE-GPL
Symbolic link
@@ -0,0 +1 @@
|
||||
../../LICENSE-GPL
|
||||
121
crates/markdown/examples/markdown.rs
Normal file
121
crates/markdown/examples/markdown.rs
Normal file
@@ -0,0 +1,121 @@
|
||||
use assets::Assets;
|
||||
use gpui::{Entity, KeyBinding, StyleRefinement, WindowOptions, prelude::*, rgb};
|
||||
use language::LanguageRegistry;
|
||||
use markdown::{Markdown, MarkdownElement, MarkdownStyle};
|
||||
use node_runtime::NodeRuntime;
|
||||
use settings::SettingsStore;
|
||||
use std::sync::Arc;
|
||||
use theme::LoadThemes;
|
||||
use ui::prelude::*;
|
||||
use ui::{App, Window, div};
|
||||
|
||||
const MARKDOWN_EXAMPLE: &str = r#"
|
||||
# Markdown Example Document
|
||||
|
||||
## Headings
|
||||
Headings are created by adding one or more `#` symbols before your heading text. The number of `#` you use will determine the size of the heading.
|
||||
|
||||
```
|
||||
function a(b: T) {
|
||||
|
||||
}
|
||||
```
|
||||
|
||||
Remember, markdown processors may have slight differences and extensions, so always refer to the specific documentation or guides relevant to your platform or editor for the best practices and additional features.
|
||||
|
||||
## Images
|
||||
|
||||
 item one
|
||||
|
||||
 item two
|
||||
|
||||
 item three
|
||||
|
||||
"#;
|
||||
|
||||
pub fn main() {
|
||||
env_logger::init();
|
||||
gpui_platform::application().with_assets(Assets).run(|cx| {
|
||||
let store = SettingsStore::test(cx);
|
||||
cx.set_global(store);
|
||||
cx.bind_keys([KeyBinding::new("cmd-c", markdown::Copy, None)]);
|
||||
|
||||
let node_runtime = NodeRuntime::unavailable();
|
||||
theme_settings::init(LoadThemes::JustBase, cx);
|
||||
|
||||
let fs = fs::FakeFs::new(cx.background_executor().clone());
|
||||
let language_registry = LanguageRegistry::new(cx.background_executor().clone());
|
||||
language_registry.set_theme(cx.theme().clone());
|
||||
let language_registry = Arc::new(language_registry);
|
||||
languages::init(language_registry.clone(), fs, node_runtime, cx);
|
||||
Assets.load_fonts(cx).unwrap();
|
||||
|
||||
cx.activate(true);
|
||||
cx.open_window(WindowOptions::default(), |_, cx| {
|
||||
cx.new(|cx| MarkdownExample::new(MARKDOWN_EXAMPLE.into(), language_registry, cx))
|
||||
})
|
||||
.unwrap();
|
||||
});
|
||||
}
|
||||
|
||||
struct MarkdownExample {
|
||||
markdown: Entity<Markdown>,
|
||||
}
|
||||
|
||||
impl MarkdownExample {
|
||||
pub fn new(text: SharedString, language_registry: Arc<LanguageRegistry>, cx: &mut App) -> Self {
|
||||
let markdown = cx
|
||||
.new(|cx| Markdown::new(text, Some(language_registry), Some("TypeScript".into()), cx));
|
||||
Self { markdown }
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for MarkdownExample {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let markdown_style = MarkdownStyle {
|
||||
base_text_style: gpui::TextStyle {
|
||||
font_family: ".ZedSans".into(),
|
||||
color: cx.theme().colors().terminal_ansi_black,
|
||||
..Default::default()
|
||||
},
|
||||
code_block: StyleRefinement::default()
|
||||
.font_family(".ZedMono")
|
||||
.m(rems(1.))
|
||||
.bg(rgb(0xAAAAAAA)),
|
||||
inline_code: gpui::TextStyleRefinement {
|
||||
font_family: Some(".ZedMono".into()),
|
||||
color: Some(cx.theme().colors().editor_foreground),
|
||||
background_color: Some(cx.theme().colors().editor_background),
|
||||
..Default::default()
|
||||
},
|
||||
rule_color: Color::Muted.color(cx),
|
||||
block_quote_border_color: Color::Muted.color(cx),
|
||||
block_quote: gpui::TextStyleRefinement {
|
||||
color: Some(Color::Muted.color(cx)),
|
||||
..Default::default()
|
||||
},
|
||||
link: gpui::TextStyleRefinement {
|
||||
color: Some(Color::Accent.color(cx)),
|
||||
underline: Some(gpui::UnderlineStyle {
|
||||
thickness: px(1.),
|
||||
color: Some(Color::Accent.color(cx)),
|
||||
wavy: false,
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
syntax: cx.theme().syntax().clone(),
|
||||
selection_background_color: cx.theme().colors().element_selection_background,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
div()
|
||||
.id("markdown-example")
|
||||
.debug_selector(|| "foo".into())
|
||||
.relative()
|
||||
.bg(gpui::white())
|
||||
.size_full()
|
||||
.p_4()
|
||||
.overflow_y_scroll()
|
||||
.child(MarkdownElement::new(self.markdown.clone(), markdown_style))
|
||||
}
|
||||
}
|
||||
113
crates/markdown/examples/markdown_as_child.rs
Normal file
113
crates/markdown/examples/markdown_as_child.rs
Normal file
@@ -0,0 +1,113 @@
|
||||
use assets::Assets;
|
||||
use gpui::{Entity, KeyBinding, Length, StyleRefinement, WindowOptions, rgb};
|
||||
use language::LanguageRegistry;
|
||||
use markdown::{Markdown, MarkdownElement, MarkdownStyle};
|
||||
use node_runtime::NodeRuntime;
|
||||
use settings::SettingsStore;
|
||||
use std::sync::Arc;
|
||||
use theme::LoadThemes;
|
||||
use ui::div;
|
||||
use ui::prelude::*;
|
||||
|
||||
const MARKDOWN_EXAMPLE: &str = r#"
|
||||
this text should be selectable
|
||||
|
||||
wow so cool
|
||||
|
||||
## Heading 2
|
||||
"#;
|
||||
pub fn main() {
|
||||
env_logger::init();
|
||||
|
||||
gpui_platform::application().with_assets(Assets).run(|cx| {
|
||||
let store = SettingsStore::test(cx);
|
||||
cx.set_global(store);
|
||||
cx.bind_keys([KeyBinding::new("cmd-c", markdown::Copy, None)]);
|
||||
|
||||
let node_runtime = NodeRuntime::unavailable();
|
||||
let language_registry = Arc::new(LanguageRegistry::new(cx.background_executor().clone()));
|
||||
let fs = fs::FakeFs::new(cx.background_executor().clone());
|
||||
languages::init(language_registry, fs, node_runtime, cx);
|
||||
theme_settings::init(LoadThemes::JustBase, cx);
|
||||
Assets.load_fonts(cx).unwrap();
|
||||
|
||||
cx.activate(true);
|
||||
let _ = cx.open_window(WindowOptions::default(), |_, cx| {
|
||||
cx.new(|cx| {
|
||||
let markdown = cx.new(|cx| Markdown::new(MARKDOWN_EXAMPLE.into(), None, None, cx));
|
||||
|
||||
HelloWorld { markdown }
|
||||
})
|
||||
});
|
||||
});
|
||||
}
|
||||
struct HelloWorld {
|
||||
markdown: Entity<Markdown>,
|
||||
}
|
||||
|
||||
impl Render for HelloWorld {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let markdown_style = MarkdownStyle {
|
||||
base_text_style: gpui::TextStyle {
|
||||
font_family: "Zed Mono".into(),
|
||||
color: cx.theme().colors().text,
|
||||
..Default::default()
|
||||
},
|
||||
code_block: StyleRefinement {
|
||||
text: gpui::TextStyleRefinement {
|
||||
font_family: Some("Zed Mono".into()),
|
||||
background_color: Some(cx.theme().colors().editor_background),
|
||||
..Default::default()
|
||||
},
|
||||
margin: gpui::EdgesRefinement {
|
||||
top: Some(Length::Definite(rems(4.).into())),
|
||||
left: Some(Length::Definite(rems(4.).into())),
|
||||
right: Some(Length::Definite(rems(4.).into())),
|
||||
bottom: Some(Length::Definite(rems(4.).into())),
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
inline_code: gpui::TextStyleRefinement {
|
||||
font_family: Some("Zed Mono".into()),
|
||||
background_color: Some(cx.theme().colors().editor_background),
|
||||
..Default::default()
|
||||
},
|
||||
rule_color: Color::Muted.color(cx),
|
||||
block_quote_border_color: Color::Muted.color(cx),
|
||||
block_quote: gpui::TextStyleRefinement {
|
||||
color: Some(Color::Muted.color(cx)),
|
||||
..Default::default()
|
||||
},
|
||||
link: gpui::TextStyleRefinement {
|
||||
color: Some(Color::Accent.color(cx)),
|
||||
underline: Some(gpui::UnderlineStyle {
|
||||
thickness: px(1.),
|
||||
color: Some(Color::Accent.color(cx)),
|
||||
wavy: false,
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
syntax: cx.theme().syntax().clone(),
|
||||
selection_background_color: cx.theme().colors().element_selection_background,
|
||||
heading: Default::default(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
div()
|
||||
.flex()
|
||||
.bg(rgb(0x2e7d32))
|
||||
.size(Length::Definite(px(700.0).into()))
|
||||
.justify_center()
|
||||
.items_center()
|
||||
.shadow_lg()
|
||||
.border_1()
|
||||
.border_color(rgb(0x0000ff))
|
||||
.text_xl()
|
||||
.text_color(rgb(0xffffff))
|
||||
.child(
|
||||
div()
|
||||
.child(MarkdownElement::new(self.markdown.clone(), markdown_style))
|
||||
.p_20(),
|
||||
)
|
||||
}
|
||||
}
|
||||
3
crates/markdown/src/html.rs
Normal file
3
crates/markdown/src/html.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
mod html_minifier;
|
||||
pub(crate) mod html_parser;
|
||||
mod html_rendering;
|
||||
829
crates/markdown/src/html/html_minifier.rs
Normal file
829
crates/markdown/src/html/html_minifier.rs
Normal file
@@ -0,0 +1,829 @@
|
||||
use html5ever::{
|
||||
Attribute, ParseOpts, QualName, parse_document,
|
||||
tendril::{Tendril, TendrilSink, fmt::UTF8},
|
||||
};
|
||||
use markup5ever_rcdom::{Node, NodeData, RcDom};
|
||||
use std::{cell::RefCell, io, rc::Rc, str};
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct MinifierOptions {
|
||||
pub omit_doctype: bool,
|
||||
pub preserve_comments: bool,
|
||||
pub collapse_whitespace: bool,
|
||||
}
|
||||
|
||||
pub(crate) struct Minifier<'a, W: io::Write> {
|
||||
w: &'a mut W,
|
||||
options: MinifierOptions,
|
||||
preceding_whitespace: bool,
|
||||
}
|
||||
|
||||
impl<'a, W> Minifier<'a, W>
|
||||
where
|
||||
W: io::Write,
|
||||
{
|
||||
/// Creates a new `Minifier` instance.
|
||||
#[inline]
|
||||
pub fn new(w: &'a mut W, options: MinifierOptions) -> Self {
|
||||
Self {
|
||||
w,
|
||||
options,
|
||||
preceding_whitespace: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Minifies the given reader input.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Will return `Err` if unable to write to the output writer.
|
||||
#[inline]
|
||||
pub fn minify<R: io::Read>(&mut self, mut r: &mut R) -> io::Result<()> {
|
||||
let dom = parse_document(RcDom::default(), ParseOpts::default())
|
||||
.from_utf8()
|
||||
.read_from(&mut r)?;
|
||||
|
||||
if !self.options.omit_doctype {
|
||||
self.w.write_all(b"<!doctype html>")?;
|
||||
}
|
||||
|
||||
self.minify_node(&None, &dom.document)
|
||||
}
|
||||
|
||||
fn minify_node<'b>(&mut self, ctx: &'b Option<Context>, node: &'b Node) -> io::Result<()> {
|
||||
match &node.data {
|
||||
NodeData::Text { contents } => {
|
||||
// Check if whitespace collapsing disabled
|
||||
let contents = contents.borrow();
|
||||
let contents = contents.as_ref();
|
||||
|
||||
if !self.options.collapse_whitespace {
|
||||
return self.w.write_all(contents.as_bytes());
|
||||
}
|
||||
|
||||
// Check if parent is whitespace preserving element or contains code (<script>, <style>)
|
||||
let (skip_collapse_whitespace, contains_code) =
|
||||
ctx.as_ref().map_or((false, false), |ctx| {
|
||||
if let NodeData::Element { name, .. } = &ctx.parent.data {
|
||||
let name = name.local.as_ref();
|
||||
|
||||
(preserve_whitespace(name), contains_code(name))
|
||||
} else {
|
||||
(false, false)
|
||||
}
|
||||
});
|
||||
|
||||
if skip_collapse_whitespace {
|
||||
return self.w.write_all(contents.as_bytes());
|
||||
}
|
||||
|
||||
if contains_code {
|
||||
return self
|
||||
.w
|
||||
.write_all(contents.trim_matches(is_ascii_whitespace).as_bytes());
|
||||
}
|
||||
|
||||
// Early exit if empty to forego expensive trim logic
|
||||
if contents.is_empty() {
|
||||
return io::Result::Ok(());
|
||||
}
|
||||
|
||||
let (trim_left, trim_right) = ctx
|
||||
.as_ref()
|
||||
.map_or((true, true), |ctx| ctx.trim(self.preceding_whitespace));
|
||||
let contents = match (trim_left, trim_right) {
|
||||
(true, true) => contents.trim_matches(is_ascii_whitespace),
|
||||
(true, false) => contents.trim_start_matches(is_ascii_whitespace),
|
||||
(false, true) => contents.trim_end_matches(is_ascii_whitespace),
|
||||
_ => contents,
|
||||
};
|
||||
|
||||
// Second empty check after trimming whitespace
|
||||
if !contents.is_empty() {
|
||||
// replace \n, \r to ' '
|
||||
let contents = contents
|
||||
.bytes()
|
||||
.map(|c| if matches!(c, b'\n' | b'\r') { b' ' } else { c })
|
||||
.collect::<Vec<u8>>();
|
||||
|
||||
self.write_collapse_whitespace(&contents, reserved_entity, None)?;
|
||||
|
||||
self.preceding_whitespace = !trim_right
|
||||
&& contents
|
||||
.iter()
|
||||
.last()
|
||||
.map_or(false, u8::is_ascii_whitespace);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
NodeData::Comment { contents } if self.options.preserve_comments => {
|
||||
self.w.write_all(b"<!--")?;
|
||||
self.w.write_all(contents.as_bytes())?;
|
||||
self.w.write_all(b"-->")
|
||||
}
|
||||
|
||||
NodeData::Document => self.minify_children(ctx, node),
|
||||
|
||||
NodeData::Element { name, attrs, .. } => {
|
||||
let attrs = attrs.borrow();
|
||||
let tag = name.local.as_ref();
|
||||
|
||||
if is_self_closing(tag) {
|
||||
return self.write_start_tag(name, &attrs);
|
||||
}
|
||||
|
||||
let (omit_start_tag, omit_end_tag) =
|
||||
self.omit_tags(ctx, node, tag, attrs.is_empty());
|
||||
|
||||
if !omit_start_tag {
|
||||
self.write_start_tag(name, &attrs)?;
|
||||
}
|
||||
|
||||
self.minify_children(ctx, node)?;
|
||||
|
||||
if !omit_end_tag {
|
||||
self.write_end_tag(name)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn next_is_comment<'b, I>(&self, v: I) -> bool
|
||||
where
|
||||
I: IntoIterator<Item = &'b Rc<Node>>,
|
||||
{
|
||||
v.into_iter()
|
||||
.find_map(|node| match &node.data {
|
||||
NodeData::Text { contents } => {
|
||||
if self.options.collapse_whitespace && is_whitespace(contents) {
|
||||
// Blocks of whitespace are skipped
|
||||
None
|
||||
} else {
|
||||
Some(false)
|
||||
}
|
||||
}
|
||||
NodeData::Comment { .. } => Some(self.options.preserve_comments),
|
||||
_ => Some(false),
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn is_whitespace(&self, s: &RefCell<Tendril<UTF8>>) -> Option<bool> {
|
||||
if self.options.collapse_whitespace && is_whitespace(s) {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
!s.borrow()
|
||||
.as_bytes()
|
||||
.iter()
|
||||
.next()
|
||||
.map_or(false, u8::is_ascii_whitespace),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Determines if start and end tags can be omitted.
|
||||
/// Whitespace rules are ignored if `collapse_whitespace` is enabled.
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn omit_tags(
|
||||
&self,
|
||||
ctx: &Option<Context>,
|
||||
node: &Node,
|
||||
name: &str,
|
||||
empty_attributes: bool,
|
||||
) -> (bool, bool) {
|
||||
ctx.as_ref().map_or((false, false), |ctx| match name {
|
||||
"html" => {
|
||||
// The end tag may be omitted if the <html> element is not immediately followed by a comment.
|
||||
let omit_end = ctx.right.map_or(true, |right| !self.next_is_comment(right));
|
||||
// The start tag may be omitted if the first thing inside the <html> element is not a comment.
|
||||
let omit_start =
|
||||
empty_attributes && omit_end && !self.next_is_comment(&*node.children.borrow());
|
||||
|
||||
(omit_start, omit_end)
|
||||
}
|
||||
"head" => {
|
||||
// The end tag may be omitted if the first thing following the <head> element is not a space character or a comment.
|
||||
let omit_end = ctx.right.map_or(true, |right| {
|
||||
right
|
||||
.iter()
|
||||
.find_map(|node| match &node.data {
|
||||
NodeData::Text { contents } => self.is_whitespace(contents),
|
||||
NodeData::Comment { .. } => {
|
||||
if self.options.preserve_comments {
|
||||
Some(false)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
_ => Some(true),
|
||||
})
|
||||
.unwrap_or(true)
|
||||
});
|
||||
// The start tag may be omitted if the first thing inside the <head> element is an element.
|
||||
let omit_start = empty_attributes
|
||||
&& omit_end
|
||||
&& node
|
||||
.children
|
||||
.borrow()
|
||||
.iter()
|
||||
.find_map(|node| match &node.data {
|
||||
NodeData::Text { contents } => self.is_whitespace(contents),
|
||||
NodeData::Element { .. } => Some(true),
|
||||
NodeData::Comment { .. } => {
|
||||
if self.options.preserve_comments {
|
||||
Some(false)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
_ => Some(false),
|
||||
})
|
||||
.unwrap_or(true);
|
||||
|
||||
(omit_start, omit_end)
|
||||
}
|
||||
"body" => {
|
||||
// The start tag may be omitted if the first thing inside it is not a space character, comment, <script> element or <style> element.
|
||||
let omit_start = empty_attributes
|
||||
&& node
|
||||
.children
|
||||
.borrow()
|
||||
.iter()
|
||||
.find_map(|node| match &node.data {
|
||||
NodeData::Text { contents } => self.is_whitespace(contents),
|
||||
NodeData::Element { name, .. } => {
|
||||
Some(!matches!(name.local.as_ref(), "script" | "style"))
|
||||
}
|
||||
NodeData::Comment { .. } => {
|
||||
if self.options.preserve_comments {
|
||||
Some(false)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
_ => Some(true),
|
||||
})
|
||||
.unwrap_or(true);
|
||||
// The end tag may be omitted if the <body> element has contents or has a start tag, and is not immediately followed by a comment.
|
||||
let omit_end = ctx.right.map_or(true, |right| !self.next_is_comment(right));
|
||||
|
||||
(omit_start && omit_end, omit_end)
|
||||
}
|
||||
"p" => {
|
||||
let omit_end = ctx.next_element().map_or(true, |node| {
|
||||
if let NodeData::Element { name, .. } = &node.data {
|
||||
matches!(
|
||||
name.local.as_ref().to_ascii_lowercase().as_str(),
|
||||
"address"
|
||||
| "article"
|
||||
| "aside"
|
||||
| "blockquote"
|
||||
| "div"
|
||||
| "dl"
|
||||
| "fieldset"
|
||||
| "footer"
|
||||
| "form"
|
||||
| "h1"
|
||||
| "h2"
|
||||
| "h3"
|
||||
| "h4"
|
||||
| "h5"
|
||||
| "h6"
|
||||
| "header"
|
||||
| "hr"
|
||||
| "menu"
|
||||
| "nav"
|
||||
| "ol"
|
||||
| "p"
|
||||
| "pre"
|
||||
| "section"
|
||||
| "table"
|
||||
| "ul"
|
||||
)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
|
||||
(false, omit_end)
|
||||
}
|
||||
// TODO: comprehensive handling of optional end element rules
|
||||
_ => (false, optional_end_tag(name)),
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(clippy::needless_pass_by_value)]
|
||||
fn minify_children(&mut self, ctx: &Option<Context>, node: &Node) -> io::Result<()> {
|
||||
let children = node.children.borrow();
|
||||
let l = children.len();
|
||||
|
||||
children.iter().enumerate().try_for_each(|(i, child)| {
|
||||
if self.preceding_whitespace && is_block_element(child) {
|
||||
self.preceding_whitespace = false;
|
||||
}
|
||||
|
||||
self.minify_node(
|
||||
&Some(Context {
|
||||
parent: node,
|
||||
parent_context: ctx.as_ref(),
|
||||
left: if i > 0 { Some(&children[..i]) } else { None },
|
||||
right: if i + 1 < l {
|
||||
Some(&children[i + 1..])
|
||||
} else {
|
||||
None
|
||||
},
|
||||
}),
|
||||
child,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn write_qualified_name(&mut self, name: &QualName) -> io::Result<()> {
|
||||
if let Some(prefix) = &name.prefix {
|
||||
self.w
|
||||
.write_all(prefix.as_ref().to_ascii_lowercase().as_bytes())?;
|
||||
self.w.write_all(b":")?;
|
||||
}
|
||||
|
||||
self.w
|
||||
.write_all(name.local.as_ref().to_ascii_lowercase().as_bytes())
|
||||
}
|
||||
|
||||
fn write_start_tag(&mut self, name: &QualName, attrs: &[Attribute]) -> io::Result<()> {
|
||||
self.w.write_all(b"<")?;
|
||||
self.write_qualified_name(name)?;
|
||||
|
||||
attrs
|
||||
.iter()
|
||||
.try_for_each(|attr| self.write_attribute(attr))?;
|
||||
|
||||
self.w.write_all(b">")
|
||||
}
|
||||
|
||||
fn write_end_tag(&mut self, name: &QualName) -> io::Result<()> {
|
||||
self.w.write_all(b"</")?;
|
||||
self.write_qualified_name(name)?;
|
||||
self.w.write_all(b">")
|
||||
}
|
||||
|
||||
fn write_attribute(&mut self, attr: &Attribute) -> io::Result<()> {
|
||||
self.w.write_all(b" ")?;
|
||||
self.write_qualified_name(&attr.name)?;
|
||||
|
||||
let value = attr.value.as_ref();
|
||||
let value = if self.options.collapse_whitespace {
|
||||
value.trim_matches(is_ascii_whitespace)
|
||||
} else {
|
||||
value
|
||||
};
|
||||
|
||||
if value.is_empty() {
|
||||
return io::Result::Ok(());
|
||||
}
|
||||
|
||||
self.w.write_all(b"=")?;
|
||||
|
||||
let b = value.as_bytes();
|
||||
let (unquoted, double, _) =
|
||||
b.iter()
|
||||
.fold((true, false, false), |(unquoted, double, single), &c| {
|
||||
let (double, single) = (double || c == b'"', single || c == b'\'');
|
||||
let unquoted =
|
||||
unquoted && !double && !single && c != b'=' && !c.is_ascii_whitespace();
|
||||
|
||||
(unquoted, double, single)
|
||||
});
|
||||
|
||||
if unquoted {
|
||||
self.w.write_all(b)
|
||||
} else if double {
|
||||
self.write_attribute_value(b, b"'", reserved_entity_with_apos)
|
||||
} else {
|
||||
self.write_attribute_value(b, b"\"", reserved_entity)
|
||||
}
|
||||
}
|
||||
|
||||
fn write_attribute_value<T: AsRef<[u8]>>(
|
||||
&mut self,
|
||||
v: T,
|
||||
quote: &[u8],
|
||||
f: EntityFn,
|
||||
) -> io::Result<()> {
|
||||
self.w.write_all(quote)?;
|
||||
|
||||
let b = v.as_ref();
|
||||
|
||||
if self.options.collapse_whitespace {
|
||||
self.write_collapse_whitespace(b, f, Some(false))
|
||||
} else {
|
||||
self.w.write_all(b)
|
||||
}?;
|
||||
|
||||
self.w.write_all(quote)
|
||||
}
|
||||
|
||||
/// Efficiently writes blocks of content, e.g. a string with no collapsed
|
||||
/// whitespace would result in a single write.
|
||||
fn write_collapse_whitespace(
|
||||
&mut self,
|
||||
b: &[u8],
|
||||
f: EntityFn,
|
||||
preceding_whitespace: Option<bool>,
|
||||
) -> io::Result<()> {
|
||||
b.iter()
|
||||
.enumerate()
|
||||
.try_fold(
|
||||
(0, preceding_whitespace.unwrap_or(self.preceding_whitespace)),
|
||||
|(pos, preceding_whitespace), (i, &c)| {
|
||||
let is_whitespace = c.is_ascii_whitespace();
|
||||
|
||||
Ok(if is_whitespace && preceding_whitespace {
|
||||
if i != pos {
|
||||
self.write(&b[pos..i], f)?;
|
||||
}
|
||||
|
||||
// ASCII whitespace = 1 byte
|
||||
(i + 1, true)
|
||||
} else {
|
||||
(pos, is_whitespace)
|
||||
})
|
||||
},
|
||||
)
|
||||
.and_then(|(pos, _)| {
|
||||
if pos < b.len() {
|
||||
self.write(&b[pos..], f)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn write(&mut self, b: &[u8], f: EntityFn) -> io::Result<()> {
|
||||
b.iter()
|
||||
.enumerate()
|
||||
.try_fold(0, |pos, (i, &c)| {
|
||||
Ok(if let Some(entity) = f(c) {
|
||||
self.w.write_all(&b[pos..i])?;
|
||||
self.w.write_all(entity)?;
|
||||
|
||||
// Reserved characters are 1 byte
|
||||
i + 1
|
||||
} else {
|
||||
pos
|
||||
})
|
||||
})
|
||||
.and_then(|pos| {
|
||||
if pos < b.len() {
|
||||
self.w.write_all(&b[pos..])?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct Context<'a> {
|
||||
parent: &'a Node,
|
||||
parent_context: Option<&'a Context<'a>>,
|
||||
left: Option<&'a [Rc<Node>]>,
|
||||
right: Option<&'a [Rc<Node>]>,
|
||||
}
|
||||
|
||||
impl<'a> Context<'a> {
|
||||
/// Determine whether to trim whitespace.
|
||||
/// Uses naive HTML5 whitespace collapsing rules.
|
||||
fn trim(&self, preceding_whitespace: bool) -> (bool, bool) {
|
||||
(preceding_whitespace || self.trim_left(), self.trim_right())
|
||||
}
|
||||
|
||||
fn trim_left(&self) -> bool {
|
||||
self.left.map_or_else(
|
||||
|| is_block_element(self.parent) || self.parent_trim_left(),
|
||||
|siblings| {
|
||||
siblings
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(Self::is_block_element)
|
||||
.unwrap_or_else(|| self.parent_trim_left())
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn parent_trim_left(&self) -> bool {
|
||||
self.parent_context.map_or(true, Context::trim_left)
|
||||
}
|
||||
|
||||
fn trim_right(&self) -> bool {
|
||||
self.right.map_or(true, |siblings| {
|
||||
siblings
|
||||
.iter()
|
||||
.find_map(Self::is_block_element)
|
||||
.unwrap_or(true)
|
||||
})
|
||||
}
|
||||
|
||||
fn next_element(&self) -> Option<&Rc<Node>> {
|
||||
self.right.and_then(|siblings| {
|
||||
siblings
|
||||
.iter()
|
||||
.find(|node| matches!(node.data, NodeData::Element { .. }))
|
||||
})
|
||||
}
|
||||
|
||||
fn is_block_element(node: &Rc<Node>) -> Option<bool> {
|
||||
if let NodeData::Element { name, .. } = &node.data {
|
||||
Some(is_block_element_name(name.local.as_ref()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type EntityFn = fn(u8) -> Option<&'static [u8]>;
|
||||
|
||||
const fn reserved_entity(v: u8) -> Option<&'static [u8]> {
|
||||
match v {
|
||||
b'<' => Some(b"<"),
|
||||
b'>' => Some(b">"),
|
||||
b'&' => Some(b"&"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
const fn reserved_entity_with_apos(v: u8) -> Option<&'static [u8]> {
|
||||
if v == b'\'' {
|
||||
Some(b"'")
|
||||
} else {
|
||||
reserved_entity(v)
|
||||
}
|
||||
}
|
||||
|
||||
fn is_whitespace(s: &RefCell<Tendril<UTF8>>) -> bool {
|
||||
s.borrow().as_bytes().iter().all(u8::is_ascii_whitespace)
|
||||
}
|
||||
|
||||
fn is_block_element_name(name: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
"address"
|
||||
| "article"
|
||||
| "aside"
|
||||
| "blockquote"
|
||||
| "body"
|
||||
| "br"
|
||||
| "details"
|
||||
| "dialog"
|
||||
| "dd"
|
||||
| "div"
|
||||
| "dl"
|
||||
| "dt"
|
||||
| "fieldset"
|
||||
| "figcaption"
|
||||
| "figure"
|
||||
| "footer"
|
||||
| "form"
|
||||
| "h1"
|
||||
| "h2"
|
||||
| "h3"
|
||||
| "h4"
|
||||
| "h5"
|
||||
| "h6"
|
||||
| "head"
|
||||
| "header"
|
||||
| "hgroup"
|
||||
| "hr"
|
||||
| "html"
|
||||
| "li"
|
||||
| "link"
|
||||
| "main"
|
||||
| "meta"
|
||||
| "nav"
|
||||
| "ol"
|
||||
| "option"
|
||||
| "p"
|
||||
| "pre"
|
||||
| "script"
|
||||
| "section"
|
||||
| "source"
|
||||
| "table"
|
||||
| "td"
|
||||
| "th"
|
||||
| "title"
|
||||
| "tr"
|
||||
| "ul"
|
||||
)
|
||||
}
|
||||
|
||||
fn is_block_element(node: &Node) -> bool {
|
||||
match &node.data {
|
||||
NodeData::Element { name, .. } => is_block_element_name(name.local.as_ref()),
|
||||
NodeData::Document => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::missing_const_for_fn)]
|
||||
fn is_ascii_whitespace(c: char) -> bool {
|
||||
c.is_ascii_whitespace()
|
||||
}
|
||||
|
||||
fn preserve_whitespace(name: &str) -> bool {
|
||||
matches!(name, "pre" | "textarea")
|
||||
}
|
||||
|
||||
fn contains_code(name: &str) -> bool {
|
||||
matches!(name, "script" | "style")
|
||||
}
|
||||
|
||||
fn is_self_closing(name: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
"area"
|
||||
| "base"
|
||||
| "br"
|
||||
| "col"
|
||||
| "embed"
|
||||
| "hr"
|
||||
| "img"
|
||||
| "input"
|
||||
| "link"
|
||||
| "meta"
|
||||
| "param"
|
||||
| "source"
|
||||
| "track"
|
||||
| "wbr"
|
||||
| "command"
|
||||
| "keygen"
|
||||
| "menuitem"
|
||||
)
|
||||
}
|
||||
|
||||
fn optional_end_tag(name: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
"basefont"
|
||||
| "colgroup"
|
||||
| "dd"
|
||||
| "dt"
|
||||
| "frame"
|
||||
| "isindex"
|
||||
| "li"
|
||||
| "option"
|
||||
| "p"
|
||||
| "tbody"
|
||||
| "td"
|
||||
| "tfoot"
|
||||
| "th"
|
||||
| "thead"
|
||||
| "tr"
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::str;
|
||||
|
||||
#[test]
|
||||
fn test_write_collapse_whitespace() {
|
||||
for &(input, expected, preceding_whitespace) in &[
|
||||
("", "", false),
|
||||
(" ", " ", false),
|
||||
(" ", " ", false),
|
||||
(" ", "", true),
|
||||
(" x y ", " x y ", false),
|
||||
(" x y ", "x y ", true),
|
||||
(" x \n \t \n y ", " x y ", false),
|
||||
(" x \n \t \n y ", "x y ", true),
|
||||
] {
|
||||
let mut w = Vec::new();
|
||||
let mut minifier = Minifier::new(&mut w, MinifierOptions::default());
|
||||
minifier.preceding_whitespace = preceding_whitespace;
|
||||
minifier
|
||||
.write_collapse_whitespace(
|
||||
input.as_bytes(),
|
||||
reserved_entity,
|
||||
Some(preceding_whitespace),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let s = str::from_utf8(&w).unwrap();
|
||||
|
||||
assert_eq!(expected, s);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_omit_tags() {
|
||||
for &(input, expected, collapse_whitespace, preserve_comments) in &[
|
||||
// <html>
|
||||
("<html>", "", true, false),
|
||||
// Comments ignored
|
||||
("<html><!-- -->", "", true, false),
|
||||
// Comments preserved
|
||||
("<html> <!-- --> ", "<html><!-- -->", true, true),
|
||||
("<html><!-- --></html>", "<html><!-- -->", true, true),
|
||||
(
|
||||
"<html><!-- --></html><!-- -->",
|
||||
"<html><!-- --></html><!-- -->",
|
||||
true,
|
||||
true,
|
||||
),
|
||||
(
|
||||
"<html> <!-- --> </html> <!-- --> ",
|
||||
"<html><!-- --></html><!-- -->",
|
||||
true,
|
||||
true,
|
||||
),
|
||||
(
|
||||
"<html> <!-- --> </html> <!-- --> ",
|
||||
// <body> is implicitly added to the DOM
|
||||
"<html><!-- --><body> </html><!-- -->",
|
||||
false,
|
||||
true,
|
||||
),
|
||||
// <head>
|
||||
(
|
||||
"<html> <head> <title>A</title> </head> <body><p> B </p> </body>",
|
||||
"<title>A</title><p>B",
|
||||
true,
|
||||
false,
|
||||
),
|
||||
(
|
||||
"<html> <head> <title>A</title> </head> <body><p> B </p> </body>",
|
||||
"<head> <title>A</title> </head> <p> B ",
|
||||
false,
|
||||
false,
|
||||
),
|
||||
(
|
||||
"<html> <head><!-- --> <title>A</title> </head> <body><p> B </p> </body>",
|
||||
"<head><!-- --><title>A</title><p>B",
|
||||
true,
|
||||
true,
|
||||
),
|
||||
// <body>
|
||||
("<body>", "", true, false),
|
||||
(
|
||||
"<body> <script>let x = 1;</script> ",
|
||||
"<body><script>let x = 1;</script>",
|
||||
true,
|
||||
false,
|
||||
),
|
||||
(
|
||||
"<body> <style>body{margin:1em}</style>",
|
||||
"<body><style>body{margin:1em}</style>",
|
||||
true,
|
||||
false,
|
||||
),
|
||||
("<body> <p>A", "<p>A", true, false),
|
||||
("<body id=main> <p>A", "<body id=main><p>A", true, false),
|
||||
// Retain whitespace, whitespace before <p>
|
||||
(
|
||||
" <body> <p>A ",
|
||||
"<body> <p>A ",
|
||||
false,
|
||||
false,
|
||||
),
|
||||
// Retain whitespace, touching <p>
|
||||
("<body><p>A</body>", "<p>A", false, false),
|
||||
// Comments ignored
|
||||
("<body><p>A</body><!-- -->", "<p>A", false, false),
|
||||
// Comments preserved
|
||||
(
|
||||
"<body><p>A</body><!-- -->",
|
||||
"<body><p>A</body><!-- -->",
|
||||
false,
|
||||
true,
|
||||
),
|
||||
// Retain end tag if touching inline element
|
||||
(
|
||||
"<p>Some text</p><button></button>",
|
||||
"<p>Some text</p><button></button>",
|
||||
false,
|
||||
false,
|
||||
),
|
||||
] {
|
||||
let mut w = Vec::new();
|
||||
let mut minifier = Minifier::new(
|
||||
&mut w,
|
||||
MinifierOptions {
|
||||
omit_doctype: true,
|
||||
preserve_comments,
|
||||
collapse_whitespace,
|
||||
},
|
||||
);
|
||||
minifier.minify(&mut input.as_bytes()).unwrap();
|
||||
|
||||
let s = str::from_utf8(&w).unwrap();
|
||||
|
||||
assert_eq!(expected, s);
|
||||
}
|
||||
}
|
||||
}
|
||||
1015
crates/markdown/src/html/html_parser.rs
Normal file
1015
crates/markdown/src/html/html_parser.rs
Normal file
File diff suppressed because it is too large
Load Diff
662
crates/markdown/src/html/html_rendering.rs
Normal file
662
crates/markdown/src/html/html_rendering.rs
Normal file
@@ -0,0 +1,662 @@
|
||||
use std::ops::Range;
|
||||
|
||||
use gpui::{
|
||||
App, FontStyle, FontWeight, StrikethroughStyle, TextAlign, TextStyleRefinement, UnderlineStyle,
|
||||
};
|
||||
use pulldown_cmark::Alignment;
|
||||
use ui::prelude::*;
|
||||
|
||||
use crate::html::html_parser::{
|
||||
HtmlHighlightStyle, HtmlImage, HtmlParagraph, HtmlParagraphChunk, ParsedHtmlBlock,
|
||||
ParsedHtmlElement, ParsedHtmlList, ParsedHtmlListItemType, ParsedHtmlTable, ParsedHtmlTableRow,
|
||||
ParsedHtmlText,
|
||||
};
|
||||
use crate::{MarkdownElement, MarkdownElementBuilder};
|
||||
|
||||
pub(crate) struct HtmlSourceAllocator {
|
||||
source_range: Range<usize>,
|
||||
next_source_index: usize,
|
||||
}
|
||||
|
||||
impl HtmlSourceAllocator {
|
||||
pub(crate) fn new(source_range: Range<usize>) -> Self {
|
||||
Self {
|
||||
next_source_index: source_range.start,
|
||||
source_range,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn allocate(&mut self, requested_len: usize) -> Range<usize> {
|
||||
let remaining = self.source_range.end.saturating_sub(self.next_source_index);
|
||||
let len = requested_len.min(remaining);
|
||||
let start = self.next_source_index;
|
||||
let end = start + len;
|
||||
self.next_source_index = end;
|
||||
start..end
|
||||
}
|
||||
}
|
||||
|
||||
impl MarkdownElement {
|
||||
pub(crate) fn render_html_block(
|
||||
&self,
|
||||
block: &ParsedHtmlBlock,
|
||||
builder: &mut MarkdownElementBuilder,
|
||||
markdown_end: usize,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let mut source_allocator = HtmlSourceAllocator::new(block.source_range.clone());
|
||||
self.render_html_elements(
|
||||
&block.children,
|
||||
&mut source_allocator,
|
||||
builder,
|
||||
markdown_end,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_html_elements(
|
||||
&self,
|
||||
elements: &[ParsedHtmlElement],
|
||||
source_allocator: &mut HtmlSourceAllocator,
|
||||
builder: &mut MarkdownElementBuilder,
|
||||
markdown_end: usize,
|
||||
cx: &mut App,
|
||||
) {
|
||||
for element in elements {
|
||||
self.render_html_element(element, source_allocator, builder, markdown_end, cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_html_element(
|
||||
&self,
|
||||
element: &ParsedHtmlElement,
|
||||
source_allocator: &mut HtmlSourceAllocator,
|
||||
builder: &mut MarkdownElementBuilder,
|
||||
markdown_end: usize,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let Some(source_range) = element.source_range() else {
|
||||
return;
|
||||
};
|
||||
|
||||
match element {
|
||||
ParsedHtmlElement::Paragraph(paragraph) => {
|
||||
self.push_markdown_paragraph(
|
||||
builder,
|
||||
&source_range,
|
||||
markdown_end,
|
||||
paragraph.text_align,
|
||||
);
|
||||
self.render_html_paragraph(
|
||||
¶graph.contents,
|
||||
source_allocator,
|
||||
builder,
|
||||
cx,
|
||||
markdown_end,
|
||||
);
|
||||
self.pop_markdown_paragraph(builder);
|
||||
}
|
||||
ParsedHtmlElement::Heading(heading) => {
|
||||
self.push_markdown_heading(
|
||||
builder,
|
||||
heading.level,
|
||||
&heading.source_range,
|
||||
markdown_end,
|
||||
heading.text_align,
|
||||
);
|
||||
self.render_html_paragraph(
|
||||
&heading.contents,
|
||||
source_allocator,
|
||||
builder,
|
||||
cx,
|
||||
markdown_end,
|
||||
);
|
||||
self.pop_markdown_heading(builder);
|
||||
}
|
||||
ParsedHtmlElement::List(list) => {
|
||||
self.render_html_list(list, source_allocator, builder, markdown_end, cx);
|
||||
}
|
||||
ParsedHtmlElement::BlockQuote(block_quote) => {
|
||||
self.push_markdown_block_quote(
|
||||
builder,
|
||||
None,
|
||||
&block_quote.source_range,
|
||||
markdown_end,
|
||||
);
|
||||
self.render_html_elements(
|
||||
&block_quote.children,
|
||||
source_allocator,
|
||||
builder,
|
||||
markdown_end,
|
||||
cx,
|
||||
);
|
||||
self.pop_markdown_block_quote(builder);
|
||||
}
|
||||
ParsedHtmlElement::Table(table) => {
|
||||
self.render_html_table(table, source_allocator, builder, markdown_end, cx);
|
||||
}
|
||||
ParsedHtmlElement::Image(image) => {
|
||||
self.render_html_image(image, builder);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_html_list(
|
||||
&self,
|
||||
list: &ParsedHtmlList,
|
||||
source_allocator: &mut HtmlSourceAllocator,
|
||||
builder: &mut MarkdownElementBuilder,
|
||||
markdown_end: usize,
|
||||
cx: &mut App,
|
||||
) {
|
||||
builder.push_div(div().pl_2p5(), &list.source_range, markdown_end);
|
||||
|
||||
for list_item in &list.items {
|
||||
let bullet = match list_item.item_type {
|
||||
ParsedHtmlListItemType::Ordered(order) => html_list_item_prefix(
|
||||
order as usize,
|
||||
list.ordered,
|
||||
list.depth.saturating_sub(1) as usize,
|
||||
),
|
||||
ParsedHtmlListItemType::Unordered => {
|
||||
html_list_item_prefix(1, false, list.depth.saturating_sub(1) as usize)
|
||||
}
|
||||
};
|
||||
|
||||
self.push_markdown_list_item(
|
||||
builder,
|
||||
div().child(bullet).into_any_element(),
|
||||
&list_item.source_range,
|
||||
markdown_end,
|
||||
);
|
||||
self.render_html_elements(
|
||||
&list_item.content,
|
||||
source_allocator,
|
||||
builder,
|
||||
markdown_end,
|
||||
cx,
|
||||
);
|
||||
self.pop_markdown_list_item(builder);
|
||||
}
|
||||
|
||||
builder.pop_div();
|
||||
}
|
||||
|
||||
fn render_html_table(
|
||||
&self,
|
||||
table: &ParsedHtmlTable,
|
||||
source_allocator: &mut HtmlSourceAllocator,
|
||||
builder: &mut MarkdownElementBuilder,
|
||||
markdown_end: usize,
|
||||
cx: &mut App,
|
||||
) {
|
||||
if let Some(caption) = &table.caption {
|
||||
builder.push_div(
|
||||
div().when(!self.style.height_is_multiple_of_line_height, |el| {
|
||||
el.mb_2().line_height(rems(1.3))
|
||||
}),
|
||||
&table.source_range,
|
||||
markdown_end,
|
||||
);
|
||||
self.render_html_paragraph(caption, source_allocator, builder, cx, markdown_end);
|
||||
builder.pop_div();
|
||||
}
|
||||
|
||||
let actual_header_column_count = html_table_columns_count(&table.header);
|
||||
let actual_body_column_count = html_table_columns_count(&table.body);
|
||||
let max_column_count = actual_header_column_count.max(actual_body_column_count);
|
||||
|
||||
if max_column_count == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let total_rows = table.header.len() + table.body.len();
|
||||
let mut grid_occupied = vec![vec![false; max_column_count]; total_rows];
|
||||
|
||||
builder.push_div(
|
||||
div()
|
||||
.id(("html-table", table.source_range.start))
|
||||
.grid()
|
||||
.grid_cols(max_column_count as u16)
|
||||
.when(self.style.table_columns_min_size, |this| {
|
||||
this.grid_cols_min_content(max_column_count as u16)
|
||||
})
|
||||
.when(!self.style.table_columns_min_size, |this| {
|
||||
this.grid_cols(max_column_count as u16)
|
||||
})
|
||||
.w_full()
|
||||
.mb_2()
|
||||
.border(px(1.5))
|
||||
.border_color(cx.theme().colors().border)
|
||||
.rounded_sm()
|
||||
.overflow_hidden(),
|
||||
&table.source_range,
|
||||
markdown_end,
|
||||
);
|
||||
|
||||
for (row_index, row) in table.header.iter().chain(table.body.iter()).enumerate() {
|
||||
let mut column_index = 0;
|
||||
|
||||
for cell in &row.columns {
|
||||
while column_index < max_column_count && grid_occupied[row_index][column_index] {
|
||||
column_index += 1;
|
||||
}
|
||||
|
||||
if column_index >= max_column_count {
|
||||
break;
|
||||
}
|
||||
|
||||
let max_span = max_column_count.saturating_sub(column_index);
|
||||
let text_align = match cell.alignment {
|
||||
Alignment::Left => TextAlign::Left,
|
||||
Alignment::Center => TextAlign::Center,
|
||||
Alignment::Right => TextAlign::Right,
|
||||
_ => self.style.base_text_style.text_align,
|
||||
};
|
||||
|
||||
let mut cell_div = div()
|
||||
.col_span(cell.col_span.min(max_span) as u16)
|
||||
.row_span(cell.row_span.min(total_rows - row_index) as u16)
|
||||
.flex()
|
||||
.flex_col()
|
||||
.when(column_index > 0, |this| this.border_l_1())
|
||||
.when(row_index > 0, |this| this.border_t_1())
|
||||
.border_color(cx.theme().colors().border)
|
||||
.px_2()
|
||||
.py_1()
|
||||
.h_full()
|
||||
.when(cell.is_header, |this| {
|
||||
this.bg(cx.theme().colors().title_bar_background)
|
||||
})
|
||||
.when(!cell.is_header && row_index % 2 == 1, |this| {
|
||||
this.bg(cx.theme().colors().panel_background)
|
||||
});
|
||||
|
||||
cell_div = match cell.alignment {
|
||||
Alignment::Center => cell_div.items_center(),
|
||||
Alignment::Right => cell_div.items_end(),
|
||||
_ => cell_div,
|
||||
};
|
||||
|
||||
builder.push_text_style(TextStyleRefinement {
|
||||
text_align: Some(text_align),
|
||||
..Default::default()
|
||||
});
|
||||
builder.push_div(cell_div, &table.source_range, markdown_end);
|
||||
builder.push_div(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.flex_1()
|
||||
.w_full()
|
||||
.justify_center()
|
||||
.text_align(text_align),
|
||||
&table.source_range,
|
||||
markdown_end,
|
||||
);
|
||||
self.render_html_paragraph(
|
||||
&cell.children,
|
||||
source_allocator,
|
||||
builder,
|
||||
cx,
|
||||
markdown_end,
|
||||
);
|
||||
builder.pop_div();
|
||||
builder.pop_div();
|
||||
builder.pop_text_style();
|
||||
|
||||
for row_offset in 0..cell.row_span {
|
||||
for column_offset in 0..cell.col_span {
|
||||
if row_index + row_offset < total_rows
|
||||
&& column_index + column_offset < max_column_count
|
||||
{
|
||||
grid_occupied[row_index + row_offset][column_index + column_offset] =
|
||||
true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
column_index += cell.col_span;
|
||||
}
|
||||
|
||||
while column_index < max_column_count {
|
||||
if grid_occupied[row_index][column_index] {
|
||||
column_index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
builder.push_div(
|
||||
div()
|
||||
.when(column_index > 0, |this| this.border_l_1())
|
||||
.when(row_index > 0, |this| this.border_t_1())
|
||||
.border_color(cx.theme().colors().border)
|
||||
.when(row_index % 2 == 1, |this| {
|
||||
this.bg(cx.theme().colors().panel_background)
|
||||
}),
|
||||
&table.source_range,
|
||||
markdown_end,
|
||||
);
|
||||
builder.pop_div();
|
||||
column_index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
builder.pop_div();
|
||||
}
|
||||
|
||||
fn render_html_paragraph(
|
||||
&self,
|
||||
paragraph: &HtmlParagraph,
|
||||
source_allocator: &mut HtmlSourceAllocator,
|
||||
builder: &mut MarkdownElementBuilder,
|
||||
cx: &mut App,
|
||||
_markdown_end: usize,
|
||||
) {
|
||||
for chunk in paragraph {
|
||||
match chunk {
|
||||
HtmlParagraphChunk::Text(text) => {
|
||||
self.render_html_text(text, source_allocator, builder, cx);
|
||||
}
|
||||
HtmlParagraphChunk::Image(image) => {
|
||||
self.render_html_image(image, builder);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_html_text(
|
||||
&self,
|
||||
text: &ParsedHtmlText,
|
||||
source_allocator: &mut HtmlSourceAllocator,
|
||||
builder: &mut MarkdownElementBuilder,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let text_contents = text.contents.as_ref();
|
||||
if text_contents.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let allocated_range = source_allocator.allocate(text_contents.len());
|
||||
let allocated_len = allocated_range.end.saturating_sub(allocated_range.start);
|
||||
|
||||
let mut boundaries = vec![0, text_contents.len()];
|
||||
for (range, _) in &text.highlights {
|
||||
boundaries.push(range.start);
|
||||
boundaries.push(range.end);
|
||||
}
|
||||
for (range, _) in &text.links {
|
||||
boundaries.push(range.start);
|
||||
boundaries.push(range.end);
|
||||
}
|
||||
boundaries.sort_unstable();
|
||||
boundaries.dedup();
|
||||
|
||||
for segment in boundaries.windows(2) {
|
||||
let start = segment[0];
|
||||
let end = segment[1];
|
||||
if start >= end {
|
||||
continue;
|
||||
}
|
||||
|
||||
let source_start = allocated_range.start + start.min(allocated_len);
|
||||
let source_end = allocated_range.start + end.min(allocated_len);
|
||||
if source_start >= source_end {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut refinement = TextStyleRefinement::default();
|
||||
let mut has_refinement = false;
|
||||
|
||||
for (highlight_range, style) in &text.highlights {
|
||||
if highlight_range.start < end && highlight_range.end > start {
|
||||
apply_html_highlight_style(&mut refinement, style);
|
||||
has_refinement = true;
|
||||
}
|
||||
}
|
||||
|
||||
let link = text.links.iter().find_map(|(link_range, link)| {
|
||||
if link_range.start < end && link_range.end > start {
|
||||
Some(link.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(link) = link.as_ref() {
|
||||
builder.push_link(link.clone(), source_start..source_end);
|
||||
let link_style = self
|
||||
.style
|
||||
.link_callback
|
||||
.as_ref()
|
||||
.and_then(|callback| callback(link.as_ref(), cx))
|
||||
.unwrap_or_else(|| self.style.link.clone());
|
||||
builder.push_text_style(link_style);
|
||||
}
|
||||
|
||||
if has_refinement {
|
||||
builder.push_text_style(refinement);
|
||||
}
|
||||
|
||||
builder.push_text(&text_contents[start..end], source_start..source_end);
|
||||
|
||||
if has_refinement {
|
||||
builder.pop_text_style();
|
||||
}
|
||||
|
||||
if link.is_some() {
|
||||
builder.pop_text_style();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_html_image(&self, image: &HtmlImage, builder: &mut MarkdownElementBuilder) {
|
||||
let Some(source) = self
|
||||
.image_resolver
|
||||
.as_ref()
|
||||
.and_then(|resolve| resolve(image.dest_url.as_ref()))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.push_markdown_image(
|
||||
builder,
|
||||
&image.source_range,
|
||||
source,
|
||||
image.dest_url.clone(),
|
||||
image.alt_text.clone(),
|
||||
image.width,
|
||||
image.height,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_html_highlight_style(refinement: &mut TextStyleRefinement, style: &HtmlHighlightStyle) {
|
||||
if style.weight != FontWeight::default() {
|
||||
refinement.font_weight = Some(style.weight);
|
||||
}
|
||||
|
||||
if style.oblique {
|
||||
refinement.font_style = Some(FontStyle::Oblique);
|
||||
} else if style.italic {
|
||||
refinement.font_style = Some(FontStyle::Italic);
|
||||
}
|
||||
|
||||
if style.underline {
|
||||
refinement.underline = Some(UnderlineStyle {
|
||||
thickness: px(1.),
|
||||
color: None,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
if style.strikethrough {
|
||||
refinement.strikethrough = Some(StrikethroughStyle {
|
||||
thickness: px(1.),
|
||||
color: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn html_list_item_prefix(order: usize, ordered: bool, depth: usize) -> String {
|
||||
let index = order.saturating_sub(1);
|
||||
const NUMBERED_PREFIXES_1: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
const NUMBERED_PREFIXES_2: &str = "abcdefghijklmnopqrstuvwxyz";
|
||||
const BULLETS: [&str; 5] = ["•", "◦", "▪", "‣", "⁃"];
|
||||
|
||||
if ordered {
|
||||
match depth {
|
||||
0 => format!("{}. ", order),
|
||||
1 => format!(
|
||||
"{}. ",
|
||||
NUMBERED_PREFIXES_1
|
||||
.chars()
|
||||
.nth(index % NUMBERED_PREFIXES_1.len())
|
||||
.unwrap()
|
||||
),
|
||||
_ => format!(
|
||||
"{}. ",
|
||||
NUMBERED_PREFIXES_2
|
||||
.chars()
|
||||
.nth(index % NUMBERED_PREFIXES_2.len())
|
||||
.unwrap()
|
||||
),
|
||||
}
|
||||
} else {
|
||||
let depth = depth.min(BULLETS.len() - 1);
|
||||
format!("{} ", BULLETS[depth])
|
||||
}
|
||||
}
|
||||
|
||||
fn html_table_columns_count(rows: &[ParsedHtmlTableRow]) -> usize {
|
||||
let mut actual_column_count = 0;
|
||||
for row in rows {
|
||||
actual_column_count = actual_column_count.max(
|
||||
row.columns
|
||||
.iter()
|
||||
.map(|column| column.col_span)
|
||||
.sum::<usize>(),
|
||||
);
|
||||
}
|
||||
actual_column_count
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use gpui::{TestAppContext, size};
|
||||
use ui::prelude::*;
|
||||
|
||||
use crate::{
|
||||
CodeBlockRenderer, CopyButtonVisibility, Markdown, MarkdownElement, MarkdownOptions,
|
||||
MarkdownStyle,
|
||||
};
|
||||
|
||||
fn ensure_theme_initialized(cx: &mut TestAppContext) {
|
||||
cx.update(|cx| {
|
||||
if !cx.has_global::<settings::SettingsStore>() {
|
||||
settings::init(cx);
|
||||
}
|
||||
if !cx.has_global::<theme::GlobalTheme>() {
|
||||
theme_settings::init(theme::LoadThemes::JustBase, cx);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn render_markdown_text(markdown: &str, cx: &mut TestAppContext) -> crate::RenderedText {
|
||||
struct TestWindow;
|
||||
|
||||
impl Render for TestWindow {
|
||||
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
}
|
||||
}
|
||||
|
||||
ensure_theme_initialized(cx);
|
||||
|
||||
let (_, cx) = cx.add_window_view(|_, _| TestWindow);
|
||||
let markdown = cx.new(|cx| Markdown::new(markdown.to_string().into(), None, None, cx));
|
||||
cx.run_until_parked();
|
||||
let (rendered, _) = cx.draw(
|
||||
Default::default(),
|
||||
size(px(600.0), px(600.0)),
|
||||
|_window, _cx| {
|
||||
MarkdownElement::new(markdown, MarkdownStyle::default()).code_block_renderer(
|
||||
CodeBlockRenderer::Default {
|
||||
copy_button_visibility: CopyButtonVisibility::Hidden,
|
||||
border: false,
|
||||
},
|
||||
)
|
||||
},
|
||||
);
|
||||
rendered.text
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_html_block_rendering_smoke(cx: &mut TestAppContext) {
|
||||
let rendered = render_markdown_text(
|
||||
"<h1>Hello</h1><blockquote><p>world</p></blockquote><ul><li>item</li></ul>",
|
||||
cx,
|
||||
);
|
||||
|
||||
let rendered_lines = rendered
|
||||
.lines
|
||||
.iter()
|
||||
.map(|line| line.layout.wrapped_text())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(
|
||||
rendered_lines.concat().replace('\n', ""),
|
||||
"<h1>Hello</h1><blockquote><p>world</p></blockquote><ul><li>item</li></ul>"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_html_block_rendering_can_be_enabled(cx: &mut TestAppContext) {
|
||||
struct TestWindow;
|
||||
|
||||
impl Render for TestWindow {
|
||||
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
}
|
||||
}
|
||||
|
||||
ensure_theme_initialized(cx);
|
||||
|
||||
let (_, cx) = cx.add_window_view(|_, _| TestWindow);
|
||||
let markdown = cx.new(|cx| {
|
||||
Markdown::new_with_options(
|
||||
"<h1>Hello</h1><blockquote><p>world</p></blockquote><ul><li>item</li></ul>".into(),
|
||||
None,
|
||||
None,
|
||||
MarkdownOptions {
|
||||
parse_html: true,
|
||||
..Default::default()
|
||||
},
|
||||
cx,
|
||||
)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let (rendered, _) = cx.draw(
|
||||
Default::default(),
|
||||
size(px(600.0), px(600.0)),
|
||||
|_window, _cx| {
|
||||
MarkdownElement::new(markdown, MarkdownStyle::default()).code_block_renderer(
|
||||
CodeBlockRenderer::Default {
|
||||
copy_button_visibility: CopyButtonVisibility::Hidden,
|
||||
border: false,
|
||||
},
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
let rendered_lines = rendered
|
||||
.text
|
||||
.lines
|
||||
.iter()
|
||||
.map(|line| line.layout.wrapped_text())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(rendered_lines[0], "Hello");
|
||||
assert_eq!(rendered_lines[1], "world");
|
||||
assert!(rendered_lines.iter().any(|line| line.contains("item")));
|
||||
}
|
||||
}
|
||||
4168
crates/markdown/src/markdown.rs
Normal file
4168
crates/markdown/src/markdown.rs
Normal file
File diff suppressed because it is too large
Load Diff
941
crates/markdown/src/mermaid.rs
Normal file
941
crates/markdown/src/mermaid.rs
Normal file
@@ -0,0 +1,941 @@
|
||||
use collections::HashMap;
|
||||
use gpui::{
|
||||
Animation, AnimationExt, AnyElement, ClickEvent, ClipboardItem, Context, Entity, Hsla,
|
||||
ImageSource, RenderImage, Rgba, StyledText, Task, img, pulsating_between,
|
||||
};
|
||||
use std::collections::BTreeMap;
|
||||
use std::ops::Range;
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use std::time::Duration;
|
||||
use ui::CopyButton;
|
||||
use ui::prelude::*;
|
||||
|
||||
use crate::parser::{CodeBlockKind, MarkdownEvent, MarkdownTag};
|
||||
use settings::Settings as _;
|
||||
use theme_settings::ThemeSettings;
|
||||
|
||||
use super::{CopyButtonVisibility, Markdown, MarkdownStyle, ParsedMarkdown};
|
||||
|
||||
type MermaidDiagramCache = HashMap<ParsedMarkdownMermaidDiagramContents, Arc<CachedMermaidDiagram>>;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct ParsedMarkdownMermaidDiagram {
|
||||
pub(crate) content_range: Range<usize>,
|
||||
pub(crate) contents: ParsedMarkdownMermaidDiagramContents,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
pub(crate) struct ParsedMarkdownMermaidDiagramContents {
|
||||
pub(crate) contents: SharedString,
|
||||
pub(crate) scale: u32,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
pub(crate) struct MermaidState {
|
||||
cache: MermaidDiagramCache,
|
||||
order: Vec<ParsedMarkdownMermaidDiagramContents>,
|
||||
}
|
||||
|
||||
struct CachedMermaidDiagram {
|
||||
render_image: Arc<OnceLock<anyhow::Result<Arc<RenderImage>>>>,
|
||||
fallback_image: Option<Arc<RenderImage>>,
|
||||
_task: Task<()>,
|
||||
}
|
||||
|
||||
impl MermaidState {
|
||||
pub(crate) fn clear(&mut self) {
|
||||
self.cache.clear();
|
||||
self.order.clear();
|
||||
}
|
||||
|
||||
fn get_fallback_image(
|
||||
idx: usize,
|
||||
old_order: &[ParsedMarkdownMermaidDiagramContents],
|
||||
new_order_len: usize,
|
||||
cache: &MermaidDiagramCache,
|
||||
) -> Option<Arc<RenderImage>> {
|
||||
if old_order.len() != new_order_len {
|
||||
return None;
|
||||
}
|
||||
|
||||
old_order.get(idx).and_then(|old_content| {
|
||||
cache.get(old_content).and_then(|old_cached| {
|
||||
old_cached
|
||||
.render_image
|
||||
.get()
|
||||
.and_then(|result| result.as_ref().ok().cloned())
|
||||
.or_else(|| old_cached.fallback_image.clone())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn update(&mut self, parsed: &ParsedMarkdown, cx: &mut Context<Markdown>) {
|
||||
let mut new_order = Vec::new();
|
||||
for mermaid_diagram in parsed.mermaid_diagrams.values() {
|
||||
new_order.push(mermaid_diagram.contents.clone());
|
||||
}
|
||||
|
||||
for (idx, new_content) in new_order.iter().enumerate() {
|
||||
if !self.cache.contains_key(new_content) {
|
||||
let fallback =
|
||||
Self::get_fallback_image(idx, &self.order, new_order.len(), &self.cache);
|
||||
self.cache.insert(
|
||||
new_content.clone(),
|
||||
Arc::new(CachedMermaidDiagram::new(new_content.clone(), fallback, cx)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let new_order_set: std::collections::HashSet<_> = new_order.iter().cloned().collect();
|
||||
self.cache
|
||||
.retain(|content, _| new_order_set.contains(content));
|
||||
self.order = new_order;
|
||||
}
|
||||
}
|
||||
|
||||
impl CachedMermaidDiagram {
|
||||
fn new(
|
||||
contents: ParsedMarkdownMermaidDiagramContents,
|
||||
fallback_image: Option<Arc<RenderImage>>,
|
||||
cx: &mut Context<Markdown>,
|
||||
) -> Self {
|
||||
let render_image = Arc::new(OnceLock::<anyhow::Result<Arc<RenderImage>>>::new());
|
||||
let render_image_clone = render_image.clone();
|
||||
let svg_renderer = cx.svg_renderer();
|
||||
let mermaid_theme = build_mermaid_theme(cx);
|
||||
let accent_classdefs = build_accent_classdefs(cx);
|
||||
|
||||
let task = cx.spawn(async move |this, cx| {
|
||||
let value = cx
|
||||
.background_spawn(async move {
|
||||
let options = mermaid_rs_renderer::RenderOptions {
|
||||
theme: mermaid_theme,
|
||||
layout: mermaid_rs_renderer::LayoutConfig::default(),
|
||||
};
|
||||
let full_source = format!("{}\n{}", contents.contents, accent_classdefs);
|
||||
let svg_string =
|
||||
mermaid_rs_renderer::render_with_options(&full_source, options)?;
|
||||
let scale = contents.scale as f32 / 100.0;
|
||||
svg_renderer
|
||||
.render_single_frame(svg_string.as_bytes(), scale)
|
||||
.map_err(|error| anyhow::anyhow!("{error}"))
|
||||
})
|
||||
.await;
|
||||
let _ = render_image_clone.set(value);
|
||||
this.update(cx, |_, cx| {
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
});
|
||||
|
||||
Self {
|
||||
render_image,
|
||||
fallback_image,
|
||||
_task: task,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn new_for_test(
|
||||
render_image: Option<Arc<RenderImage>>,
|
||||
fallback_image: Option<Arc<RenderImage>>,
|
||||
) -> Self {
|
||||
let result = Arc::new(OnceLock::new());
|
||||
if let Some(render_image) = render_image {
|
||||
let _ = result.set(Ok(render_image));
|
||||
}
|
||||
Self {
|
||||
render_image: result,
|
||||
fallback_image,
|
||||
_task: Task::ready(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts an HSLA color to a CSS hex string (e.g. `#1a2b3c`).
|
||||
fn hsla_to_hex(color: Hsla) -> String {
|
||||
let rgba: Rgba = color.to_rgb();
|
||||
let r = (rgba.r * 255.0).round() as u8;
|
||||
let g = (rgba.g * 255.0).round() as u8;
|
||||
let b = (rgba.b * 255.0).round() as u8;
|
||||
format!("#{r:02x}{g:02x}{b:02x}")
|
||||
}
|
||||
|
||||
fn build_mermaid_theme(cx: &Context<Markdown>) -> mermaid_rs_renderer::Theme {
|
||||
let colors = cx.theme().colors();
|
||||
let theme_settings = ThemeSettings::get_global(cx);
|
||||
let mut theme = mermaid_rs_renderer::Theme::modern();
|
||||
|
||||
theme.font_family = theme_settings.ui_font.family.to_string();
|
||||
theme.background = hsla_to_hex(colors.editor_background);
|
||||
theme.primary_color = hsla_to_hex(colors.surface_background);
|
||||
theme.primary_text_color = hsla_to_hex(colors.text);
|
||||
theme.primary_border_color = hsla_to_hex(colors.border);
|
||||
theme.line_color = hsla_to_hex(colors.border);
|
||||
theme.secondary_color = hsla_to_hex(colors.element_background);
|
||||
theme.tertiary_color = hsla_to_hex(colors.ghost_element_hover);
|
||||
theme.edge_label_background = hsla_to_hex(colors.editor_background);
|
||||
theme.cluster_background = hsla_to_hex(colors.panel_background);
|
||||
theme.cluster_border = hsla_to_hex(colors.border_variant);
|
||||
theme.text_color = hsla_to_hex(colors.text);
|
||||
let accents = cx.theme().accents();
|
||||
let pie_colors: [String; 12] =
|
||||
std::array::from_fn(|i| hsla_to_hex(accents.color_for_index(i as u32)));
|
||||
theme.pie_colors = pie_colors;
|
||||
theme.pie_title_text_color = hsla_to_hex(colors.text);
|
||||
theme.pie_section_text_color = "#fff".to_string();
|
||||
theme.pie_legend_text_color = hsla_to_hex(colors.text);
|
||||
theme.pie_stroke_color = hsla_to_hex(colors.border);
|
||||
theme.pie_outer_stroke_color = hsla_to_hex(colors.border);
|
||||
|
||||
theme.sequence_actor_fill = hsla_to_hex(colors.element_background);
|
||||
theme.sequence_actor_border = hsla_to_hex(colors.border);
|
||||
theme.sequence_actor_line = hsla_to_hex(colors.border);
|
||||
theme.sequence_note_fill = hsla_to_hex(colors.surface_background);
|
||||
theme.sequence_note_border = hsla_to_hex(colors.border_variant);
|
||||
theme.sequence_activation_fill = hsla_to_hex(colors.ghost_element_hover);
|
||||
theme.sequence_activation_border = hsla_to_hex(colors.border);
|
||||
|
||||
let players = cx.theme().players();
|
||||
theme.git_colors = std::array::from_fn(|i| hsla_to_hex(players.0[i % players.0.len()].cursor));
|
||||
theme.git_inv_colors =
|
||||
std::array::from_fn(|i| hsla_to_hex(players.0[i % players.0.len()].background));
|
||||
theme.git_branch_label_colors = std::array::from_fn(|_| "#fff".to_string());
|
||||
theme.git_commit_label_color = hsla_to_hex(colors.text);
|
||||
theme.git_commit_label_background = hsla_to_hex(colors.element_background);
|
||||
theme.git_tag_label_color = hsla_to_hex(colors.text);
|
||||
theme.git_tag_label_background = hsla_to_hex(colors.element_background);
|
||||
theme.git_tag_label_border = hsla_to_hex(colors.border);
|
||||
|
||||
theme
|
||||
}
|
||||
|
||||
fn build_accent_classdefs(cx: &Context<Markdown>) -> String {
|
||||
use std::fmt::Write;
|
||||
let players = &cx.theme().players();
|
||||
let is_light = cx.theme().appearance.is_light();
|
||||
let mut defs = String::new();
|
||||
for (i, player) in players.0.iter().enumerate() {
|
||||
let (fill, text_color) = accent_fill_and_text(player.background, is_light);
|
||||
let fill = hsla_to_hex(fill);
|
||||
let stroke = hsla_to_hex(player.cursor);
|
||||
let text_color = hsla_to_hex(text_color);
|
||||
writeln!(
|
||||
defs,
|
||||
"classDef accent{i} fill:{fill},stroke:{stroke},color:{text_color}"
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
defs
|
||||
}
|
||||
|
||||
/// Adjusts an accent fill color to ensure readable text contrast.
|
||||
///
|
||||
/// On dark themes, darkens the fill and uses white text.
|
||||
/// On light themes, lightens the fill and uses black text.
|
||||
/// The fill is adjusted until it meets a minimum WCAG contrast ratio
|
||||
/// of ~4.5:1 against the chosen text color.
|
||||
fn accent_fill_and_text(color: Hsla, is_light: bool) -> (Hsla, Hsla) {
|
||||
let mut fill = color;
|
||||
if is_light {
|
||||
// Lighten fill until luminance is high enough for black text.
|
||||
// Target: relative luminance >= 0.35 → contrast ratio ~8:1 with black.
|
||||
for _ in 0..50 {
|
||||
if relative_luminance(fill) >= 0.35 {
|
||||
break;
|
||||
}
|
||||
fill.l = (fill.l + 0.02).min(1.0);
|
||||
}
|
||||
(fill, gpui::black())
|
||||
} else {
|
||||
// Darken fill until luminance is low enough for white text.
|
||||
// Target: relative luminance <= 0.18 → contrast ratio ~4.6:1 with white.
|
||||
for _ in 0..50 {
|
||||
if relative_luminance(fill) <= 0.18 {
|
||||
break;
|
||||
}
|
||||
fill.l = (fill.l - 0.02).max(0.0);
|
||||
}
|
||||
(fill, gpui::white())
|
||||
}
|
||||
}
|
||||
|
||||
fn relative_luminance(color: Hsla) -> f32 {
|
||||
let rgba: Rgba = color.to_rgb();
|
||||
fn linearize(c: f32) -> f32 {
|
||||
if c <= 0.04045 {
|
||||
c / 12.92
|
||||
} else {
|
||||
((c + 0.055) / 1.055).powf(2.4)
|
||||
}
|
||||
}
|
||||
0.2126 * linearize(rgba.r) + 0.7152 * linearize(rgba.g) + 0.0722 * linearize(rgba.b)
|
||||
}
|
||||
|
||||
fn parse_mermaid_info(info: &str) -> Option<u32> {
|
||||
let mut parts = info.split_whitespace();
|
||||
if parts.next()? != "mermaid" {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(
|
||||
parts
|
||||
.next()
|
||||
.and_then(|scale| scale.parse().ok())
|
||||
.unwrap_or(100)
|
||||
.clamp(10, 500),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn extract_mermaid_diagrams(
|
||||
source: &str,
|
||||
events: &[(Range<usize>, MarkdownEvent)],
|
||||
) -> BTreeMap<usize, ParsedMarkdownMermaidDiagram> {
|
||||
let mut mermaid_diagrams = BTreeMap::default();
|
||||
|
||||
for (source_range, event) in events {
|
||||
let MarkdownEvent::Start(MarkdownTag::CodeBlock { kind, metadata }) = event else {
|
||||
continue;
|
||||
};
|
||||
if !metadata.is_fenced_closed {
|
||||
continue;
|
||||
}
|
||||
let scale = match kind {
|
||||
CodeBlockKind::FencedLang(info) => match parse_mermaid_info(info.as_ref()) {
|
||||
Some(scale) => scale,
|
||||
None => continue,
|
||||
},
|
||||
CodeBlockKind::FencedSrc(path_range) => {
|
||||
let path = Path::new(path_range.path.as_ref());
|
||||
match path.extension().and_then(|ext| ext.to_str()) {
|
||||
Some("mermaid" | "mmd") => 100,
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
let contents = source[metadata.content_range.clone()]
|
||||
.strip_suffix('\n')
|
||||
.unwrap_or(&source[metadata.content_range.clone()])
|
||||
.to_string();
|
||||
mermaid_diagrams.insert(
|
||||
source_range.start,
|
||||
ParsedMarkdownMermaidDiagram {
|
||||
content_range: metadata.content_range.clone(),
|
||||
contents: ParsedMarkdownMermaidDiagramContents {
|
||||
contents: contents.into(),
|
||||
scale,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
mermaid_diagrams
|
||||
}
|
||||
|
||||
pub(crate) fn render_mermaid_diagram(
|
||||
parsed: &ParsedMarkdownMermaidDiagram,
|
||||
mermaid_state: &MermaidState,
|
||||
style: &MarkdownStyle,
|
||||
markdown: Entity<Markdown>,
|
||||
source_offset: usize,
|
||||
showing_code: bool,
|
||||
copy_button_visibility: CopyButtonVisibility,
|
||||
) -> AnyElement {
|
||||
let cached = mermaid_state.cache.get(&parsed.contents);
|
||||
let render_result = cached.and_then(|cached| cached.render_image.get());
|
||||
let show_interactive = copy_button_visibility != CopyButtonVisibility::Hidden;
|
||||
|
||||
let code = parsed.contents.contents.clone();
|
||||
|
||||
let mut container = div().group("code_block").relative().w_full().rounded_lg();
|
||||
container.style().refine(&style.code_block);
|
||||
|
||||
match render_result {
|
||||
Some(Ok(render_image)) => {
|
||||
let body = if showing_code {
|
||||
render_mermaid_code_view(&parsed.contents.contents)
|
||||
} else {
|
||||
div()
|
||||
.w_full()
|
||||
.child(
|
||||
img(ImageSource::Render(render_image.clone()))
|
||||
.max_w_full()
|
||||
.with_fallback(|| {
|
||||
div()
|
||||
.child(Label::new("Failed to load mermaid diagram"))
|
||||
.into_any_element()
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
};
|
||||
|
||||
container
|
||||
.when(show_interactive, |container| {
|
||||
container.child(render_mermaid_tab_header(
|
||||
source_offset,
|
||||
showing_code,
|
||||
markdown.clone(),
|
||||
))
|
||||
})
|
||||
.child(body)
|
||||
.when(show_interactive, |container| {
|
||||
container.child(render_mermaid_copy_button(
|
||||
source_offset,
|
||||
code.to_string(),
|
||||
markdown,
|
||||
))
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
Some(Err(_)) => {
|
||||
// Render failed — show the source code without tabs
|
||||
container
|
||||
.child(render_mermaid_code_view(&parsed.contents.contents))
|
||||
.when(show_interactive, |container| {
|
||||
container.child(render_mermaid_copy_button(
|
||||
source_offset,
|
||||
code.to_string(),
|
||||
markdown,
|
||||
))
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
None => {
|
||||
// Still rendering
|
||||
if let Some(fallback) = cached.and_then(|cached| cached.fallback_image.as_ref()) {
|
||||
container
|
||||
.child(
|
||||
div()
|
||||
.w_full()
|
||||
.child(
|
||||
img(ImageSource::Render(fallback.clone()))
|
||||
.max_w_full()
|
||||
.with_fallback(|| {
|
||||
div()
|
||||
.child(Label::new("Failed to load mermaid diagram"))
|
||||
.into_any_element()
|
||||
}),
|
||||
)
|
||||
.with_animation(
|
||||
"mermaid-fallback-pulse",
|
||||
Animation::new(Duration::from_secs(2))
|
||||
.repeat()
|
||||
.with_easing(pulsating_between(0.6, 1.0)),
|
||||
|element, delta| element.opacity(delta),
|
||||
),
|
||||
)
|
||||
.when(show_interactive, |container| {
|
||||
container.child(render_mermaid_copy_button(
|
||||
source_offset,
|
||||
code.to_string(),
|
||||
markdown,
|
||||
))
|
||||
})
|
||||
.into_any_element()
|
||||
} else {
|
||||
// No fallback — show the code so the user has something to look at
|
||||
container
|
||||
.child(render_mermaid_code_view(&parsed.contents.contents))
|
||||
.child(
|
||||
div().absolute().top_1().right_2().child(
|
||||
Label::new("Rendering...")
|
||||
.size(LabelSize::XSmall)
|
||||
.color(Color::Muted)
|
||||
.with_animation(
|
||||
"mermaid-loading-pulse",
|
||||
Animation::new(Duration::from_secs(2))
|
||||
.repeat()
|
||||
.with_easing(pulsating_between(0.4, 0.8)),
|
||||
|label, delta| label.alpha(delta),
|
||||
),
|
||||
),
|
||||
)
|
||||
.when(show_interactive, |container| {
|
||||
container.child(render_mermaid_copy_button(
|
||||
source_offset,
|
||||
code.to_string(),
|
||||
markdown,
|
||||
))
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_mermaid_tab_header(
|
||||
source_offset: usize,
|
||||
showing_code: bool,
|
||||
markdown: Entity<Markdown>,
|
||||
) -> impl IntoElement {
|
||||
let preview_markdown = markdown.clone();
|
||||
let code_markdown = markdown;
|
||||
|
||||
h_flex()
|
||||
.gap_0p5()
|
||||
.p_0p5()
|
||||
.mb_1()
|
||||
.child(render_mermaid_tab_button(
|
||||
"Preview",
|
||||
source_offset,
|
||||
!showing_code,
|
||||
move |_event, _window, cx| {
|
||||
preview_markdown.update(cx, |md, cx| {
|
||||
if md.is_mermaid_showing_code(source_offset) {
|
||||
md.toggle_mermaid_tab(source_offset);
|
||||
cx.notify();
|
||||
}
|
||||
});
|
||||
},
|
||||
))
|
||||
.child(render_mermaid_tab_button(
|
||||
"Code",
|
||||
source_offset,
|
||||
showing_code,
|
||||
move |_event, _window, cx| {
|
||||
code_markdown.update(cx, |md, cx| {
|
||||
if !md.is_mermaid_showing_code(source_offset) {
|
||||
md.toggle_mermaid_tab(source_offset);
|
||||
cx.notify();
|
||||
}
|
||||
});
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
fn render_mermaid_tab_button(
|
||||
label: &'static str,
|
||||
source_offset: usize,
|
||||
is_selected: bool,
|
||||
on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
|
||||
) -> impl IntoElement {
|
||||
div()
|
||||
.id(ElementId::named_usize(
|
||||
SharedString::from(format!("mermaid-tab-{label}")),
|
||||
source_offset,
|
||||
))
|
||||
.cursor_pointer()
|
||||
.px_1p5()
|
||||
.py_0p5()
|
||||
.rounded_md()
|
||||
.text_size(rems(0.75))
|
||||
.when(is_selected, |this| this.bg(gpui::hsla(0., 0., 0.5, 0.15)))
|
||||
.when(!is_selected, |this| {
|
||||
this.hover(|this| this.bg(gpui::hsla(0., 0., 0.5, 0.08)))
|
||||
})
|
||||
.child(label)
|
||||
.on_click(on_click)
|
||||
}
|
||||
|
||||
fn render_mermaid_copy_button(
|
||||
source_offset: usize,
|
||||
code: String,
|
||||
markdown: Entity<Markdown>,
|
||||
) -> impl IntoElement {
|
||||
let id = ElementId::named_usize("copy-mermaid-code", source_offset);
|
||||
|
||||
h_flex()
|
||||
.w_4()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.right_0()
|
||||
.justify_end()
|
||||
.visible_on_hover("code_block")
|
||||
.child(CopyButton::new(id.clone(), code.clone()).custom_on_click({
|
||||
move |_window, cx| {
|
||||
let id = id.clone();
|
||||
markdown.update(cx, |this, cx| {
|
||||
this.copied_code_blocks.insert(id.clone());
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(code.clone()));
|
||||
cx.spawn(async move |this, cx| {
|
||||
cx.background_executor().timer(Duration::from_secs(2)).await;
|
||||
cx.update(|cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.copied_code_blocks.remove(&id);
|
||||
cx.notify();
|
||||
})
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
});
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
fn render_mermaid_code_view(contents: &SharedString) -> AnyElement {
|
||||
div()
|
||||
.w_full()
|
||||
.child(StyledText::new(contents.clone()))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
CachedMermaidDiagram, MermaidDiagramCache, MermaidState,
|
||||
ParsedMarkdownMermaidDiagramContents, extract_mermaid_diagrams, parse_mermaid_info,
|
||||
};
|
||||
use crate::{
|
||||
CodeBlockRenderer, CopyButtonVisibility, Markdown, MarkdownElement, MarkdownOptions,
|
||||
MarkdownStyle,
|
||||
};
|
||||
use collections::HashMap;
|
||||
use gpui::{Context, Hsla, IntoElement, Render, RenderImage, TestAppContext, Window, size};
|
||||
use std::sync::Arc;
|
||||
use ui::prelude::*;
|
||||
|
||||
#[gpui::property_test]
|
||||
fn accent_fill_and_text_sufficient_contrast(
|
||||
#[strategy = Hsla::opaque_strategy()] color: Hsla,
|
||||
light_mode: bool,
|
||||
) {
|
||||
let (fill, text) = super::accent_fill_and_text(color, light_mode);
|
||||
let fill_luminance = super::relative_luminance(fill);
|
||||
let text_luminance = super::relative_luminance(text);
|
||||
let lighter = fill_luminance.max(text_luminance);
|
||||
let darker = fill_luminance.min(text_luminance);
|
||||
let contrast_ratio = (lighter + 0.05) / (darker + 0.05);
|
||||
assert!(contrast_ratio >= 4.5,);
|
||||
}
|
||||
|
||||
fn ensure_theme_initialized(cx: &mut TestAppContext) {
|
||||
cx.update(|cx| {
|
||||
if !cx.has_global::<settings::SettingsStore>() {
|
||||
settings::init(cx);
|
||||
}
|
||||
if !cx.has_global::<theme::GlobalTheme>() {
|
||||
theme_settings::init(theme::LoadThemes::JustBase, cx);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn render_markdown_with_options(
|
||||
markdown: &str,
|
||||
options: MarkdownOptions,
|
||||
cx: &mut TestAppContext,
|
||||
) -> crate::RenderedText {
|
||||
struct TestWindow;
|
||||
|
||||
impl Render for TestWindow {
|
||||
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
}
|
||||
}
|
||||
|
||||
ensure_theme_initialized(cx);
|
||||
|
||||
let (_, cx) = cx.add_window_view(|_, _| TestWindow);
|
||||
let markdown = cx.new(|cx| {
|
||||
Markdown::new_with_options(markdown.to_string().into(), None, None, options, cx)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let (rendered, _) = cx.draw(
|
||||
Default::default(),
|
||||
size(px(600.0), px(600.0)),
|
||||
|_window, _cx| {
|
||||
MarkdownElement::new(markdown, MarkdownStyle::default()).code_block_renderer(
|
||||
CodeBlockRenderer::Default {
|
||||
copy_button_visibility: CopyButtonVisibility::Hidden,
|
||||
border: false,
|
||||
},
|
||||
)
|
||||
},
|
||||
);
|
||||
rendered.text
|
||||
}
|
||||
|
||||
fn mock_render_image(cx: &mut TestAppContext) -> Arc<RenderImage> {
|
||||
cx.update(|cx| {
|
||||
cx.svg_renderer()
|
||||
.render_single_frame(
|
||||
br#"<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"></svg>"#,
|
||||
1.0,
|
||||
)
|
||||
.unwrap()
|
||||
})
|
||||
}
|
||||
|
||||
fn mermaid_contents(contents: &str) -> ParsedMarkdownMermaidDiagramContents {
|
||||
ParsedMarkdownMermaidDiagramContents {
|
||||
contents: contents.to_string().into(),
|
||||
scale: 100,
|
||||
}
|
||||
}
|
||||
|
||||
fn mermaid_sequence(diagrams: &[&str]) -> Vec<ParsedMarkdownMermaidDiagramContents> {
|
||||
diagrams
|
||||
.iter()
|
||||
.map(|diagram| mermaid_contents(diagram))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn mermaid_fallback(
|
||||
new_diagram: &str,
|
||||
new_full_order: &[ParsedMarkdownMermaidDiagramContents],
|
||||
old_full_order: &[ParsedMarkdownMermaidDiagramContents],
|
||||
cache: &MermaidDiagramCache,
|
||||
) -> Option<Arc<RenderImage>> {
|
||||
let new_content = mermaid_contents(new_diagram);
|
||||
let idx = new_full_order
|
||||
.iter()
|
||||
.position(|diagram| diagram == &new_content)?;
|
||||
MermaidState::get_fallback_image(idx, old_full_order, new_full_order.len(), cache)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_mermaid_info() {
|
||||
assert_eq!(parse_mermaid_info("mermaid"), Some(100));
|
||||
assert_eq!(parse_mermaid_info("mermaid 150"), Some(150));
|
||||
assert_eq!(parse_mermaid_info("mermaid 5"), Some(10));
|
||||
assert_eq!(parse_mermaid_info("mermaid 999"), Some(500));
|
||||
assert_eq!(parse_mermaid_info("rust"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_mermaid_diagrams_parses_scale() {
|
||||
let markdown = "```mermaid 150\ngraph TD;\n```\n\n```rust\nfn main() {}\n```";
|
||||
let events = crate::parser::parse_markdown_with_options(markdown, false, false).events;
|
||||
let diagrams = extract_mermaid_diagrams(markdown, &events);
|
||||
|
||||
assert_eq!(diagrams.len(), 1);
|
||||
let diagram = diagrams.values().next().unwrap();
|
||||
assert_eq!(diagram.contents.contents, "graph TD;");
|
||||
assert_eq!(diagram.contents.scale, 150);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_mermaid_fallback_on_edit(cx: &mut TestAppContext) {
|
||||
let old_full_order = mermaid_sequence(&["graph A", "graph B", "graph C"]);
|
||||
let new_full_order = mermaid_sequence(&["graph A", "graph B modified", "graph C"]);
|
||||
|
||||
let svg_b = mock_render_image(cx);
|
||||
|
||||
let mut cache: MermaidDiagramCache = HashMap::default();
|
||||
cache.insert(
|
||||
mermaid_contents("graph A"),
|
||||
Arc::new(CachedMermaidDiagram::new_for_test(
|
||||
Some(mock_render_image(cx)),
|
||||
None,
|
||||
)),
|
||||
);
|
||||
cache.insert(
|
||||
mermaid_contents("graph B"),
|
||||
Arc::new(CachedMermaidDiagram::new_for_test(
|
||||
Some(svg_b.clone()),
|
||||
None,
|
||||
)),
|
||||
);
|
||||
cache.insert(
|
||||
mermaid_contents("graph C"),
|
||||
Arc::new(CachedMermaidDiagram::new_for_test(
|
||||
Some(mock_render_image(cx)),
|
||||
None,
|
||||
)),
|
||||
);
|
||||
|
||||
let fallback =
|
||||
mermaid_fallback("graph B modified", &new_full_order, &old_full_order, &cache);
|
||||
|
||||
assert_eq!(fallback.as_ref().map(|image| image.id), Some(svg_b.id));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_mermaid_no_fallback_on_add_in_middle(cx: &mut TestAppContext) {
|
||||
let old_full_order = mermaid_sequence(&["graph A", "graph C"]);
|
||||
let new_full_order = mermaid_sequence(&["graph A", "graph NEW", "graph C"]);
|
||||
|
||||
let mut cache: MermaidDiagramCache = HashMap::default();
|
||||
cache.insert(
|
||||
mermaid_contents("graph A"),
|
||||
Arc::new(CachedMermaidDiagram::new_for_test(
|
||||
Some(mock_render_image(cx)),
|
||||
None,
|
||||
)),
|
||||
);
|
||||
cache.insert(
|
||||
mermaid_contents("graph C"),
|
||||
Arc::new(CachedMermaidDiagram::new_for_test(
|
||||
Some(mock_render_image(cx)),
|
||||
None,
|
||||
)),
|
||||
);
|
||||
|
||||
let fallback = mermaid_fallback("graph NEW", &new_full_order, &old_full_order, &cache);
|
||||
|
||||
assert!(fallback.is_none());
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_mermaid_fallback_chains_on_rapid_edits(cx: &mut TestAppContext) {
|
||||
let old_full_order = mermaid_sequence(&["graph A", "graph B modified", "graph C"]);
|
||||
let new_full_order = mermaid_sequence(&["graph A", "graph B modified again", "graph C"]);
|
||||
|
||||
let original_svg = mock_render_image(cx);
|
||||
|
||||
let mut cache: MermaidDiagramCache = HashMap::default();
|
||||
cache.insert(
|
||||
mermaid_contents("graph A"),
|
||||
Arc::new(CachedMermaidDiagram::new_for_test(
|
||||
Some(mock_render_image(cx)),
|
||||
None,
|
||||
)),
|
||||
);
|
||||
cache.insert(
|
||||
mermaid_contents("graph B modified"),
|
||||
Arc::new(CachedMermaidDiagram::new_for_test(
|
||||
None,
|
||||
Some(original_svg.clone()),
|
||||
)),
|
||||
);
|
||||
cache.insert(
|
||||
mermaid_contents("graph C"),
|
||||
Arc::new(CachedMermaidDiagram::new_for_test(
|
||||
Some(mock_render_image(cx)),
|
||||
None,
|
||||
)),
|
||||
);
|
||||
|
||||
let fallback = mermaid_fallback(
|
||||
"graph B modified again",
|
||||
&new_full_order,
|
||||
&old_full_order,
|
||||
&cache,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
fallback.as_ref().map(|image| image.id),
|
||||
Some(original_svg.id)
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_mermaid_fallback_with_duplicate_blocks_edit_second(cx: &mut TestAppContext) {
|
||||
let old_full_order = mermaid_sequence(&["graph A", "graph A", "graph B"]);
|
||||
let new_full_order = mermaid_sequence(&["graph A", "graph A edited", "graph B"]);
|
||||
|
||||
let svg_a = mock_render_image(cx);
|
||||
|
||||
let mut cache: MermaidDiagramCache = HashMap::default();
|
||||
cache.insert(
|
||||
mermaid_contents("graph A"),
|
||||
Arc::new(CachedMermaidDiagram::new_for_test(
|
||||
Some(svg_a.clone()),
|
||||
None,
|
||||
)),
|
||||
);
|
||||
cache.insert(
|
||||
mermaid_contents("graph B"),
|
||||
Arc::new(CachedMermaidDiagram::new_for_test(
|
||||
Some(mock_render_image(cx)),
|
||||
None,
|
||||
)),
|
||||
);
|
||||
|
||||
let fallback = mermaid_fallback("graph A edited", &new_full_order, &old_full_order, &cache);
|
||||
|
||||
assert_eq!(fallback.as_ref().map(|image| image.id), Some(svg_a.id));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_mermaid_rendering_replaces_code_block_text(cx: &mut TestAppContext) {
|
||||
let rendered = render_markdown_with_options(
|
||||
"```mermaid\ngraph TD;\n```",
|
||||
MarkdownOptions {
|
||||
render_mermaid_diagrams: true,
|
||||
..Default::default()
|
||||
},
|
||||
cx,
|
||||
);
|
||||
|
||||
let text = rendered
|
||||
.lines
|
||||
.iter()
|
||||
.map(|line| line.layout.wrapped_text())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
assert!(!text.contains("graph TD;"));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_mermaid_source_anchor_maps_inside_block(cx: &mut TestAppContext) {
|
||||
struct TestWindow;
|
||||
|
||||
impl Render for TestWindow {
|
||||
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
}
|
||||
}
|
||||
|
||||
ensure_theme_initialized(cx);
|
||||
|
||||
let (_, cx) = cx.add_window_view(|_, _| TestWindow);
|
||||
let markdown = cx.new(|cx| {
|
||||
Markdown::new_with_options(
|
||||
"```mermaid\ngraph TD;\n```".into(),
|
||||
None,
|
||||
None,
|
||||
MarkdownOptions {
|
||||
render_mermaid_diagrams: true,
|
||||
..Default::default()
|
||||
},
|
||||
cx,
|
||||
)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let render_image = mock_render_image(cx);
|
||||
markdown.update(cx, |markdown, _| {
|
||||
let contents = markdown
|
||||
.parsed_markdown
|
||||
.mermaid_diagrams
|
||||
.values()
|
||||
.next()
|
||||
.unwrap()
|
||||
.contents
|
||||
.clone();
|
||||
markdown.mermaid_state.cache.insert(
|
||||
contents.clone(),
|
||||
Arc::new(CachedMermaidDiagram::new_for_test(Some(render_image), None)),
|
||||
);
|
||||
markdown.mermaid_state.order = vec![contents];
|
||||
});
|
||||
|
||||
let (rendered, _) = cx.draw(
|
||||
Default::default(),
|
||||
size(px(600.0), px(600.0)),
|
||||
|_window, _cx| {
|
||||
MarkdownElement::new(markdown.clone(), MarkdownStyle::default())
|
||||
.code_block_renderer(CodeBlockRenderer::Default {
|
||||
copy_button_visibility: CopyButtonVisibility::Hidden,
|
||||
border: false,
|
||||
})
|
||||
},
|
||||
);
|
||||
|
||||
let mermaid_diagram = markdown.update(cx, |markdown, _| {
|
||||
markdown
|
||||
.parsed_markdown
|
||||
.mermaid_diagrams
|
||||
.values()
|
||||
.next()
|
||||
.unwrap()
|
||||
.clone()
|
||||
});
|
||||
assert!(
|
||||
rendered
|
||||
.text
|
||||
.position_for_source_index(mermaid_diagram.content_range.start)
|
||||
.is_some()
|
||||
);
|
||||
assert!(
|
||||
rendered
|
||||
.text
|
||||
.position_for_source_index(mermaid_diagram.content_range.end.saturating_sub(1))
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
}
|
||||
1349
crates/markdown/src/parser.rs
Normal file
1349
crates/markdown/src/parser.rs
Normal file
File diff suppressed because it is too large
Load Diff
245
crates/markdown/src/path_range.rs
Normal file
245
crates/markdown/src/path_range.rs
Normal file
@@ -0,0 +1,245 @@
|
||||
use std::{ops::Range, sync::Arc};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct PathWithRange {
|
||||
pub path: Arc<str>,
|
||||
pub range: Option<Range<LineCol>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub struct LineCol {
|
||||
pub line: u32,
|
||||
pub col: Option<u32>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for LineCol {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self.col {
|
||||
Some(col) => write!(f, "L{}:{}", self.line, col),
|
||||
None => write!(f, "L{}", self.line),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LineCol {
|
||||
pub fn new(str: impl AsRef<str>) -> Option<Self> {
|
||||
let str = str.as_ref();
|
||||
match str.split_once(':') {
|
||||
Some((line, col)) => match (line.parse::<u32>(), col.parse::<u32>()) {
|
||||
(Ok(line), Ok(col)) => Some(Self {
|
||||
line,
|
||||
col: Some(col),
|
||||
}),
|
||||
_ => None,
|
||||
},
|
||||
None => match str.parse::<u32>() {
|
||||
Ok(line) => Some(Self { line, col: None }),
|
||||
Err(_) => None,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PathWithRange {
|
||||
// Note: We could try out this as an alternative, and see how it does on evals.
|
||||
//
|
||||
// The closest to a standard way of including a filename is this:
|
||||
// ```rust filename="path/to/file.rs#42:43"
|
||||
// ```
|
||||
//
|
||||
// or, alternatively,
|
||||
// ```rust filename="path/to/file.rs" lines="42:43"
|
||||
// ```
|
||||
//
|
||||
// Examples where it's used this way:
|
||||
// - https://mdxjs.com/guides/syntax-highlighting/#syntax-highlighting-with-the-meta-field
|
||||
// - https://docusaurus.io/docs/markdown-features/code-blocks
|
||||
// - https://spec.commonmark.org/0.31.2/#example-143
|
||||
pub fn new(str: impl AsRef<str>) -> Self {
|
||||
let str = str.as_ref();
|
||||
// Sometimes the model will include a language at the start,
|
||||
// e.g. "```rust zed/crates/markdown/src/markdown.rs#L1"
|
||||
// We just discard that.
|
||||
let str = match str.trim_end().rfind(' ') {
|
||||
Some(space) => &str[space + 1..],
|
||||
None => str.trim_start(),
|
||||
};
|
||||
|
||||
match str.rsplit_once('#') {
|
||||
Some((path, after_hash)) => {
|
||||
// Be tolerant to the model omitting the "L" prefix, lowercasing it,
|
||||
// or including it more than once.
|
||||
let after_hash = after_hash.replace(['L', 'l'], "");
|
||||
|
||||
let range = {
|
||||
let mut iter = after_hash.split('-').flat_map(LineCol::new);
|
||||
iter.next()
|
||||
.map(|start| iter.next().map(|end| start..end).unwrap_or(start..start))
|
||||
};
|
||||
|
||||
Self {
|
||||
path: path.into(),
|
||||
range,
|
||||
}
|
||||
}
|
||||
None => Self {
|
||||
path: str.into(),
|
||||
range: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_linecol_parsing() {
|
||||
let line_col = LineCol::new("10:5");
|
||||
assert_eq!(
|
||||
line_col,
|
||||
Some(LineCol {
|
||||
line: 10,
|
||||
col: Some(5)
|
||||
})
|
||||
);
|
||||
|
||||
let line_only = LineCol::new("42");
|
||||
assert_eq!(
|
||||
line_only,
|
||||
Some(LineCol {
|
||||
line: 42,
|
||||
col: None
|
||||
})
|
||||
);
|
||||
|
||||
assert_eq!(LineCol::new(""), None);
|
||||
assert_eq!(LineCol::new("not a number"), None);
|
||||
assert_eq!(LineCol::new("10:not a number"), None);
|
||||
assert_eq!(LineCol::new("not:5"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pathrange_parsing() {
|
||||
let path_range = PathWithRange::new("file.rs#L10-L20");
|
||||
assert_eq!(path_range.path.as_ref(), "file.rs");
|
||||
assert!(path_range.range.is_some());
|
||||
if let Some(range) = path_range.range {
|
||||
assert_eq!(range.start.line, 10);
|
||||
assert_eq!(range.start.col, None);
|
||||
assert_eq!(range.end.line, 20);
|
||||
assert_eq!(range.end.col, None);
|
||||
}
|
||||
|
||||
let single_line = PathWithRange::new("file.rs#L15");
|
||||
assert_eq!(single_line.path.as_ref(), "file.rs");
|
||||
assert!(single_line.range.is_some());
|
||||
if let Some(range) = single_line.range {
|
||||
assert_eq!(range.start.line, 15);
|
||||
assert_eq!(range.end.line, 15);
|
||||
}
|
||||
|
||||
let no_range = PathWithRange::new("file.rs");
|
||||
assert_eq!(no_range.path.as_ref(), "file.rs");
|
||||
assert!(no_range.range.is_none());
|
||||
|
||||
let lowercase = PathWithRange::new("file.rs#l5-l10");
|
||||
assert_eq!(lowercase.path.as_ref(), "file.rs");
|
||||
assert!(lowercase.range.is_some());
|
||||
if let Some(range) = lowercase.range {
|
||||
assert_eq!(range.start.line, 5);
|
||||
assert_eq!(range.end.line, 10);
|
||||
}
|
||||
|
||||
let complex = PathWithRange::new("src/path/to/file.rs#L100");
|
||||
assert_eq!(complex.path.as_ref(), "src/path/to/file.rs");
|
||||
assert!(complex.range.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pathrange_from_str() {
|
||||
let with_range = PathWithRange::new("file.rs#L10-L20");
|
||||
assert!(with_range.range.is_some());
|
||||
assert_eq!(with_range.path.as_ref(), "file.rs");
|
||||
|
||||
let without_range = PathWithRange::new("file.rs");
|
||||
assert!(without_range.range.is_none());
|
||||
|
||||
let single_line = PathWithRange::new("file.rs#L15");
|
||||
assert!(single_line.range.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pathrange_leading_text_trimming() {
|
||||
let with_language = PathWithRange::new("```rust file.rs#L10");
|
||||
assert_eq!(with_language.path.as_ref(), "file.rs");
|
||||
assert!(with_language.range.is_some());
|
||||
if let Some(range) = with_language.range {
|
||||
assert_eq!(range.start.line, 10);
|
||||
}
|
||||
|
||||
let with_spaces = PathWithRange::new("``` file.rs#L10-L20");
|
||||
assert_eq!(with_spaces.path.as_ref(), "file.rs");
|
||||
assert!(with_spaces.range.is_some());
|
||||
|
||||
let with_words = PathWithRange::new("```rust code example file.rs#L15:10");
|
||||
assert_eq!(with_words.path.as_ref(), "file.rs");
|
||||
assert!(with_words.range.is_some());
|
||||
if let Some(range) = with_words.range {
|
||||
assert_eq!(range.start.line, 15);
|
||||
assert_eq!(range.start.col, Some(10));
|
||||
}
|
||||
|
||||
let with_whitespace = PathWithRange::new(" file.rs#L5");
|
||||
assert_eq!(with_whitespace.path.as_ref(), "file.rs");
|
||||
assert!(with_whitespace.range.is_some());
|
||||
|
||||
let no_leading = PathWithRange::new("file.rs#L10");
|
||||
assert_eq!(no_leading.path.as_ref(), "file.rs");
|
||||
assert!(no_leading.range.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pathrange_with_line_and_column() {
|
||||
let line_and_col = PathWithRange::new("file.rs#L10:5");
|
||||
assert_eq!(line_and_col.path.as_ref(), "file.rs");
|
||||
assert!(line_and_col.range.is_some());
|
||||
if let Some(range) = line_and_col.range {
|
||||
assert_eq!(range.start.line, 10);
|
||||
assert_eq!(range.start.col, Some(5));
|
||||
assert_eq!(range.end.line, 10);
|
||||
assert_eq!(range.end.col, Some(5));
|
||||
}
|
||||
|
||||
let full_range = PathWithRange::new("file.rs#L10:5-L20:15");
|
||||
assert_eq!(full_range.path.as_ref(), "file.rs");
|
||||
assert!(full_range.range.is_some());
|
||||
if let Some(range) = full_range.range {
|
||||
assert_eq!(range.start.line, 10);
|
||||
assert_eq!(range.start.col, Some(5));
|
||||
assert_eq!(range.end.line, 20);
|
||||
assert_eq!(range.end.col, Some(15));
|
||||
}
|
||||
|
||||
let mixed_range1 = PathWithRange::new("file.rs#L10:5-L20");
|
||||
assert_eq!(mixed_range1.path.as_ref(), "file.rs");
|
||||
assert!(mixed_range1.range.is_some());
|
||||
if let Some(range) = mixed_range1.range {
|
||||
assert_eq!(range.start.line, 10);
|
||||
assert_eq!(range.start.col, Some(5));
|
||||
assert_eq!(range.end.line, 20);
|
||||
assert_eq!(range.end.col, None);
|
||||
}
|
||||
|
||||
let mixed_range2 = PathWithRange::new("file.rs#L10-L20:15");
|
||||
assert_eq!(mixed_range2.path.as_ref(), "file.rs");
|
||||
assert!(mixed_range2.range.is_some());
|
||||
if let Some(range) = mixed_range2.range {
|
||||
assert_eq!(range.start.line, 10);
|
||||
assert_eq!(range.start.col, None);
|
||||
assert_eq!(range.end.line, 20);
|
||||
assert_eq!(range.end.col, Some(15));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user