Skip to main content

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}