Skip to main content

gpui_component/text/
mod.rs

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