Skip to main content

gpui_base/text/
style.rs

1use std::sync::Arc;
2
3use gpui::{HighlightStyle, Hsla, Pixels, Rems, StyleRefinement, px, 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_base_font_size: Pixels,
23    heading_font_size: Option<Arc<dyn Fn(u8, Pixels) -> Pixels + Send + Sync + 'static>>,
24    code_block: StyleRefinement,
25    table: StyleRefinement,
26    table_head: StyleRefinement,
27    table_cell: StyleRefinement,
28    inline_code: HighlightStyle,
29    is_dark: bool,
30}
31
32impl PartialEq for TextViewStyle {
33    fn eq(&self, other: &Self) -> bool {
34        self.paragraph_gap == other.paragraph_gap
35            && self.foreground == other.foreground
36            && self.muted_foreground == other.muted_foreground
37            && self.link == other.link
38            && self.selection == other.selection
39            && self.code_background == other.code_background
40            && self.border == other.border
41            && self.heading_base_font_size == other.heading_base_font_size
42            && match (&self.heading_font_size, &other.heading_font_size) {
43                (Some(left), Some(right)) => (1..=6).all(|level| {
44                    left(level, self.heading_base_font_size)
45                        == right(level, other.heading_base_font_size)
46                }),
47                (None, None) => true,
48                _ => false,
49            }
50            && self.code_block == other.code_block
51            && self.table == other.table
52            && self.table_head == other.table_head
53            && self.table_cell == other.table_cell
54            && self.inline_code == other.inline_code
55            && self.is_dark == other.is_dark
56    }
57}
58
59impl Default for TextViewStyle {
60    fn default() -> Self {
61        Self::from_colors(&ColorTokens::light(), false)
62    }
63}
64
65impl TextViewStyle {
66    /// Derives rich-text colors from Base semantic theme tokens.
67    pub fn from_theme(theme: &crate::Theme) -> Self {
68        Self::from_colors(
69            &theme.tokens.colors,
70            theme.appearance == crate::ThemeAppearance::Dark,
71        )
72    }
73
74    /// Derives rich-text colors from one palette.
75    ///
76    /// Rich text needs a handful of roles the palette does not name directly —
77    /// a code background, a link color — so they are mapped here once instead
78    /// of at every call site.
79    fn from_colors(colors: &ColorTokens, is_dark: bool) -> Self {
80        Self {
81            foreground: colors.foreground,
82            muted_foreground: colors.muted_foreground,
83            link: colors.primary,
84            selection: colors.selection,
85            code_background: colors.accent,
86            border: colors.border,
87            paragraph_gap: rems(1.),
88            heading_base_font_size: px(14.),
89            heading_font_size: None,
90            code_block: StyleRefinement::default(),
91            table: StyleRefinement::default(),
92            table_head: StyleRefinement::default(),
93            table_cell: StyleRefinement::default(),
94            inline_code: HighlightStyle {
95                background_color: Some(colors.accent),
96                ..Default::default()
97            },
98            is_dark,
99        }
100    }
101
102    /// Sets the default body-text color.
103    pub fn with_foreground(mut self, color: Hsla) -> Self {
104        self.foreground = color;
105        self
106    }
107
108    /// Sets the secondary text color.
109    pub fn with_muted_foreground(mut self, color: Hsla) -> Self {
110        self.muted_foreground = color;
111        self
112    }
113
114    /// Sets the link text color.
115    pub fn with_link(mut self, color: Hsla) -> Self {
116        self.link = color;
117        self
118    }
119
120    /// Sets the background painted behind selected text.
121    ///
122    /// Selection quads are painted under the glyphs, so this is normally a
123    /// translucent wash rather than a solid fill.
124    pub fn with_selection(mut self, color: Hsla) -> Self {
125        self.selection = color;
126        self
127    }
128
129    /// Sets the background of fenced code blocks and table header rows.
130    pub fn with_code_background(mut self, color: Hsla) -> Self {
131        self.code_background = color;
132        self
133    }
134
135    /// Sets the color of borders and horizontal rules.
136    pub fn with_border(mut self, color: Hsla) -> Self {
137        self.border = color;
138        self
139    }
140
141    /// Sets the gap between paragraphs. Defaults to 1 rem.
142    pub fn with_paragraph_gap(mut self, gap: Rems) -> Self {
143        self.paragraph_gap = gap;
144        self
145    }
146
147    /// Sets the base font size headings are derived from. Defaults to 14px.
148    pub fn with_heading_base_font_size(mut self, size: Pixels) -> Self {
149        self.heading_base_font_size = size;
150        self
151    }
152
153    /// Sets the function that resolves a heading's font size.
154    ///
155    /// The first parameter is the heading level (1-6), the second is
156    /// [`Self::heading_base_font_size`].
157    pub fn with_heading_font_size<F>(mut self, f: F) -> Self
158    where
159        F: Fn(u8, Pixels) -> Pixels + Send + Sync + 'static,
160    {
161        self.heading_font_size = Some(Arc::new(f));
162        self
163    }
164
165    /// Sets the style refinement for code blocks.
166    pub fn with_code_block(mut self, style: StyleRefinement) -> Self {
167        self.code_block = style;
168        self
169    }
170
171    /// Sets the highlight style for inline code spans.
172    ///
173    /// When `background_color` is `None`, the neutral code background is used,
174    /// which keeps [`TextViewStyle::default`] usable without a theme.
175    pub fn with_inline_code(mut self, style: HighlightStyle) -> Self {
176        self.inline_code = style;
177        self
178    }
179
180    /// Sets the style refinement for the table container (the bordered wrapper
181    /// in wrap mode, the scroll viewport in horizontal-scroll mode).
182    ///
183    /// Set `overflow_x: scroll` on the refinement for adaptive table layout:
184    /// columns fit their content when space allows, shrink (wrapping cell
185    /// text) down to a per-column floor when the frame is narrower, and below
186    /// that the table scrolls horizontally instead of squeezing further.
187    pub fn with_table(mut self, style: StyleRefinement) -> Self {
188        self.table = style;
189        self
190    }
191
192    /// Sets the style refinement for the header row (the first row) of a
193    /// table, applied on top of the header background and foreground.
194    pub fn with_table_head(mut self, style: StyleRefinement) -> Self {
195        self.table_head = style;
196        self
197    }
198
199    /// Sets the style refinement for each table cell.
200    ///
201    /// With the scroll table layout, `white_space: nowrap` here keeps cells on
202    /// a single line — columns then never shrink and the table scrolls as soon
203    /// as the content is wider than the frame.
204    pub fn with_table_cell(mut self, style: StyleRefinement) -> Self {
205        self.table_cell = style;
206        self
207    }
208
209    /// Sets whether content-specific assets should use their dark variant.
210    pub fn with_dark(mut self, is_dark: bool) -> Self {
211        self.is_dark = is_dark;
212        self
213    }
214
215    /// The default body-text color.
216    pub fn foreground(&self) -> Hsla {
217        self.foreground
218    }
219
220    /// The secondary text color.
221    pub fn muted_foreground(&self) -> Hsla {
222        self.muted_foreground
223    }
224
225    /// The link text color.
226    pub fn link(&self) -> Hsla {
227        self.link
228    }
229
230    /// The background painted behind selected text.
231    pub fn selection(&self) -> Hsla {
232        self.selection
233    }
234
235    /// The background of fenced code blocks and table header rows.
236    pub fn code_background(&self) -> Hsla {
237        self.code_background
238    }
239
240    /// The color of borders and horizontal rules.
241    pub fn border(&self) -> Hsla {
242        self.border
243    }
244
245    /// The gap between paragraphs.
246    pub fn paragraph_gap(&self) -> Rems {
247        self.paragraph_gap
248    }
249
250    /// The base font size headings are derived from.
251    pub fn heading_base_font_size(&self) -> Pixels {
252        self.heading_base_font_size
253    }
254
255    /// The size this style gives a heading of `level`, when it resolves
256    /// heading sizes itself.
257    ///
258    /// `None` means the caller keeps whatever size it had already derived from
259    /// [`Self::heading_base_font_size`].
260    pub fn heading_font_size(&self, level: u8) -> Option<Pixels> {
261        self.heading_font_size
262            .as_ref()
263            .map(|f| f(level, self.heading_base_font_size))
264    }
265
266    /// The style refinement for code blocks.
267    pub fn code_block(&self) -> &StyleRefinement {
268        &self.code_block
269    }
270
271    /// The style refinement for the table container.
272    pub fn table(&self) -> &StyleRefinement {
273        &self.table
274    }
275
276    /// The style refinement for table header rows.
277    pub fn table_head(&self) -> &StyleRefinement {
278        &self.table_head
279    }
280
281    /// The style refinement for table cells.
282    pub fn table_cell(&self) -> &StyleRefinement {
283        &self.table_cell
284    }
285
286    /// The highlight style for inline code, before the code-background
287    /// fallback in [`Self::inline_code_highlight`] applies.
288    pub fn inline_code(&self) -> HighlightStyle {
289        self.inline_code
290    }
291
292    /// Whether content-specific assets should use their dark variant.
293    pub fn is_dark(&self) -> bool {
294        self.is_dark
295    }
296
297    /// Returns the [`HighlightStyle`] to use for inline code, falling back to
298    /// the code background when no custom background was supplied.
299    pub(crate) fn inline_code_highlight(&self) -> HighlightStyle {
300        let mut style = self.inline_code;
301        if style.background_color.is_none() {
302            style.background_color = Some(self.code_background);
303        }
304        style
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311
312    #[test]
313    fn selection_layout_fingerprint_covers_callback_table_and_theme_fields() {
314        let base = TextViewStyle::default();
315        let heading = base.clone().with_heading_font_size(|_, size| size);
316        assert!(heading == base.clone().with_heading_font_size(|_, size| size));
317        assert!(heading != base.clone().with_heading_font_size(|_, size| size * 2.));
318
319        let mut table = StyleRefinement::default();
320        table.text.white_space = Some(gpui::WhiteSpace::Nowrap);
321        assert!(base != base.clone().with_table_cell(table));
322
323        assert!(base != base.clone().with_dark(true));
324    }
325
326    #[test]
327    fn cloning_preserves_the_same_heading_callback_fingerprint() {
328        let style = TextViewStyle::default().with_heading_font_size(|_, size| size);
329        assert!(style == style.clone());
330    }
331
332    #[test]
333    fn default_style_is_readable_without_an_application_theme() {
334        let style = TextViewStyle::default();
335
336        assert_eq!(style.foreground().a, 1.0);
337        assert_eq!(style.link().a, 1.0);
338        assert!(style.selection().a > 0.0);
339        assert!(style.inline_code().background_color.is_some());
340        assert!(style.code_background().a > 0.0);
341        assert!(style.border().a > 0.0);
342        assert_eq!(style.code_block().corner_radii.top_left, None);
343        assert_eq!(style.code_block().corner_radii.top_right, None);
344        assert_eq!(style.code_block().corner_radii.bottom_left, None);
345        assert_eq!(style.code_block().corner_radii.bottom_right, None);
346    }
347
348    #[test]
349    fn heading_font_size_resolves_through_the_installed_callback() {
350        let style = TextViewStyle::default();
351        assert_eq!(style.heading_font_size(1), None);
352
353        let style = style.with_heading_font_size(|level, base| base * (7. - level as f32));
354        assert_eq!(style.heading_font_size(1), Some(px(14.) * 6.));
355        assert_eq!(style.heading_font_size(6), Some(px(14.)));
356    }
357
358    #[test]
359    fn inline_code_falls_back_to_the_code_background() {
360        let style = TextViewStyle::default()
361            .with_code_background(gpui::rgb(0x123456).into())
362            .with_inline_code(HighlightStyle::default());
363
364        assert_eq!(
365            style.inline_code_highlight().background_color,
366            Some(gpui::rgb(0x123456).into())
367        );
368    }
369
370    #[test]
371    fn from_theme_maps_base_semantic_tokens() {
372        let mut theme = crate::Theme::default();
373        theme.tokens.colors.foreground = gpui::rgb(0x112233).into();
374        theme.tokens.colors.muted_foreground = gpui::rgb(0x445566).into();
375        theme.tokens.colors.primary = gpui::rgb(0x3366ff).into();
376        theme.tokens.colors.accent = gpui::rgb(0xddeeff).into();
377        theme.tokens.colors.border = gpui::rgb(0x778899).into();
378        theme.tokens.colors.selection = gpui::rgb(0x55a0fc).into();
379
380        let style = TextViewStyle::from_theme(&theme);
381        assert_eq!(style.foreground(), theme.tokens.colors.foreground);
382        assert_eq!(style.link(), theme.tokens.colors.primary);
383        assert_eq!(style.selection(), theme.tokens.colors.selection);
384        assert_eq!(style.code_background(), theme.tokens.colors.accent);
385        assert_eq!(style.border(), theme.tokens.colors.border);
386    }
387}