Skip to main content

gpui_base/text/
style.rs

1use std::sync::Arc;
2
3use gpui::{HighlightStyle, Hsla, Rems, StyleRefinement, rems};
4
5use crate::ColorTokens;
6
7/// TextViewStyle used to customize the style for [`super::TextView`].
8///
9/// The fields are private because this type crosses the `gpui-base` seam:
10/// build one with the `with_*` methods and read it back through the accessors
11/// of the same name, so a later field is an additive change rather than a
12/// breaking one.
13#[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    /// The table body background, or the theme surface when `None`. Only
29    /// [`Self::on_text_color`] sets it, to let a table on an inverted surface
30    /// show that surface.
31    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    /// Derives rich-text colors from Base semantic theme tokens.
64    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    /// Derives rich-text colors from one palette.
72    ///
73    /// Rich text needs a handful of roles the palette does not name directly —
74    /// a code background, a link color — so they are mapped here once instead
75    /// of at every call site.
76    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    /// Sets the default body-text color.
100    pub fn with_foreground(mut self, color: Hsla) -> Self {
101        self.foreground = color;
102        self
103    }
104
105    /// Sets the secondary text color.
106    pub fn with_muted_foreground(mut self, color: Hsla) -> Self {
107        self.muted_foreground = color;
108        self
109    }
110
111    /// Sets the link text color.
112    pub fn with_link(mut self, color: Hsla) -> Self {
113        self.link = color;
114        self
115    }
116
117    /// Sets the background painted behind selected text.
118    ///
119    /// Selection quads are painted under the glyphs, so this is normally a
120    /// translucent wash rather than a solid fill.
121    pub fn with_selection(mut self, color: Hsla) -> Self {
122        self.selection = color;
123        self
124    }
125
126    /// Sets the background of fenced code blocks and table header rows.
127    pub fn with_code_background(mut self, color: Hsla) -> Self {
128        self.code_background = color;
129        self
130    }
131
132    /// Sets the color of borders and horizontal rules.
133    pub fn with_border(mut self, color: Hsla) -> Self {
134        self.border = color;
135        self
136    }
137
138    /// Sets the gap between paragraphs. Defaults to 0.75 rem.
139    pub fn with_paragraph_gap(mut self, gap: Rems) -> Self {
140        self.paragraph_gap = gap;
141        self
142    }
143
144    /// Sets the style refinement for headings, selected by heading level (1-6).
145    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    /// Sets the style refinement for code blocks.
154    ///
155    /// Set `overflow.y` to `Overflow::Scroll` together with a max height to
156    /// scroll long code inside the block: it gets its own scrollbar, and wheel
157    /// input over it no longer scrolls an ancestor list until the code reaches
158    /// its edge.
159    pub fn with_code_block(mut self, style: StyleRefinement) -> Self {
160        self.code_block = style;
161        self
162    }
163
164    /// Sets the highlight style for inline code spans.
165    ///
166    /// When `background_color` is `None`, the neutral code background is used,
167    /// which keeps [`TextViewStyle::default`] usable without a theme.
168    pub fn with_inline_code(mut self, style: HighlightStyle) -> Self {
169        self.inline_code = style;
170        self
171    }
172
173    /// Sets the style refinement for the table container (the bordered wrapper
174    /// in wrap mode, the scroll viewport in horizontal-scroll mode).
175    ///
176    /// Set `overflow_x: scroll` on the refinement for adaptive table layout:
177    /// columns fit their content when space allows, shrink (wrapping cell
178    /// text) down to a per-column floor when the frame is narrower, and below
179    /// that the table scrolls horizontally instead of squeezing further.
180    pub fn with_table(mut self, style: StyleRefinement) -> Self {
181        self.table = style;
182        self
183    }
184
185    /// Sets the style refinement for the header row (the first row) of a
186    /// table, applied on top of the header background and foreground.
187    pub fn with_table_head(mut self, style: StyleRefinement) -> Self {
188        self.table_head = style;
189        self
190    }
191
192    /// Sets the style refinement for each table cell.
193    ///
194    /// With the scroll table layout, `white_space: nowrap` here keeps cells on
195    /// a single line — columns then never shrink and the table scrolls as soon
196    /// as the content is wider than the frame.
197    pub fn with_table_cell(mut self, style: StyleRefinement) -> Self {
198        self.table_cell = style;
199        self
200    }
201
202    /// Sets whether content-specific assets should use their dark variant.
203    pub fn with_dark(mut self, is_dark: bool) -> Self {
204        self.is_dark = is_dark;
205        self
206    }
207
208    /// The default body-text color.
209    pub fn foreground(&self) -> Hsla {
210        self.foreground
211    }
212
213    /// The secondary text color.
214    pub fn muted_foreground(&self) -> Hsla {
215        self.muted_foreground
216    }
217
218    /// The link text color.
219    pub fn link(&self) -> Hsla {
220        self.link
221    }
222
223    /// The background painted behind selected text.
224    pub fn selection(&self) -> Hsla {
225        self.selection
226    }
227
228    /// The background of fenced code blocks and table header rows.
229    pub fn code_background(&self) -> Hsla {
230        self.code_background
231    }
232
233    /// The color of borders and horizontal rules.
234    pub fn border(&self) -> Hsla {
235        self.border
236    }
237
238    /// The gap between paragraphs.
239    pub fn paragraph_gap(&self) -> Rems {
240        self.paragraph_gap
241    }
242
243    /// The style refinement for a heading at `level` (1-6).
244    pub fn heading(&self, level: u8) -> StyleRefinement {
245        (self.heading)(level)
246    }
247
248    /// The style refinement for code blocks.
249    pub fn code_block(&self) -> &StyleRefinement {
250        &self.code_block
251    }
252
253    /// The style refinement for the table container.
254    pub fn table(&self) -> &StyleRefinement {
255        &self.table
256    }
257
258    /// The style refinement for table header rows.
259    pub fn table_head(&self) -> &StyleRefinement {
260        &self.table_head
261    }
262
263    /// The style refinement for table cells.
264    pub fn table_cell(&self) -> &StyleRefinement {
265        &self.table_cell
266    }
267
268    /// The highlight style for inline code, before the code-background
269    /// fallback in [`Self::inline_code_highlight`] applies.
270    pub fn inline_code(&self) -> HighlightStyle {
271        self.inline_code
272    }
273
274    /// Whether content-specific assets should use their dark variant.
275    pub fn is_dark(&self) -> bool {
276        self.is_dark
277    }
278
279    /// The table body background, when it is not the theme surface.
280    pub(crate) fn table_background(&self) -> Option<Hsla> {
281        self.table_background
282    }
283
284    /// Returns the [`HighlightStyle`] to use for inline code, falling back to
285    /// the code background when no custom background was supplied.
286    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    /// This style adapted to body text drawn in `color`, the text color a
295    /// container sets for its surface.
296    ///
297    /// The body text always takes `color`. When `color` is far from this
298    /// style's foreground in lightness, the surface is inverted from the one
299    /// this style was made for (a `primary` fill, say): the link, muted text,
300    /// code, border and selection colors would vanish on it, so they are all
301    /// derived from `color`, and [`Self::is_dark`] flips.
302    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    /// Whether body text in `color` sits on a surface inverted from the one
329    /// this style was made for.
330    ///
331    /// Mid-tone text such as a destructive red reads on either kind of surface,
332    /// so only a lightness gap wider than that counts as inverted.
333    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
339/// The perceptual (Oklab) lightness of `color`, from 0 (black) to 1 (white).
340fn 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}