1use 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
39pub 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 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 .line_height(relative(1.))
68 .font_weight(FontWeight::NORMAL)
69 .text_color(t.secondary)
70 .child(key.into())
71}
72
73pub 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
82pub 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
95pub 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
103pub 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 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
188pub 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
258pub 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 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
315pub 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
335pub 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
360pub 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}