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