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 {}