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