theme/theme/layout.rs
1//! Layout constants. Numbers drive layout, colors are paint: these live as
2//! plain numbers and never depend on which color is painted.
3
4use std::sync::atomic::{AtomicU32, Ordering};
5
6use gpui::{Styled, px};
7
8use crate::theme::{
9 Theme,
10 typography::{TextStyle, Typeset},
11};
12
13/// A control's size — SwiftUI's `ControlSize`. A closed pair, not a scale: the
14/// two that ship are the row of a form and the chip on an overlay.
15#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16pub enum ControlSize {
17 /// The chip: `Callout` on a `control_radius` corner.
18 Small,
19 /// The form row: `Body` on a `button_radius` corner.
20 Regular,
21}
22
23impl ControlSize {
24 /// The type role the size paints at, which decides the rest.
25 pub const fn text(self) -> TextStyle {
26 match self {
27 ControlSize::Small => TextStyle::Callout,
28 ControlSize::Regular => TextStyle::Body,
29 }
30 }
31
32 /// Height: the measured platform control, which [`Self::pad_y`] is then the
33 /// remainder of.
34 pub const fn height(self) -> f32 {
35 match self {
36 ControlSize::Small => Theme::CONTROL_HEIGHT_SMALL,
37 ControlSize::Regular => Theme::BUTTON_HEIGHT,
38 }
39 }
40
41 pub const fn pad_x(self) -> f32 {
42 match self {
43 ControlSize::Small => 8.0,
44 ControlSize::Regular => 12.0,
45 }
46 }
47
48 /// What the height has left over its role's line box, halved. Derived
49 /// rather than stored: the pair drifted once already, when the line box
50 /// moved off gpui's phi and the two heights stayed where phi had put them.
51 pub const fn pad_y(self) -> f32 {
52 (self.height() - self.text().line_height()) / 2.0
53 }
54
55 pub fn radius(self) -> f32 {
56 match self {
57 ControlSize::Small => Theme::control_radius(),
58 ControlSize::Regular => Theme::button_radius(),
59 }
60 }
61}
62
63/// The size ladder, on anything styled — SwiftUI's `.controlSize(..)`, and
64/// [`Typeset`]'s shape for the metrics that come with a type role.
65///
66/// It carries horizontal padding, so a control whose width is its height
67/// instead (an icon button) is not one of these.
68pub trait Sizing: Styled + Sized {
69 fn control_size(self, size: ControlSize) -> Self {
70 self.min_h(px(size.height()))
71 .px(px(size.pad_x()))
72 .py(px(size.pad_y()))
73 .rounded(px(size.radius()))
74 .text_style(size.text())
75 }
76}
77
78impl<E: Styled> Sizing for E {}
79
80/// The branded base radius behind [`Theme::radius`], as raw `f32` bits.
81static BASE: AtomicU32 = AtomicU32::new(Theme::BASE_RADIUS.to_bits());
82
83/// Point the radius accessors at a base. Called by
84/// [`Theme::install`](crate::theme::Theme::install).
85pub(crate) fn set_base_radius(radius: f32) {
86 BASE.store(radius.to_bits(), Ordering::Relaxed);
87}
88
89impl Theme {
90 // ---- numbers drive layout (px) ----
91 /// The alpha [`Brand::vibrancy_alpha`](crate::Brand::vibrancy_alpha) starts from.
92 /// Matched by eye to a reference Electron app's dark glass: its scrim is
93 /// 0.76 over `hsl(0 0% 3%)`, but sits on the `under-window` vibrancy
94 /// MATERIAL, which pre-darkens the blur; a bare backdrop blur has no such
95 /// layer, so ours runs heavier to land on the same perceived tone.
96 ///
97 /// A coverage, not a switch: whether a window has a material behind it at
98 /// all is [`behind_window_blending`](crate::behind_window_blending).
99 pub const VIBRANCY_ALPHA: f32 = 0.80;
100 /// The radius [`Brand::window_blur`](crate::Brand::window_blur) starts
101 /// from: gpui's own default for a `Blurred` window.
102 pub const WINDOW_BLUR: f32 = 60.0;
103 /// Main-panel header height (the reference `h-11`) — in-card headers (changes pane).
104 pub const HEADER_HEIGHT: f32 = 44.0;
105 /// The unified window titlebar (traffic lights + cluster + tabs). Content
106 /// rides [`Self::TITLEBAR_TOP_PAD`] lower than center so the air above
107 /// matches the perceived gap to the inset card below (border + card body).
108 pub const TITLEBAR_HEIGHT: f32 = 38.0;
109 /// Downward shift of titlebar content within the bar.
110 pub const TITLEBAR_TOP_PAD: f32 = 2.0;
111 /// Leading room the macOS traffic lights need where AppKit puts them —
112 /// zed's `TRAFFIC_LIGHT_PADDING` on the macOS 26 SDK (71.0 before it), and
113 /// the same 78 `../desktop` measured for its Tauri window. An app that
114 /// *moves* the lights with `TitlebarOptions::traffic_light_position` owns
115 /// this number too.
116 pub const TRAFFIC_LIGHT_INSET: f32 = if cfg!(target_os = "macos") { 78.0 } else { 0.0 };
117 /// One caption button — minimize, maximize, close — where the app paints
118 /// them itself. Windows' own metric; the height is the bar's.
119 pub const CAPTION_BUTTON_WIDTH: f32 = 46.0;
120 /// The band a client-decorated window keeps outside its content: the
121 /// resize target on all four edges, and the room its shadow falls in.
122 ///
123 /// Handed to `Window::set_client_inset`, so the compositor's own resize
124 /// area and the one `bezel::ui::window` hit-tests are the same band.
125 pub const CLIENT_INSET: f32 = 10.0;
126 /// The coverage [`Brand::content_opacity`](crate::Brand::content_opacity)
127 /// starts from.
128 pub const CONTENT_OPACITY: f32 = crate::Material::Thick.opacity();
129 /// The chrome between a floating `bezel::ui::window::content` card and the
130 /// window's edge, and between two such cards.
131 pub const CONTENT_INSET: f32 = 8.0;
132 /// Reserved status strip under the content outlet (the reference `h-6`) — the
133 /// WorkingIndicator row; reserving it keeps the composer from shifting.
134 pub const STATUS_STRIP_HEIGHT: f32 = 24.0;
135 /// Height of the gradient that fades the transcript into the panel
136 /// background at its bottom edge. The transcript's last row must pad
137 /// itself past this band so settled content (message text, the
138 /// hover-revealed timestamp) never sits inside the fade when scrolled
139 /// to the bottom.
140 pub const TRANSCRIPT_FADE_BAND: f32 = 24.0;
141 /// Button, text field and select-trigger height. Measured 2026-09-01:
142 /// `NSButton`, `NSTextField` and `NSPopUpButton` all report 24 at
143 /// `.regular` — `Body`'s 16pt line box with 4 above and below.
144 pub const BUTTON_HEIGHT: f32 = 24.0;
145 /// The same controls at `.small`.
146 pub const CONTROL_HEIGHT_SMALL: f32 = 20.0;
147 /// Button, text field and select-trigger radius — the crate's most-used
148 /// corner after the derived ones, and unnamed until the concentric pass
149 /// separated the eight sites that *chose* 8.0 from the ones that only
150 /// arrived at it as `12 − 4`.
151 ///
152 /// Every other corner is a ratio of this one, so
153 /// [`Brand::radius`](crate::Brand::radius) moves the whole set together.
154 pub const BASE_RADIUS: f32 = 8.0;
155
156 /// Message bubble corner radius.
157 pub fn bubble_radius() -> f32 {
158 Self::radius(2.0)
159 }
160 /// Floating-surface corner radius — popovers, menus, the command palette,
161 /// group boxes.
162 ///
163 /// A glass surface paints this on its border **and** hands the same number
164 /// to `bezel::ui::surface`'s backdrop blur. The two must agree: a blur cut
165 /// to a different radius frosts square corners outside a round border, and
166 /// it shows only on glass and only at the corners. So the radius is named
167 /// once and read at both ends, rather than written twice sixty lines apart
168 /// — which is how three independent `12.0`s came to exist here.
169 pub fn surface_radius() -> f32 {
170 Self::radius(1.5)
171 }
172 /// Panel / card corner radius.
173 pub fn panel_radius() -> f32 {
174 Self::radius(1.25)
175 }
176 /// Button, text field and select-trigger radius.
177 pub fn button_radius() -> f32 {
178 Self::radius(1.0)
179 }
180 /// Small control radius (chips, tags, steppers) — a size down from
181 /// [`Self::button_radius`], for things that sit inside a control rather
182 /// than being one.
183 pub fn control_radius() -> f32 {
184 Self::radius(0.75)
185 }
186
187 /// A corner as a multiple of the branded base radius.
188 ///
189 /// Read from a process-wide mirror rather than the theme global for the
190 /// reason [`current_appearance`](crate::paint::current_appearance) is: the
191 /// element builders that round a corner are free functions with no `cx` in
192 /// scope, and a radius is one number for the whole app.
193 fn radius(ratio: f32) -> f32 {
194 f32::from_bits(BASE.load(Ordering::Relaxed)) * ratio
195 }
196
197 /// The concentric child of a surface: a row inset by `inset` inside a
198 /// container of radius `outer` keeps its corners parallel to the
199 /// container's, rather than looking pasted onto it.
200 ///
201 /// This is SwiftUI's `ContainerRelativeShape` rule done as arithmetic. gpui
202 /// has no container shape to inherit at paint time, so the relationship is
203 /// stated where the child is *defined* instead of resolved at runtime —
204 /// which means a container that changes its padding carries its rows with
205 /// it, and the derived value never becomes a constant of its own.
206 pub const fn inset_radius(outer: f32, inset: f32) -> f32 {
207 if outer > inset { outer - inset } else { 0.0 }
208 }
209 /// The gap between siblings. Measured on macOS 26, 2026-08-31:
210 /// `NSStackView().spacing`, visual format's `-`, and
211 /// `constraint(equalToSystemSpacingAfter:multiplier: 1)` all report 8.
212 ///
213 /// Carried by `ui::stack::row` and `ui::stack::column`, so a call site
214 /// that wants the standard gap writes no number at all — SwiftUI's shape,
215 /// where `VStack(spacing:)` takes the system's when given nothing.
216 pub const SPACE: f32 = 8.0;
217 /// The margin from content to its container's edge. Same measurement,
218 /// visual format's `|-`.
219 pub const CONTENT_MARGIN: f32 = 20.0;
220 /// From a column's edge to the glyph of the control at the end of a row,
221 /// measured to the glyph rather than the button. Mirrored at the trailing
222 /// end.
223 pub const EDGE: f32 = 12.0;
224}