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