gpui_kit_material/components/
snackbar.rs1use super::InteractionPreview;
6use super::focus::{RingPlacement, RingedButton};
7use super::text::accessible_text;
8use crate::color::ColorRole::*;
9use crate::icons::IconName;
10use crate::motion::{self, MotionToken};
11use crate::theme::color;
12use crate::tokens::{elevation, shape::*, state, typography::*};
13use gpui::{prelude::*, *};
14use std::rc::Rc;
15
16const SNACKBAR_ELEVATION: u8 = 3;
18pub const SNACKBAR_CLOSE_ICON: Rems = rem(24.);
20const BUTTON_HEIGHT: f32 = 40.;
21const CLOSE_BOX: f32 = 48.;
22const TRAVEL: f32 = 12.;
24
25type SnackbarHandler = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>;
26
27#[derive(gpui::IntoElement)]
37pub struct Snackbar {
38 id: ElementId,
39 message: SharedString,
40 action: Option<(SharedString, SnackbarHandler)>,
41 close: Option<(SharedString, SnackbarHandler)>,
42 longer_action: bool,
43 open: bool,
44 preview: Option<InteractionPreview>,
45}
46
47impl Snackbar {
48 pub fn new(id: impl Into<ElementId>, message: impl Into<SharedString>) -> Self {
49 Self {
50 id: id.into(),
51 message: message.into(),
52 action: None,
53 close: None,
54 longer_action: false,
55 open: true,
56 preview: None,
57 }
58 }
59
60 pub fn action(
62 mut self,
63 label: impl Into<SharedString>,
64 handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
65 ) -> Self {
66 self.action = Some((label.into(), Rc::new(handler)));
67 self
68 }
69
70 pub fn close(
72 mut self,
73 label: impl Into<SharedString>,
74 handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
75 ) -> Self {
76 self.close = Some((label.into(), Rc::new(handler)));
77 self
78 }
79
80 pub fn longer_action(mut self, longer: bool) -> Self {
82 self.longer_action = longer;
83 self
84 }
85
86 pub fn open(mut self, open: bool) -> Self {
88 self.open = open;
89 self
90 }
91
92 pub fn preview_state(mut self, state: InteractionPreview) -> Self {
95 self.preview = Some(state);
96 self
97 }
98}
99
100fn inverse_button(
103 id: ElementId,
104 fg: Rgba,
105 handler: SnackbarHandler,
106 preview: Option<InteractionPreview>,
107) -> RingedButton {
108 let clear = Rgba { a: 0., ..fg };
109 let (hover, press) = (state::hovered(clear, fg), state::pressed(clear, fg));
110 let focused = state::layer(clear, fg, state::FOCUS);
111 let button = RingedButton::new(id, RingPlacement::Outer(Corners::all(RADIUS_FULL)))
112 .map(move |button| {
113 button
114 .flex()
115 .items_center()
116 .justify_center()
117 .flex_shrink_0()
118 .h(rem(BUTTON_HEIGHT))
119 .rounded_full()
120 .text_color(fg)
121 .when(preview == Some(InteractionPreview::Focused), |b| {
122 b.bg(focused)
123 })
124 })
125 .state_layers(hover, press)
126 .on_click(move |event, window, cx| handler(event, window, cx));
127 match preview {
128 Some(state) => button.preview_state(state),
129 None => button,
130 }
131}
132
133fn hidden(open: bool, shown: f32) -> bool {
135 !open && shown <= 0.
136}
137
138impl RenderOnce for Snackbar {
139 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
140 let id = self.id;
141 let child = |name: &'static str| ElementId::NamedChild(id.clone().into(), name.into());
142 let shown = motion::token_value(
143 child("shown"),
144 if self.open { 1. } else { 0. },
145 MotionToken::DefaultEffects,
146 window,
147 cx,
148 )
149 .clamp(0., 1.);
150 if hidden(self.open, shown) {
151 return div().id(id).hidden().into_any_element();
152 }
153 let preview = self.preview;
154 let label = TypeRole::LabelLarge.scale();
155 let action = self.action.map(|(text, handler)| {
156 inverse_button(child("action"), color(INVERSE_PRIMARY), handler, preview)
157 .when(cfg!(test), |b| {
158 b.debug_selector(|| "snackbar-action".into())
159 })
160 .px(rem(12.))
161 .text_size(label.font_size)
162 .line_height(label.line_height)
163 .font_weight(FontWeight(label.font_weight as f32))
164 .child(text)
165 });
166 let close = self.close.map(|(text, handler)| {
167 div()
168 .flex()
169 .items_center()
170 .justify_center()
171 .flex_shrink_0()
172 .size(rem(CLOSE_BOX))
173 .child(
174 inverse_button(child("close"), color(INVERSE_ON_SURFACE), handler, preview)
175 .when(cfg!(test), |b| b.debug_selector(|| "snackbar-close".into()))
176 .accessibility_label(text)
177 .w(rem(BUTTON_HEIGHT))
178 .child(IconName::Close.icon().size(SNACKBAR_CLOSE_ICON)),
179 )
180 });
181 let has_close = close.is_some();
182 let has_buttons = action.is_some() || has_close;
183 let body = TypeRole::BodyMedium.scale();
184 let message = accessible_text(child("message"), self.message)
185 .role(Role::Status)
186 .flex_1()
187 .min_w_0()
188 .py(rem(14.))
189 .whitespace_normal();
190 let buttons = div()
191 .flex()
192 .flex_shrink_0()
193 .items_center()
194 .children(action)
195 .children(close);
196 let surface = div()
197 .id(id.clone())
198 .flex()
199 .min_h(rem(48.))
200 .max_w(rem(560.))
201 .rounded(RADIUS_EXTRA_SMALL)
202 .bg(color(INVERSE_SURFACE))
203 .text_color(color(INVERSE_ON_SURFACE))
204 .text_size(body.font_size)
205 .line_height(body.line_height)
206 .shadow(elevation::level(SNACKBAR_ELEVATION));
207 let surface = if self.longer_action && has_buttons {
208 surface
209 .flex_col()
210 .gap(rem(10.))
211 .child(div().flex().px(rem(16.)).child(message.pb_0()))
212 .child(
213 buttons
214 .justify_end()
215 .h(rem(48.))
216 .pl(rem(8.))
217 .pr(rem(if has_close { 0. } else { 8. })),
218 )
219 } else {
220 surface
221 .items_center()
222 .gap(rem(4.))
223 .pl(rem(16.))
224 .pr(rem(if has_close {
225 0.
226 } else if has_buttons {
227 8.
228 } else {
229 16.
230 }))
231 .child(message)
232 .child(buttons)
233 };
234 let surface = surface
237 .relative()
238 .top(rem(TRAVEL * (1. - shown)))
239 .opacity(shown);
240 if self.open {
241 motion::state_enter(child("enter"), surface)
242 } else {
243 surface.into_any_element()
244 }
245 }
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251 use core::prelude::v1::test;
252 use gpui::{Context, Modifiers, TestAppContext};
253
254 struct Harness {
255 buttons: bool,
256 longer: bool,
257 open: bool,
258 actions: usize,
259 closes: usize,
260 }
261
262 impl Render for Harness {
263 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
264 window.set_rem_size(px(14.));
265 let snackbar = Snackbar::new("saved", "Icon saved")
266 .longer_action(self.longer)
267 .open(self.open);
268 let snackbar = if self.buttons {
269 snackbar
270 .action("Undo", cx.listener(|this, _, _, _| this.actions += 1))
271 .close("Dismiss", cx.listener(|this, _, _, _| this.closes += 1))
272 } else {
273 snackbar
274 };
275 div().size_full().child(
276 div()
277 .w(px(344.))
278 .debug_selector(|| "bar".into())
279 .child(snackbar),
280 )
281 }
282 }
283
284 fn open(
285 cx: &mut TestAppContext,
286 buttons: bool,
287 ) -> (Entity<Harness>, &mut gpui::VisualTestContext) {
288 cx.update(|cx| cx.set_reduce_motion(true));
289 let (view, cx) = cx.add_window_view(move |_, _| Harness {
290 buttons,
291 longer: false,
292 open: true,
293 actions: 0,
294 closes: 0,
295 });
296 cx.run_until_parked();
297 (view, cx)
298 }
299
300 #[gpui::test]
301 fn action_and_close_report_their_own_clicks(cx: &mut TestAppContext) {
302 let (view, cx) = open(cx, true);
303 let action = cx.debug_bounds("snackbar-action").expect("action").center();
304 let close = cx.debug_bounds("snackbar-close").expect("close").center();
305 cx.simulate_click(action, Modifiers::default());
306 cx.simulate_click(close, Modifiers::default());
307 cx.simulate_click(close, Modifiers::default());
308 view.read_with(cx, |h, _| {
309 assert_eq!(h.actions, 1);
310 assert_eq!(h.closes, 2);
311 });
312 }
313
314 #[gpui::test]
315 fn single_line_is_48dp_and_the_close_target_is_40dp(cx: &mut TestAppContext) {
316 let (_, cx) = open(cx, true);
317 assert_eq!(cx.debug_bounds("bar").unwrap().size.height, px(48.));
318 let close = cx.debug_bounds("snackbar-close").unwrap();
319 assert_eq!(close.size, size(px(40.), px(40.)));
320 assert_eq!(SNACKBAR_CLOSE_ICON, rem(24.));
321 }
322
323 #[gpui::test]
324 fn a_longer_action_moves_below_the_message(cx: &mut TestAppContext) {
325 let (view, cx) = open(cx, true);
326 view.update(cx, |h, cx| {
327 h.longer = true;
328 cx.notify();
329 });
330 redraw(cx);
331 let bar = cx.debug_bounds("bar").unwrap();
332 let action = cx.debug_bounds("snackbar-action").unwrap();
333 assert!(
334 action.top() > bar.top() + px(40.),
335 "the action sits on its own row: {bar:?} {action:?}"
336 );
337 assert_eq!(bar.size.height, px(14. + 20. + 10. + 48.));
338 }
339
340 #[gpui::test]
341 fn closing_hides_once_the_exit_settles(cx: &mut TestAppContext) {
342 let (view, cx) = open(cx, true);
343 view.update(cx, |h, cx| {
344 h.open = false;
345 cx.notify();
346 });
347 redraw(cx);
348 cx.executor()
349 .advance_clock(std::time::Duration::from_secs(1));
350 redraw(cx);
351 assert!(cx.debug_bounds("snackbar-action").is_none());
352 assert!(hidden(false, 0.) && !hidden(true, 0.) && !hidden(false, 0.5));
353 }
354
355 #[test]
356 fn snackbar_uses_m3_elevation_level_3() {
357 assert_eq!(SNACKBAR_ELEVATION, 3);
358 assert_eq!(elevation::level(SNACKBAR_ELEVATION), elevation::level3());
359 }
360
361 #[gpui::test]
362 fn snackbar_buttons_show_the_ring_only_for_keyboard_focus(cx: &mut TestAppContext) {
363 cx.update(gpui_base::init);
364 let (_, cx) = open(cx, true);
365 let action = cx.debug_bounds("snackbar-action").expect("action");
366 cx.simulate_click(action.center(), Modifiers::default());
367 redraw(cx);
368 assert!(
369 crate::components::focus::test_rings::painted("action").is_none(),
370 "pointer focus must not show the ring"
371 );
372 press_key(cx);
373 assert_eq!(
374 crate::components::focus::test_rings::painted("action"),
375 Some(action.dilate(px(5.))),
376 "keyboard focus shows the 3dp ring 2dp outside the button"
377 );
378 }
379
380 fn redraw(cx: &mut gpui::VisualTestContext) {
381 cx.update(|window, cx| {
382 window.refresh();
383 window.draw(cx).clear(cx);
384 });
385 }
386
387 fn press_key(cx: &mut gpui::VisualTestContext) {
389 let keystroke =
390 cx.update(|_, cx| gpui::Keystroke::parse_with_os("a", cx.platform_info().os).unwrap());
391 cx.simulate_event(gpui::KeyDownEvent {
392 keystroke,
393 is_held: false,
394 prefer_character_input: false,
395 });
396 redraw(cx);
397 }
398
399 #[gpui::test]
400 fn a_plain_snackbar_has_no_buttons(cx: &mut TestAppContext) {
401 let (_, cx) = open(cx, false);
402 assert!(cx.debug_bounds("snackbar-action").is_none());
403 assert!(cx.debug_bounds("snackbar-close").is_none());
404 }
405}