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