1use std::sync::Arc;
2
3use gpui::{HighlightStyle, Hsla, Rems, StyleRefinement, rems};
4
5use crate::ColorTokens;
6
7#[derive(Clone)]
14pub struct TextViewStyle {
15 foreground: Hsla,
16 muted_foreground: Hsla,
17 link: Hsla,
18 selection: Hsla,
19 code_background: Hsla,
20 border: Hsla,
21 paragraph_gap: Rems,
22 heading: Arc<dyn Fn(u8) -> StyleRefinement + Send + Sync + 'static>,
23 code_block: StyleRefinement,
24 table: StyleRefinement,
25 table_head: StyleRefinement,
26 table_cell: StyleRefinement,
27 inline_code: HighlightStyle,
28 inline_code_style: StyleRefinement,
30 list_style: StyleRefinement,
32 blockquote_style: StyleRefinement,
34 table_background: Option<Hsla>,
38 is_dark: bool,
39}
40
41impl PartialEq for TextViewStyle {
42 fn eq(&self, other: &Self) -> bool {
43 self.paragraph_gap == other.paragraph_gap
44 && self.foreground == other.foreground
45 && self.muted_foreground == other.muted_foreground
46 && self.link == other.link
47 && self.selection == other.selection
48 && self.code_background == other.code_background
49 && self.border == other.border
50 && (Arc::ptr_eq(&self.heading, &other.heading)
51 || (1..=6).all(|level| (self.heading)(level) == (other.heading)(level)))
52 && self.code_block == other.code_block
53 && self.table == other.table
54 && self.table_head == other.table_head
55 && self.table_cell == other.table_cell
56 && self.inline_code == other.inline_code
57 && self.inline_code_style == other.inline_code_style
58 && self.list_style == other.list_style
59 && self.blockquote_style == other.blockquote_style
60 && self.table_background == other.table_background
61 && self.is_dark == other.is_dark
62 }
63}
64
65impl Default for TextViewStyle {
66 fn default() -> Self {
67 Self::from_colors(&ColorTokens::light(), false)
68 }
69}
70
71impl TextViewStyle {
72 pub fn from_theme(theme: &crate::Theme) -> Self {
74 Self::from_colors(
75 &theme.tokens.colors,
76 theme.appearance == crate::ThemeAppearance::Dark,
77 )
78 }
79
80 fn from_colors(colors: &ColorTokens, is_dark: bool) -> Self {
86 Self {
87 foreground: colors.foreground,
88 muted_foreground: colors.muted_foreground,
89 link: colors.primary,
90 selection: colors.selection,
91 code_background: colors.accent,
92 border: colors.border,
93 paragraph_gap: rems(0.75),
94 heading: Arc::new(|_| StyleRefinement::default()),
95 code_block: StyleRefinement::default(),
96 table: StyleRefinement::default(),
97 table_head: StyleRefinement::default(),
98 table_cell: StyleRefinement::default(),
99 inline_code: HighlightStyle {
100 background_color: Some(colors.accent),
101 ..Default::default()
102 },
103 inline_code_style: StyleRefinement::default(),
104 list_style: StyleRefinement::default(),
105 blockquote_style: StyleRefinement::default(),
106 table_background: None,
107 is_dark,
108 }
109 }
110
111 pub fn with_foreground(mut self, color: Hsla) -> Self {
113 self.foreground = color;
114 self
115 }
116
117 pub fn with_muted_foreground(mut self, color: Hsla) -> Self {
119 self.muted_foreground = color;
120 self
121 }
122
123 pub fn with_link(mut self, color: Hsla) -> Self {
125 self.link = color;
126 self
127 }
128
129 pub fn with_selection(mut self, color: Hsla) -> Self {
134 self.selection = color;
135 self
136 }
137
138 pub fn with_code_background(mut self, color: Hsla) -> Self {
140 self.code_background = color;
141 self
142 }
143
144 pub fn with_border(mut self, color: Hsla) -> Self {
146 self.border = color;
147 self
148 }
149
150 pub fn with_paragraph_gap(mut self, gap: Rems) -> Self {
152 self.paragraph_gap = gap;
153 self
154 }
155
156 pub fn with_heading<F>(mut self, heading: F) -> Self
158 where
159 F: Fn(u8) -> StyleRefinement + Send + Sync + 'static,
160 {
161 self.heading = Arc::new(heading);
162 self
163 }
164
165 pub fn with_code_block(mut self, style: StyleRefinement) -> Self {
172 self.code_block = style;
173 self
174 }
175
176 pub fn with_inline_code(mut self, style: HighlightStyle) -> Self {
181 self.inline_code = style;
182 self
183 }
184
185 pub fn with_inline_code_style(mut self, style: StyleRefinement) -> Self {
196 self.inline_code_style = normalize_inline_code_style(style);
197 self
198 }
199
200 pub fn with_list_style(mut self, style: StyleRefinement) -> Self {
210 self.list_style = style;
211 self
212 }
213
214 pub fn with_blockquote_style(mut self, style: StyleRefinement) -> Self {
224 self.blockquote_style = style;
225 self
226 }
227
228 pub fn with_table(mut self, style: StyleRefinement) -> Self {
236 self.table = style;
237 self
238 }
239
240 pub fn with_table_head(mut self, style: StyleRefinement) -> Self {
243 self.table_head = style;
244 self
245 }
246
247 pub fn with_table_cell(mut self, style: StyleRefinement) -> Self {
253 self.table_cell = style;
254 self
255 }
256
257 pub fn with_dark(mut self, is_dark: bool) -> Self {
259 self.is_dark = is_dark;
260 self
261 }
262
263 pub fn foreground(&self) -> Hsla {
265 self.foreground
266 }
267
268 pub fn muted_foreground(&self) -> Hsla {
270 self.muted_foreground
271 }
272
273 pub fn link(&self) -> Hsla {
275 self.link
276 }
277
278 pub fn selection(&self) -> Hsla {
280 self.selection
281 }
282
283 pub fn code_background(&self) -> Hsla {
285 self.code_background
286 }
287
288 pub fn border(&self) -> Hsla {
290 self.border
291 }
292
293 pub fn paragraph_gap(&self) -> Rems {
295 self.paragraph_gap
296 }
297
298 pub fn heading(&self, level: u8) -> StyleRefinement {
300 (self.heading)(level)
301 }
302
303 pub fn code_block(&self) -> &StyleRefinement {
305 &self.code_block
306 }
307
308 pub fn table(&self) -> &StyleRefinement {
310 &self.table
311 }
312
313 pub fn table_head(&self) -> &StyleRefinement {
315 &self.table_head
316 }
317
318 pub fn table_cell(&self) -> &StyleRefinement {
320 &self.table_cell
321 }
322
323 pub fn inline_code(&self) -> HighlightStyle {
326 self.inline_code
327 }
328
329 pub fn inline_code_style(&self) -> &StyleRefinement {
331 &self.inline_code_style
332 }
333
334 pub fn list_style(&self) -> &StyleRefinement {
336 &self.list_style
337 }
338
339 pub fn blockquote_style(&self) -> &StyleRefinement {
341 &self.blockquote_style
342 }
343
344 pub fn is_dark(&self) -> bool {
346 self.is_dark
347 }
348
349 pub(crate) fn table_background(&self) -> Option<Hsla> {
351 self.table_background
352 }
353
354 pub(crate) fn inline_code_highlight(&self) -> HighlightStyle {
357 let mut style = self.inline_code;
358 if style.background_color.is_none() {
359 style.background_color = Some(self.code_background);
360 }
361 style
362 }
363
364 pub(crate) fn on_text_color(&self, color: Hsla) -> Self {
373 let style = self.clone().with_foreground(color);
374 if !self.is_inverted_by(color) {
375 return style;
376 }
377
378 let code_background = color.opacity(0.12);
379 let mut table_head = self.table_head.clone();
380 table_head.background = Some(code_background.into());
381 table_head.text.color = Some(color);
382 let mut style = style
383 .with_muted_foreground(color.opacity(0.7))
384 .with_link(color)
385 .with_selection(color.opacity(0.25))
386 .with_code_background(code_background)
387 .with_border(color.opacity(0.2))
388 .with_inline_code(HighlightStyle {
389 background_color: Some(code_background),
390 ..self.inline_code
391 })
392 .with_table_head(table_head)
393 .with_dark(!self.is_dark);
394 style.table_background = Some(gpui::transparent_black());
395 style
396 }
397
398 pub(crate) fn is_inverted_by(&self, color: Hsla) -> bool {
404 const INVERTED_LIGHTNESS_GAP: f32 = 0.6;
405 (oklab_lightness(color) - oklab_lightness(self.foreground)).abs() > INVERTED_LIGHTNESS_GAP
406 }
407}
408
409fn oklab_lightness(color: Hsla) -> f32 {
411 let rgb = color.to_rgb();
412 let linear = |c: f32| {
413 if c <= 0.04045 {
414 c / 12.92
415 } else {
416 ((c + 0.055) / 1.055).powf(2.4)
417 }
418 };
419 let (r, g, b) = (linear(rgb.r), linear(rgb.g), linear(rgb.b));
420 let l = (0.412_221_46 * r + 0.536_332_55 * g + 0.051_445_995 * b).cbrt();
421 let m = (0.211_903_5 * r + 0.680_699_5 * g + 0.107_396_96 * b).cbrt();
422 let s = (0.088_302_46 * r + 0.281_718_85 * g + 0.629_978_7 * b).cbrt();
423 0.210_454_26 * l + 0.793_617_8 * m - 0.004_072_047 * s
424}
425
426pub(super) fn normalize_inline_code_style(mut style: StyleRefinement) -> StyleRefinement {
429 if style.text.font_size.is_some_and(|size| {
430 let size = size.to_pixels(gpui::px(1.)).as_f32();
431 !size.is_finite() || size <= 0.
432 }) {
433 style.text.font_size = None;
434 }
435 style
436}
437
438#[cfg(test)]
439mod tests {
440 use gpui::{Styled as _, px};
441
442 use super::*;
443
444 #[test]
445 fn selection_layout_fingerprint_covers_callback_table_and_theme_fields() {
446 let base = TextViewStyle::default();
447 let heading = base
448 .clone()
449 .with_heading(|_| StyleRefinement::default().text_size(px(14.)));
450 assert!(
451 heading
452 == base
453 .clone()
454 .with_heading(|_| StyleRefinement::default().text_size(px(14.)))
455 );
456 assert!(
457 heading
458 != base
459 .clone()
460 .with_heading(|_| StyleRefinement::default().text_size(px(28.)))
461 );
462
463 let mut table = StyleRefinement::default();
464 table.text.white_space = Some(gpui::WhiteSpace::Nowrap);
465 assert!(base != base.clone().with_table_cell(table));
466
467 assert!(base != base.clone().with_dark(true));
468 assert!(
469 base != base
470 .clone()
471 .with_inline_code_style(StyleRefinement::default().border_color(gpui::red()))
472 );
473 assert!(
474 base != base
475 .clone()
476 .with_list_style(StyleRefinement::default().text_color(gpui::red()))
477 );
478 assert!(
479 base != base
480 .clone()
481 .with_blockquote_style(StyleRefinement::default().border_color(gpui::red()))
482 );
483 }
484
485 #[test]
486 fn heading_equality_skips_shared_callbacks_and_compares_distinct_ones() {
487 use std::sync::atomic::{AtomicUsize, Ordering};
488
489 let calls = Arc::new(AtomicUsize::new(0));
490 let calls_for_heading = calls.clone();
491 let style = TextViewStyle::default().with_heading(move |_| {
492 calls_for_heading.fetch_add(1, Ordering::Relaxed);
493 StyleRefinement::default().text_size(px(14.))
494 });
495
496 assert!(style == style.clone());
497 assert_eq!(calls.load(Ordering::Relaxed), 0);
498 assert!(style != style.clone().with_paragraph_gap(rems(1.)));
499 assert!(style != style.clone().with_dark(!style.is_dark()));
500 assert_eq!(calls.load(Ordering::Relaxed), 0);
501
502 let equal = style
503 .clone()
504 .with_heading(|_| StyleRefinement::default().text_size(px(14.)));
505 assert!(style == equal);
506 assert_eq!(calls.load(Ordering::Relaxed), 6);
507
508 calls.store(0, Ordering::Relaxed);
509 let different = style.clone().with_heading(|level| {
510 StyleRefinement::default().text_size(px(if level == 6 { 28. } else { 14. }))
511 });
512 assert!(style != different);
513 assert_eq!(calls.load(Ordering::Relaxed), 6);
514 }
515
516 #[test]
517 fn default_style_is_readable_without_an_application_theme() {
518 let style = TextViewStyle::default();
519
520 assert_eq!(style.foreground().a, 1.0);
521 assert_eq!(style.link().a, 1.0);
522 assert!(style.selection().a > 0.0);
523 assert!(style.inline_code().background_color.is_some());
524 assert!(style.code_background().a > 0.0);
525 assert!(style.border().a > 0.0);
526 assert_eq!(style.code_block().corner_radii.top_left, None);
527 assert_eq!(style.code_block().corner_radii.top_right, None);
528 assert_eq!(style.code_block().corner_radii.bottom_left, None);
529 assert_eq!(style.code_block().corner_radii.bottom_right, None);
530 }
531
532 #[test]
533 fn heading_refinement_defaults_empty_and_resolves_by_level() {
534 let base = TextViewStyle::default();
535 assert_eq!(base.heading(1), StyleRefinement::default());
536
537 let style = base.clone().with_heading(|level| match level {
538 1 => StyleRefinement::default().pt(rems(1.)).pb(rems(0.5)),
539 _ => StyleRefinement::default().pb(rems(0.25)),
540 });
541
542 assert_eq!(
543 style.heading(1),
544 StyleRefinement::default().pt(rems(1.)).pb(rems(0.5))
545 );
546 assert_eq!(style.heading(2), StyleRefinement::default().pb(rems(0.25)));
547 assert!(style != base);
548 }
549
550 #[test]
551 fn inline_code_falls_back_to_the_code_background() {
552 let style = TextViewStyle::default()
553 .with_code_background(gpui::rgb(0x123456).into())
554 .with_inline_code(HighlightStyle::default());
555
556 assert_eq!(
557 style.inline_code_highlight().background_color,
558 Some(gpui::rgb(0x123456).into())
559 );
560 }
561
562 #[test]
563 fn inline_code_refinements_remain_explicit_on_an_inverted_surface() {
564 let colors = ColorTokens::light();
565 let plain = TextViewStyle::from_colors(&colors, false);
566 assert_eq!(plain.inline_code_style(), &StyleRefinement::default());
567 for size in [f32::NAN, f32::INFINITY, 0., -1.] {
568 assert!(
569 plain
570 .clone()
571 .with_inline_code_style(StyleRefinement::default().text_size(px(size)))
572 .inline_code_style()
573 .text
574 .font_size
575 .is_none()
576 );
577 }
578 let refinement = StyleRefinement::default()
579 .text_size(gpui::rems(1.))
580 .bg(gpui::red())
581 .border_1()
582 .border_color(gpui::blue());
583 let style = plain.with_inline_code_style(refinement.clone());
584 assert_eq!(style.inline_code_style(), &refinement);
585 assert_eq!(
586 style
587 .on_text_color(colors.primary_foreground)
588 .inline_code_style(),
589 &refinement
590 );
591 }
592
593 #[test]
594 fn list_and_blockquote_refinements_remain_explicit_on_an_inverted_surface() {
595 let colors = ColorTokens::light();
596 let plain = TextViewStyle::from_colors(&colors, false);
597 assert_eq!(plain.list_style(), &StyleRefinement::default());
598 assert_eq!(plain.blockquote_style(), &StyleRefinement::default());
599
600 let marker = StyleRefinement::default()
601 .text_color(gpui::red())
602 .font_weight(gpui::FontWeight::BOLD);
603 let quote = StyleRefinement::default()
604 .border_l_4()
605 .border_color(gpui::blue())
606 .bg(gpui::green());
607 let style = plain
608 .with_list_style(marker.clone())
609 .with_blockquote_style(quote.clone());
610 assert_eq!(style.list_style(), &marker);
611 assert_eq!(style.blockquote_style(), "e);
612
613 let inverted = style.on_text_color(colors.primary_foreground);
614 assert_eq!(inverted.list_style(), &marker);
615 assert_eq!(inverted.blockquote_style(), "e);
616 }
617
618 #[test]
619 fn text_color_of_a_matching_surface_only_replaces_the_body_text() {
620 let style = TextViewStyle::from_colors(&ColorTokens::light(), false);
621 let destructive = ColorTokens::light().destructive;
622
623 let adapted = style.on_text_color(destructive);
624 assert_eq!(adapted.foreground(), destructive);
625 assert_eq!(adapted.link(), style.link());
626 assert_eq!(adapted.muted_foreground(), style.muted_foreground());
627 assert_eq!(adapted.code_background(), style.code_background());
628 assert_eq!(adapted.table_background(), None);
629 assert!(!adapted.is_dark());
630 }
631
632 #[test]
633 fn text_color_of_an_inverted_surface_derives_every_color_from_it() {
634 for (colors, is_dark) in [(ColorTokens::light(), false), (ColorTokens::dark(), true)] {
635 let style = TextViewStyle::from_colors(&colors, is_dark);
636 let text = colors.primary_foreground;
637
638 let adapted = style.on_text_color(text);
639 assert_eq!(adapted.foreground(), text);
640 assert_eq!(adapted.link(), text);
641 assert_eq!(adapted.muted_foreground(), text.opacity(0.7));
642 assert_eq!(adapted.code_background(), text.opacity(0.12));
643 assert_eq!(
644 adapted.inline_code_highlight().background_color,
645 Some(text.opacity(0.12))
646 );
647 assert_eq!(adapted.border(), text.opacity(0.2));
648 assert_eq!(adapted.selection(), text.opacity(0.25));
649 assert_eq!(adapted.table_background(), Some(gpui::transparent_black()));
650 assert_eq!(adapted.table_head().text.color, Some(text));
651 assert_eq!(adapted.is_dark(), !is_dark);
652 }
653 }
654
655 #[test]
656 fn from_theme_maps_base_semantic_tokens() {
657 let mut theme = crate::Theme::default();
658 theme.tokens.colors.foreground = gpui::rgb(0x112233).into();
659 theme.tokens.colors.muted_foreground = gpui::rgb(0x445566).into();
660 theme.tokens.colors.primary = gpui::rgb(0x3366ff).into();
661 theme.tokens.colors.accent = gpui::rgb(0xddeeff).into();
662 theme.tokens.colors.border = gpui::rgb(0x778899).into();
663 theme.tokens.colors.selection = gpui::rgb(0x55a0fc).into();
664
665 let style = TextViewStyle::from_theme(&theme);
666 assert_eq!(style.foreground(), theme.tokens.colors.foreground);
667 assert_eq!(style.link(), theme.tokens.colors.primary);
668 assert_eq!(style.selection(), theme.tokens.colors.selection);
669 assert_eq!(style.code_background(), theme.tokens.colors.accent);
670 assert_eq!(style.border(), theme.tokens.colors.border);
671 }
672}