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, SelectionFormat,
14    TableData, TextViewMotion, TextViewState, markdown_ast,
15};
16pub use style::TextViewStyle;
17
18#[cfg(feature = "tree-sitter")]
19use std::{cell::RefCell, collections::HashMap};
20
21use gpui::Styled as _;
22#[cfg(feature = "tree-sitter")]
23use gpui_base::input::{InputEdit, Point, RopeExt as _};
24#[cfg(feature = "tree-sitter")]
25use ropey::Rope;
26
27#[cfg(feature = "tree-sitter")]
28use crate::highlighter::{LanguageRegistry, SyntaxHighlighter};
29
30#[cfg(test)]
31mod window_selection;
32
33/// Derives the Base rich-text style installed by the component theme adapter.
34pub(crate) fn base_text_view_style(theme: &crate::Theme) -> gpui_base::TextViewStyle {
35    let radius = theme.semantic_tokens().radius.md;
36    let mut table = gpui::StyleRefinement::default();
37    table.corner_radii.top_left = Some(radius.into());
38    table.corner_radii.top_right = Some(radius.into());
39    table.corner_radii.bottom_left = Some(radius.into());
40    table.corner_radii.bottom_right = Some(radius.into());
41    let mut code_block = gpui::StyleRefinement::default();
42    code_block.corner_radii = table.corner_radii.clone();
43    let table_head = gpui::StyleRefinement::default()
44        .bg(theme.table_head)
45        .text_color(theme.table_head_foreground);
46
47    gpui_base::TextViewStyle::default()
48        .with_foreground(theme.foreground)
49        .with_muted_foreground(theme.muted_foreground)
50        .with_link(theme.link)
51        .with_selection(theme.selection)
52        .with_code_background(theme.muted)
53        .with_border(theme.border)
54        .with_code_block(code_block)
55        .with_table(table)
56        .with_table_head(table_head)
57        .with_inline_code(gpui::HighlightStyle {
58            background_color: Some(theme.accent),
59            ..Default::default()
60        })
61        .with_dark(theme.is_dark())
62}
63
64pub(crate) fn install_text_view_defaults(theme: &crate::Theme, cx: &mut gpui::App) {
65    let defaults = gpui_base::TextViewDefaults::new().with_style(base_text_view_style(theme));
66
67    #[cfg(feature = "tree-sitter")]
68    let defaults = defaults.with_code_block_highlighter(component_code_block_highlighter(
69        theme.highlight_theme.clone(),
70    ));
71
72    defaults.install(cx);
73}
74
75#[cfg(feature = "tree-sitter")]
76pub(crate) fn component_code_block_highlighter(
77    highlight_theme: std::sync::Arc<crate::highlighter::HighlightTheme>,
78) -> impl Fn(&gpui_base::text::CodeBlock) -> Vec<(std::ops::Range<usize>, gpui::HighlightStyle)>
79+ Send
80+ Sync
81+ 'static {
82    move |block| {
83        thread_local! {
84            static HIGHLIGHTERS: RefCell<HashMap<gpui::SharedString, SyntaxHighlighter>> =
85                RefCell::new(HashMap::new());
86        }
87
88        let Some(lang) = block.lang() else {
89            return Vec::new();
90        };
91        let code = block.code();
92        HIGHLIGHTERS.with(|cache| {
93            let mut cache = cache.borrow_mut();
94            let highlighter = cache
95                .entry(lang.clone())
96                .or_insert_with(|| SyntaxHighlighter::new(lang.as_ref()));
97            if let Some(config) = LanguageRegistry::singleton().language(lang.as_ref())
98                && highlighter.language() != &config.name
99            {
100                *highlighter = SyntaxHighlighter::new(lang.as_ref());
101            }
102
103            let old_end_byte = highlighter.text().len();
104            let old_end_position = highlighter.text().offset_to_point(old_end_byte);
105            let code_rope = Rope::from_str(code.as_ref());
106            let edit = InputEdit {
107                start_byte: 0,
108                old_end_byte,
109                new_end_byte: code.len(),
110                start_position: Point::new(0, 0),
111                old_end_position,
112                new_end_position: code_rope.offset_to_point(code.len()),
113            };
114            highlighter.update_input(Some(edit), &code_rope, None);
115            highlighter.styles(&(0..code.len()), highlight_theme.as_ref())
116        })
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use crate::Theme;
123
124    /// The component highlighter is the only place that still knows about
125    /// `LanguageRegistry` and `HighlightTheme`, so these two cases follow it
126    /// here from the code block it used to live in.
127    #[cfg(feature = "tree-sitter")]
128    mod code_block_highlighter {
129        use std::ops::Range;
130
131        use gpui::{HighlightStyle, Hsla, SharedString};
132        use gpui_base::text::CodeBlock;
133
134        use crate::highlighter::{HighlightTheme, LanguageConfig, LanguageRegistry};
135
136        fn register_json(lang: &SharedString) {
137            LanguageRegistry::singleton().register(
138                lang.as_ref(),
139                &LanguageConfig::new(
140                    lang.clone(),
141                    tree_sitter_json::LANGUAGE.into(),
142                    vec![],
143                    "(number) @number",
144                    "",
145                    "",
146                ),
147            );
148        }
149
150        fn color_at(
151            styles: &[(Range<usize>, HighlightStyle)],
152            range: Range<usize>,
153        ) -> Option<Hsla> {
154            styles
155                .iter()
156                .find(|(span, _)| span.start <= range.start && span.end >= range.end)
157                .and_then(|(_, style)| style.color)
158        }
159
160        #[test]
161        fn registering_a_language_refreshes_the_cached_highlighter() {
162            let lang = SharedString::from("json-cache-test");
163            let code = SharedString::from(r#"{"value": 42}"#);
164            let number = code.find("42").unwrap()..code.find("42").unwrap() + 2;
165            let highlighter =
166                super::super::component_code_block_highlighter(HighlightTheme::default_light());
167
168            // The first call caches a plain-text highlighter for the unknown
169            // language; the cache must not outlive the registration.
170            let block = CodeBlock::from_code(code.clone(), Some(lang.clone()));
171            assert_eq!(color_at(&highlighter(&block), number.clone()), None);
172
173            register_json(&lang);
174
175            let block = CodeBlock::from_code(code, Some(lang));
176            assert!(
177                color_at(&highlighter(&block), number).is_some(),
178                "a newly registered language must reach the cached highlighter"
179            );
180        }
181
182        #[test]
183        fn styles_follow_the_highlight_theme_they_were_built_with() {
184            let lang = SharedString::from("json-theme-test");
185            register_json(&lang);
186            let code = SharedString::from(r#"{"value": 42}"#);
187            let number = code.find("42").unwrap()..code.find("42").unwrap() + 2;
188
189            let light = HighlightTheme::default_light();
190            let dark = HighlightTheme::default_dark();
191            let light_number = light.style("number").and_then(|style| style.color);
192            let dark_number = dark.style("number").and_then(|style| style.color);
193            assert_ne!(
194                light_number, dark_number,
195                "the default themes must use different number colors"
196            );
197
198            let block = CodeBlock::from_code(code, Some(lang));
199            let light_styles = super::super::component_code_block_highlighter(light)(&block);
200            let dark_styles = super::super::component_code_block_highlighter(dark)(&block);
201
202            assert_eq!(color_at(&light_styles, number.clone()), light_number);
203            assert_eq!(
204                color_at(&dark_styles, number),
205                dark_number,
206                "a theme change must not reuse syntax styles from the previous theme"
207            );
208        }
209    }
210
211    #[test]
212    fn component_theme_adapter_maps_text_colors_without_highlighting() {
213        let theme = Theme::default();
214        let style = super::base_text_view_style(&theme);
215
216        assert_eq!(style.foreground(), theme.foreground);
217        assert_eq!(style.muted_foreground(), theme.muted_foreground);
218        assert_eq!(style.link(), theme.link);
219        assert_eq!(style.selection(), theme.selection);
220        assert_eq!(style.inline_code().background_color, Some(theme.accent));
221        let radius = theme.semantic_tokens().radius.md;
222        assert_eq!(style.table().corner_radii.top_left, Some(radius.into()));
223        assert_eq!(style.table().corner_radii.top_right, Some(radius.into()));
224        assert_eq!(style.table().corner_radii.bottom_left, Some(radius.into()));
225        assert_eq!(style.table().corner_radii.bottom_right, Some(radius.into()));
226    }
227
228    #[test]
229    fn component_text_view_table_respects_square_base_radius_token() {
230        let mut theme = Theme::default();
231        theme.radius = gpui::px(0.);
232
233        let style = super::base_text_view_style(&theme);
234        let square = Some(gpui::px(0.).into());
235        assert_eq!(style.table().corner_radii.top_left, square);
236        assert_eq!(style.table().corner_radii.top_right, square);
237        assert_eq!(style.table().corner_radii.bottom_left, square);
238        assert_eq!(style.table().corner_radii.bottom_right, square);
239    }
240
241    #[cfg(feature = "tree-sitter")]
242    #[gpui::test]
243    fn component_initialization_installs_default_code_highlighting(cx: &mut gpui::TestAppContext) {
244        cx.update(crate::init);
245
246        cx.update(|cx| {
247            assert!(gpui_base::TextViewDefaults::global(cx).has_code_block_highlighter());
248        });
249    }
250
251    #[test]
252    fn legacy_text_paths_reexport_base_implementation() {
253        let mut style = super::TextViewStyle::default();
254        style.highlight_theme = crate::highlighter::HighlightTheme::default_dark();
255
256        let _: super::TextView = super::markdown("# compatible")
257            .style(style)
258            .selectable(true)
259            .scrollable(true);
260    }
261
262    #[test]
263    fn legacy_text_view_keeps_element_associated_types() {
264        fn assert_element_types<T>()
265        where
266            T: gpui::Element<
267                    RequestLayoutState = super::TextViewLayoutState,
268                    PrepaintState = super::TextViewPrepaintState,
269                >,
270        {
271        }
272
273        assert_element_types::<super::TextView>();
274    }
275
276    #[test]
277    fn legacy_default_style_keeps_active_component_theme_colors() {
278        let mut theme = Theme::default();
279        theme.foreground = gpui::rgb(0xf4f4f5).into();
280        theme.link = gpui::rgb(0x38bdf8).into();
281        theme.selection = gpui::rgba(0x2563eb66).into();
282
283        let style = super::compat::resolve_component_style(&theme, super::TextViewStyle::default());
284
285        assert_eq!(style.foreground(), theme.foreground);
286        assert_eq!(style.link(), theme.link);
287        assert_eq!(style.selection(), theme.selection);
288    }
289
290    #[test]
291    fn legacy_table_refinement_keeps_component_radius() {
292        let theme = Theme::default();
293        let mut table = gpui::StyleRefinement::default();
294        table.overflow.x = Some(gpui::Overflow::Scroll);
295
296        let style = super::compat::resolve_component_style(
297            &theme,
298            super::TextViewStyle::default().table(table),
299        );
300
301        let radius = Some(theme.semantic_tokens().radius.md.into());
302        assert_eq!(style.table().corner_radii.top_left, radius);
303        assert_eq!(style.table().corner_radii.top_right, radius);
304        assert_eq!(style.table().corner_radii.bottom_left, radius);
305        assert_eq!(style.table().corner_radii.bottom_right, radius);
306        assert_eq!(style.table().overflow.x, Some(gpui::Overflow::Scroll));
307    }
308
309    #[test]
310    fn legacy_partial_styles_refine_component_theme_defaults() {
311        let theme = Theme::default();
312        let mut table_head = gpui::StyleRefinement::default();
313        table_head.text.font_weight = Some(gpui::FontWeight::BOLD);
314        let inline_code = gpui::HighlightStyle {
315            font_style: Some(gpui::FontStyle::Italic),
316            ..Default::default()
317        };
318
319        let style = super::compat::resolve_component_style(
320            &theme,
321            super::TextViewStyle::default()
322                .table_head(table_head)
323                .inline_code(inline_code),
324        );
325
326        assert_eq!(style.table_head().background, Some(theme.table_head.into()));
327        assert_eq!(
328            style.table_head().text.color,
329            Some(theme.table_head_foreground)
330        );
331        assert_eq!(
332            style.table_head().text.font_weight,
333            Some(gpui::FontWeight::BOLD)
334        );
335        assert_eq!(style.inline_code().background_color, Some(theme.accent));
336        assert_eq!(
337            style.inline_code().font_style,
338            Some(gpui::FontStyle::Italic)
339        );
340    }
341}