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 table_background: Option<Hsla>,
32 is_dark: bool,
33}
34
35impl PartialEq for TextViewStyle {
36 fn eq(&self, other: &Self) -> bool {
37 self.paragraph_gap == other.paragraph_gap
38 && self.foreground == other.foreground
39 && self.muted_foreground == other.muted_foreground
40 && self.link == other.link
41 && self.selection == other.selection
42 && self.code_background == other.code_background
43 && self.border == other.border
44 && (Arc::ptr_eq(&self.heading, &other.heading)
45 || (1..=6).all(|level| (self.heading)(level) == (other.heading)(level)))
46 && self.code_block == other.code_block
47 && self.table == other.table
48 && self.table_head == other.table_head
49 && self.table_cell == other.table_cell
50 && self.inline_code == other.inline_code
51 && self.table_background == other.table_background
52 && self.is_dark == other.is_dark
53 }
54}
55
56impl Default for TextViewStyle {
57 fn default() -> Self {
58 Self::from_colors(&ColorTokens::light(), false)
59 }
60}
61
62impl TextViewStyle {
63 pub fn from_theme(theme: &crate::Theme) -> Self {
65 Self::from_colors(
66 &theme.tokens.colors,
67 theme.appearance == crate::ThemeAppearance::Dark,
68 )
69 }
70
71 fn from_colors(colors: &ColorTokens, is_dark: bool) -> Self {
77 Self {
78 foreground: colors.foreground,
79 muted_foreground: colors.muted_foreground,
80 link: colors.primary,
81 selection: colors.selection,
82 code_background: colors.accent,
83 border: colors.border,
84 paragraph_gap: rems(0.75),
85 heading: Arc::new(|_| StyleRefinement::default()),
86 code_block: StyleRefinement::default(),
87 table: StyleRefinement::default(),
88 table_head: StyleRefinement::default(),
89 table_cell: StyleRefinement::default(),
90 inline_code: HighlightStyle {
91 background_color: Some(colors.accent),
92 ..Default::default()
93 },
94 table_background: None,
95 is_dark,
96 }
97 }
98
99 pub fn with_foreground(mut self, color: Hsla) -> Self {
101 self.foreground = color;
102 self
103 }
104
105 pub fn with_muted_foreground(mut self, color: Hsla) -> Self {
107 self.muted_foreground = color;
108 self
109 }
110
111 pub fn with_link(mut self, color: Hsla) -> Self {
113 self.link = color;
114 self
115 }
116
117 pub fn with_selection(mut self, color: Hsla) -> Self {
122 self.selection = color;
123 self
124 }
125
126 pub fn with_code_background(mut self, color: Hsla) -> Self {
128 self.code_background = color;
129 self
130 }
131
132 pub fn with_border(mut self, color: Hsla) -> Self {
134 self.border = color;
135 self
136 }
137
138 pub fn with_paragraph_gap(mut self, gap: Rems) -> Self {
140 self.paragraph_gap = gap;
141 self
142 }
143
144 pub fn with_heading<F>(mut self, heading: F) -> Self
146 where
147 F: Fn(u8) -> StyleRefinement + Send + Sync + 'static,
148 {
149 self.heading = Arc::new(heading);
150 self
151 }
152
153 pub fn with_code_block(mut self, style: StyleRefinement) -> Self {
160 self.code_block = style;
161 self
162 }
163
164 pub fn with_inline_code(mut self, style: HighlightStyle) -> Self {
169 self.inline_code = style;
170 self
171 }
172
173 pub fn with_table(mut self, style: StyleRefinement) -> Self {
181 self.table = style;
182 self
183 }
184
185 pub fn with_table_head(mut self, style: StyleRefinement) -> Self {
188 self.table_head = style;
189 self
190 }
191
192 pub fn with_table_cell(mut self, style: StyleRefinement) -> Self {
198 self.table_cell = style;
199 self
200 }
201
202 pub fn with_dark(mut self, is_dark: bool) -> Self {
204 self.is_dark = is_dark;
205 self
206 }
207
208 pub fn foreground(&self) -> Hsla {
210 self.foreground
211 }
212
213 pub fn muted_foreground(&self) -> Hsla {
215 self.muted_foreground
216 }
217
218 pub fn link(&self) -> Hsla {
220 self.link
221 }
222
223 pub fn selection(&self) -> Hsla {
225 self.selection
226 }
227
228 pub fn code_background(&self) -> Hsla {
230 self.code_background
231 }
232
233 pub fn border(&self) -> Hsla {
235 self.border
236 }
237
238 pub fn paragraph_gap(&self) -> Rems {
240 self.paragraph_gap
241 }
242
243 pub fn heading(&self, level: u8) -> StyleRefinement {
245 (self.heading)(level)
246 }
247
248 pub fn code_block(&self) -> &StyleRefinement {
250 &self.code_block
251 }
252
253 pub fn table(&self) -> &StyleRefinement {
255 &self.table
256 }
257
258 pub fn table_head(&self) -> &StyleRefinement {
260 &self.table_head
261 }
262
263 pub fn table_cell(&self) -> &StyleRefinement {
265 &self.table_cell
266 }
267
268 pub fn inline_code(&self) -> HighlightStyle {
271 self.inline_code
272 }
273
274 pub fn is_dark(&self) -> bool {
276 self.is_dark
277 }
278
279 pub(crate) fn table_background(&self) -> Option<Hsla> {
281 self.table_background
282 }
283
284 pub(crate) fn inline_code_highlight(&self) -> HighlightStyle {
287 let mut style = self.inline_code;
288 if style.background_color.is_none() {
289 style.background_color = Some(self.code_background);
290 }
291 style
292 }
293
294 pub(crate) fn on_text_color(&self, color: Hsla) -> Self {
303 let style = self.clone().with_foreground(color);
304 if !self.is_inverted_by(color) {
305 return style;
306 }
307
308 let code_background = color.opacity(0.12);
309 let mut table_head = self.table_head.clone();
310 table_head.background = Some(code_background.into());
311 table_head.text.color = Some(color);
312 let mut style = style
313 .with_muted_foreground(color.opacity(0.7))
314 .with_link(color)
315 .with_selection(color.opacity(0.25))
316 .with_code_background(code_background)
317 .with_border(color.opacity(0.2))
318 .with_inline_code(HighlightStyle {
319 background_color: Some(code_background),
320 ..self.inline_code
321 })
322 .with_table_head(table_head)
323 .with_dark(!self.is_dark);
324 style.table_background = Some(gpui::transparent_black());
325 style
326 }
327
328 pub(crate) fn is_inverted_by(&self, color: Hsla) -> bool {
334 const INVERTED_LIGHTNESS_GAP: f32 = 0.6;
335 (oklab_lightness(color) - oklab_lightness(self.foreground)).abs() > INVERTED_LIGHTNESS_GAP
336 }
337}
338
339fn oklab_lightness(color: Hsla) -> f32 {
341 let rgb = color.to_rgb();
342 let linear = |c: f32| {
343 if c <= 0.04045 {
344 c / 12.92
345 } else {
346 ((c + 0.055) / 1.055).powf(2.4)
347 }
348 };
349 let (r, g, b) = (linear(rgb.r), linear(rgb.g), linear(rgb.b));
350 let l = (0.412_221_46 * r + 0.536_332_55 * g + 0.051_445_995 * b).cbrt();
351 let m = (0.211_903_5 * r + 0.680_699_5 * g + 0.107_396_96 * b).cbrt();
352 let s = (0.088_302_46 * r + 0.281_718_85 * g + 0.629_978_7 * b).cbrt();
353 0.210_454_26 * l + 0.793_617_8 * m - 0.004_072_047 * s
354}
355
356#[cfg(test)]
357mod tests {
358 use gpui::{Styled as _, px};
359
360 use super::*;
361
362 #[test]
363 fn selection_layout_fingerprint_covers_callback_table_and_theme_fields() {
364 let base = TextViewStyle::default();
365 let heading = base
366 .clone()
367 .with_heading(|_| StyleRefinement::default().text_size(px(14.)));
368 assert!(
369 heading
370 == base
371 .clone()
372 .with_heading(|_| StyleRefinement::default().text_size(px(14.)))
373 );
374 assert!(
375 heading
376 != base
377 .clone()
378 .with_heading(|_| StyleRefinement::default().text_size(px(28.)))
379 );
380
381 let mut table = StyleRefinement::default();
382 table.text.white_space = Some(gpui::WhiteSpace::Nowrap);
383 assert!(base != base.clone().with_table_cell(table));
384
385 assert!(base != base.clone().with_dark(true));
386 }
387
388 #[test]
389 fn heading_equality_skips_shared_callbacks_and_compares_distinct_ones() {
390 use std::sync::atomic::{AtomicUsize, Ordering};
391
392 let calls = Arc::new(AtomicUsize::new(0));
393 let calls_for_heading = calls.clone();
394 let style = TextViewStyle::default().with_heading(move |_| {
395 calls_for_heading.fetch_add(1, Ordering::Relaxed);
396 StyleRefinement::default().text_size(px(14.))
397 });
398
399 assert!(style == style.clone());
400 assert_eq!(calls.load(Ordering::Relaxed), 0);
401 assert!(style != style.clone().with_paragraph_gap(rems(1.)));
402 assert!(style != style.clone().with_dark(!style.is_dark()));
403 assert_eq!(calls.load(Ordering::Relaxed), 0);
404
405 let equal = style
406 .clone()
407 .with_heading(|_| StyleRefinement::default().text_size(px(14.)));
408 assert!(style == equal);
409 assert_eq!(calls.load(Ordering::Relaxed), 6);
410
411 calls.store(0, Ordering::Relaxed);
412 let different = style.clone().with_heading(|level| {
413 StyleRefinement::default().text_size(px(if level == 6 { 28. } else { 14. }))
414 });
415 assert!(style != different);
416 assert_eq!(calls.load(Ordering::Relaxed), 6);
417 }
418
419 #[test]
420 fn default_style_is_readable_without_an_application_theme() {
421 let style = TextViewStyle::default();
422
423 assert_eq!(style.foreground().a, 1.0);
424 assert_eq!(style.link().a, 1.0);
425 assert!(style.selection().a > 0.0);
426 assert!(style.inline_code().background_color.is_some());
427 assert!(style.code_background().a > 0.0);
428 assert!(style.border().a > 0.0);
429 assert_eq!(style.code_block().corner_radii.top_left, None);
430 assert_eq!(style.code_block().corner_radii.top_right, None);
431 assert_eq!(style.code_block().corner_radii.bottom_left, None);
432 assert_eq!(style.code_block().corner_radii.bottom_right, None);
433 }
434
435 #[test]
436 fn heading_refinement_defaults_empty_and_resolves_by_level() {
437 let base = TextViewStyle::default();
438 assert_eq!(base.heading(1), StyleRefinement::default());
439
440 let style = base.clone().with_heading(|level| match level {
441 1 => StyleRefinement::default().pt(rems(1.)).pb(rems(0.5)),
442 _ => StyleRefinement::default().pb(rems(0.25)),
443 });
444
445 assert_eq!(
446 style.heading(1),
447 StyleRefinement::default().pt(rems(1.)).pb(rems(0.5))
448 );
449 assert_eq!(style.heading(2), StyleRefinement::default().pb(rems(0.25)));
450 assert!(style != base);
451 }
452
453 #[test]
454 fn inline_code_falls_back_to_the_code_background() {
455 let style = TextViewStyle::default()
456 .with_code_background(gpui::rgb(0x123456).into())
457 .with_inline_code(HighlightStyle::default());
458
459 assert_eq!(
460 style.inline_code_highlight().background_color,
461 Some(gpui::rgb(0x123456).into())
462 );
463 }
464
465 #[test]
466 fn text_color_of_a_matching_surface_only_replaces_the_body_text() {
467 let style = TextViewStyle::from_colors(&ColorTokens::light(), false);
468 let destructive = ColorTokens::light().destructive;
469
470 let adapted = style.on_text_color(destructive);
471 assert_eq!(adapted.foreground(), destructive);
472 assert_eq!(adapted.link(), style.link());
473 assert_eq!(adapted.muted_foreground(), style.muted_foreground());
474 assert_eq!(adapted.code_background(), style.code_background());
475 assert_eq!(adapted.table_background(), None);
476 assert!(!adapted.is_dark());
477 }
478
479 #[test]
480 fn text_color_of_an_inverted_surface_derives_every_color_from_it() {
481 for (colors, is_dark) in [(ColorTokens::light(), false), (ColorTokens::dark(), true)] {
482 let style = TextViewStyle::from_colors(&colors, is_dark);
483 let text = colors.primary_foreground;
484
485 let adapted = style.on_text_color(text);
486 assert_eq!(adapted.foreground(), text);
487 assert_eq!(adapted.link(), text);
488 assert_eq!(adapted.muted_foreground(), text.opacity(0.7));
489 assert_eq!(adapted.code_background(), text.opacity(0.12));
490 assert_eq!(
491 adapted.inline_code_highlight().background_color,
492 Some(text.opacity(0.12))
493 );
494 assert_eq!(adapted.border(), text.opacity(0.2));
495 assert_eq!(adapted.selection(), text.opacity(0.25));
496 assert_eq!(adapted.table_background(), Some(gpui::transparent_black()));
497 assert_eq!(adapted.table_head().text.color, Some(text));
498 assert_eq!(adapted.is_dark(), !is_dark);
499 }
500 }
501
502 #[test]
503 fn from_theme_maps_base_semantic_tokens() {
504 let mut theme = crate::Theme::default();
505 theme.tokens.colors.foreground = gpui::rgb(0x112233).into();
506 theme.tokens.colors.muted_foreground = gpui::rgb(0x445566).into();
507 theme.tokens.colors.primary = gpui::rgb(0x3366ff).into();
508 theme.tokens.colors.accent = gpui::rgb(0xddeeff).into();
509 theme.tokens.colors.border = gpui::rgb(0x778899).into();
510 theme.tokens.colors.selection = gpui::rgb(0x55a0fc).into();
511
512 let style = TextViewStyle::from_theme(&theme);
513 assert_eq!(style.foreground(), theme.tokens.colors.foreground);
514 assert_eq!(style.link(), theme.tokens.colors.primary);
515 assert_eq!(style.selection(), theme.tokens.colors.selection);
516 assert_eq!(style.code_background(), theme.tokens.colors.accent);
517 assert_eq!(style.border(), theme.tokens.colors.border);
518 }
519}