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