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