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, TaskToggleEvent, TextViewMotion,
15 TextViewState, markdown_ast,
16};
17pub use style::TextViewStyle;
18
19#[cfg(any(feature = "tree-sitter", feature = "syntect"))]
20use std::cell::RefCell;
21#[cfg(feature = "tree-sitter")]
22use std::collections::HashMap;
23
24use gpui::Styled as _;
25#[cfg(feature = "tree-sitter")]
26use gpui_base::input::{InputEdit, Point, RopeExt as _};
27#[cfg(feature = "tree-sitter")]
28use ropey::Rope;
29
30#[cfg(feature = "tree-sitter")]
31use crate::highlighter::{LanguageRegistry, SyntaxHighlighter};
32
33#[cfg(test)]
34mod window_selection;
35
36pub(crate) fn base_text_view_style(theme: &crate::Theme) -> gpui_base::TextViewStyle {
38 let radius = theme.semantic_tokens().radius.md;
39 let mut table = gpui::StyleRefinement::default();
40 table.corner_radii.top_left = Some(radius.into());
41 table.corner_radii.top_right = Some(radius.into());
42 table.corner_radii.bottom_left = Some(radius.into());
43 table.corner_radii.bottom_right = Some(radius.into());
44 let mut code_block = gpui::StyleRefinement::default();
45 code_block.corner_radii = table.corner_radii.clone();
46 let table_head = gpui::StyleRefinement::default()
47 .bg(theme.table_head)
48 .text_color(theme.table_head_foreground);
49
50 gpui_base::TextViewStyle::default()
51 .with_foreground(theme.foreground)
52 .with_muted_foreground(theme.muted_foreground)
53 .with_link(theme.link)
54 .with_selection(theme.selection)
55 .with_code_background(theme.muted)
56 .with_border(theme.border)
57 .with_code_block(code_block)
58 .with_table(table)
59 .with_table_head(table_head)
60 .with_table_row_hover(theme.table_hover)
61 .with_inline_code(gpui::HighlightStyle {
62 background_color: Some(theme.accent),
63 ..Default::default()
64 })
65 .with_dark(theme.is_dark())
66}
67
68pub(crate) fn install_text_view_defaults(theme: &crate::Theme, cx: &mut gpui::App) {
69 let defaults = gpui_base::TextViewDefaults::new()
72 .with_style(base_text_view_style(theme))
73 .with_inherit_text_color(true);
74
75 #[cfg(any(feature = "tree-sitter", feature = "syntect"))]
76 let defaults = defaults.with_code_block_highlighter(component_code_block_highlighter(
77 theme.highlight_theme.clone(),
78 ));
79
80 defaults.install(cx);
81}
82
83#[cfg(feature = "tree-sitter")]
84pub(crate) fn component_code_block_highlighter(
85 highlight_theme: std::sync::Arc<crate::highlighter::HighlightTheme>,
86) -> impl Fn(&gpui_base::text::CodeBlock) -> Vec<(std::ops::Range<usize>, gpui::HighlightStyle)>
87+ Send
88+ Sync
89+ 'static {
90 move |block| {
91 thread_local! {
92 static HIGHLIGHTERS: RefCell<HashMap<gpui::SharedString, SyntaxHighlighter>> =
93 RefCell::new(HashMap::new());
94 }
95
96 let Some(lang) = block.lang() else {
97 return Vec::new();
98 };
99 let code = block.code();
100 HIGHLIGHTERS.with(|cache| {
101 let mut cache = cache.borrow_mut();
102 let highlighter = cache
103 .entry(lang.clone())
104 .or_insert_with(|| SyntaxHighlighter::new(lang.as_ref()));
105 if let Some(config) = LanguageRegistry::singleton().language(lang.as_ref())
106 && highlighter.language() != &config.name
107 {
108 *highlighter = SyntaxHighlighter::new(lang.as_ref());
109 }
110
111 let old_end_byte = highlighter.text().len();
112 let old_end_position = highlighter.text().offset_to_point(old_end_byte);
113 let code_rope = Rope::from_str(code.as_ref());
114 let edit = InputEdit {
115 start_byte: 0,
116 old_end_byte,
117 new_end_byte: code.len(),
118 start_position: Point::new(0, 0),
119 old_end_position,
120 new_end_position: code_rope.offset_to_point(code.len()),
121 };
122 highlighter.update_input(Some(edit), &code_rope, None);
123 highlighter.styles(&(0..code.len()), highlight_theme.as_ref())
124 })
125 }
126}
127
128#[cfg(all(feature = "syntect", not(feature = "tree-sitter")))]
131pub(crate) fn component_code_block_highlighter(
132 highlight_theme: std::sync::Arc<crate::highlighter::HighlightTheme>,
133) -> impl Fn(&gpui_base::text::CodeBlock) -> Vec<(std::ops::Range<usize>, gpui::HighlightStyle)>
134+ Send
135+ Sync
136+ 'static {
137 move |block| match block.lang() {
138 Some(lang) => crate::highlighter::highlight_code(
139 lang.as_ref(),
140 block.code().as_ref(),
141 highlight_theme.as_ref(),
142 ),
143 None => Vec::new(),
144 }
145}
146
147#[cfg(any(feature = "tree-sitter", feature = "syntect"))]
149pub(crate) type SharedCodeBlockHighlighter = dyn Fn(&gpui_base::text::CodeBlock) -> Vec<(std::ops::Range<usize>, gpui::HighlightStyle)>
150 + Send
151 + Sync;
152
153#[cfg(any(feature = "tree-sitter", feature = "syntect"))]
166pub(crate) fn shared_code_block_highlighter(
167 highlight_theme: &std::sync::Arc<crate::highlighter::HighlightTheme>,
168) -> std::sync::Arc<SharedCodeBlockHighlighter> {
169 #[cfg(feature = "tree-sitter")]
171 let generation = LanguageRegistry::singleton().generation();
172 #[cfg(not(feature = "tree-sitter"))]
173 let generation = 0;
174 shared_code_block_highlighter_at(highlight_theme, generation)
175}
176
177#[cfg(any(feature = "tree-sitter", feature = "syntect"))]
179fn shared_code_block_highlighter_at(
180 highlight_theme: &std::sync::Arc<crate::highlighter::HighlightTheme>,
181 generation: u64,
182) -> std::sync::Arc<SharedCodeBlockHighlighter> {
183 use std::sync::Arc;
184
185 use crate::highlighter::HighlightTheme;
186
187 type SharedHighlighters = (
189 u64,
190 Vec<(Arc<HighlightTheme>, Arc<SharedCodeBlockHighlighter>)>,
191 );
192
193 const CAPACITY: usize = 4;
194 thread_local! {
195 static SHARED: RefCell<SharedHighlighters> = const { RefCell::new((0, Vec::new())) };
196 }
197
198 SHARED.with(|cache| {
199 let (cached_generation, shared) = &mut *cache.borrow_mut();
200 if *cached_generation != generation {
201 *cached_generation = generation;
202 shared.clear();
203 }
204 if let Some((_, highlighter)) = shared
205 .iter()
206 .find(|(theme, _)| Arc::ptr_eq(theme, highlight_theme))
207 {
208 return highlighter.clone();
209 }
210
211 let highlighter: Arc<SharedCodeBlockHighlighter> =
212 Arc::new(component_code_block_highlighter(highlight_theme.clone()));
213 if shared.len() == CAPACITY {
214 shared.remove(0);
215 }
216 shared.push((highlight_theme.clone(), highlighter.clone()));
217 highlighter
218 })
219}
220
221#[cfg(test)]
222mod tests {
223 use std::sync::Arc;
224
225 use gpui::{StyleRefinement, Styled as _, px};
226
227 use crate::Theme;
228
229 #[cfg(all(feature = "syntect", not(feature = "tree-sitter")))]
235 #[test]
236 fn syntect_highlights_a_code_fence() {
237 use gpui_base::text::CodeBlock;
238
239 use crate::highlighter::HighlightTheme;
240
241 let code = "[package]\nname = \"demo\"\n";
242 let highlighter = super::component_code_block_highlighter(HighlightTheme::default_light());
243 let styles = highlighter(&CodeBlock::from_code(code, Some("toml")));
244 let string = code.find("\"demo\"").unwrap();
245 assert!(
246 styles
247 .iter()
248 .any(|(range, style)| range.contains(&string) && style.color.is_some()),
249 "the string must be colored: {styles:?}"
250 );
251 assert_eq!(
252 styles.last().map(|(range, _)| range.end),
253 Some(code.len()),
254 "the runs cover the whole block"
255 );
256 assert!(highlighter(&CodeBlock::from_code(code, Some("no-such-language"))).is_empty());
257 }
258
259 #[cfg(feature = "tree-sitter")]
260 mod code_block_highlighter {
261 use std::ops::Range;
262
263 use gpui::{HighlightStyle, Hsla, SharedString};
264 use gpui_base::text::CodeBlock;
265
266 use crate::highlighter::{HighlightTheme, LanguageConfig, LanguageRegistry};
267
268 fn register_json(lang: &SharedString) {
269 LanguageRegistry::singleton().register(
270 lang.as_ref(),
271 &LanguageConfig::new(
272 lang.clone(),
273 tree_sitter_json::LANGUAGE.into(),
274 vec![],
275 "(number) @number",
276 "",
277 "",
278 ),
279 );
280 }
281
282 fn color_at(
283 styles: &[(Range<usize>, HighlightStyle)],
284 range: Range<usize>,
285 ) -> Option<Hsla> {
286 styles
287 .iter()
288 .find(|(span, _)| span.start <= range.start && span.end >= range.end)
289 .and_then(|(_, style)| style.color)
290 }
291
292 #[test]
293 fn registering_a_language_refreshes_the_cached_highlighter() {
294 let lang = SharedString::from("json-cache-test");
295 let code = SharedString::from(r#"{"value": 42}"#);
296 let number = code.find("42").unwrap()..code.find("42").unwrap() + 2;
297 let highlighter =
298 super::super::component_code_block_highlighter(HighlightTheme::default_light());
299
300 let block = CodeBlock::from_code(code.clone(), Some(lang.clone()));
303 assert_eq!(color_at(&highlighter(&block), number.clone()), None);
304
305 register_json(&lang);
306
307 let block = CodeBlock::from_code(code, Some(lang));
308 assert!(
309 color_at(&highlighter(&block), number).is_some(),
310 "a newly registered language must reach the cached highlighter"
311 );
312 }
313
314 #[test]
315 fn styles_follow_the_highlight_theme_they_were_built_with() {
316 let lang = SharedString::from("json-theme-test");
317 register_json(&lang);
318 let code = SharedString::from(r#"{"value": 42}"#);
319 let number = code.find("42").unwrap()..code.find("42").unwrap() + 2;
320
321 let light = HighlightTheme::default_light();
322 let dark = HighlightTheme::default_dark();
323 let light_number = light.style("number").and_then(|style| style.color);
324 let dark_number = dark.style("number").and_then(|style| style.color);
325 assert_ne!(
326 light_number, dark_number,
327 "the default themes must use different number colors"
328 );
329
330 let block = CodeBlock::from_code(code, Some(lang));
331 let light_styles = super::super::component_code_block_highlighter(light)(&block);
332 let dark_styles = super::super::component_code_block_highlighter(dark)(&block);
333
334 assert_eq!(color_at(&light_styles, number.clone()), light_number);
335 assert_eq!(
336 color_at(&dark_styles, number),
337 dark_number,
338 "a theme change must not reuse syntax styles from the previous theme"
339 );
340 }
341
342 #[test]
343 fn shared_highlighter_is_reused_for_the_same_theme() {
344 const GENERATION: u64 = u64::MAX;
347 let light = HighlightTheme::default_light();
348 let dark = HighlightTheme::default_dark();
349
350 let first = super::super::shared_code_block_highlighter_at(&light, GENERATION);
351 let again = super::super::shared_code_block_highlighter_at(&light, GENERATION);
352 let other = super::super::shared_code_block_highlighter_at(&dark, GENERATION);
353
354 assert!(std::sync::Arc::ptr_eq(&first, &again));
357 assert!(!std::sync::Arc::ptr_eq(&first, &other));
358 }
359
360 #[test]
361 fn registering_a_language_replaces_the_shared_highlighter() {
362 let light = HighlightTheme::default_light();
363
364 let before = super::super::shared_code_block_highlighter_at(&light, u64::MAX - 1);
365 let after = super::super::shared_code_block_highlighter_at(&light, u64::MAX - 2);
366
367 assert!(!std::sync::Arc::ptr_eq(&before, &after));
370 }
371 }
372
373 #[test]
374 fn component_theme_adapter_maps_text_colors_without_highlighting() {
375 let theme = Theme::default();
376 let style = super::base_text_view_style(&theme);
377
378 assert_eq!(style.foreground(), theme.foreground);
379 assert_eq!(style.muted_foreground(), theme.muted_foreground);
380 assert_eq!(style.link(), theme.link);
381 assert_eq!(style.selection(), theme.selection);
382 assert_eq!(style.inline_code().background_color, Some(theme.accent));
383 assert_eq!(style.table_row_hover(), Some(theme.table_hover));
384 let radius = theme.semantic_tokens().radius.md;
385 assert_eq!(style.table().corner_radii.top_left, Some(radius.into()));
386 assert_eq!(style.table().corner_radii.top_right, Some(radius.into()));
387 assert_eq!(style.table().corner_radii.bottom_left, Some(radius.into()));
388 assert_eq!(style.table().corner_radii.bottom_right, Some(radius.into()));
389 }
390
391 #[test]
392 fn component_text_view_table_respects_square_base_radius_token() {
393 let mut theme = Theme::default();
394 theme.radius = gpui::px(0.);
395
396 let style = super::base_text_view_style(&theme);
397 let square = Some(gpui::px(0.).into());
398 assert_eq!(style.table().corner_radii.top_left, square);
399 assert_eq!(style.table().corner_radii.top_right, square);
400 assert_eq!(style.table().corner_radii.bottom_left, square);
401 assert_eq!(style.table().corner_radii.bottom_right, square);
402 }
403
404 #[cfg(feature = "tree-sitter")]
405 #[gpui::test]
406 fn component_initialization_installs_default_code_highlighting(cx: &mut gpui::TestAppContext) {
407 cx.update(crate::init);
408
409 cx.update(|cx| {
410 assert!(gpui_base::TextViewDefaults::global(cx).has_code_block_highlighter());
411 });
412 }
413
414 #[test]
415 fn test_text_view_builder() {
416 let mut style = super::TextViewStyle::default();
417 style.highlight_theme = crate::highlighter::HighlightTheme::default_dark();
418
419 let _: super::TextView = super::markdown("# compatible")
420 .style(style)
421 .selectable(true)
422 .scrollable(true)
423 .image_source(|uri| gpui::ImageSource::from(std::path::PathBuf::from(uri.as_ref())))
424 .on_task_toggle(|_event, _, _| {});
425 }
426
427 #[test]
428 fn legacy_text_view_keeps_element_associated_types() {
429 fn assert_element_types<T>()
430 where
431 T: gpui::Element<
432 RequestLayoutState = super::TextViewLayoutState,
433 PrepaintState = super::TextViewPrepaintState,
434 >,
435 {
436 }
437
438 assert_element_types::<super::TextView>();
439 }
440
441 #[test]
442 fn legacy_default_style_keeps_active_component_theme_colors() {
443 let mut theme = Theme::default();
444 theme.foreground = gpui::rgb(0xf4f4f5).into();
445 theme.link = gpui::rgb(0x38bdf8).into();
446 theme.selection = gpui::rgba(0x2563eb66).into();
447
448 let style = super::compat::resolve_component_style(&theme, super::TextViewStyle::default());
449
450 assert_eq!(style.foreground(), theme.foreground);
451 assert_eq!(style.link(), theme.link);
452 assert_eq!(style.selection(), theme.selection);
453 }
454
455 #[test]
456 fn legacy_table_refinement_keeps_component_radius() {
457 let theme = Theme::default();
458 let mut table = gpui::StyleRefinement::default();
459 table.overflow.x = Some(gpui::Overflow::Scroll);
460
461 let style = super::compat::resolve_component_style(
462 &theme,
463 super::TextViewStyle::default().table(table),
464 );
465
466 let radius = Some(theme.semantic_tokens().radius.md.into());
467 assert_eq!(style.table().corner_radii.top_left, radius);
468 assert_eq!(style.table().corner_radii.top_right, radius);
469 assert_eq!(style.table().corner_radii.bottom_left, radius);
470 assert_eq!(style.table().corner_radii.bottom_right, radius);
471 assert_eq!(style.table().overflow.x, Some(gpui::Overflow::Scroll));
472 }
473
474 #[test]
475 fn legacy_partial_styles_refine_component_theme_defaults() {
476 let theme = Theme::default();
477 let mut table_head = gpui::StyleRefinement::default();
478 table_head.text.font_weight = Some(gpui::FontWeight::BOLD);
479 let inline_code = gpui::HighlightStyle {
480 font_style: Some(gpui::FontStyle::Italic),
481 ..Default::default()
482 };
483
484 let style = super::compat::resolve_component_style(
485 &theme,
486 super::TextViewStyle::default()
487 .table_head(table_head)
488 .inline_code(inline_code),
489 );
490
491 assert_eq!(style.table_head().background, Some(theme.table_head.into()));
492 assert_eq!(
493 style.table_head().text.color,
494 Some(theme.table_head_foreground)
495 );
496 assert_eq!(
497 style.table_head().text.font_weight,
498 Some(gpui::FontWeight::BOLD)
499 );
500 assert_eq!(style.inline_code().background_color, Some(theme.accent));
501 assert_eq!(
502 style.inline_code().font_style,
503 Some(gpui::FontStyle::Italic)
504 );
505 }
506
507 #[test]
508 fn legacy_heading_configuration_maps_to_base_heading_refinements() {
509 let theme = Theme::default();
510 let mut legacy = super::TextViewStyle::default();
511 legacy.heading_base_font_size = px(10.);
512 legacy.heading_font_size = Some(Arc::new(|level, base| base * level as f32));
513
514 let style = super::compat::resolve_component_style(&theme, legacy);
515
516 assert_eq!(
517 style.heading(2),
518 StyleRefinement::default().text_size(px(20.))
519 );
520 }
521}