1#![cfg_attr(windows, windows_subsystem = "windows")]
2
3#[path = "pages/mod.rs"]
4mod pages;
5
6use iced::time::Instant;
7use iced::{Size, Subscription, Task};
8use material::Theme;
9use material::widget::{navigation, theme_picker};
10use material_ui_rs as material;
11
12pub fn main() -> iced::Result {
13 let window_size = Size::new(1080.0, 980.0);
14
15 material::application(Showcase::default, update, view)
16 .title("material-ui-rs showcase")
17 .subscription(subscription)
18 .theme(theme)
19 .window(material::window_with_min_size(
20 window_size,
21 Size::new(420.0, 720.0),
22 ))
23 .run()
24}
25
26#[derive(Debug, Clone)]
27enum Message {
28 Navigate(ShowcasePage),
29 Increment,
30 Decrement,
31 TextChanged(String),
32 EditorAction(material::widget::text_editor::Action),
33 SelectChanged(&'static str),
34 ComboboxSelected(&'static str),
35 ComboboxInputChanged(String),
36 SearchChanged(String),
37 DatePickerChanged(material::widget::picker::DatePickerAction),
38 DateRangePickerChanged(material::widget::picker::DateRangePickerAction),
39 TimePickerChanged(material::widget::picker::TimePickerAction),
40 SliderChanged(f32),
41 EnabledChanged(bool),
42 ThemeChanged(theme_picker::ThemeAction),
43 ChoiceSelected(RadioChoice),
44 SegmentSelected(SegmentChoice),
45 PrimaryTabSelected(TabChoice),
46 SecondaryTabSelected(TabChoice),
47 MenuPressed,
48 DialogOpened,
49 DialogDismissed,
50 DialogConfirmed,
51 ShowSnackbar,
52 SnackbarUndo,
53 WindowResized(Size),
54 Frame(Instant),
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58enum ShowcasePage {
59 Inputs,
60 Controls,
61 Feedback,
62 Surfaces,
63 Navigation,
64 Structure,
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68enum RadioChoice {
69 Standard,
70 Expressive,
71 Dense,
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75enum SegmentChoice {
76 List,
77 Grid,
78 Map,
79}
80
81impl SegmentChoice {
82 const fn index(self) -> usize {
83 match self {
84 Self::List => 0,
85 Self::Grid => 1,
86 Self::Map => 2,
87 }
88 }
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92enum TabChoice {
93 Inputs,
94 Controls,
95 Feedback,
96}
97
98impl TabChoice {
99 const fn index(self) -> usize {
100 match self {
101 Self::Inputs => 0,
102 Self::Controls => 1,
103 Self::Feedback => 2,
104 }
105 }
106}
107
108#[derive(Debug, Clone, Copy)]
109struct InventoryRow {
110 component: &'static str,
111 status: &'static str,
112 count: u32,
113}
114
115const NAV_DESTINATIONS: [navigation::Destination<ShowcasePage>; 6] = [
116 navigation::Destination::new(ShowcasePage::Inputs, "input", "Inputs"),
117 navigation::Destination::new(ShowcasePage::Controls, "tune", "Controls"),
118 navigation::Destination::new(ShowcasePage::Feedback, "info", "Feedback").badge("3"),
119 navigation::Destination::new(ShowcasePage::Surfaces, "layers", "Surfaces").small_badge(),
120 navigation::Destination::new(ShowcasePage::Navigation, "navigation", "Navigation"),
121 navigation::Destination::new(ShowcasePage::Structure, "layers", "Structure"),
122];
123
124const INVENTORY_ROWS: [InventoryRow; 3] = [
125 InventoryRow {
126 component: "Buttons",
127 status: "Enabled",
128 count: 4,
129 },
130 InventoryRow {
131 component: "Selection",
132 status: "Animated",
133 count: 3,
134 },
135 InventoryRow {
136 component: "Inputs",
137 status: "Focused",
138 count: 5,
139 },
140];
141
142#[derive(Debug)]
143struct Showcase {
144 navigation: navigation::NavigationState<ShowcasePage>,
145 window_size: Size,
146 count: i32,
147 note: String,
148 editor_content: material::widget::text_editor::Content,
149 select_choice: Option<&'static str>,
150 combobox_options: material::widget::combobox::State<&'static str>,
151 combobox_choice: Option<&'static str>,
152 combobox_input: String,
153 search_query: String,
154 date_picker: material::widget::picker::DatePickerState,
155 date_range_picker: material::widget::picker::DateRangePickerState,
156 time_picker: material::widget::picker::TimePickerState,
157 progress: f32,
158 enabled: bool,
159 radio_choice: Option<RadioChoice>,
160 segment_choice: SegmentChoice,
161 segment_state: material::widget::segmented_button::State,
162 primary_tab: TabChoice,
163 primary_tab_state: material::widget::tabs::State,
164 secondary_tab: TabChoice,
165 secondary_tab_state: material::widget::tabs::State,
166 progress_animation: material::widget::progress_bar::IndeterminateState,
167 alert_dialog: material::widget::dialog::Transition,
168 snackbar: material::widget::snackbar::Transition,
169 theme_controller: theme_picker::ThemeController,
170}
171
172impl Default for Showcase {
173 fn default() -> Self {
174 Self {
175 navigation: navigation::NavigationState::new(ShowcasePage::Inputs),
176 window_size: Size::new(1080.0, 980.0),
177 count: 0,
178 note: String::new(),
179 editor_content: material::widget::text_editor::Content::with_text(
180 "Material 3 multi-line text editor",
181 ),
182 select_choice: Some("Assist"),
183 combobox_options: material::widget::combobox::State::with_selection(
184 vec!["Assist", "Suggestion", "Filter"],
185 Some(&"Suggestion"),
186 ),
187 combobox_choice: Some("Suggestion"),
188 combobox_input: String::new(),
189 search_query: String::new(),
190 date_picker: material::widget::picker::DatePickerState::new(
191 material::widget::picker::Date::new(2026, 7, 4),
192 ),
193 date_range_picker: material::widget::picker::DateRangePickerState::new(
194 material::widget::picker::Date::new(2026, 7, 4),
195 material::widget::picker::Date::new(2026, 7, 10),
196 ),
197 time_picker: material::widget::picker::TimePickerState::new(14, 30, false),
198 progress: 42.0,
199 enabled: true,
200 radio_choice: Some(RadioChoice::Standard),
201 segment_choice: SegmentChoice::List,
202 segment_state: material::widget::segmented_button::State::new(
203 SegmentChoice::List.index(),
204 ),
205 primary_tab: TabChoice::Inputs,
206 primary_tab_state: material::widget::tabs::State::new(TabChoice::Inputs.index()),
207 secondary_tab: TabChoice::Controls,
208 secondary_tab_state: material::widget::tabs::State::new(TabChoice::Controls.index()),
209 progress_animation: material::widget::progress_bar::IndeterminateState::new(
210 Instant::now(),
211 ),
212 alert_dialog: material::widget::dialog::Transition::default(),
213 snackbar: material::widget::snackbar::Transition::default(),
214 theme_controller: theme_picker::ThemeController::default(),
215 }
216 }
217}
218
219impl Showcase {
220 fn theme(&self) -> Theme {
221 self.theme_controller.theme("Material 3 animated")
222 }
223
224 fn navigation_selection(&self) -> navigation::Selection<ShowcasePage> {
225 self.navigation.selection()
226 }
227
228 fn adaptive_navigation_layout(&self) -> navigation::AdaptiveLayout {
229 navigation::adaptive_layout(self.window_size.width, self.window_size.height)
230 }
231}
232
233fn update(state: &mut Showcase, message: Message) -> Task<Message> {
234 match message {
235 Message::Navigate(page) => {
236 state
237 .navigation
238 .select(page, Instant::now(), state.adaptive_navigation_layout());
239 Task::none()
240 }
241 Message::Increment => {
242 state.count += 1;
243 Task::none()
244 }
245 Message::Decrement => {
246 state.count -= 1;
247 Task::none()
248 }
249 Message::TextChanged(note) => {
250 state.note = note;
251 Task::none()
252 }
253 Message::EditorAction(action) => {
254 state.editor_content.perform(action);
255 Task::none()
256 }
257 Message::SelectChanged(choice) => {
258 state.select_choice = Some(choice);
259 Task::none()
260 }
261 Message::ComboboxSelected(choice) => {
262 state.combobox_choice = Some(choice);
263 state.combobox_input.clear();
264 state.combobox_options.set_selection(Some(&choice));
265 Task::none()
266 }
267 Message::ComboboxInputChanged(input) => {
268 state.combobox_options.set_input(input.clone());
269 state.combobox_input = input;
270 state.combobox_choice = None;
271 Task::none()
272 }
273 Message::SearchChanged(query) => {
274 state.search_query = query;
275 Task::none()
276 }
277 Message::DatePickerChanged(action) => state
278 .date_picker
279 .update_and_scroll_to_displayed_year(action),
280 Message::DateRangePickerChanged(action) => state
281 .date_range_picker
282 .update_and_scroll_to_displayed_year(action),
283 Message::TimePickerChanged(action) => {
284 state.time_picker.update(action);
285 Task::none()
286 }
287 Message::SliderChanged(progress) => {
288 state.progress = progress;
289 Task::none()
290 }
291 Message::EnabledChanged(enabled) => {
292 state.enabled = enabled;
293 Task::none()
294 }
295 Message::ChoiceSelected(choice) => {
296 state.radio_choice = Some(choice);
297 Task::none()
298 }
299 Message::SegmentSelected(choice) => {
300 state.segment_choice = choice;
301 state.segment_state.select(choice.index(), Instant::now());
302 Task::none()
303 }
304 Message::PrimaryTabSelected(choice) => {
305 state.primary_tab = choice;
306 state.primary_tab_state.select(
307 choice.index(),
308 Instant::now(),
309 material::widget::tabs::Variant::Primary,
310 );
311 Task::none()
312 }
313 Message::SecondaryTabSelected(choice) => {
314 state.secondary_tab = choice;
315 state.secondary_tab_state.select(
316 choice.index(),
317 Instant::now(),
318 material::widget::tabs::Variant::Secondary,
319 );
320 Task::none()
321 }
322 Message::MenuPressed => {
323 state.navigation.toggle_menu_now();
324 Task::none()
325 }
326 Message::DialogOpened => {
327 state.alert_dialog.show(Instant::now());
328 Task::none()
329 }
330 Message::DialogDismissed => {
331 state.alert_dialog.dismiss(Instant::now());
332 Task::none()
333 }
334 Message::DialogConfirmed => {
335 state.alert_dialog.dismiss(Instant::now());
336 state.count += 1;
337 Task::none()
338 }
339 Message::ShowSnackbar => {
340 state.snackbar.show(Instant::now());
341 Task::none()
342 }
343 Message::SnackbarUndo => {
344 state.count -= 1;
345 state.snackbar.dismiss(Instant::now());
346 Task::none()
347 }
348 Message::WindowResized(size) => {
349 state.window_size = size;
350 Task::none()
351 }
352 Message::ThemeChanged(action) => {
353 state.theme_controller.update(
354 action,
355 state.window_size,
356 theme_picker::bottom_margin_for_navigation_layout(
357 state.adaptive_navigation_layout(),
358 ),
359 Instant::now(),
360 );
361 Task::none()
362 }
363 Message::Frame(now) => {
364 let _ = state.theme_controller.advance(now);
365 let _ = state.navigation.advance(now);
366 let _ = state.segment_state.advance(now);
367 let _ = state.primary_tab_state.advance(now);
368 let _ = state.secondary_tab_state.advance(now);
369 state.progress_animation.advance(now);
370 let _ = state.alert_dialog.advance(now);
371 let _ = state.snackbar.advance(now);
372 let _ = state.date_picker.advance(now);
373 let _ = state.date_range_picker.advance(now);
374 let _ = state.time_picker.advance(now);
375 Task::none()
376 }
377 }
378}
379
380fn theme(state: &Showcase) -> Theme {
381 state.theme()
382}
383
384fn subscription(state: &Showcase) -> Subscription<Message> {
385 let mut subscriptions =
386 vec![iced::window::resize_events().map(|(_id, size)| Message::WindowResized(size))];
387
388 if state.theme_controller.is_animating()
389 || state.navigation.is_animating()
390 || state.segment_state.is_animating()
391 || state.primary_tab_state.is_animating()
392 || state.secondary_tab_state.is_animating()
393 || state.alert_dialog.is_animating()
394 || state.snackbar.is_active()
395 || state.date_picker.is_animating()
396 || state.date_range_picker.is_animating()
397 || state.time_picker.is_animating()
398 || (state.navigation.selected() == ShowcasePage::Feedback
399 && state.progress_animation.is_animating())
400 {
401 subscriptions.push(iced::window::frames().map(Message::Frame));
402 }
403
404 Subscription::batch(subscriptions)
405}
406
407fn view(state: &Showcase) -> material::Element<'_, Message> {
408 let now = Instant::now();
409 let page_content = material::widget::snackbar::host_single_line_with_action(
410 pages::view(state),
411 &state.snackbar,
412 now,
413 "Photo archived",
414 "Undo",
415 Message::SnackbarUndo,
416 );
417
418 let content = navigation::suite(&NAV_DESTINATIONS, &state.navigation)
419 .layout(state.adaptive_navigation_layout())
420 .with_menu("Showcase", Message::MenuPressed)
421 .view(Message::Navigate, page_content);
422 let content = state.theme_controller.controls_over(
423 content,
424 theme_picker::bottom_margin_for_navigation_layout(state.adaptive_navigation_layout()),
425 Message::ThemeChanged,
426 );
427
428 let content = material::widget::dialog::modal_animated(
429 content,
430 &state.alert_dialog,
431 now,
432 alert_dialog(state.alert_dialog.alpha(now)),
433 );
434
435 state.theme_controller.reveal_over(content, now)
436}
437
438fn alert_dialog(alpha: f32) -> material::Element<'static, Message> {
439 material::widget::dialog::alert_with_icon_alpha(
440 "info",
441 "Discard draft?",
442 "Your current changes will be removed from this device.",
443 material::widget::dialog::actions([
444 material::widget::dialog::action_button_alpha(
445 "Cancel",
446 Message::DialogDismissed,
447 alpha,
448 ),
449 material::widget::dialog::action_button_alpha(
450 "Discard",
451 Message::DialogConfirmed,
452 alpha,
453 ),
454 ]),
455 alpha,
456 )
457 .into()
458}
459
460#[cfg(test)]
461#[allow(unused_must_use)]
462mod tests {
463 use super::*;
464 use iced::Point;
465
466 #[test]
467 fn combobox_input_preserves_typed_query_and_clears_stale_selection() {
468 let mut showcase = Showcase::default();
469
470 update(&mut showcase, Message::ComboboxInputChanged("xxx".into()));
471
472 assert_eq!(showcase.combobox_choice, None);
473 assert_eq!(showcase.combobox_input, "xxx");
474
475 update(&mut showcase, Message::ComboboxSelected("Assist"));
476
477 assert_eq!(showcase.combobox_choice, Some("Assist"));
478 assert_eq!(showcase.combobox_input, "");
479 }
480
481 #[test]
482 fn date_picker_action_updates_showcase_state() {
483 let mut showcase = Showcase::default();
484 let date = material::widget::picker::Date::new(2026, 12, 25).unwrap();
485
486 update(
487 &mut showcase,
488 Message::DatePickerChanged(material::widget::picker::DatePickerAction::SelectDate(
489 date,
490 )),
491 );
492
493 assert_eq!(showcase.date_picker.selected_date(), Some(date));
494 assert_eq!(
495 showcase.date_picker.displayed_month(),
496 material::widget::picker::YearMonth::new(2026, 12).unwrap()
497 );
498 }
499
500 #[test]
501 fn date_range_picker_action_updates_showcase_state() {
502 let mut showcase = Showcase::default();
503 let start = material::widget::picker::Date::new(2026, 8, 1).unwrap();
504 let end = material::widget::picker::Date::new(2026, 8, 5).unwrap();
505
506 update(
507 &mut showcase,
508 Message::DateRangePickerChanged(
509 material::widget::picker::DateRangePickerAction::SelectDate(start),
510 ),
511 );
512 update(
513 &mut showcase,
514 Message::DateRangePickerChanged(
515 material::widget::picker::DateRangePickerAction::SelectDate(end),
516 ),
517 );
518
519 assert_eq!(
520 showcase.date_range_picker.selected_start_date(),
521 Some(start)
522 );
523 assert_eq!(showcase.date_range_picker.selected_end_date(), Some(end));
524 }
525
526 #[test]
527 fn time_picker_action_updates_showcase_state() {
528 let mut showcase = Showcase::default();
529
530 update(
531 &mut showcase,
532 Message::TimePickerChanged(material::widget::picker::TimePickerAction::SelectHour(9)),
533 );
534 update(
535 &mut showcase,
536 Message::TimePickerChanged(material::widget::picker::TimePickerAction::SelectMinute(
537 45,
538 )),
539 );
540
541 assert_eq!(showcase.time_picker.hour(), 21);
542 assert_eq!(showcase.time_picker.minute(), 45);
543 }
544
545 #[test]
546 fn navigation_starts_selection_animation() {
547 let mut showcase = Showcase::default();
548
549 update(&mut showcase, Message::Navigate(ShowcasePage::Controls));
550
551 assert_eq!(showcase.navigation.selected(), ShowcasePage::Controls);
552 assert!(showcase.navigation.is_animating());
553 assert_eq!(
554 showcase
555 .navigation
556 .selection()
557 .progress(ShowcasePage::Controls),
558 0.0
559 );
560 assert_eq!(
561 showcase
562 .navigation
563 .selection()
564 .progress(ShowcasePage::Inputs),
565 1.0
566 );
567 }
568
569 #[test]
570 fn alert_dialog_messages_toggle_modal_state() {
571 let mut showcase = Showcase::default();
572
573 update(&mut showcase, Message::DialogOpened);
574 assert_eq!(
575 showcase.alert_dialog.phase(),
576 material::widget::dialog::TransitionPhase::Showing
577 );
578 assert!(showcase.alert_dialog.is_active());
579
580 update(&mut showcase, Message::DialogDismissed);
581 assert_eq!(
582 showcase.alert_dialog.phase(),
583 material::widget::dialog::TransitionPhase::Dismissing
584 );
585
586 update(&mut showcase, Message::DialogOpened);
587 update(&mut showcase, Message::DialogConfirmed);
588 assert_eq!(
589 showcase.alert_dialog.phase(),
590 material::widget::dialog::TransitionPhase::Dismissing
591 );
592 assert_eq!(showcase.count, 1);
593 }
594
595 #[test]
596 fn snackbar_button_starts_android_transition() {
597 let mut showcase = Showcase::default();
598
599 update(&mut showcase, Message::ShowSnackbar);
600
601 assert_eq!(
602 showcase.snackbar.phase(),
603 material::widget::snackbar::TransitionPhase::Showing
604 );
605 assert!(showcase.snackbar.is_active());
606 }
607
608 #[test]
609 fn snackbar_action_dismisses_with_exit_transition() {
610 let mut showcase = Showcase::default();
611
612 update(&mut showcase, Message::ShowSnackbar);
613 update(&mut showcase, Message::SnackbarUndo);
614
615 assert_eq!(showcase.count, -1);
616 assert_eq!(
617 showcase.snackbar.phase(),
618 material::widget::snackbar::TransitionPhase::Dismissing
619 );
620 }
621
622 #[test]
623 fn theme_picker_uses_navigation_bar_clearance() {
624 assert_eq!(
625 theme_picker::bottom_margin_for_navigation_layout(
626 navigation::AdaptiveLayout::NavigationBar
627 ),
628 theme_picker::FLOATING_MARGIN
629 + material::tokens::component::navigation_bar::CONTAINER_HEIGHT
630 );
631 assert_eq!(
632 theme_picker::bottom_margin_for_navigation_layout(
633 navigation::AdaptiveLayout::NavigationRail
634 ),
635 theme_picker::FLOATING_MARGIN
636 );
637 }
638
639 #[test]
640 fn selecting_current_theme_does_not_start_animation() {
641 let mut showcase = Showcase::default();
642
643 update(
644 &mut showcase,
645 Message::ThemeChanged(theme_picker::ThemeAction::SetDarkMode {
646 dark_mode: true,
647 origin: Point::new(120.0, 360.0),
648 }),
649 );
650
651 assert!(!showcase.theme_controller.is_animating());
652 assert!(showcase.theme_controller.dark_mode());
653 }
654
655 #[test]
656 fn dark_mode_action_starts_reveal_from_switch_origin() {
657 let mut showcase = Showcase::default();
658 let origin = Point::new(120.0, 640.0);
659
660 update(
661 &mut showcase,
662 Message::ThemeChanged(theme_picker::ThemeAction::SetDarkMode {
663 dark_mode: false,
664 origin,
665 }),
666 );
667
668 let animation = showcase
669 .theme_controller
670 .transition()
671 .expect("dark mode should animate");
672
673 assert!(!showcase.theme_controller.dark_mode());
674 assert_eq!(animation.origin(), origin);
675 }
676
677 #[test]
678 fn theme_picker_selects_color_and_closes() {
679 let mut showcase = Showcase::default();
680
681 update(
682 &mut showcase,
683 Message::ThemeChanged(theme_picker::ThemeAction::TogglePicker),
684 );
685 assert!(showcase.theme_controller.is_picker_open());
686
687 update(
688 &mut showcase,
689 Message::ThemeChanged(theme_picker::ThemeAction::SelectColor(
690 theme_picker::MaterialColor::Blue,
691 )),
692 );
693
694 let expected_origin = theme_picker::swatch_center(
695 showcase.window_size,
696 theme_picker::bottom_margin_for_navigation_layout(
697 showcase.adaptive_navigation_layout(),
698 ),
699 theme_picker::MaterialColor::Blue,
700 );
701 let animation = showcase
702 .theme_controller
703 .transition()
704 .expect("theme selection should animate");
705
706 assert_eq!(
707 showcase.theme_controller.selected_color(),
708 theme_picker::MaterialColor::Blue
709 );
710 assert!(!showcase.theme_controller.is_picker_open());
711 assert_eq!(animation.origin(), expected_origin);
712 }
713
714 #[test]
715 fn navigation_uses_material_symbol_icon_names() {
716 assert_eq!(material::fonts::all().len(), 5);
717 assert_eq!(
718 NAV_DESTINATIONS.map(|destination| destination.icon),
719 ["input", "tune", "info", "layers", "navigation", "layers"]
720 );
721
722 for destination in NAV_DESTINATIONS {
723 assert!(material::fonts::material_symbol_codepoint(destination.icon).is_some());
724 }
725 }
726
727 #[test]
728 fn resize_updates_adaptive_layout_inputs() {
729 let mut showcase = Showcase::default();
730
731 update(
732 &mut showcase,
733 Message::WindowResized(Size::new(500.0, 900.0)),
734 );
735
736 assert_eq!(
737 showcase.adaptive_navigation_layout(),
738 material::widget::navigation::AdaptiveLayout::NavigationBar
739 );
740
741 update(
742 &mut showcase,
743 Message::WindowResized(Size::new(900.0, 900.0)),
744 );
745
746 assert_eq!(
747 showcase.adaptive_navigation_layout(),
748 material::widget::navigation::AdaptiveLayout::NavigationRail
749 );
750 }
751}