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 table_row_hover: Option<Hsla>,
41 is_dark: bool,
42}
43
44impl PartialEq for TextViewStyle {
45 fn eq(&self, other: &Self) -> bool {
46 self.paragraph_gap == other.paragraph_gap
47 && self.foreground == other.foreground
48 && self.muted_foreground == other.muted_foreground
49 && self.link == other.link
50 && self.selection == other.selection
51 && self.code_background == other.code_background
52 && self.border == other.border
53 && (Arc::ptr_eq(&self.heading, &other.heading)
54 || (1..=6).all(|level| (self.heading)(level) == (other.heading)(level)))
55 && self.code_block == other.code_block
56 && self.table == other.table
57 && self.table_head == other.table_head
58 && self.table_cell == other.table_cell
59 && self.inline_code == other.inline_code
60 && self.inline_code_style == other.inline_code_style
61 && self.list_style == other.list_style
62 && self.blockquote_style == other.blockquote_style
63 && self.table_background == other.table_background
64 && self.table_row_hover == other.table_row_hover
65 && self.is_dark == other.is_dark
66 }
67}
68
69impl Default for TextViewStyle {
70 fn default() -> Self {
71 Self::from_colors(&ColorTokens::light(), false)
72 }
73}
74
75impl TextViewStyle {
76 pub fn from_theme(theme: &crate::Theme) -> Self {
78 Self::from_colors(
79 &theme.tokens.colors,
80 theme.appearance == crate::ThemeAppearance::Dark,
81 )
82 }
83
84 fn from_colors(colors: &ColorTokens, is_dark: bool) -> Self {
90 Self {
91 foreground: colors.foreground,
92 muted_foreground: colors.muted_foreground,
93 link: colors.primary,
94 selection: colors.selection,
95 code_background: colors.accent,
96 border: colors.border,
97 paragraph_gap: rems(0.75),
98 heading: Arc::new(|_| StyleRefinement::default()),
99 code_block: StyleRefinement::default(),
100 table: StyleRefinement::default(),
101 table_head: StyleRefinement::default(),
102 table_cell: StyleRefinement::default(),
103 inline_code: HighlightStyle {
104 background_color: Some(colors.accent),
105 ..Default::default()
106 },
107 inline_code_style: StyleRefinement::default(),
108 list_style: StyleRefinement::default(),
109 blockquote_style: StyleRefinement::default(),
110 table_background: None,
111 table_row_hover: None,
112 is_dark,
113 }
114 }
115
116 pub fn with_foreground(mut self, color: Hsla) -> Self {
118 self.foreground = color;
119 self
120 }
121
122 pub fn with_muted_foreground(mut self, color: Hsla) -> Self {
124 self.muted_foreground = color;
125 self
126 }
127
128 pub fn with_link(mut self, color: Hsla) -> Self {
130 self.link = color;
131 self
132 }
133
134 pub fn with_selection(mut self, color: Hsla) -> Self {
139 self.selection = color;
140 self
141 }
142
143 pub fn with_code_background(mut self, color: Hsla) -> Self {
145 self.code_background = color;
146 self
147 }
148
149 pub fn with_border(mut self, color: Hsla) -> Self {
151 self.border = color;
152 self
153 }
154
155 pub fn with_paragraph_gap(mut self, gap: Rems) -> Self {
157 self.paragraph_gap = gap;
158 self
159 }
160
161 pub fn with_heading<F>(mut self, heading: F) -> Self
163 where
164 F: Fn(u8) -> StyleRefinement + Send + Sync + 'static,
165 {
166 self.heading = Arc::new(heading);
167 self
168 }
169
170 pub fn with_code_block(mut self, style: StyleRefinement) -> Self {
177 self.code_block = style;
178 self
179 }
180
181 pub fn with_inline_code(mut self, style: HighlightStyle) -> Self {
186 self.inline_code = style;
187 self
188 }
189
190 pub fn with_inline_code_style(mut self, style: StyleRefinement) -> Self {
201 self.inline_code_style = normalize_inline_code_style(style);
202 self
203 }
204
205 pub fn with_list_style(mut self, style: StyleRefinement) -> Self {
215 self.list_style = style;
216 self
217 }
218
219 pub fn with_blockquote_style(mut self, style: StyleRefinement) -> Self {
229 self.blockquote_style = style;
230 self
231 }
232
233 pub fn with_table(mut self, style: StyleRefinement) -> Self {
241 self.table = style;
242 self
243 }
244
245 pub fn with_table_head(mut self, style: StyleRefinement) -> Self {
248 self.table_head = style;
249 self
250 }
251
252 pub fn with_table_cell(mut self, style: StyleRefinement) -> Self {
258 self.table_cell = style;
259 self
260 }
261
262 pub fn with_table_row_hover(mut self, color: Hsla) -> Self {
267 self.table_row_hover = Some(color);
268 self
269 }
270
271 pub fn with_dark(mut self, is_dark: bool) -> Self {
273 self.is_dark = is_dark;
274 self
275 }
276
277 pub fn foreground(&self) -> Hsla {
279 self.foreground
280 }
281
282 pub fn muted_foreground(&self) -> Hsla {
284 self.muted_foreground
285 }
286
287 pub fn link(&self) -> Hsla {
289 self.link
290 }
291
292 pub fn selection(&self) -> Hsla {
294 self.selection
295 }
296
297 pub fn code_background(&self) -> Hsla {
299 self.code_background
300 }
301
302 pub fn border(&self) -> Hsla {
304 self.border
305 }
306
307 pub fn paragraph_gap(&self) -> Rems {
309 self.paragraph_gap
310 }
311
312 pub fn heading(&self, level: u8) -> StyleRefinement {
314 (self.heading)(level)
315 }
316
317 pub fn code_block(&self) -> &StyleRefinement {
319 &self.code_block
320 }
321
322 pub fn table(&self) -> &StyleRefinement {
324 &self.table
325 }
326
327 pub fn table_head(&self) -> &StyleRefinement {
329 &self.table_head
330 }
331
332 pub fn table_cell(&self) -> &StyleRefinement {
334 &self.table_cell
335 }
336
337 pub fn table_row_hover(&self) -> Option<Hsla> {
339 self.table_row_hover
340 }
341
342 pub fn inline_code(&self) -> HighlightStyle {
345 self.inline_code
346 }
347
348 pub fn inline_code_style(&self) -> &StyleRefinement {
350 &self.inline_code_style
351 }
352
353 pub fn list_style(&self) -> &StyleRefinement {
355 &self.list_style
356 }
357
358 pub fn blockquote_style(&self) -> &StyleRefinement {
360 &self.blockquote_style
361 }
362
363 pub fn is_dark(&self) -> bool {
365 self.is_dark
366 }
367
368 pub(crate) fn table_background(&self) -> Option<Hsla> {
370 self.table_background
371 }
372
373 pub(crate) fn inline_code_highlight(&self) -> HighlightStyle {
376 let mut style = self.inline_code;
377 if style.background_color.is_none() {
378 style.background_color = Some(self.code_background);
379 }
380 style
381 }
382
383 pub(crate) fn on_text_color(&self, color: Hsla) -> Self {
392 let style = self.clone().with_foreground(color);
393 if !self.is_inverted_by(color) {
394 return style;
395 }
396
397 let code_background = color.opacity(0.12);
398 let mut table_head = self.table_head.clone();
399 table_head.background = Some(code_background.into());
400 table_head.text.color = Some(color);
401 let mut style = style
402 .with_muted_foreground(color.opacity(0.7))
403 .with_link(color)
404 .with_selection(color.opacity(0.25))
405 .with_code_background(code_background)
406 .with_border(color.opacity(0.2))
407 .with_inline_code(HighlightStyle {
408 background_color: Some(code_background),
409 ..self.inline_code
410 })
411 .with_table_head(table_head)
412 .with_dark(!self.is_dark);
413 style.table_background = Some(gpui::transparent_black());
414 style.table_row_hover = self.table_row_hover.map(|_| color.opacity(0.08));
415 style
416 }
417
418 pub(crate) fn is_inverted_by(&self, color: Hsla) -> bool {
424 const INVERTED_LIGHTNESS_GAP: f32 = 0.6;
425 (oklab_lightness(color) - oklab_lightness(self.foreground)).abs() > INVERTED_LIGHTNESS_GAP
426 }
427}
428
429fn oklab_lightness(color: Hsla) -> f32 {
431 let rgb = color.to_rgb();
432 let linear = |c: f32| {
433 if c <= 0.04045 {
434 c / 12.92
435 } else {
436 ((c + 0.055) / 1.055).powf(2.4)
437 }
438 };
439 let (r, g, b) = (linear(rgb.r), linear(rgb.g), linear(rgb.b));
440 let l = (0.412_221_46 * r + 0.536_332_55 * g + 0.051_445_995 * b).cbrt();
441 let m = (0.211_903_5 * r + 0.680_699_5 * g + 0.107_396_96 * b).cbrt();
442 let s = (0.088_302_46 * r + 0.281_718_85 * g + 0.629_978_7 * b).cbrt();
443 0.210_454_26 * l + 0.793_617_8 * m - 0.004_072_047 * s
444}
445
446pub(super) fn normalize_inline_code_style(mut style: StyleRefinement) -> StyleRefinement {
449 if style.text.font_size.is_some_and(|size| {
450 let size = size.to_pixels(gpui::px(1.)).as_f32();
451 !size.is_finite() || size <= 0.
452 }) {
453 style.text.font_size = None;
454 }
455 style
456}
457
458#[cfg(test)]
459mod tests {
460 use gpui::{Styled as _, px};
461
462 use super::*;
463
464 #[test]
465 fn selection_layout_fingerprint_covers_callback_table_and_theme_fields() {
466 let base = TextViewStyle::default();
467 let heading = base
468 .clone()
469 .with_heading(|_| StyleRefinement::default().text_size(px(14.)));
470 assert!(
471 heading
472 == base
473 .clone()
474 .with_heading(|_| StyleRefinement::default().text_size(px(14.)))
475 );
476 assert!(
477 heading
478 != base
479 .clone()
480 .with_heading(|_| StyleRefinement::default().text_size(px(28.)))
481 );
482
483 let mut table = StyleRefinement::default();
484 table.text.white_space = Some(gpui::WhiteSpace::Nowrap);
485 assert!(base != base.clone().with_table_cell(table));
486 assert!(base != base.clone().with_table_row_hover(gpui::red()));
487
488 assert!(base != base.clone().with_dark(true));
489 assert!(
490 base != base
491 .clone()
492 .with_inline_code_style(StyleRefinement::default().border_color(gpui::red()))
493 );
494 assert!(
495 base != base
496 .clone()
497 .with_list_style(StyleRefinement::default().text_color(gpui::red()))
498 );
499 assert!(
500 base != base
501 .clone()
502 .with_blockquote_style(StyleRefinement::default().border_color(gpui::red()))
503 );
504 }
505
506 #[test]
507 fn heading_equality_skips_shared_callbacks_and_compares_distinct_ones() {
508 use std::sync::atomic::{AtomicUsize, Ordering};
509
510 let calls = Arc::new(AtomicUsize::new(0));
511 let calls_for_heading = calls.clone();
512 let style = TextViewStyle::default().with_heading(move |_| {
513 calls_for_heading.fetch_add(1, Ordering::Relaxed);
514 StyleRefinement::default().text_size(px(14.))
515 });
516
517 assert!(style == style.clone());
518 assert_eq!(calls.load(Ordering::Relaxed), 0);
519 assert!(style != style.clone().with_paragraph_gap(rems(1.)));
520 assert!(style != style.clone().with_dark(!style.is_dark()));
521 assert_eq!(calls.load(Ordering::Relaxed), 0);
522
523 let equal = style
524 .clone()
525 .with_heading(|_| StyleRefinement::default().text_size(px(14.)));
526 assert!(style == equal);
527 assert_eq!(calls.load(Ordering::Relaxed), 6);
528
529 calls.store(0, Ordering::Relaxed);
530 let different = style.clone().with_heading(|level| {
531 StyleRefinement::default().text_size(px(if level == 6 { 28. } else { 14. }))
532 });
533 assert!(style != different);
534 assert_eq!(calls.load(Ordering::Relaxed), 6);
535 }
536
537 #[test]
538 fn default_style_is_readable_without_an_application_theme() {
539 let style = TextViewStyle::default();
540
541 assert_eq!(style.foreground().a, 1.0);
542 assert_eq!(style.link().a, 1.0);
543 assert!(style.selection().a > 0.0);
544 assert!(style.inline_code().background_color.is_some());
545 assert!(style.code_background().a > 0.0);
546 assert!(style.border().a > 0.0);
547 assert_eq!(style.table_row_hover(), None);
548 assert_eq!(style.code_block().corner_radii.top_left, None);
549 assert_eq!(style.code_block().corner_radii.top_right, None);
550 assert_eq!(style.code_block().corner_radii.bottom_left, None);
551 assert_eq!(style.code_block().corner_radii.bottom_right, None);
552 }
553
554 #[test]
555 fn heading_refinement_defaults_empty_and_resolves_by_level() {
556 let base = TextViewStyle::default();
557 assert_eq!(base.heading(1), StyleRefinement::default());
558
559 let style = base.clone().with_heading(|level| match level {
560 1 => StyleRefinement::default().pt(rems(1.)).pb(rems(0.5)),
561 _ => StyleRefinement::default().pb(rems(0.25)),
562 });
563
564 assert_eq!(
565 style.heading(1),
566 StyleRefinement::default().pt(rems(1.)).pb(rems(0.5))
567 );
568 assert_eq!(style.heading(2), StyleRefinement::default().pb(rems(0.25)));
569 assert!(style != base);
570 }
571
572 #[test]
573 fn inline_code_falls_back_to_the_code_background() {
574 let style = TextViewStyle::default()
575 .with_code_background(gpui::rgb(0x123456).into())
576 .with_inline_code(HighlightStyle::default());
577
578 assert_eq!(
579 style.inline_code_highlight().background_color,
580 Some(gpui::rgb(0x123456).into())
581 );
582 }
583
584 #[test]
585 fn inline_code_refinements_remain_explicit_on_an_inverted_surface() {
586 let colors = ColorTokens::light();
587 let plain = TextViewStyle::from_colors(&colors, false);
588 assert_eq!(plain.inline_code_style(), &StyleRefinement::default());
589 for size in [f32::NAN, f32::INFINITY, 0., -1.] {
590 assert!(
591 plain
592 .clone()
593 .with_inline_code_style(StyleRefinement::default().text_size(px(size)))
594 .inline_code_style()
595 .text
596 .font_size
597 .is_none()
598 );
599 }
600 let refinement = StyleRefinement::default()
601 .text_size(gpui::rems(1.))
602 .bg(gpui::red())
603 .border_1()
604 .border_color(gpui::blue());
605 let style = plain.with_inline_code_style(refinement.clone());
606 assert_eq!(style.inline_code_style(), &refinement);
607 assert_eq!(
608 style
609 .on_text_color(colors.primary_foreground)
610 .inline_code_style(),
611 &refinement
612 );
613 }
614
615 #[test]
616 fn list_and_blockquote_refinements_remain_explicit_on_an_inverted_surface() {
617 let colors = ColorTokens::light();
618 let plain = TextViewStyle::from_colors(&colors, false);
619 assert_eq!(plain.list_style(), &StyleRefinement::default());
620 assert_eq!(plain.blockquote_style(), &StyleRefinement::default());
621
622 let marker = StyleRefinement::default()
623 .text_color(gpui::red())
624 .font_weight(gpui::FontWeight::BOLD);
625 let quote = StyleRefinement::default()
626 .border_l_4()
627 .border_color(gpui::blue())
628 .bg(gpui::green());
629 let style = plain
630 .with_list_style(marker.clone())
631 .with_blockquote_style(quote.clone());
632 assert_eq!(style.list_style(), &marker);
633 assert_eq!(style.blockquote_style(), "e);
634
635 let inverted = style.on_text_color(colors.primary_foreground);
636 assert_eq!(inverted.list_style(), &marker);
637 assert_eq!(inverted.blockquote_style(), "e);
638 }
639
640 #[test]
641 fn text_color_of_a_matching_surface_only_replaces_the_body_text() {
642 let style = TextViewStyle::from_colors(&ColorTokens::light(), false)
643 .with_table_row_hover(ColorTokens::light().muted);
644 let destructive = ColorTokens::light().destructive;
645
646 let adapted = style.on_text_color(destructive);
647 assert_eq!(adapted.foreground(), destructive);
648 assert_eq!(adapted.link(), style.link());
649 assert_eq!(adapted.muted_foreground(), style.muted_foreground());
650 assert_eq!(adapted.code_background(), style.code_background());
651 assert_eq!(adapted.table_background(), None);
652 assert_eq!(adapted.table_row_hover(), style.table_row_hover());
653 assert!(!adapted.is_dark());
654 }
655
656 #[test]
657 fn text_color_of_an_inverted_surface_derives_every_color_from_it() {
658 for (colors, is_dark) in [(ColorTokens::light(), false), (ColorTokens::dark(), true)] {
659 let style = TextViewStyle::from_colors(&colors, is_dark);
660 let text = colors.primary_foreground;
661 assert_eq!(style.on_text_color(text).table_row_hover(), None);
663
664 let style = style.with_table_row_hover(colors.muted);
665 let adapted = style.on_text_color(text);
666 assert_eq!(adapted.foreground(), text);
667 assert_eq!(adapted.link(), text);
668 assert_eq!(adapted.muted_foreground(), text.opacity(0.7));
669 assert_eq!(adapted.code_background(), text.opacity(0.12));
670 assert_eq!(
671 adapted.inline_code_highlight().background_color,
672 Some(text.opacity(0.12))
673 );
674 assert_eq!(adapted.border(), text.opacity(0.2));
675 assert_eq!(adapted.selection(), text.opacity(0.25));
676 assert_eq!(adapted.table_background(), Some(gpui::transparent_black()));
677 assert_eq!(adapted.table_row_hover(), Some(text.opacity(0.08)));
678 assert_eq!(adapted.table_head().text.color, Some(text));
679 assert_eq!(adapted.is_dark(), !is_dark);
680 }
681 }
682
683 #[test]
684 fn from_theme_maps_base_semantic_tokens() {
685 let mut theme = crate::Theme::default();
686 theme.tokens.colors.foreground = gpui::rgb(0x112233).into();
687 theme.tokens.colors.muted_foreground = gpui::rgb(0x445566).into();
688 theme.tokens.colors.primary = gpui::rgb(0x3366ff).into();
689 theme.tokens.colors.accent = gpui::rgb(0xddeeff).into();
690 theme.tokens.colors.border = gpui::rgb(0x778899).into();
691 theme.tokens.colors.selection = gpui::rgb(0x55a0fc).into();
692
693 let style = TextViewStyle::from_theme(&theme);
694 assert_eq!(style.foreground(), theme.tokens.colors.foreground);
695 assert_eq!(style.link(), theme.tokens.colors.primary);
696 assert_eq!(style.selection(), theme.tokens.colors.selection);
697 assert_eq!(style.code_background(), theme.tokens.colors.accent);
698 assert_eq!(style.border(), theme.tokens.colors.border);
699 }
700}