Skip to main content

gpui_macos/
surface.rs

1//! Rounded content surfaces and non-interactive information components.
2use crate::ActiveTheme;
3use gpui_kit::base::{Progress, ProgressIndicator, ProgressTrack};
4use gpui_kit::rems;
5use gpui_kit::{
6    Action, App, Div, ElementId, FontWeight, IntoElement, Modifiers, ParentElement, RenderOnce,
7    SharedString, Styled, Window, div, relative,
8};
9
10pub fn panel(title: impl Into<SharedString>, cx: &App) -> Div {
11    let t = cx.macos();
12    div()
13        .flex()
14        .flex_col()
15        .min_w_0()
16        .gap(rems(0.875))
17        .p(rems(1.125))
18        .rounded(rems(0.75))
19        .bg(t.normal_fill())
20        .font_family(t.font.clone())
21        .text_size(rems(0.8125))
22        .text_color(t.foreground)
23        .child(
24            div()
25                .text_size(rems(0.875))
26                .font_weight(FontWeight::SEMIBOLD)
27                .child(title.into()),
28        )
29}
30
31pub fn separator(cx: &App) -> Div {
32    div()
33        .w_full()
34        .h(rems(0.0625))
35        .flex_shrink_0()
36        .bg(cx.macos().divider())
37}
38
39/// A vertical rule for toolbars. Override height to fit a different row size.
40pub fn vertical_separator(cx: &App) -> Div {
41    div()
42        .w(rems(0.0625))
43        .h(rems(1.25))
44        .flex_shrink_0()
45        .bg(cx.macos().divider())
46}
47
48pub fn keycap(key: impl Into<SharedString>, cx: &App) -> Div {
49    let t = cx.macos();
50    // macOS draws ⌘⌥⇧ from the system UI face (Menlo lacks them and falls back
51    // to undersized glyphs), a touch larger so the symbols stay legible.
52    let (font, size) = if cfg!(target_os = "macos") {
53        (t.font.clone(), rems(0.75))
54    } else {
55        (t.mono_font.clone(), rems(0.6875))
56    };
57    div()
58        .px(rems(0.25))
59        .py(rems(0.125))
60        .border_1()
61        .rounded(rems(0.25))
62        .border_color(t.divider())
63        .bg(t.inset)
64        .font_family(font)
65        .text_size(size)
66        // Pin to the cap's own font size so an inherited line height can't inflate it.
67        .line_height(relative(1.))
68        .font_weight(FontWeight::NORMAL)
69        .text_color(t.secondary)
70        .child(key.into())
71}
72
73/// A row of keycaps, one per keystroke, e.g. `["⌘Q"]` or `["ctrl+k", "ctrl+s"]`.
74pub fn keycaps(keys: impl IntoIterator<Item = impl Into<SharedString>>, cx: &App) -> Div {
75    div()
76        .flex()
77        .items_center()
78        .gap(rems(0.25))
79        .children(keys.into_iter().map(|key| keycap(key, cx)))
80}
81
82/// The keycaps for whichever binding the keymap resolves for `action`, or
83/// `None` when nothing is bound. The lookup reads the last rendered frame, so
84/// context-scoped bindings show once their context has been painted.
85pub fn keycap_for_action(action: &dyn Action, window: &Window, cx: &App) -> Option<Div> {
86    let binding = window.highest_precedence_binding_for_action(action)?;
87    let keys: Vec<_> = binding
88        .keystrokes()
89        .iter()
90        .map(|stroke| keystroke_label(stroke.modifiers(), stroke.key()))
91        .collect();
92    (!keys.is_empty()).then(|| keycaps(keys, cx))
93}
94
95/// One keystroke as a single keycap label: `⌘Q` on macOS, `ctrl+q` elsewhere.
96pub fn keystroke_label(modifiers: &Modifiers, key: &str) -> SharedString {
97    let separator = if cfg!(target_os = "macos") { "" } else { "+" };
98    let labels = keystroke_labels(modifiers, key);
99    let labels: Vec<&str> = labels.iter().map(AsRef::as_ref).collect();
100    labels.join(separator).into()
101}
102
103/// Split a keystroke into the labels this platform prints on its keys: on
104/// macOS symbols in its order (⌃⌥⇧⌘) with capitalized keys like its menus,
105/// elsewhere lowercase words in macOS's style (ctrl alt shift super).
106pub fn keystroke_labels(modifiers: &Modifiers, key: &str) -> Vec<SharedString> {
107    let mac = cfg!(target_os = "macos");
108    let pick = |symbol: &'static str, word: &'static str| if mac { symbol } else { word };
109    let platform = pick(
110        "⌘",
111        if cfg!(target_os = "windows") {
112            "win"
113        } else {
114            "super"
115        },
116    );
117    let mut labels: Vec<SharedString> = [
118        (modifiers.control, pick("⌃", "ctrl")),
119        (modifiers.alt, pick("⌥", "alt")),
120        (modifiers.shift, pick("⇧", "shift")),
121        (modifiers.platform, platform),
122        (modifiers.function, "fn"),
123    ]
124    .into_iter()
125    .filter(|(held, _)| *held)
126    .map(|(_, label)| label.into())
127    .collect();
128    let label = match key {
129        "" => return labels,
130        "ctrl" | "control" => pick("⌃", "ctrl"),
131        "alt" => pick("⌥", "alt"),
132        "shift" => pick("⇧", "shift"),
133        "cmd" | "super" | "win" | "platform" => platform,
134        "enter" => pick("⏎", "enter"),
135        "escape" => pick("⎋", "esc"),
136        "backspace" => pick("⌫", "backspace"),
137        "delete" => pick("⌦", "delete"),
138        "tab" => pick("⇥", "tab"),
139        "left" => pick("←", "left"),
140        "right" => pick("→", "right"),
141        "up" => pick("↑", "up"),
142        "down" => pick("↓", "down"),
143        "pageup" => pick("⇞", "page up"),
144        "pagedown" => pick("⇟", "page down"),
145        key if mac => {
146            let mut chars = key.chars();
147            let first = chars.next().map(char::to_uppercase);
148            labels.push(
149                first
150                    .into_iter()
151                    .flatten()
152                    .chain(chars)
153                    .collect::<String>()
154                    .into(),
155            );
156            return labels;
157        }
158        key => {
159            labels.push(key.to_lowercase().into());
160            return labels;
161        }
162    };
163    labels.push(label.into());
164    labels
165}
166
167#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
168pub enum Status {
169    #[default]
170    Neutral,
171    Success,
172    Warning,
173    Error,
174}
175impl Status {
176    /// The theme colour that carries this status.
177    pub fn color(self, cx: &App) -> gpui_kit::Hsla {
178        let t = cx.macos();
179        match self {
180            Status::Neutral => t.secondary,
181            Status::Success => t.success,
182            Status::Warning => t.warning,
183            Status::Error => t.danger,
184        }
185    }
186}
187
188/// Inline feedback in a softly tinted surface with semantic theme colors.
189///
190/// Unlike [`crate::alert_dialog`] this stays in the layout, so it suits
191/// persistent conditions the reader can act on without leaving the view.
192pub fn alert(message: impl Into<SharedString>, status: Status, cx: &App) -> Div {
193    let t = cx.macos();
194    let color = status.color(cx);
195    div()
196        .flex()
197        .items_center()
198        .gap(rems(0.625))
199        .p(rems(0.625))
200        .border_1()
201        .rounded(rems(0.625))
202        .border_color(color.opacity(0.2))
203        .bg(color.opacity(0.08))
204        .text_color(color)
205        .font_family(t.font.clone())
206        .child(
207            crate::icon(match status {
208                Status::Success => crate::IconName::Check,
209                Status::Neutral => crate::IconName::Minus,
210                Status::Warning | Status::Error => crate::IconName::TriangleAlert,
211            })
212            .size(rems(0.875))
213            .flex_shrink_0()
214            .text_color(color),
215        )
216        .child(div().flex_1().min_w_0().child(message.into()))
217}
218
219pub fn badge(label: impl Into<SharedString>, status: Status, cx: &App) -> Div {
220    let t = cx.macos();
221    let color = match status {
222        Status::Neutral => t.secondary,
223        Status::Success => t.success,
224        Status::Warning => t.warning,
225        Status::Error => t.danger,
226    };
227    div()
228        .px(rems(0.375))
229        .py(rems(0.125))
230        .rounded_full()
231        .bg(color.opacity(0.12))
232        .text_color(color)
233        .font_family(t.font.clone())
234        .text_size(rems(0.6875))
235        .child(label.into())
236}
237
238pub fn empty_state(
239    title: impl Into<SharedString>,
240    description: impl Into<SharedString>,
241    cx: &App,
242) -> Div {
243    let t = cx.macos();
244    div()
245        .flex()
246        .flex_col()
247        .items_center()
248        .text_center()
249        .gap(rems(0.5))
250        .p(rems(1.125))
251        .font_family(t.font.clone())
252        .text_size(rems(0.8125))
253        .text_color(t.foreground)
254        .child(div().font_weight(FontWeight::SEMIBOLD).child(title.into()))
255        .child(div().text_color(t.secondary).child(description.into()))
256}
257
258/// Determinate progress, clamped to 0..=100; non-finite values become zero.
259pub fn progress(id: impl Into<ElementId>, value: f32, _: &App) -> Progress {
260    let value = normalized_progress(value);
261    Progress::new(id)
262        .value(value)
263        .w_full()
264        .child(NativeProgressPaint(value))
265}
266
267#[derive(IntoElement)]
268struct NativeProgressPaint(f32);
269
270impl RenderOnce for NativeProgressPaint {
271    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
272        let t = cx.macos();
273        let fill = if window.is_window_active() {
274            t.accent
275        } else {
276            // Both captured AppKit appearances resolve the inactive fill to
277            // RGB 170; the track and surrounding surfaces keep their own roles.
278            gpui_kit::rgb(0xaaaaaa).into()
279        };
280        ProgressTrack::new()
281            .relative()
282            .w_full()
283            .h(rems(0.5))
284            .rounded_full()
285            .overflow_hidden()
286            .bg(gpui_kit::rgb(if t.is_dark() { 0x404144 } else { 0xd8d8d8 }))
287            .child(
288                div()
289                    .absolute()
290                    .top(rems(0.03125))
291                    .bottom(rems(0.03125))
292                    .left(rems(0.03125))
293                    .right(rems(0.03125))
294                    .rounded_full()
295                    .bg(gpui_kit::rgb(if t.is_dark() { 0x38393c } else { 0xe8e8e8 })),
296            )
297            .child(
298                ProgressIndicator::new()
299                    .h_full()
300                    .rounded_full()
301                    .w(relative(self.0 / 100.))
302                    .bg(fill),
303            )
304    }
305}
306
307fn normalized_progress(value: f32) -> f32 {
308    if value.is_finite() {
309        value.clamp(0., 100.)
310    } else {
311        0.
312    }
313}
314
315/// Notification surface; compose actions and use gpui-base's ToastManager for
316/// application-owned stacking and timeout policy.
317pub fn toast(id: impl Into<gpui_kit::ElementId>, cx: &App) -> gpui_kit::base::Toast {
318    let t = cx.macos();
319    gpui_kit::base::Toast::new(id)
320        .flex()
321        .flex_col()
322        .gap(rems(0.625))
323        .p(rems(0.875))
324        .w_full()
325        .border_1()
326        .rounded(rems(1.))
327        .border_color(t.glass_border())
328        .bg(t.glass_fill())
329        .shadow(t.popover_shadow())
330        .text_color(t.foreground)
331        .font_family(t.font.clone())
332        .text_size(rems(0.8125))
333}
334
335/// A circular identity marker. Supply initials or an icon in the fallback slot;
336/// callers can replace it with `avatar_image` through the base image builder.
337pub fn avatar(initials: impl Into<SharedString>, cx: &App) -> gpui_kit::base::Avatar {
338    let t = cx.macos();
339    gpui_kit::base::Avatar::new()
340        .size(rems(2.))
341        .flex_shrink_0()
342        .overflow_hidden()
343        .rounded_full()
344        .border_1()
345        .border_color(t.control_border())
346        .bg(t.hover_fill())
347        .font_family(t.font.clone())
348        .text_size(rems(0.75))
349        .text_color(t.foreground)
350        .fallback(
351            gpui_kit::base::AvatarFallback::new()
352                .size_full()
353                .flex()
354                .items_center()
355                .justify_center()
356                .child(initials.into()),
357        )
358}
359
360/// An image slot sized to its avatar. Image loading and failure policy remain
361/// with the application, matching gpui-base's explicit slot API.
362pub fn avatar_image(source: impl Into<gpui_kit::ImageSource>) -> gpui_kit::base::AvatarImage {
363    gpui_kit::base::AvatarImage::new(source).size_full()
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369
370    #[gpui_kit::test]
371    fn progress_paints_native_active_and_inactive_colors_without_changing_value(
372        cx: &mut gpui_kit::TestAppContext,
373    ) {
374        use gpui_kit::{Context, Render, px, size};
375        struct Harness;
376        impl Render for Harness {
377            fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
378                div().w(px(200.)).child(progress("transfer", 50., cx))
379            }
380        }
381        cx.update(crate::init);
382        let (_, cx) = cx.add_window_view(|_, _| Harness);
383        for theme in [crate::Theme::light(), crate::Theme::dark()] {
384            cx.update(|window, cx| {
385                theme.clone().apply(cx);
386                window.activate_window();
387            });
388            cx.run_until_parked();
389            let active_bounds = cx.update(|window, cx| {
390                assert!(window.is_window_active());
391                window.draw(cx).clear(cx);
392                let quads = window.painted_quads();
393                let border: gpui_kit::Hsla =
394                    gpui_kit::rgb(if theme.is_dark() { 0x404144 } else { 0xd8d8d8 }).into();
395                let interior: gpui_kit::Hsla =
396                    gpui_kit::rgb(if theme.is_dark() { 0x38393c } else { 0xe8e8e8 }).into();
397                let track = quads
398                    .iter()
399                    .find(|quad| quad.background.as_solid() == Some(border))
400                    .expect("native progress keeps its subtle outer border");
401                assert_eq!(
402                    track.bounds.size,
403                    size(px(200.), px(8.)).scale(window.scale_factor())
404                );
405                let inner = quads
406                    .iter()
407                    .find(|quad| quad.background.as_solid() == Some(interior))
408                    .expect("native progress paints its lighter empty interior");
409                assert_eq!(
410                    inner.bounds.size,
411                    size(px(199.), px(7.)).scale(window.scale_factor())
412                );
413                let indicator = quads
414                    .iter()
415                    .find(|quad| quad.background.as_solid() == Some(theme.accent))
416                    .expect("active progress paints the native accent");
417                assert_eq!(
418                    indicator.bounds.size,
419                    size(px(100.), px(8.)).scale(window.scale_factor())
420                );
421                indicator.bounds
422            });
423            cx.deactivate_window();
424            cx.update(|window, cx| {
425                assert!(!window.is_window_active());
426                window.draw(cx).clear(cx);
427                let indicator = window
428                    .painted_quads()
429                    .into_iter()
430                    .find(|quad| quad.bounds == active_bounds)
431                    .expect("activation does not change the progress value or track geometry");
432                let fill = indicator.background.as_solid().unwrap();
433                assert_eq!(fill.s, 0., "inactive progress is neutral");
434                assert_eq!(fill.a, 1.);
435                assert!(
436                    fill.l > 0.5 && fill.l < 0.8,
437                    "the native inactive fill remains visible"
438                );
439                assert_ne!(fill, theme.accent);
440            });
441            cx.update(|window, _| window.activate_window());
442            cx.run_until_parked();
443            cx.update(|window, cx| {
444                window.draw(cx).clear(cx);
445                assert!(
446                    window
447                        .painted_quads()
448                        .into_iter()
449                        .any(|quad| quad.bounds == active_bounds
450                            && quad.background.as_solid() == Some(theme.accent)),
451                    "reactivation restores the native accent"
452                );
453            });
454        }
455    }
456    #[test]
457    fn keystroke_labels_follow_platform_case_and_order() {
458        let modifiers = Modifiers {
459            platform: true,
460            shift: true,
461            ..Default::default()
462        };
463        let label = keystroke_label(&modifiers, "Q");
464        if cfg!(target_os = "macos") {
465            assert_eq!(label.as_ref(), "⇧⌘Q");
466            assert_eq!(keystroke_labels(&Modifiers::none(), "f12"), ["F12"]);
467        } else if cfg!(target_os = "windows") {
468            assert_eq!(label.as_ref(), "shift+win+q");
469        } else {
470            assert_eq!(label.as_ref(), "shift+super+q");
471        }
472        assert_eq!(
473            keystroke_labels(&Modifiers::none(), "pagedown"),
474            [if cfg!(target_os = "macos") {
475                "⇟"
476            } else {
477                "page down"
478            }]
479        );
480    }
481    #[test]
482    fn progress_handles_invalid_and_out_of_range_values() {
483        for (input, expected) in [
484            (-1., 0.),
485            (42., 42.),
486            (150., 100.),
487            (f32::NAN, 0.),
488            (f32::INFINITY, 0.),
489        ] {
490            assert_eq!(normalized_progress(input), expected);
491        }
492    }
493}