From c3f08d52b9d6493ff6f3b2fbacd04b7ae25b5f2a Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Tue, 4 Mar 2025 21:50:50 +0800 Subject: [PATCH] text_view: Add syntax highlight to CodeBlock. (#684) image image --- Cargo.lock | 99 ++- README.md | 2 +- crates/story/examples/markdown.md | 25 +- crates/story/examples/markdown.rs | 27 +- crates/ui/Cargo.toml | 1 + crates/ui/src/highlighter/highlight.rs | 150 +++++ crates/ui/src/highlighter/mod.rs | 3 + crates/ui/src/highlighter/themes/README.md | 1 + crates/ui/src/highlighter/themes/dark.tmTheme | 597 ++++++++++++++++++ .../ui/src/highlighter/themes/light.tmTheme | 467 ++++++++++++++ crates/ui/src/lib.rs | 1 + crates/ui/src/text/element.rs | 73 ++- crates/ui/src/text/markdown.rs | 247 ++++---- crates/ui/src/text/text_view.rs | 9 +- crates/ui/src/theme.rs | 3 +- 15 files changed, 1547 insertions(+), 158 deletions(-) create mode 100644 crates/ui/src/highlighter/highlight.rs create mode 100644 crates/ui/src/highlighter/mod.rs create mode 100644 crates/ui/src/highlighter/themes/README.md create mode 100644 crates/ui/src/highlighter/themes/dark.tmTheme create mode 100644 crates/ui/src/highlighter/themes/light.tmTheme diff --git a/Cargo.lock b/Cargo.lock index a2e8b6e0..a2d64330 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -543,6 +543,15 @@ dependencies = [ "simd-abstraction", ] +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + [[package]] name = "bindgen" version = "0.69.5" @@ -552,7 +561,7 @@ dependencies = [ "bitflags 2.6.0", "cexpr", "clang-sys", - "itertools 0.10.5", + "itertools 0.12.1", "lazy_static", "lazycell", "log", @@ -575,7 +584,7 @@ dependencies = [ "bitflags 2.6.0", "cexpr", "clang-sys", - "itertools 0.10.5", + "itertools 0.13.0", "log", "prettyplease", "proc-macro2", @@ -2657,6 +2666,7 @@ dependencies = [ "serde_json", "smallvec", "smol 1.3.0", + "syntect", "unicode-segmentation", "usvg", "uuid", @@ -3482,6 +3492,12 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + [[package]] name = "linux-raw-sys" version = "0.3.8" @@ -4035,7 +4051,7 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af1844ef2428cc3e1cb900be36181049ef3d3193c63e43026cfe202983b27a56" dependencies = [ - "proc-macro-crate 1.3.1", + "proc-macro-crate 3.2.0", "proc-macro2", "quote", "syn 2.0.96", @@ -4283,6 +4299,28 @@ version = "1.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" +[[package]] +name = "onig" +version = "6.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c4b31c8722ad9171c6d77d3557db078cab2bd50afcc9d09c8b315c59df8ca4f" +dependencies = [ + "bitflags 1.3.2", + "libc", + "once_cell", + "onig_sys", +] + +[[package]] +name = "onig_sys" +version = "69.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b829e3d7e9cc74c7e315ee8edb185bf4190da5acde74afd7fc59c35b1f086e7" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "oo7" version = "0.4.0" @@ -4700,6 +4738,19 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" +[[package]] +name = "plist" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42cf17e9a1800f5f396bc67d193dc9411b59012a5876445ef450d449881e1016" +dependencies = [ + "base64", + "indexmap 2.7.1", + "quick-xml 0.32.0", + "serde", + "time", +] + [[package]] name = "png" version = "0.17.14" @@ -4919,6 +4970,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" +[[package]] +name = "quick-xml" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d3a6e5838b60e0e8fa7a43f22ade549a37d61f8bdbe636d0d7816191de969c2" +dependencies = [ + "memchr", +] + [[package]] name = "quick-xml" version = "0.36.2" @@ -6409,6 +6469,28 @@ dependencies = [ "futures-core", ] +[[package]] +name = "syntect" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874dcfa363995604333cf947ae9f751ca3af4522c60886774c4963943b4746b1" +dependencies = [ + "bincode", + "bitflags 1.3.2", + "flate2", + "fnv", + "once_cell", + "onig", + "plist", + "regex-syntax", + "serde", + "serde_derive", + "serde_json", + "thiserror 1.0.66", + "walkdir", + "yaml-rust", +] + [[package]] name = "sys-locale" version = "0.3.1" @@ -7350,7 +7432,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597f2001b2e5fc1121e3d5b9791d3e78f05ba6bfa4641053846248e3a13661c3" dependencies = [ "proc-macro2", - "quick-xml", + "quick-xml 0.36.2", "quote", ] @@ -8067,6 +8149,15 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" +[[package]] +name = "yaml-rust" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" +dependencies = [ + "linked-hash-map", +] + [[package]] name = "yazi" version = "0.1.6" diff --git a/README.md b/README.md index f67b5264..4f3e3966 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ UI components for building fantastic desktop application by using [GPUI](https:/ - Sidebar - Breadcrumb - Badge -- TextView (Markdown, Simple HTML) to native rendering. +- TextView (Markdown, Simple HTML) to native rendering, syntax highlighting. ## Showcase diff --git a/crates/story/examples/markdown.md b/crates/story/examples/markdown.md index 9d61cf84..89ddf97c 100644 --- a/crates/story/examples/markdown.md +++ b/crates/story/examples/markdown.md @@ -11,6 +11,23 @@ This is an additional demonstration paragraph in English demonstrating more cont 这是一个中文演示段落,用于展示更多的 [Markdown GFM](https://github.github.com/gfm/) 内容。您可以在此尝试使用使用**粗体**、*斜体*和`代码`等样式。これは日本語のデモ段落です。Markdown の多言語サポートを示すためのテキストが含まれています。例えば、、**ボールド**、_イタリック_、および`コード`のスタイルなどを試すことができます。 +### Code block + +```rust +struct Repository { + /// Name of the repository. + name: String, +} + +fn main() { + let _ = Repository { + name: "GPUI Component".to_string(), + }; + + println!("Hello, World!"); +} +``` + --- ## Heading for [Links](https://www.google.com) @@ -61,14 +78,6 @@ Here is a link to [Google](https://www.google.com), and another to [Rust](https: - [ ] Task 2, going to do something if there is a long text that needs to be wrapped to the next line. - [ ] Task 3 -#### Heading for Code - -```rust -fn main() { - println!("Hello, World!"); -} -``` - ### HTML #### Paragraph and Text diff --git a/crates/story/examples/markdown.rs b/crates/story/examples/markdown.rs index 84f66776..243b1217 100644 --- a/crates/story/examples/markdown.rs +++ b/crates/story/examples/markdown.rs @@ -1,5 +1,12 @@ +use std::rc::Rc; + use gpui::*; -use gpui_component::{input::TextInput, text::TextView, ActiveTheme as _}; +use gpui_component::{ + highlighter::HighlightTheme, + input::TextInput, + text::{TextView, TextViewStyle}, + ActiveTheme as _, +}; use story::Assets; pub struct Example { @@ -39,6 +46,12 @@ impl Example { impl Render for Example { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = if cx.theme().mode.is_dark() { + HighlightTheme::default_dark() + } else { + HighlightTheme::default_light() + }; + div() .flex() .flex_row() @@ -61,10 +74,14 @@ impl Render for Example { .p_5() .flex_1() .overflow_y_scroll() - .child(TextView::markdown( - "preview", - self.text_input.read(cx).text(), - )), + .child( + TextView::markdown("preview", self.text_input.read(cx).text()).style( + TextViewStyle { + highlight_theme: Rc::new(theme), + ..Default::default() + }, + ), + ), ) } } diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index 7f1c5397..3cb8b551 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -47,6 +47,7 @@ markup5ever_rcdom = "0.3.0" minify-html = "0.15.0" # Calendar chrono = "0.4.38" +syntect = { version = "5.2", features = ["plist-load"] } [dev-dependencies] indoc = "2" diff --git a/crates/ui/src/highlighter/highlight.rs b/crates/ui/src/highlighter/highlight.rs new file mode 100644 index 00000000..fb294586 --- /dev/null +++ b/crates/ui/src/highlighter/highlight.rs @@ -0,0 +1,150 @@ +use gpui::{HighlightStyle, Hsla, SharedString}; +use std::{ + ops::Range, + sync::{Arc, LazyLock}, +}; +use syntect::{highlighting, parsing}; + +static SYNTAXES: LazyLock = + LazyLock::new(parsing::SyntaxSet::load_defaults_nonewlines); + +static DEFAULT_LIGHT: LazyLock> = LazyLock::new(|| { + let mut cursor = std::io::Cursor::new(include_bytes!("./themes/light.tmTheme")); + Arc::new(highlighting::ThemeSet::load_from_reader(&mut cursor).unwrap()) +}); + +static DEFAULT_DARK: LazyLock> = LazyLock::new(|| { + let mut cursor = std::io::Cursor::new(include_bytes!("./themes/dark.tmTheme")); + Arc::new(highlighting::ThemeSet::load_from_reader(&mut cursor).unwrap()) +}); + +/// Represents a theme for syntax highlighting. +#[derive(Debug, Clone, PartialEq)] +pub struct HighlightTheme { + name: SharedString, + inner: Arc, +} + +impl HighlightTheme { + /// Default light theme. + pub fn default_light() -> Self { + Self { + name: "default-light".into(), + inner: DEFAULT_LIGHT.clone(), + } + } + + /// Default dark theme. + pub fn default_dark() -> Self { + Self { + name: "default-dark".into(), + inner: DEFAULT_DARK.clone(), + } + } + + /// Parse a theme from a string (tmTheme) + pub fn parse(name: &str, theme_str: &str) -> anyhow::Result { + let mut cursor = std::io::Cursor::new(theme_str); + let theme = highlighting::ThemeSet::load_from_reader(&mut cursor)?; + + Ok(Self { + name: SharedString::from(name.to_string()), + inner: Arc::new(theme), + }) + } +} + +/// Inspired by the `iced` crate's `Highlighter` struct. +/// +/// https://github.com/iced-rs/iced/blob/master/highlighter/src/lib.rs#L24 +pub struct Highlighter<'a> { + syntax: &'static parsing::SyntaxReference, + highlighter: highlighting::Highlighter<'a>, +} + +impl<'a> Highlighter<'a> { + pub fn new(lang: Option<&str>, theme: &'a HighlightTheme) -> Self { + let syntax = lang + .and_then(|lang| SYNTAXES.find_syntax_by_token(&lang)) + .unwrap_or_else(|| SYNTAXES.find_syntax_plain_text()); + let highlighter = highlighting::Highlighter::new(&theme.inner); + + Self { + syntax, + highlighter, + } + } + + /// Highlight a text and returns a vector of ranges and highlight styles + pub fn highlight(&self, text: &str) -> Vec<(Range, HighlightStyle)> { + let mut parser = parsing::ParseState::new(self.syntax); + let mut stack = parsing::ScopeStack::new(); + + let ops = parser.parse_line(text, &SYNTAXES).unwrap_or_default(); + + ScopeRangeIterator { + ops, + line_length: text.len(), + index: 0, + last_str_index: 0, + } + .filter_map(move |(range, scope)| { + let _ = stack.apply(&scope); + if range.is_empty() { + return None; + } else { + let style_mod = self.highlighter.style_mod_for_stack(&stack.scopes); + let mut style = HighlightStyle::default(); + style.color = style_mod.foreground.map(color_to_hsla); + style.background_color = style_mod.background.map(color_to_hsla); + Some((range, style)) + } + }) + .collect() + } +} + +fn color_to_hsla(color: highlighting::Color) -> Hsla { + gpui::Rgba { + r: color.r as f32 / 255., + g: color.g as f32 / 255., + b: color.b as f32 / 255., + a: color.a as f32 / 100., + } + .into() +} + +struct ScopeRangeIterator { + ops: Vec<(usize, parsing::ScopeStackOp)>, + line_length: usize, + index: usize, + last_str_index: usize, +} + +impl Iterator for ScopeRangeIterator { + type Item = (std::ops::Range, parsing::ScopeStackOp); + + fn next(&mut self) -> Option { + if self.index > self.ops.len() { + return None; + } + + let next_str_i = if self.index == self.ops.len() { + self.line_length + } else { + self.ops[self.index].0 + }; + + let range = self.last_str_index..next_str_i; + self.last_str_index = next_str_i; + + let op = if self.index == 0 { + parsing::ScopeStackOp::Noop + } else { + self.ops[self.index - 1].1.clone() + }; + + self.index += 1; + Some((range, op)) + } +} diff --git a/crates/ui/src/highlighter/mod.rs b/crates/ui/src/highlighter/mod.rs new file mode 100644 index 00000000..05ded566 --- /dev/null +++ b/crates/ui/src/highlighter/mod.rs @@ -0,0 +1,3 @@ +mod highlight; + +pub use highlight::*; diff --git a/crates/ui/src/highlighter/themes/README.md b/crates/ui/src/highlighter/themes/README.md new file mode 100644 index 00000000..b22a9c8a --- /dev/null +++ b/crates/ui/src/highlighter/themes/README.md @@ -0,0 +1 @@ +https://github.com/textmate/themes.tmbundle/tree/master/Themes diff --git a/crates/ui/src/highlighter/themes/dark.tmTheme b/crates/ui/src/highlighter/themes/dark.tmTheme new file mode 100644 index 00000000..0068ece3 --- /dev/null +++ b/crates/ui/src/highlighter/themes/dark.tmTheme @@ -0,0 +1,597 @@ + + + + + author + Michael Sheets + gutterSettings + + background + #2F2F31 + divider + #414143 + foreground + #8F8F8F + selectionBackground + #414143 + selectionBorder + #484848 + selectionForeground + #BABABA + + name + Twilight + semanticClass + theme.dark.twilight + settings + + + settings + + background + #181818 + caret + #A7A7A7 + foreground + #F8F8F8 + invisibles + #FFFFFF40 + lineHighlight + #FFFFFF08 + selection + #DDF0FF33 + + + + name + Foldings + scope + deco.folding + settings + + foreground + #5A6A65 + + + + name + Comment + scope + comment + settings + + fontStyle + italic + foreground + #5F5A60 + + + + name + Constant + scope + constant + settings + + foreground + #CF6A4C + + + + name + Entity + scope + entity + settings + + fontStyle + + foreground + #9B703F + + + + name + Keyword + scope + keyword + settings + + fontStyle + + foreground + #CDA869 + + + + name + Storage + scope + storage + settings + + fontStyle + + foreground + #F9EE98 + + + + name + String + scope + string + settings + + fontStyle + + foreground + #8F9D6A + + + + name + Support + scope + support + settings + + fontStyle + + foreground + #9B859D + + + + name + Variable + scope + variable + settings + + foreground + #7587A6 + + + + name + Invalid – Deprecated + scope + invalid.deprecated + settings + + fontStyle + italic underline + foreground + #D2A8A1 + + + + name + Invalid – Illegal + scope + invalid.illegal + settings + + background + #562D56BF + foreground + #F8F8F8 + + + + name + ----------------------------------- + settings + + + + name + ♦ Embedded Source + scope + meta.embedded.block, punctuation.whitespace.embedded + settings + + background + #B0B3BA14 + + + + name + ♦ Embedded Source (Bright) + scope + meta.embedded.line + settings + + background + #B1B3BA21 + + + + name + ♦ Entity inherited-class + scope + entity.other.inherited-class + settings + + fontStyle + italic + foreground + #9B5C2E + + + + name + ♦ String embedded-source + scope + string meta.embedded + settings + + fontStyle + + foreground + #DAEFA3 + + + + name + ♦ String constant + scope + string constant + settings + + foreground + #DDF2A4 + + + + name + ♦ String.regexp + scope + string.regexp + settings + + fontStyle + + foreground + #E9C062 + + + + name + ♦ String.regexp.«special» + scope + string.regexp constant.character.escape, string.regexp source.ruby.embedded, string.regexp string.regexp.arbitrary-repetition + settings + + foreground + #CF7D34 + + + + name + ♦ String variable + scope + string variable + settings + + foreground + #8A9A95 + + + + name + ♦ Support.function + scope + support.function + settings + + fontStyle + + foreground + #DAD085 + + + + name + ♦ Support.constant + scope + support.constant + settings + + fontStyle + + foreground + #CF6A4C + + + + name + c C/C++ Preprocessor Line + scope + meta.preprocessor.c, source.swift meta.preprocessor + settings + + foreground + #8996A8 + + + + name + c C/C++ Preprocessor Directive + scope + meta.preprocessor.c keyword, source.swift meta.preprocessor keyword + settings + + foreground + #AFC4DB + + + + name + ✘ Doctype/XML Processing + scope + meta.tag.metadata.doctype, meta.tag.metadata.doctype entity, meta.tag.metadata.doctype string, meta.tag.metadata.processing.xml, meta.tag.metadata.processing.xml entity, meta.tag.metadata.processing.xml string + settings + + foreground + #494949 + + + + name + ✘ Meta.tag.«all» + scope + declaration.tag, declaration.tag entity, meta.tag, meta.tag entity + settings + + foreground + #AC885B + + + + name + ✘ Meta.tag.inline + scope + declaration.tag.inline, declaration.tag.inline entity, meta.tag.inline, meta.tag.inline entity + settings + + foreground + #E0C589 + + + + name + § css tag-name + scope + meta.selector.css entity.name.tag + settings + + foreground + #CDA869 + + + + name + § css:pseudo-class + scope + meta.selector.css entity.other.attribute-name.tag.pseudo-class + settings + + foreground + #8F9D6A + + + + name + § css#id + scope + meta.selector.css entity.other.attribute-name.id + settings + + foreground + #8B98AB + + + + name + § css.class + scope + meta.selector.css entity.other.attribute-name.class + settings + + foreground + #9B703F + + + + name + § css property-name: + scope + support.type.property-name.css + settings + + foreground + #C5AF75 + + + + name + § css property-value; + scope + meta.property-group support.constant.property-value.css, meta.property-value support.constant.property-value.css + settings + + foreground + #F9EE98 + + + + name + § css @at-rule + scope + meta.preprocessor.at-rule keyword.control.at-rule + settings + + foreground + #8693A5 + + + + name + § css additional-constants + scope + meta.property-value support.constant.named-color.css, meta.property-value constant + settings + + foreground + #CA7840 + + + + name + § css constructor.argument + scope + meta.constructor.argument.css + settings + + foreground + #8F9D6A + + + + name + ⎇ diff.header + scope + meta.diff, meta.diff.header, meta.separator, meta.diff.range, meta.diff.index + settings + + background + #0E2231 + fontStyle + italic + foreground + #F8F8F8 + + + + name + ⎇ diff.deleted + scope + markup.deleted + settings + + background + #420E09 + foreground + #F8F8F8 + + + + name + ⎇ diff.changed + scope + markup.changed + settings + + background + #4A410D + foreground + #F8F8F8 + + + + name + ⎇ diff.inserted + scope + markup.inserted + settings + + background + #253B22 + foreground + #F8F8F8 + + + + name + Markup: List + scope + markup.list + settings + + foreground + #F9EE98 + + + + name + Markup: Heading + scope + markup.heading + settings + + foreground + #CF6A4C + + + + name + Markup: Raw Block + scope + markup.raw.block + settings + + background + #B0B3BA14 + + + + name + Test: 1 + scope + meta.test1 + settings + + background + #0E2231C5 + + + + name + Test: 2 + scope + meta.test2 + settings + + background + #420E0990 + + + + name + Test: 3 + scope + meta.test3 + settings + + background + #4A410D90 + + + + name + Test: 4 + scope + meta.test4 + settings + + background + #253B2290 + + + + uuid + 766026CB-703D-4610-B070-8DE07D967C5F + + diff --git a/crates/ui/src/highlighter/themes/light.tmTheme b/crates/ui/src/highlighter/themes/light.tmTheme new file mode 100644 index 00000000..74b740f1 --- /dev/null +++ b/crates/ui/src/highlighter/themes/light.tmTheme @@ -0,0 +1,467 @@ + + + + + author + Chris Thomas + name + Mac Classic + semanticClass + theme.light.mac-classic + settings + + + settings + + background + #FFFFFF + caret + #000000 + foreground + #000000 + invisibles + #BFBFBF + lineHighlight + #00000012 + selection + #4D97FF54 + + + + name + Comment + scope + comment + settings + + fontStyle + italic + foreground + #0066FF + + + + name + Foldings + scope + deco.folding + settings + + foreground + #BFBFBF + + + + name + Keyword + scope + keyword, storage + settings + + fontStyle + bold + foreground + #0000FF + + + + name + Number + scope + constant.numeric + settings + + fontStyle + + foreground + #0000CD + + + + name + User-defined constant + scope + constant + settings + + fontStyle + bold + foreground + #C5060B + + + + name + Built-in constant + scope + constant.language + settings + + fontStyle + bold + foreground + #585CF6 + + + + name + Variable + scope + variable.language, variable.other + settings + + fontStyle + + foreground + #318495 + + + + name + String + scope + string + settings + + fontStyle + + foreground + #036A07 + + + + name + String interpolation + scope + constant.character.escape, string meta.embedded + settings + + fontStyle + + foreground + #26B31A + + + + name + Preprocessor line + scope + meta.preprocessor + settings + + fontStyle + + foreground + #1A921C + + + + name + Preprocessor directive + scope + keyword.control.import + settings + + fontStyle + bold + foreground + #0C450D + + + + name + Function name + scope + entity.name.function, support.function.any-method + settings + + fontStyle + bold + foreground + #0000A2 + + + + name + Type name + scope + entity.name.type + settings + + fontStyle + underline + + + + name + Inherited class name + scope + entity.other.inherited-class + settings + + fontStyle + italic + + + + name + Function parameter + scope + variable.parameter + settings + + fontStyle + italic + + + + name + Function argument and result types + scope + storage.type.method + settings + + fontStyle + + foreground + #70727E + + + + name + Section + scope + meta.section entity.name.section, declaration.section entity.name.section + settings + + fontStyle + italic + + + + name + Library function + scope + support.function + settings + + fontStyle + bold + foreground + #3C4C72 + + + + name + Library object + scope + support.class, support.type + settings + + fontStyle + bold + foreground + #6D79DE + + + + name + Library constant + scope + support.constant + settings + + fontStyle + bold + foreground + #06960E + + + + name + Library variable + scope + support.variable + settings + + fontStyle + bold + foreground + #21439C + + + + name + JS: Operator + scope + keyword.operator.js + settings + + foreground + #687687 + + + + name + Invalid + scope + invalid + settings + + background + #990000 + caret + #FFFFFF + foreground + #FFFFFF + selection + #4D97FF + + + + name + Invalid trailing whitespace + scope + invalid.deprecated.trailing-whitespace + settings + + background + #FFD0D0 + + + + name + Embedded source + scope + text source, string.unquoted, meta.embedded + settings + + background + #0000000D + + + + name + Embedded embedded source + scope + text source string.unquoted, text source text source + settings + + background + #0000000F + + + + name + Markup XML declaration + scope + meta.tag.preprocessor.xml + settings + + fontStyle + + foreground + #68685B + + + + name + Markup DOCTYPE + scope + meta.tag.metadata.doctype, meta.tag.metadata.doctype entity, meta.tag.metadata.doctype string, meta.tag.metadata.processing.xml, meta.tag.metadata.processing.xml entity, meta.tag.metadata.processing.xml string + settings + + fontStyle + + foreground + #888888 + + + + name + Markup DTD + scope + meta.tag.metadata.doctype string.quoted + settings + + fontStyle + italic + + + + name + Markup tag + scope + meta.tag, declaration.tag + settings + + fontStyle + + foreground + #1C02FF + + + + name + Markup name of tag + scope + entity.name.tag + settings + + fontStyle + bold + + + + name + Markup tag attribute + scope + entity.other.attribute-name + settings + + fontStyle + italic + + + + name + Markup: Heading + scope + markup.heading + settings + + fontStyle + bold + foreground + #0C07FF + + + + name + Markup: Quote + scope + markup.quote + settings + + fontStyle + italic + foreground + #000000 + + + + name + Markup: List + scope + markup.list + settings + + foreground + #B90690 + + + + uuid + 71D40D9D-AE48-11D9-920A-000D93589AF6 + + diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 4dcd1961..70964eaf 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -25,6 +25,7 @@ pub mod dock; pub mod drawer; pub mod dropdown; pub mod form; +pub mod highlighter; pub mod history; pub mod indicator; pub mod input; diff --git a/crates/ui/src/text/element.rs b/crates/ui/src/text/element.rs index a1b10d22..f0655dcb 100644 --- a/crates/ui/src/text/element.rs +++ b/crates/ui/src/text/element.rs @@ -1,14 +1,14 @@ use std::ops::Range; use gpui::{ - div, img, prelude::FluentBuilder as _, px, relative, rems, App, DefiniteLength, ElementId, - FontStyle, FontWeight, Half, HighlightStyle, InteractiveElement as _, InteractiveText, - IntoElement, Length, ObjectFit, ParentElement, RenderOnce, SharedString, SharedUri, Styled, - StyledImage as _, StyledText, Window, + div, img, prelude::FluentBuilder as _, px, relative, rems, AnyElement, App, DefiniteLength, + ElementId, FontStyle, FontWeight, Half, HighlightStyle, InteractiveElement as _, + InteractiveText, IntoElement, Length, ObjectFit, ParentElement, Rems, RenderOnce, SharedString, + SharedUri, Styled, StyledImage as _, StyledText, Window, }; use markdown::mdast; -use crate::{h_flex, v_flex, ActiveTheme as _, Icon, IconName}; +use crate::{h_flex, highlighter::Highlighter, v_flex, ActiveTheme as _, Icon, IconName}; use super::{utils::list_item_prefix, TextViewStyle}; @@ -199,6 +199,28 @@ impl Paragraph { } } +#[derive(Debug, Clone, PartialEq)] +pub struct CodeBlock { + code: SharedString, + lang: Option, + styles: Vec<(Range, HighlightStyle)>, +} + +impl CodeBlock { + pub fn new( + code: SharedString, + lang: Option, + text_view_style: &TextViewStyle, + ) -> Self { + let highlight = Highlighter::new( + lang.as_ref().map(|v| v.as_ref()), + text_view_style.highlight_theme.as_ref(), + ); + let styles = highlight.highlight(code.as_ref()); + Self { code, lang, styles } + } +} + /// Ref: /// https://ui.shadcn.com/docs/components/typography #[allow(unused)] @@ -224,10 +246,7 @@ pub enum Node { /// Whether the list item is checked, if None, it's not a checkbox checked: Option, }, - CodeBlock { - code: SharedString, - lang: Option, - }, + CodeBlock(CodeBlock), Table(Table), Break { html: bool, @@ -548,6 +567,24 @@ impl Node { } } + fn render_codeblock( + code_block: CodeBlock, + mb: Rems, + _: &mut Window, + cx: &mut App, + ) -> AnyElement { + div() + .mb(mb) + .p_3() + .rounded(cx.theme().radius) + .bg(cx.theme().secondary) + .font_family("Menlo, Monaco, Consolas, monospace") + .text_size(rems(0.875)) + .relative() + .child(StyledText::new(code_block.code.clone()).with_highlights(code_block.styles)) + .into_any_element() + } + pub(crate) fn render( self, list_state: Option, @@ -634,15 +671,7 @@ impl Node { items }) .into_any_element(), - Node::CodeBlock { code, .. } => div() - .mb(mb) - .rounded(cx.theme().radius) - .bg(cx.theme().secondary) - .p_3() - .text_size(rems(0.875)) - .relative() - .child(code) - .into_any_element(), + Node::CodeBlock(code_block) => Self::render_codeblock(code_block, mb, window, cx), Node::Table { .. } => Self::render_table(&self, window, cx).into_any_element(), Node::Divider => div() .bg(cx.theme().border) @@ -765,8 +794,12 @@ impl Node { .join("\n") ) } - Node::CodeBlock { code, lang } => { - format!("```{}\n{}\n```", lang.clone().unwrap_or_default(), code) + Node::CodeBlock(code_block) => { + format!( + "```{}\n{}\n```", + code_block.lang.clone().unwrap_or_default(), + code_block.code + ) } Node::Table(table) => { let header = table diff --git a/crates/ui/src/text/markdown.rs b/crates/ui/src/text/markdown.rs index 9cecb398..7c07209a 100644 --- a/crates/ui/src/text/markdown.rs +++ b/crates/ui/src/text/markdown.rs @@ -10,7 +10,9 @@ use markdown::{ use crate::v_flex; use super::{ - element::{self, ImageNode, InlineTextStyle, LinkMark, Paragraph, Span, Table, TableRow}, + element::{ + self, CodeBlock, ImageNode, InlineTextStyle, LinkMark, Paragraph, Span, Table, TableRow, + }, html::parse_html, TextViewStyle, }; @@ -54,18 +56,20 @@ impl MarkdownElement { pub struct MarkdownState { raw: SharedString, root: Option>, + style: TextViewStyle, } impl MarkdownState { - fn parse_if_needed(&mut self, new_text: SharedString) { - let is_changed = self.raw != new_text; + fn parse_if_needed(&mut self, new_text: SharedString, style: &TextViewStyle) { + let is_changed = self.raw != new_text || self.style != *style; if self.root.is_some() && !is_changed { return; } self.raw = new_text; - self.root = Some(parse_markdown(&self.raw)); + self.root = Some(parse_markdown(&self.raw, &style)); + self.style = style.clone(); } } @@ -93,7 +97,7 @@ impl Element for MarkdownElement { ) -> (gpui::LayoutId, Self::RequestLayoutState) { window.with_element_state(id.unwrap(), |state, window| { let mut state: MarkdownState = state.unwrap_or_default(); - state.parse_if_needed(self.text.clone()); + state.parse_if_needed(self.text.clone(), &self.style); let root = state .root @@ -143,9 +147,9 @@ impl Element for MarkdownElement { } /// Parse Markdown into a tree of nodes. -fn parse_markdown(raw: &str) -> Result { +fn parse_markdown(raw: &str, style: &TextViewStyle) -> Result { markdown::to_mdast(&raw, &ParseOptions::gfm()) - .map(|n| n.into()) + .map(|n| ast_to_node(n, style)) .map_err(|e| e.to_string().into()) } @@ -335,137 +339,144 @@ fn parse_paragraph(paragraph: &mut Paragraph, node: &mdast::Node) -> String { text } -impl From for element::Node { - fn from(value: Node) -> Self { - match value { - Node::Root(val) => { - let children = val.children.into_iter().map(|c| c.into()).collect(); - element::Node::Root { children } - } - Node::Paragraph(val) => { - let mut paragraph = Paragraph::default(); - val.children.iter().for_each(|c| { - parse_paragraph(&mut paragraph, c); - }); +fn ast_to_node(value: mdast::Node, style: &TextViewStyle) -> element::Node { + match value { + Node::Root(val) => { + let children = val + .children + .into_iter() + .map(|c| ast_to_node(c, style)) + .collect(); + element::Node::Root { children } + } + Node::Paragraph(val) => { + let mut paragraph = Paragraph::default(); + val.children.iter().for_each(|c| { + parse_paragraph(&mut paragraph, c); + }); - element::Node::Paragraph(paragraph) - } - Node::Blockquote(val) => { - let mut paragraph = Paragraph::default(); - val.children.iter().for_each(|c| { - parse_paragraph(&mut paragraph, c); - }); + element::Node::Paragraph(paragraph) + } + Node::Blockquote(val) => { + let mut paragraph = Paragraph::default(); + val.children.iter().for_each(|c| { + parse_paragraph(&mut paragraph, c); + }); - element::Node::Blockquote(paragraph) + element::Node::Blockquote(paragraph) + } + Node::List(list) => { + let children = list + .children + .into_iter() + .map(|c| ast_to_node(c, style)) + .collect(); + element::Node::List { + ordered: list.ordered, + children, } - Node::List(list) => { - let children = list.children.into_iter().map(|c| c.into()).collect(); - element::Node::List { - ordered: list.ordered, - children, - } + } + Node::ListItem(val) => { + let children = val + .children + .into_iter() + .map(|c| ast_to_node(c, style)) + .collect(); + element::Node::ListItem { + children, + spread: val.spread, + checked: val.checked, } - Node::ListItem(val) => { - let children = val.children.into_iter().map(|c| c.into()).collect(); - element::Node::ListItem { - children, - spread: val.spread, - checked: val.checked, - } - } - Node::Break(_) => element::Node::Break { html: false }, - Node::Code(raw) => element::Node::CodeBlock { - code: raw.value.into(), - lang: raw.lang.map(|s| s.into()), - }, - Node::Heading(val) => { - let mut paragraph = Paragraph::default(); - val.children.iter().for_each(|c| { - parse_paragraph(&mut paragraph, c); - }); + } + Node::Break(_) => element::Node::Break { html: false }, + Node::Code(raw) => element::Node::CodeBlock(CodeBlock::new( + raw.value.into(), + raw.lang.map(|s| s.into()), + style, + )), + Node::Heading(val) => { + let mut paragraph = Paragraph::default(); + val.children.iter().for_each(|c| { + parse_paragraph(&mut paragraph, c); + }); - element::Node::Heading { - level: val.depth, - children: paragraph, - } + element::Node::Heading { + level: val.depth, + children: paragraph, } - Node::Math(val) => element::Node::CodeBlock { - code: val.value.into(), - lang: Some("math".into()), - }, - Node::Html(val) => match parse_html(&val.value) { - Ok(el) => el, - Err(err) => { - if cfg!(debug_assertions) { - eprintln!("[markdown] error parsing html: {:#?}", err); - } - - element::Node::Paragraph(val.value.into()) - } - }, - Node::MdxFlowExpression(val) => element::Node::CodeBlock { - code: val.value.into(), - lang: Some("mdx".into()), - }, - Node::Yaml(val) => element::Node::CodeBlock { - code: val.value.into(), - lang: Some("yaml".into()), - }, - Node::Toml(val) => element::Node::CodeBlock { - code: val.value.into(), - lang: Some("toml".into()), - }, - Node::MdxJsxTextElement(val) => { - println!("MdxJsxTextElement: {:#?}", val); - let mut paragraph = Paragraph::default(); - val.children.iter().for_each(|c| { - parse_paragraph(&mut paragraph, c); - }); - element::Node::Paragraph(paragraph) - } - Node::MdxJsxFlowElement(val) => { - println!("MdxJsxFlowElement: {:#?}", val); - let mut paragraph = Paragraph::default(); - val.children.iter().for_each(|c| { - parse_paragraph(&mut paragraph, c); - }); - element::Node::Paragraph(paragraph) - } - Node::ThematicBreak(_) => element::Node::Divider, - Node::Table(val) => { - let mut table = Table::default(); - table.column_aligns = val - .align - .clone() - .into_iter() - .map(|align| align.into()) - .collect(); - val.children.iter().for_each(|c| { - if let Node::TableRow(row) = c { - parse_table_row(&mut table, row); - } - }); - - element::Node::Table(table) - } - _ => { + } + Node::Math(val) => element::Node::CodeBlock(CodeBlock::new(val.value.into(), None, style)), + Node::Html(val) => match parse_html(&val.value) { + Ok(el) => el, + Err(err) => { if cfg!(debug_assertions) { - eprintln!("[markdown] unsupported node: {:#?}", value); + eprintln!("[markdown] error parsing html: {:#?}", err); } - element::Node::Unknown + + element::Node::Paragraph(val.value.into()) } + }, + Node::MdxFlowExpression(val) => { + element::Node::CodeBlock(CodeBlock::new(val.value.into(), Some("mdx".into()), style)) + } + Node::Yaml(val) => { + element::Node::CodeBlock(CodeBlock::new(val.value.into(), Some("yml".into()), style)) + } + Node::Toml(val) => { + element::Node::CodeBlock(CodeBlock::new(val.value.into(), Some("toml".into()), style)) + } + Node::MdxJsxTextElement(val) => { + println!("MdxJsxTextElement: {:#?}", val); + let mut paragraph = Paragraph::default(); + val.children.iter().for_each(|c| { + parse_paragraph(&mut paragraph, c); + }); + element::Node::Paragraph(paragraph) + } + Node::MdxJsxFlowElement(val) => { + println!("MdxJsxFlowElement: {:#?}", val); + let mut paragraph = Paragraph::default(); + val.children.iter().for_each(|c| { + parse_paragraph(&mut paragraph, c); + }); + element::Node::Paragraph(paragraph) + } + Node::ThematicBreak(_) => element::Node::Divider, + Node::Table(val) => { + let mut table = Table::default(); + table.column_aligns = val + .align + .clone() + .into_iter() + .map(|align| align.into()) + .collect(); + val.children.iter().for_each(|c| { + if let Node::TableRow(row) = c { + parse_table_row(&mut table, row); + } + }); + + element::Node::Table(table) + } + _ => { + if cfg!(debug_assertions) { + eprintln!("[markdown] unsupported node: {:#?}", value); + } + element::Node::Unknown } } } #[cfg(test)] mod tests { + use crate::text::TextViewStyle; + use super::parse_markdown; #[test] fn test_parse_br() { let raw = "Row 1
Row 2
[Link](https://github.com)"; - let node = parse_markdown(&raw).unwrap(); + let node = parse_markdown(&raw, &TextViewStyle::default()).unwrap(); assert_eq!( node.to_markdown(), "Row 1\nRow 2\n[Link](https://github.com)" diff --git a/crates/ui/src/text/text_view.rs b/crates/ui/src/text/text_view.rs index f9c2bb64..e9f8b2a7 100644 --- a/crates/ui/src/text/text_view.rs +++ b/crates/ui/src/text/text_view.rs @@ -1,5 +1,9 @@ +use std::rc::Rc; + use gpui::{px, rems, App, ElementId, IntoElement, Pixels, Rems, RenderOnce, SharedString, Window}; +use crate::highlighter::HighlightTheme; + use super::{html::HtmlElement, markdown::MarkdownElement}; /// A text view that can render Markdown or HTML. @@ -65,12 +69,14 @@ impl RenderOnce for Text { } /// TextViewStyle used to customize the style for [`TextView`]. -#[derive(Copy, Clone)] +#[derive(Clone, PartialEq)] pub struct TextViewStyle { /// Gap of each paragraphs, default is 1 rem. pub paragraph_gap: Rems, /// Base font size for headings, default is 14px. pub heading_base_font_size: Pixels, + /// Highlight theme for code blocks. Default: [`HighlightTheme::default_light()`] + pub highlight_theme: Rc, } impl Default for TextViewStyle { @@ -78,6 +84,7 @@ impl Default for TextViewStyle { Self { paragraph_gap: rems(1.), heading_base_font_size: px(14.), + highlight_theme: Rc::new(HighlightTheme::default_light()), } } } diff --git a/crates/ui/src/theme.rs b/crates/ui/src/theme.rs index 776ce1f9..edae57e3 100644 --- a/crates/ui/src/theme.rs +++ b/crates/ui/src/theme.rs @@ -541,8 +541,9 @@ impl Theme { impl From for Theme { fn from(colors: ThemeColor) -> Self { + let mode = ThemeMode::default(); Theme { - mode: ThemeMode::default(), + mode, transparent: Hsla::transparent_black(), font_size: px(16.), font_family: if cfg!(target_os = "macos") {