1mod 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
30pub(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 #[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 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}