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