Skip to main content

gpui_component/text/
mod.rs

1//! Compatibility facade for rich text now owned by `gpui-base`.
2
3mod compat;
4mod frontmatter;
5mod style;
6
7pub use compat::{
8    Text, TextView, TextViewLayoutState, TextViewPlugin, TextViewPrepaintState, html, markdown,
9};
10pub use frontmatter::FrontmatterPlugin;
11pub use gpui_base::text::{
12    InlineElement, InlineRenderContext, MarkdownBlockParserFn, MarkdownBlockRenderFn,
13    MarkdownExtensions, MarkdownNode, MarkdownParseContext, MarkdownPlugin, RangeHighlight,
14    RangeHighlightError, RenderedText, SelectionFormat, TableData, TextViewMotion, TextViewState,
15    markdown_ast,
16};
17pub use style::TextViewStyle;
18
19#[cfg(any(feature = "tree-sitter", feature = "syntect"))]
20use std::cell::RefCell;
21#[cfg(feature = "tree-sitter")]
22use std::collections::HashMap;
23
24use gpui::Styled as _;
25#[cfg(feature = "tree-sitter")]
26use gpui_base::input::{InputEdit, Point, RopeExt as _};
27#[cfg(feature = "tree-sitter")]
28use ropey::Rope;
29
30#[cfg(feature = "tree-sitter")]
31use crate::highlighter::{LanguageRegistry, SyntaxHighlighter};
32
33#[cfg(test)]
34mod window_selection;
35
36/// Derives the Base rich-text style installed by the component theme adapter.
37pub(crate) fn base_text_view_style(theme: &crate::Theme) -> gpui_base::TextViewStyle {
38    let radius = theme.semantic_tokens().radius.md;
39    let mut table = gpui::StyleRefinement::default();
40    table.corner_radii.top_left = Some(radius.into());
41    table.corner_radii.top_right = Some(radius.into());
42    table.corner_radii.bottom_left = Some(radius.into());
43    table.corner_radii.bottom_right = Some(radius.into());
44    let mut code_block = gpui::StyleRefinement::default();
45    code_block.corner_radii = table.corner_radii.clone();
46    let table_head = gpui::StyleRefinement::default()
47        .bg(theme.table_head)
48        .text_color(theme.table_head_foreground);
49
50    gpui_base::TextViewStyle::default()
51        .with_foreground(theme.foreground)
52        .with_muted_foreground(theme.muted_foreground)
53        .with_link(theme.link)
54        .with_selection(theme.selection)
55        .with_code_background(theme.muted)
56        .with_border(theme.border)
57        .with_code_block(code_block)
58        .with_table(table)
59        .with_table_head(table_head)
60        .with_inline_code(gpui::HighlightStyle {
61            background_color: Some(theme.accent),
62            ..Default::default()
63        })
64        .with_dark(theme.is_dark())
65}
66
67pub(crate) fn install_text_view_defaults(theme: &crate::Theme, cx: &mut gpui::App) {
68    // Component's Root sets the theme foreground, so every container that
69    // sets its own text color is one a text view should follow.
70    let defaults = gpui_base::TextViewDefaults::new()
71        .with_style(base_text_view_style(theme))
72        .with_inherit_text_color(true);
73
74    #[cfg(any(feature = "tree-sitter", feature = "syntect"))]
75    let defaults = defaults.with_code_block_highlighter(component_code_block_highlighter(
76        theme.highlight_theme.clone(),
77    ));
78
79    defaults.install(cx);
80}
81
82#[cfg(feature = "tree-sitter")]
83pub(crate) fn component_code_block_highlighter(
84    highlight_theme: std::sync::Arc<crate::highlighter::HighlightTheme>,
85) -> impl Fn(&gpui_base::text::CodeBlock) -> Vec<(std::ops::Range<usize>, gpui::HighlightStyle)>
86+ Send
87+ Sync
88+ 'static {
89    move |block| {
90        thread_local! {
91            static HIGHLIGHTERS: RefCell<HashMap<gpui::SharedString, SyntaxHighlighter>> =
92                RefCell::new(HashMap::new());
93        }
94
95        let Some(lang) = block.lang() else {
96            return Vec::new();
97        };
98        let code = block.code();
99        HIGHLIGHTERS.with(|cache| {
100            let mut cache = cache.borrow_mut();
101            let highlighter = cache
102                .entry(lang.clone())
103                .or_insert_with(|| SyntaxHighlighter::new(lang.as_ref()));
104            if let Some(config) = LanguageRegistry::singleton().language(lang.as_ref())
105                && highlighter.language() != &config.name
106            {
107                *highlighter = SyntaxHighlighter::new(lang.as_ref());
108            }
109
110            let old_end_byte = highlighter.text().len();
111            let old_end_position = highlighter.text().offset_to_point(old_end_byte);
112            let code_rope = Rope::from_str(code.as_ref());
113            let edit = InputEdit {
114                start_byte: 0,
115                old_end_byte,
116                new_end_byte: code.len(),
117                start_position: Point::new(0, 0),
118                old_end_position,
119                new_end_position: code_rope.offset_to_point(code.len()),
120            };
121            highlighter.update_input(Some(edit), &code_rope, None);
122            highlighter.styles(&(0..code.len()), highlight_theme.as_ref())
123        })
124    }
125}
126
127/// The syntect counterpart: syntect keeps no state between calls, and the
128/// highlights of an unchanged block are cached by the text view itself.
129#[cfg(all(feature = "syntect", not(feature = "tree-sitter")))]
130pub(crate) fn component_code_block_highlighter(
131    highlight_theme: std::sync::Arc<crate::highlighter::HighlightTheme>,
132) -> impl Fn(&gpui_base::text::CodeBlock) -> Vec<(std::ops::Range<usize>, gpui::HighlightStyle)>
133+ Send
134+ Sync
135+ 'static {
136    move |block| match block.lang() {
137        Some(lang) => crate::highlighter::highlight_code(
138            lang.as_ref(),
139            block.code().as_ref(),
140            highlight_theme.as_ref(),
141        ),
142        None => Vec::new(),
143    }
144}
145
146/// The type [`shared_code_block_highlighter`] returns.
147#[cfg(any(feature = "tree-sitter", feature = "syntect"))]
148pub(crate) type SharedCodeBlockHighlighter = dyn Fn(&gpui_base::text::CodeBlock) -> Vec<(std::ops::Range<usize>, gpui::HighlightStyle)>
149    + Send
150    + Sync;
151
152/// The [`component_code_block_highlighter`] for `highlight_theme`, built once
153/// and handed out again for the same theme.
154///
155/// A code block reuses its highlights only while the highlighter is the same
156/// `Arc`, and a text view with a custom theme is laid out every frame, so a
157/// fresh highlighter per frame reparsed every code block with tree-sitter on
158/// every frame. Each entry keeps its theme alive, so only the most recent few
159/// themes are kept.
160///
161/// Entries are also keyed on the [`LanguageRegistry`] generation: registering
162/// a language drops them, so the next frame gets a new highlighter and code
163/// blocks painted before the language existed are highlighted again.
164#[cfg(any(feature = "tree-sitter", feature = "syntect"))]
165pub(crate) fn shared_code_block_highlighter(
166    highlight_theme: &std::sync::Arc<crate::highlighter::HighlightTheme>,
167) -> std::sync::Arc<SharedCodeBlockHighlighter> {
168    // Syntect's languages are fixed at build time, so its entries never go stale.
169    #[cfg(feature = "tree-sitter")]
170    let generation = LanguageRegistry::singleton().generation();
171    #[cfg(not(feature = "tree-sitter"))]
172    let generation = 0;
173    shared_code_block_highlighter_at(highlight_theme, generation)
174}
175
176/// [`shared_code_block_highlighter`] at an explicit registry `generation`.
177#[cfg(any(feature = "tree-sitter", feature = "syntect"))]
178fn shared_code_block_highlighter_at(
179    highlight_theme: &std::sync::Arc<crate::highlighter::HighlightTheme>,
180    generation: u64,
181) -> std::sync::Arc<SharedCodeBlockHighlighter> {
182    use std::sync::Arc;
183
184    use crate::highlighter::HighlightTheme;
185
186    /// The registry generation the entries were built at, and the entries.
187    type SharedHighlighters = (
188        u64,
189        Vec<(Arc<HighlightTheme>, Arc<SharedCodeBlockHighlighter>)>,
190    );
191
192    const CAPACITY: usize = 4;
193    thread_local! {
194        static SHARED: RefCell<SharedHighlighters> = const { RefCell::new((0, Vec::new())) };
195    }
196
197    SHARED.with(|cache| {
198        let (cached_generation, shared) = &mut *cache.borrow_mut();
199        if *cached_generation != generation {
200            *cached_generation = generation;
201            shared.clear();
202        }
203        if let Some((_, highlighter)) = shared
204            .iter()
205            .find(|(theme, _)| Arc::ptr_eq(theme, highlight_theme))
206        {
207            return highlighter.clone();
208        }
209
210        let highlighter: Arc<SharedCodeBlockHighlighter> =
211            Arc::new(component_code_block_highlighter(highlight_theme.clone()));
212        if shared.len() == CAPACITY {
213            shared.remove(0);
214        }
215        shared.push((highlight_theme.clone(), highlighter.clone()));
216        highlighter
217    })
218}
219
220#[cfg(test)]
221mod tests {
222    use std::sync::Arc;
223
224    use gpui::{StyleRefinement, Styled as _, px};
225
226    use crate::Theme;
227
228    /// The component highlighter is the only place that still knows about
229    /// `LanguageRegistry` and `HighlightTheme`, so these two cases follow it
230    /// here from the code block it used to live in.
231    /// A Markdown code fence in a language tree-sitter is not built with —
232    /// TOML here — is highlighted by syntect in a build that chose it.
233    #[cfg(all(feature = "syntect", not(feature = "tree-sitter")))]
234    #[test]
235    fn syntect_highlights_a_code_fence() {
236        use gpui_base::text::CodeBlock;
237
238        use crate::highlighter::HighlightTheme;
239
240        let code = "[package]\nname = \"demo\"\n";
241        let highlighter = super::component_code_block_highlighter(HighlightTheme::default_light());
242        let styles = highlighter(&CodeBlock::from_code(code, Some("toml")));
243        let string = code.find("\"demo\"").unwrap();
244        assert!(
245            styles
246                .iter()
247                .any(|(range, style)| range.contains(&string) && style.color.is_some()),
248            "the string must be colored: {styles:?}"
249        );
250        assert_eq!(
251            styles.last().map(|(range, _)| range.end),
252            Some(code.len()),
253            "the runs cover the whole block"
254        );
255        assert!(highlighter(&CodeBlock::from_code(code, Some("no-such-language"))).is_empty());
256    }
257
258    #[cfg(feature = "tree-sitter")]
259    mod code_block_highlighter {
260        use std::ops::Range;
261
262        use gpui::{HighlightStyle, Hsla, SharedString};
263        use gpui_base::text::CodeBlock;
264
265        use crate::highlighter::{HighlightTheme, LanguageConfig, LanguageRegistry};
266
267        fn register_json(lang: &SharedString) {
268            LanguageRegistry::singleton().register(
269                lang.as_ref(),
270                &LanguageConfig::new(
271                    lang.clone(),
272                    tree_sitter_json::LANGUAGE.into(),
273                    vec![],
274                    "(number) @number",
275                    "",
276                    "",
277                ),
278            );
279        }
280
281        fn color_at(
282            styles: &[(Range<usize>, HighlightStyle)],
283            range: Range<usize>,
284        ) -> Option<Hsla> {
285            styles
286                .iter()
287                .find(|(span, _)| span.start <= range.start && span.end >= range.end)
288                .and_then(|(_, style)| style.color)
289        }
290
291        #[test]
292        fn registering_a_language_refreshes_the_cached_highlighter() {
293            let lang = SharedString::from("json-cache-test");
294            let code = SharedString::from(r#"{"value": 42}"#);
295            let number = code.find("42").unwrap()..code.find("42").unwrap() + 2;
296            let highlighter =
297                super::super::component_code_block_highlighter(HighlightTheme::default_light());
298
299            // The first call caches a plain-text highlighter for the unknown
300            // language; the cache must not outlive the registration.
301            let block = CodeBlock::from_code(code.clone(), Some(lang.clone()));
302            assert_eq!(color_at(&highlighter(&block), number.clone()), None);
303
304            register_json(&lang);
305
306            let block = CodeBlock::from_code(code, Some(lang));
307            assert!(
308                color_at(&highlighter(&block), number).is_some(),
309                "a newly registered language must reach the cached highlighter"
310            );
311        }
312
313        #[test]
314        fn styles_follow_the_highlight_theme_they_were_built_with() {
315            let lang = SharedString::from("json-theme-test");
316            register_json(&lang);
317            let code = SharedString::from(r#"{"value": 42}"#);
318            let number = code.find("42").unwrap()..code.find("42").unwrap() + 2;
319
320            let light = HighlightTheme::default_light();
321            let dark = HighlightTheme::default_dark();
322            let light_number = light.style("number").and_then(|style| style.color);
323            let dark_number = dark.style("number").and_then(|style| style.color);
324            assert_ne!(
325                light_number, dark_number,
326                "the default themes must use different number colors"
327            );
328
329            let block = CodeBlock::from_code(code, Some(lang));
330            let light_styles = super::super::component_code_block_highlighter(light)(&block);
331            let dark_styles = super::super::component_code_block_highlighter(dark)(&block);
332
333            assert_eq!(color_at(&light_styles, number.clone()), light_number);
334            assert_eq!(
335                color_at(&dark_styles, number),
336                dark_number,
337                "a theme change must not reuse syntax styles from the previous theme"
338            );
339        }
340
341        #[test]
342        fn shared_highlighter_is_reused_for_the_same_theme() {
343            // An explicit generation, so registrations by tests running in
344            // parallel cannot drop the entries between calls.
345            const GENERATION: u64 = u64::MAX;
346            let light = HighlightTheme::default_light();
347            let dark = HighlightTheme::default_dark();
348
349            let first = super::super::shared_code_block_highlighter_at(&light, GENERATION);
350            let again = super::super::shared_code_block_highlighter_at(&light, GENERATION);
351            let other = super::super::shared_code_block_highlighter_at(&dark, GENERATION);
352
353            // Code blocks keep their highlights only while the highlighter is
354            // the same `Arc`.
355            assert!(std::sync::Arc::ptr_eq(&first, &again));
356            assert!(!std::sync::Arc::ptr_eq(&first, &other));
357        }
358
359        #[test]
360        fn registering_a_language_replaces_the_shared_highlighter() {
361            let light = HighlightTheme::default_light();
362
363            let before = super::super::shared_code_block_highlighter_at(&light, u64::MAX - 1);
364            let after = super::super::shared_code_block_highlighter_at(&light, u64::MAX - 2);
365
366            // A new `Arc` makes code blocks painted before the registration
367            // highlight again.
368            assert!(!std::sync::Arc::ptr_eq(&before, &after));
369        }
370    }
371
372    #[test]
373    fn component_theme_adapter_maps_text_colors_without_highlighting() {
374        let theme = Theme::default();
375        let style = super::base_text_view_style(&theme);
376
377        assert_eq!(style.foreground(), theme.foreground);
378        assert_eq!(style.muted_foreground(), theme.muted_foreground);
379        assert_eq!(style.link(), theme.link);
380        assert_eq!(style.selection(), theme.selection);
381        assert_eq!(style.inline_code().background_color, Some(theme.accent));
382        let radius = theme.semantic_tokens().radius.md;
383        assert_eq!(style.table().corner_radii.top_left, Some(radius.into()));
384        assert_eq!(style.table().corner_radii.top_right, Some(radius.into()));
385        assert_eq!(style.table().corner_radii.bottom_left, Some(radius.into()));
386        assert_eq!(style.table().corner_radii.bottom_right, Some(radius.into()));
387    }
388
389    #[test]
390    fn component_text_view_table_respects_square_base_radius_token() {
391        let mut theme = Theme::default();
392        theme.radius = gpui::px(0.);
393
394        let style = super::base_text_view_style(&theme);
395        let square = Some(gpui::px(0.).into());
396        assert_eq!(style.table().corner_radii.top_left, square);
397        assert_eq!(style.table().corner_radii.top_right, square);
398        assert_eq!(style.table().corner_radii.bottom_left, square);
399        assert_eq!(style.table().corner_radii.bottom_right, square);
400    }
401
402    #[cfg(feature = "tree-sitter")]
403    #[gpui::test]
404    fn component_initialization_installs_default_code_highlighting(cx: &mut gpui::TestAppContext) {
405        cx.update(crate::init);
406
407        cx.update(|cx| {
408            assert!(gpui_base::TextViewDefaults::global(cx).has_code_block_highlighter());
409        });
410    }
411
412    #[test]
413    fn legacy_text_paths_reexport_base_implementation() {
414        let mut style = super::TextViewStyle::default();
415        style.highlight_theme = crate::highlighter::HighlightTheme::default_dark();
416
417        let _: super::TextView = super::markdown("# compatible")
418            .style(style)
419            .selectable(true)
420            .scrollable(true);
421    }
422
423    #[test]
424    fn legacy_text_view_keeps_element_associated_types() {
425        fn assert_element_types<T>()
426        where
427            T: gpui::Element<
428                    RequestLayoutState = super::TextViewLayoutState,
429                    PrepaintState = super::TextViewPrepaintState,
430                >,
431        {
432        }
433
434        assert_element_types::<super::TextView>();
435    }
436
437    #[test]
438    fn legacy_default_style_keeps_active_component_theme_colors() {
439        let mut theme = Theme::default();
440        theme.foreground = gpui::rgb(0xf4f4f5).into();
441        theme.link = gpui::rgb(0x38bdf8).into();
442        theme.selection = gpui::rgba(0x2563eb66).into();
443
444        let style = super::compat::resolve_component_style(&theme, super::TextViewStyle::default());
445
446        assert_eq!(style.foreground(), theme.foreground);
447        assert_eq!(style.link(), theme.link);
448        assert_eq!(style.selection(), theme.selection);
449    }
450
451    #[test]
452    fn legacy_table_refinement_keeps_component_radius() {
453        let theme = Theme::default();
454        let mut table = gpui::StyleRefinement::default();
455        table.overflow.x = Some(gpui::Overflow::Scroll);
456
457        let style = super::compat::resolve_component_style(
458            &theme,
459            super::TextViewStyle::default().table(table),
460        );
461
462        let radius = Some(theme.semantic_tokens().radius.md.into());
463        assert_eq!(style.table().corner_radii.top_left, radius);
464        assert_eq!(style.table().corner_radii.top_right, radius);
465        assert_eq!(style.table().corner_radii.bottom_left, radius);
466        assert_eq!(style.table().corner_radii.bottom_right, radius);
467        assert_eq!(style.table().overflow.x, Some(gpui::Overflow::Scroll));
468    }
469
470    #[test]
471    fn legacy_partial_styles_refine_component_theme_defaults() {
472        let theme = Theme::default();
473        let mut table_head = gpui::StyleRefinement::default();
474        table_head.text.font_weight = Some(gpui::FontWeight::BOLD);
475        let inline_code = gpui::HighlightStyle {
476            font_style: Some(gpui::FontStyle::Italic),
477            ..Default::default()
478        };
479
480        let style = super::compat::resolve_component_style(
481            &theme,
482            super::TextViewStyle::default()
483                .table_head(table_head)
484                .inline_code(inline_code),
485        );
486
487        assert_eq!(style.table_head().background, Some(theme.table_head.into()));
488        assert_eq!(
489            style.table_head().text.color,
490            Some(theme.table_head_foreground)
491        );
492        assert_eq!(
493            style.table_head().text.font_weight,
494            Some(gpui::FontWeight::BOLD)
495        );
496        assert_eq!(style.inline_code().background_color, Some(theme.accent));
497        assert_eq!(
498            style.inline_code().font_style,
499            Some(gpui::FontStyle::Italic)
500        );
501    }
502
503    #[test]
504    fn legacy_heading_configuration_maps_to_base_heading_refinements() {
505        let theme = Theme::default();
506        let mut legacy = super::TextViewStyle::default();
507        legacy.heading_base_font_size = px(10.);
508        legacy.heading_font_size = Some(Arc::new(|level, base| base * level as f32));
509
510        let style = super::compat::resolve_component_style(&theme, legacy);
511
512        assert_eq!(
513            style.heading(2),
514            StyleRefinement::default().text_size(px(20.))
515        );
516    }
517}