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