1use std::{collections::VecDeque, time::Duration};
8
9use tessera_ui::{
10 Callback, Color, Dp, FocusManager, FocusRestorationTarget, FocusScopeNode, Modifier,
11 RenderSlotWith, State,
12 accesskit::{Live, Role},
13 current_frame_nanos,
14 layout::layout,
15 modifier::FocusModifierExt as _,
16 provide_context, receive_frame_nanos, remember, tessera, use_context,
17};
18
19use crate::{
20 alignment::Alignment,
21 button::button,
22 icon_button::{IconButtonVariant, icon_button},
23 material_icons::filled,
24 modifier::{ModifierExt as _, Padding, SemanticsArgs, with_keyboard_input},
25 res::I18N,
26 shape_def::Shape,
27 surface::surface,
28 text::text,
29 theme::MaterialTheme,
30};
31
32const SHORT_SNACKBAR_DURATION: Duration = Duration::from_millis(4_000);
33const LONG_SNACKBAR_DURATION: Duration = Duration::from_millis(10_000);
34
35#[derive(Clone, Copy, Debug, PartialEq, Eq)]
37pub enum SnackbarResult {
38 Dismissed,
40 ActionPerformed,
42}
43
44#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
46pub enum SnackbarDuration {
47 #[default]
49 Short,
50 Long,
52 Indefinite,
54}
55
56impl SnackbarDuration {
57 fn timeout(self) -> Option<Duration> {
58 match self {
59 SnackbarDuration::Short => Some(SHORT_SNACKBAR_DURATION),
60 SnackbarDuration::Long => Some(LONG_SNACKBAR_DURATION),
61 SnackbarDuration::Indefinite => None,
62 }
63 }
64
65 fn default_for_action(has_action: bool) -> Self {
66 if has_action {
67 SnackbarDuration::Indefinite
68 } else {
69 SnackbarDuration::Short
70 }
71 }
72}
73
74#[derive(Clone, Debug)]
76pub struct SnackbarRequest {
77 pub message: String,
79 pub action_label: Option<String>,
81 pub with_dismiss_action: bool,
83 pub duration: Option<SnackbarDuration>,
85}
86
87impl SnackbarRequest {
88 pub fn new(message: impl Into<String>) -> Self {
90 Self {
91 message: message.into(),
92 action_label: None,
93 with_dismiss_action: false,
94 duration: None,
95 }
96 }
97}
98
99impl From<String> for SnackbarRequest {
100 fn from(message: String) -> Self {
101 Self::new(message)
102 }
103}
104
105impl From<&str> for SnackbarRequest {
106 fn from(message: &str) -> Self {
107 Self::new(message)
108 }
109}
110
111#[derive(Clone, PartialEq, Debug)]
112struct ResolvedSnackbar {
113 message: String,
114 action_label: Option<String>,
115 with_dismiss_action: bool,
116 duration: SnackbarDuration,
117}
118
119impl From<SnackbarRequest> for ResolvedSnackbar {
120 fn from(request: SnackbarRequest) -> Self {
121 let action_label = request.action_label.filter(|label| !label.is_empty());
122 let has_action = action_label.is_some();
123 let duration = request
124 .duration
125 .unwrap_or_else(|| SnackbarDuration::default_for_action(has_action));
126 Self {
127 message: request.message,
128 action_label,
129 with_dismiss_action: request.with_dismiss_action,
130 duration,
131 }
132 }
133}
134
135#[derive(Clone, PartialEq)]
137pub struct SnackbarData {
138 message: String,
139 action_label: Option<String>,
140 with_dismiss_action: bool,
141 duration: SnackbarDuration,
142 host_state: State<SnackbarHostState>,
143 id: u64,
144}
145
146impl SnackbarData {
147 fn new(record: SnackbarRecord, host_state: State<SnackbarHostState>) -> Self {
148 let SnackbarRecord { id, resolved } = record;
149 let ResolvedSnackbar {
150 message,
151 action_label,
152 with_dismiss_action,
153 duration,
154 } = resolved;
155 Self {
156 message,
157 action_label,
158 with_dismiss_action,
159 duration,
160 host_state,
161 id,
162 }
163 }
164
165 pub fn message(&self) -> &str {
167 &self.message
168 }
169
170 pub fn action_label(&self) -> Option<&str> {
172 self.action_label.as_deref()
173 }
174
175 pub fn with_dismiss_action(&self) -> bool {
177 self.with_dismiss_action
178 }
179
180 pub fn duration(&self) -> SnackbarDuration {
182 self.duration
183 }
184
185 pub fn perform_action(&self) {
187 self.host_state.with_mut(|state| {
188 state.resolve_current(self.id, SnackbarResult::ActionPerformed);
189 });
190 }
191
192 pub fn dismiss(&self) {
194 self.host_state.with_mut(|state| {
195 state.resolve_current(self.id, SnackbarResult::Dismissed);
196 });
197 }
198}
199
200#[derive(Clone, PartialEq)]
201struct SnackbarRecord {
202 id: u64,
203 resolved: ResolvedSnackbar,
204}
205
206pub struct SnackbarHostState {
208 queue: VecDeque<SnackbarRecord>,
209 current: Option<SnackbarRecord>,
210 current_started_frame_nanos: Option<u64>,
211 next_id: u64,
212 last_result: Option<SnackbarResult>,
213}
214
215impl SnackbarHostState {
216 pub fn new() -> Self {
218 Self {
219 queue: VecDeque::new(),
220 current: None,
221 current_started_frame_nanos: None,
222 next_id: 1,
223 last_result: None,
224 }
225 }
226
227 pub fn show_snackbar(&mut self, request: impl Into<SnackbarRequest>) -> u64 {
231 let resolved: ResolvedSnackbar = request.into().into();
232 let id = self.next_id;
233 self.next_id = self.next_id.wrapping_add(1);
234 self.queue.push_back(SnackbarRecord { id, resolved });
235 if self.current.is_none() {
236 self.advance_queue();
237 }
238 id
239 }
240
241 pub fn is_showing(&self) -> bool {
243 self.current.is_some()
244 }
245
246 pub fn dismiss_current(&mut self) {
248 if let Some(current) = &self.current {
249 self.resolve_current(current.id, SnackbarResult::Dismissed);
250 }
251 }
252
253 pub fn take_last_result(&mut self) -> Option<SnackbarResult> {
255 self.last_result.take()
256 }
257
258 fn poll(&mut self, frame_nanos: u64) -> Option<SnackbarRecord> {
259 if self.current.is_none() {
260 self.advance_queue();
261 }
262
263 if self.current.is_some() && self.current_started_frame_nanos.is_none() {
264 self.current_started_frame_nanos = Some(frame_nanos);
265 }
266
267 let mut should_dismiss = false;
268 if let Some(current) = &self.current
269 && let Some(timeout) = current.resolved.duration.timeout()
270 && let Some(started_frame_nanos) = self.current_started_frame_nanos
271 {
272 let elapsed_nanos = frame_nanos.saturating_sub(started_frame_nanos);
273 let timeout_nanos = timeout.as_nanos().min(u64::MAX as u128) as u64;
274 if elapsed_nanos >= timeout_nanos {
275 should_dismiss = true;
276 }
277 }
278
279 if should_dismiss && let Some(current) = &self.current {
280 self.resolve_current(current.id, SnackbarResult::Dismissed);
281 }
282
283 self.current.clone()
284 }
285
286 fn has_pending_timeout(&self, frame_nanos: u64) -> bool {
287 let Some(current) = &self.current else {
288 return false;
289 };
290 let Some(timeout) = current.resolved.duration.timeout() else {
291 return false;
292 };
293
294 self.current_started_frame_nanos
295 .map(|started_frame_nanos| {
296 let elapsed_nanos = frame_nanos.saturating_sub(started_frame_nanos);
297 let timeout_nanos = timeout.as_nanos().min(u64::MAX as u128) as u64;
298 elapsed_nanos < timeout_nanos
299 })
300 .unwrap_or(true)
301 }
302
303 fn should_dismiss_current_timeout(&self, frame_nanos: u64) -> bool {
304 let Some(current) = &self.current else {
305 return false;
306 };
307 let Some(timeout) = current.resolved.duration.timeout() else {
308 return false;
309 };
310 let Some(started_frame_nanos) = self.current_started_frame_nanos else {
311 return false;
312 };
313
314 let elapsed_nanos = frame_nanos.saturating_sub(started_frame_nanos);
315 let timeout_nanos = timeout.as_nanos().min(u64::MAX as u128) as u64;
316 elapsed_nanos >= timeout_nanos
317 }
318
319 fn should_poll(&self, frame_nanos: u64) -> bool {
320 if self.current.is_none() {
321 return !self.queue.is_empty();
322 }
323
324 if self.current_started_frame_nanos.is_none() {
325 return true;
326 }
327
328 self.should_dismiss_current_timeout(frame_nanos)
329 }
330
331 fn resolve_current(&mut self, id: u64, result: SnackbarResult) -> bool {
332 if self.current.as_ref().map(|record| record.id) != Some(id) {
333 return false;
334 }
335 self.last_result = Some(result);
336 self.advance_queue();
337 true
338 }
339
340 fn advance_queue(&mut self) {
341 self.current = self.queue.pop_front();
342 self.current_started_frame_nanos = None;
343 }
344}
345
346impl Default for SnackbarHostState {
347 fn default() -> Self {
348 Self::new()
349 }
350}
351
352pub struct SnackbarDefaults;
354
355impl SnackbarDefaults {
356 pub const MAX_WIDTH: Dp = Dp(600.0);
358 pub const MIN_HEIGHT_ONE_LINE: Dp = Dp(48.0);
360 pub const MIN_HEIGHT_TWO_LINE: Dp = Dp(68.0);
362 pub const CONTAINER_ELEVATION: Dp = Dp(6.0);
364 pub const CONTENT_PADDING: Padding = Padding::symmetric(Dp(16.0), Dp(14.0));
366 pub const HOST_PADDING: Padding = Padding::all(Dp(12.0));
368 pub const ACTION_SPACING: Dp = Dp(8.0);
370 pub const ACTION_VERTICAL_SPACING: Dp = Dp(4.0);
372
373 pub fn shape() -> crate::shape_def::Shape {
375 crate::shape_def::Shape::RoundedRectangle {
376 top_left: crate::shape_def::RoundedCorner::manual(Dp(4.0), 3.0),
377 top_right: crate::shape_def::RoundedCorner::manual(Dp(4.0), 3.0),
378 bottom_right: crate::shape_def::RoundedCorner::manual(Dp(4.0), 3.0),
379 bottom_left: crate::shape_def::RoundedCorner::manual(Dp(4.0), 3.0),
380 }
381 }
382
383 pub fn container_color() -> Color {
385 Color::new(0.1, 0.1, 0.1, 1.0)
386 }
387
388 pub fn content_color() -> Color {
390 Color::new(0.95, 0.95, 0.95, 1.0)
391 }
392
393 pub fn action_color() -> Color {
395 Color::new(0.7, 0.8, 1.0, 1.0)
396 }
397
398 pub fn dismiss_action_color() -> Color {
400 Color::new(0.95, 0.95, 0.95, 1.0)
401 }
402
403 pub fn min_height(message: &str, action_on_new_line: bool) -> Dp {
409 let message_height = if message.contains('\n') {
410 Self::MIN_HEIGHT_TWO_LINE
411 } else {
412 Self::MIN_HEIGHT_ONE_LINE
413 };
414 if action_on_new_line {
415 Dp(message_height.0 + 48.0 + Self::ACTION_VERTICAL_SPACING.0)
416 } else {
417 message_height
418 }
419 }
420}
421
422#[tessera]
475pub fn snackbar(
476 modifier: Option<Modifier>,
477 #[prop(into)] message: Option<String>,
478 #[prop(into)] action_label: Option<String>,
479 with_dismiss_action: Option<bool>,
480 action_on_new_line: Option<bool>,
481 shape: Option<Shape>,
482 container_color: Option<Color>,
483 content_color: Option<Color>,
484 action_color: Option<Color>,
485 dismiss_action_color: Option<Color>,
486 #[prop(into)] dismiss_action_label: Option<String>,
487 content_padding: Option<Padding>,
488 on_action: Option<Callback>,
489 on_dismiss: Option<Callback>,
490) {
491 let modifier = modifier.unwrap_or_default();
492 let message = message.unwrap_or_default();
493 let with_dismiss_action = with_dismiss_action.unwrap_or(false);
494 let action_on_new_line = action_on_new_line.unwrap_or(false);
495 let theme = use_context::<MaterialTheme>()
496 .expect("MaterialTheme must be provided")
497 .get();
498 let typography = theme.typography;
499 let scheme = theme.color_scheme;
500 let shape = shape.unwrap_or(theme.shapes.extra_small);
501 let container_color = container_color.unwrap_or(scheme.inverse_surface);
502 let content_color = content_color.unwrap_or(scheme.inverse_on_surface);
503 let action_color = action_color.unwrap_or(scheme.inverse_primary);
504 let dismiss_action_color = dismiss_action_color.unwrap_or(scheme.inverse_on_surface);
505 let dismiss_action_label =
506 dismiss_action_label.unwrap_or_else(|| I18N.text("md3-dismiss").into());
507 let action_label = action_label.filter(|label| !label.is_empty());
508 let has_action = action_label.is_some();
509 let action_on_new_line = action_on_new_line && has_action;
510 let modifier = modifier.size_in(None, Some(SnackbarDefaults::MAX_WIDTH), None, None);
511 let on_action = if has_action {
512 Some(on_action.unwrap_or_default())
513 } else {
514 None
515 };
516 let show_dismiss_action = with_dismiss_action || on_dismiss.is_some();
517 let on_dismiss = if show_dismiss_action {
518 Some(on_dismiss.unwrap_or_default())
519 } else {
520 None
521 };
522 let modifier = if let Some(on_dismiss) = on_dismiss {
523 with_keyboard_input(modifier, move |input: tessera_ui::KeyboardInput<'_>| {
524 input
525 .keyboard_events
526 .retain(|event| !dismiss_key(event.physical_key, event.state, on_dismiss));
527 })
528 } else {
529 modifier
530 };
531
532 surface()
533 .modifier(modifier)
534 .style(container_color.into())
535 .shape(shape)
536 .content_alignment(if action_on_new_line {
537 Alignment::TopStart
538 } else {
539 Alignment::CenterStart
540 })
541 .block_input(true)
542 .content_color(content_color)
543 .elevation(SnackbarDefaults::CONTAINER_ELEVATION)
544 .child(move || {
545 let message = message.clone();
546 let action_label = action_label.clone();
547 let dismiss_action_label = dismiss_action_label.clone();
548 layout()
549 .policy(snackbar_layout::SnackbarLayout {
550 stacked: action_on_new_line,
551 action: has_action,
552 dismiss: show_dismiss_action,
553 padding: content_padding,
554 })
555 .child(move || {
556 provide_context(|| typography.body_medium, {
557 let message = message.clone();
558 move || {
559 text(message.clone())
560 .modifier(Modifier::new().semantics(SemanticsArgs {
561 role: Some(Role::Status),
562 live: Some(Live::Polite),
563 ..Default::default()
564 }))
565 .color(content_color);
566 }
567 });
568 if let Some(label) = action_label.clone() {
569 button()
570 .text()
571 .accessibility_label(label.clone())
572 .on_click_shared(on_action.unwrap_or_default())
573 .content_color(action_color)
574 .ripple_color(action_color)
575 .child(move || {
576 text(label.clone()).color(action_color);
577 });
578 }
579 if let Some(on_dismiss) = on_dismiss {
580 icon_button()
581 .icon(filled::CLOSE_SVG)
582 .accessibility_label(dismiss_action_label.clone())
583 .variant(IconButtonVariant::Standard)
584 .content_color(dismiss_action_color)
585 .on_click_shared(on_dismiss);
586 }
587 });
588 });
589}
590
591fn dismiss_key(
592 key: tessera_ui::winit::keyboard::PhysicalKey,
593 state: tessera_ui::winit::event::ElementState,
594 on_dismiss: Callback,
595) -> bool {
596 if state == tessera_ui::winit::event::ElementState::Pressed
597 && key
598 == tessera_ui::winit::keyboard::PhysicalKey::Code(
599 tessera_ui::winit::keyboard::KeyCode::Escape,
600 )
601 {
602 on_dismiss.call();
603 true
604 } else {
605 false
606 }
607}
608
609#[tessera]
653pub fn snackbar_host(
654 modifier: Option<Modifier>,
655 state: Option<State<SnackbarHostState>>,
656 snackbar: Option<RenderSlotWith<SnackbarData>>,
657) {
658 let modifier = modifier.unwrap_or_default();
659 let state = match state {
660 Some(value) => value,
661 None => remember(SnackbarHostState::default),
662 };
663 let snackbar_slot = snackbar;
664 let frame_nanos = current_frame_nanos();
665 let should_poll = state.with(|host| host.should_poll(frame_nanos));
666 let record = if should_poll {
667 state.with_mut(|host| host.poll(frame_nanos))
668 } else {
669 state.with(|host| host.current.clone())
670 };
671 if state.with(|host| host.has_pending_timeout(frame_nanos)) {
672 receive_frame_nanos(move |frame_nanos| {
673 let should_dismiss =
674 state.with(|host| host.should_dismiss_current_timeout(frame_nanos));
675 if should_dismiss {
676 state.with_mut(|host| {
677 let _ = host.poll(frame_nanos);
678 });
679 }
680 let has_pending_timeout = state.with(|host| host.has_pending_timeout(frame_nanos));
681 if has_pending_timeout {
682 tessera_ui::FrameNanosControl::Continue
683 } else {
684 tessera_ui::FrameNanosControl::Stop
685 }
686 });
687 }
688 let scope = remember(FocusScopeNode::new).get();
689 let showing = remember(|| false);
690 let origin = remember(|| None::<FocusRestorationTarget>);
691 let pending_restore = remember(|| None::<FocusRestorationTarget>);
692 let visible = record.is_some();
693 if visible != showing.get() {
694 if visible {
695 origin.set(FocusManager::current().save_focus());
696 } else {
697 if scope.has_focus() {
698 pending_restore.set(origin.get());
699 FocusManager::current().defer_keyboard_until_layout();
700 }
701 origin.set(None);
702 }
703 showing.set(visible);
704 }
705 let mut modifier = modifier.focus_scope_with(scope);
706 if pending_restore.get().is_some() {
707 modifier = modifier.on_globally_positioned(move |_| {
708 if let Some(target) = pending_restore.with_mut(Option::take) {
709 target.restore();
710 }
711 });
712 }
713 let data = record.map(|record| SnackbarData::new(record, state));
714 layout().modifier(modifier).child(move || {
715 let Some(data) = data.clone() else {
716 return;
717 };
718 if let Some(snackbar_slot) = snackbar_slot {
719 snackbar_slot.render(data.clone());
720 } else {
721 layout()
722 .modifier(Modifier::new().padding(SnackbarDefaults::HOST_PADDING))
723 .child(move || {
724 snackbar_from_data().data(data.clone());
725 });
726 }
727 });
728}
729
730#[tessera]
731fn snackbar_from_data(data: Option<SnackbarData>) {
732 let Some(data) = data else {
733 return;
734 };
735 let current = remember(|| data.clone());
736 if current.get() != data {
737 current.set(data.clone());
738 }
739 let on_action = if data.action_label.is_some() {
740 Some(Callback::new(move || current.get().perform_action()))
741 } else {
742 None
743 };
744 let on_dismiss = if data.with_dismiss_action {
745 Some(Callback::new(move || current.get().dismiss()))
746 } else {
747 None
748 };
749 snackbar()
750 .message(data.message)
751 .with_dismiss_action(data.with_dismiss_action)
752 .action_label_optional(data.action_label)
753 .on_action_optional(on_action)
754 .on_dismiss_optional(on_dismiss);
755}
756
757#[cfg(test)]
758mod accessibility_tests;
759
760#[cfg(test)]
761mod host_tests;
762
763mod snackbar_layout;
764
765#[cfg(test)]
766mod geometry_tests;