Skip to main content

theme/theme/
typography.rs

1//! The system type ladder: eleven roles, each carrying a size and a weight.
2//!
3//! Sizes measured on macOS 26, 2026-08-31, through
4//! `NSFont.preferredFont(forTextStyle:)`; line heights 2026-09-01, through
5//! `NSLayoutManager.defaultLineHeight(for:)` on the same fonts.
6
7use std::sync::atomic::{AtomicU32, Ordering};
8
9use gpui::{App, FontWeight, Styled, px};
10
11/// The body size every painted role is scaled against, as raw `f32` bits.
12static BASE: AtomicU32 = AtomicU32::new(TextStyle::Body.size().to_bits());
13
14/// Set the body size in points; every other role keeps its ratio to it, the way
15/// every corner is a ratio of [`Brand::radius`](crate::Brand::radius).
16///
17/// A probe for the chrome that does not grow with the text —
18/// [`Theme::HEADER_HEIGHT`], [`Theme::STATUS_STRIP_HEIGHT`] and every fixed
19/// `py`. The measured ramp is non-linear per role, so one ratio finds that
20/// coupling without describing the ramp; [`TextStyle::size`] stays the measured
21/// table at any setting.
22///
23/// [`Theme::HEADER_HEIGHT`]: crate::Theme::HEADER_HEIGHT
24/// [`Theme::STATUS_STRIP_HEIGHT`]: crate::Theme::STATUS_STRIP_HEIGHT
25pub fn set_base_text_size(points: f32, cx: &mut App) {
26    BASE.store(points.to_bits(), Ordering::Relaxed);
27    cx.refresh_windows();
28}
29
30/// The body size in points. [`TextStyle::Body`]'s own size paints the measured
31/// ladder.
32pub fn base_text_size() -> f32 {
33    f32::from_bits(BASE.load(Ordering::Relaxed))
34}
35
36/// A role in the type ladder — SwiftUI's `Font.TextStyle`.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
38pub enum TextStyle {
39    LargeTitle,
40    Title,
41    Title2,
42    Title3,
43    Headline,
44    Subheadline,
45    Body,
46    Callout,
47    Footnote,
48    Caption,
49    Caption2,
50}
51
52impl TextStyle {
53    /// The role's measured size in points.
54    pub const fn size(self) -> f32 {
55        match self {
56            Self::LargeTitle => 26.0,
57            Self::Title => 22.0,
58            Self::Title2 => 17.0,
59            Self::Title3 => 15.0,
60            Self::Headline | Self::Body => 13.0,
61            Self::Callout => 12.0,
62            Self::Subheadline => 11.0,
63            Self::Footnote | Self::Caption | Self::Caption2 => 10.0,
64        }
65    }
66
67    /// The size this role paints at, which [`set_base_text_size`] moves.
68    pub fn painted(self) -> f32 {
69        self.size() * base_text_size() / Self::Body.size()
70    }
71
72    /// The role's measured line height in points, at its measured [`Self::size`].
73    ///
74    /// A table beside `size`, because the ratio is not one number: it runs 1.18
75    /// at `Title` up to 1.33 at `Title3`, and does not move monotonically with
76    /// the size. Left unset, gpui leads every line at phi — 21pt on a 13pt body
77    /// against the platform's 16.
78    pub const fn line_height(self) -> f32 {
79        match self {
80            Self::LargeTitle => 32.0,
81            Self::Title => 26.0,
82            Self::Title2 => 22.0,
83            Self::Title3 => 20.0,
84            Self::Headline | Self::Body => 16.0,
85            Self::Callout => 15.0,
86            Self::Subheadline => 14.0,
87            Self::Footnote | Self::Caption | Self::Caption2 => 13.0,
88        }
89    }
90
91    /// The line box this role paints in, which [`set_base_text_size`] moves.
92    pub fn painted_line_height(self) -> f32 {
93        self.line_height() * base_text_size() / Self::Body.size()
94    }
95
96    /// The role's weight. Three roles share 13pt and three share 10pt, so this
97    /// is what separates them.
98    pub const fn weight(self) -> FontWeight {
99        match self {
100            Self::Headline => FontWeight::BOLD,
101            Self::Caption2 => FontWeight::MEDIUM,
102            _ => FontWeight::NORMAL,
103        }
104    }
105}
106
107/// One role as it is actually set: a rung on the ladder, the leading it carries,
108/// and the weight it is set in.
109#[derive(Clone, Copy, Debug, PartialEq)]
110pub struct Metrics {
111    pub role: TextStyle,
112    /// Line height as a multiple of the painted size, so leading follows the
113    /// type wherever [`set_base_text_size`] puts it.
114    pub leading: f32,
115    /// The ladder carries one bold cell, so a set needing several heading
116    /// weights names its own here rather than reading it off the role.
117    pub weight: FontWeight,
118    /// A factor over the painted ladder, for one surface sized apart from the
119    /// rest — a document the reader has zoomed. 1.0 is the ladder itself.
120    pub scale: f32,
121}
122
123impl Metrics {
124    pub const fn new(role: TextStyle, leading: f32, weight: FontWeight) -> Self {
125        Self {
126            role,
127            leading,
128            weight,
129            scale: 1.0,
130        }
131    }
132
133    /// The same metrics at `scale` times the ladder. Replaces rather than
134    /// compounds, so a slider handing over an absolute factor cannot drift.
135    pub const fn scaled(self, scale: f32) -> Self {
136        Self { scale, ..self }
137    }
138
139    pub fn size(self) -> f32 {
140        self.role.painted() * self.scale
141    }
142
143    pub fn line_height(self) -> f32 {
144        self.size() * self.leading
145    }
146}
147
148impl From<TextStyle> for Metrics {
149    /// The ladder's own setting for a role: its measured leading and weight.
150    /// A set that wants prose leading names its own through [`Metrics::new`].
151    fn from(role: TextStyle) -> Self {
152        Self::new(role, role.line_height() / role.size(), role.weight())
153    }
154}
155
156/// The ladder, on anything styled.
157pub trait Typeset: Styled + Sized {
158    /// Size and weight together, from [`TextStyle`].
159    fn text_style(self, style: TextStyle) -> Self {
160        self.text_size(px(style.painted()))
161            .line_height(px(style.painted_line_height()))
162            .font_weight(style.weight())
163    }
164}
165
166impl<E: Styled> Typeset for E {}