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