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