gpui_ai/sizing.rs
1//! Semantic size policy: control heights and leading glyph slots.
2//!
3//! Components draw their interactive controls at one of three heights and
4//! seat leading glyphs in one of two slots, so a table's row action, a
5//! sidebar's header control, and a composer's send button read as one
6//! family instead of six coincidental heights. The values here are
7//! defaults, not constants: an application replaces the policy wholesale
8//! before its windows render, and components that legitimately vary
9//! (density, embedded chrome) expose builder overrides that win over both.
10//!
11//! # Example
12//!
13//! ```no_run
14//! # use std::time::Duration;
15//! # fn example(cx: &mut gpui::App) {
16//! gpui_ai::init(cx);
17//! gpui_ai::sizing::SizeTokens::default()
18//! .with_control_lg(gpui::px(36.))
19//! .set(cx);
20//! # }
21//! ```
22
23use gpui::{App, Global, Pixels, px};
24
25/// A status indicator's own box: wide enough for the extra-small spinner
26/// that replaces the dot, and no wider.
27///
28/// Named rather than written inline because the policy's defaults share a
29/// struct literal, and two identical literals inside one cannot be told
30/// apart by the raw-pixel gate's own reckoning.
31const STATUS_INDICATOR_SLOT: Pixels = px(12.);
32
33/// The crate's size policy: three control heights and two glyph slots.
34///
35/// `control` heights size pressable controls — `sm` for compact chips and
36/// icon buttons, `md` for standalone controls, `lg` for controls that sit
37/// beside inputs and table rows and must match their height. `slot` sizes
38/// the fixed leading box a glyph is centered in: `sm` matches the
39/// extra-small text line, `md` the small text line, so a glyph beside
40/// wrappable text stays centered on the first line no matter how far the
41/// text wraps.
42#[derive(Debug, Clone, Copy, PartialEq)]
43pub struct SizeTokens {
44 control_sm: Pixels,
45 control_md: Pixels,
46 control_lg: Pixels,
47 control_padding_sm: Pixels,
48 control_padding_md: Pixels,
49 slot_xs: Pixels,
50 slot_sm: Pixels,
51 slot_md: Pixels,
52}
53
54impl Global for SizeTokens {}
55
56impl Default for SizeTokens {
57 fn default() -> Self {
58 Self::DEFAULT
59 }
60}
61
62impl SizeTokens {
63 /// The crate's default size policy.
64 ///
65 /// Control heights land on the 4px grid the spacing scale already
66 /// walks, with `lg` equal to the upstream input and table-row height so
67 /// adjacent controls rail. Slots equal the text line-heights they seat
68 /// glyphs beside (16px for extra-small text, 20px for small).
69 pub const DEFAULT: Self = Self {
70 control_sm: px(24.),
71 control_md: px(28.),
72 control_lg: px(32.),
73 // A control's label needs room to read as a label rather than as
74 // text wedged into a pill. The upstream button's own eight pixels
75 // are what the 0.4.0 feel review saw as tight; these are the ramp
76 // shadcn and the reference sites settle on, scaled to our tiers.
77 //
78 // Two tiers rather than three: the controls at the large height are
79 // pills and header rails that take their inset from the spacing scale
80 // for stated reasons, so a large padding tier was a knob that read
81 // back whatever was written to it and changed nothing on screen.
82 control_padding_sm: px(12.),
83 control_padding_md: px(14.),
84 slot_xs: STATUS_INDICATOR_SLOT,
85 slot_sm: px(16.),
86 slot_md: px(20.),
87 };
88
89 /// The installed policy, or the default when the application has not
90 /// chosen one (or asks before [`crate::init`] ran).
91 pub fn read(cx: &App) -> &Self {
92 if cx.has_global::<Self>() {
93 cx.global::<Self>()
94 } else {
95 &Self::DEFAULT
96 }
97 }
98
99 /// Installs this policy for every component in the application.
100 pub fn set(self, cx: &mut App) {
101 cx.set_global(self);
102 }
103
104 /// Compact chips, icon buttons, and row actions.
105 pub const fn control_sm(&self) -> Pixels {
106 self.control_sm
107 }
108
109 /// Standalone controls: prompt and selection actions.
110 pub const fn control_md(&self) -> Pixels {
111 self.control_md
112 }
113
114 /// Controls that sit beside inputs and table rows.
115 pub const fn control_lg(&self) -> Pixels {
116 self.control_lg
117 }
118
119 /// Horizontal padding inside a compact control.
120 pub const fn control_padding_sm(&self) -> Pixels {
121 self.control_padding_sm
122 }
123
124 /// Horizontal padding inside a standalone control.
125 pub const fn control_padding_md(&self) -> Pixels {
126 self.control_padding_md
127 }
128
129 /// Replaces [`control_padding_sm`](Self::control_padding_sm).
130 pub const fn with_control_padding_sm(mut self, padding: Pixels) -> Self {
131 self.control_padding_sm = padding;
132 self
133 }
134
135 /// Replaces [`control_padding_md`](Self::control_padding_md).
136 pub const fn with_control_padding_md(mut self, padding: Pixels) -> Self {
137 self.control_padding_md = padding;
138 self
139 }
140
141 /// The status-indicator box: a dot, or the spinner that replaces it.
142 pub const fn slot_xs(&self) -> Pixels {
143 self.slot_xs
144 }
145
146 /// Replaces the [`slot_xs`](Self::slot_xs) box.
147 pub const fn with_slot_xs(mut self, size: Pixels) -> Self {
148 self.slot_xs = size;
149 self
150 }
151
152 /// The leading glyph box beside extra-small text.
153 pub const fn slot_sm(&self) -> Pixels {
154 self.slot_sm
155 }
156
157 /// The leading glyph box beside small text.
158 pub const fn slot_md(&self) -> Pixels {
159 self.slot_md
160 }
161
162 /// Replaces the [`control_sm`](Self::control_sm) height.
163 pub const fn with_control_sm(mut self, height: Pixels) -> Self {
164 self.control_sm = height;
165 self
166 }
167
168 /// Replaces the [`control_md`](Self::control_md) height.
169 pub const fn with_control_md(mut self, height: Pixels) -> Self {
170 self.control_md = height;
171 self
172 }
173
174 /// Replaces the [`control_lg`](Self::control_lg) height.
175 pub const fn with_control_lg(mut self, height: Pixels) -> Self {
176 self.control_lg = height;
177 self
178 }
179
180 /// Replaces the [`slot_sm`](Self::slot_sm) box.
181 pub const fn with_slot_sm(mut self, size: Pixels) -> Self {
182 self.slot_sm = size;
183 self
184 }
185
186 /// Replaces the [`slot_md`](Self::slot_md) box.
187 pub const fn with_slot_md(mut self, size: Pixels) -> Self {
188 self.slot_md = size;
189 self
190 }
191}
192
193/// Installs the default policy unless the application already chose one,
194/// so either order of `init` and customization lands on the application's
195/// values.
196pub(crate) fn install(cx: &mut App) {
197 if !cx.has_global::<SizeTokens>() {
198 SizeTokens::DEFAULT.set(cx);
199 }
200}
201
202/// The sizes, asked for directly.
203///
204/// Every one of these is `SizeTokens::read(cx).<name>()`. Naming them at
205/// module level means a call site says which size it wants rather than how
206/// the policy is stored, and the override path stays the only way to change
207/// what they answer.
208pub(crate) fn control_sm(cx: &App) -> Pixels {
209 SizeTokens::read(cx).control_sm()
210}
211
212pub(crate) fn control_md(cx: &App) -> Pixels {
213 SizeTokens::read(cx).control_md()
214}
215
216pub(crate) fn control_lg(cx: &App) -> Pixels {
217 SizeTokens::read(cx).control_lg()
218}
219
220pub(crate) fn slot_xs(cx: &App) -> Pixels {
221 SizeTokens::read(cx).slot_xs()
222}
223
224pub(crate) fn slot_sm(cx: &App) -> Pixels {
225 SizeTokens::read(cx).slot_sm()
226}
227
228pub(crate) fn slot_md(cx: &App) -> Pixels {
229 SizeTokens::read(cx).slot_md()
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235 use gpui::TestAppContext;
236
237 #[gpui::test]
238 fn install_respects_a_policy_the_application_chose_first(cx: &mut TestAppContext) {
239 cx.update(|cx| {
240 SizeTokens::default().with_control_lg(px(36.)).set(cx);
241 crate::init(cx);
242 assert_eq!(SizeTokens::read(cx).control_lg(), px(36.));
243 });
244 }
245
246 /// Installing really installs. `read` falls back to the default when
247 /// no policy is present, so only the global's presence distinguishes
248 /// an installed policy from an absent one.
249 #[gpui::test]
250 fn install_provides_the_default_policy_when_none_was_chosen(cx: &mut TestAppContext) {
251 cx.update(|cx| {
252 crate::init(cx);
253 assert!(cx.has_global::<SizeTokens>());
254 assert_eq!(*SizeTokens::read(cx), SizeTokens::DEFAULT);
255 });
256 }
257
258 /// The tiers stay ordered, so an override cannot silently invert one.
259 #[test]
260 fn the_default_tiers_are_ordered() {
261 let tokens = SizeTokens::DEFAULT;
262 assert!(tokens.control_sm() < tokens.control_md());
263 assert!(tokens.control_md() < tokens.control_lg());
264 assert!(tokens.slot_xs() < tokens.slot_sm());
265 assert!(tokens.slot_sm() < tokens.slot_md());
266 assert!(tokens.control_padding_sm() < tokens.control_padding_md());
267 }
268}