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.date_picker.update_and_scroll(action),
278 Message::DateRangePickerChanged(action) => {
279 state.date_range_picker.update_and_scroll(action)
280 }
281 Message::TimePickerChanged(action) => {
282 state.time_picker.update(action);
283 Task::none()
284 }
285 Message::SliderChanged(progress) => {
286 state.progress = progress;
287 Task::none()
288 }
289 Message::EnabledChanged(enabled) => {
290 state.enabled = enabled;
291 Task::none()
292 }
293 Message::ChoiceSelected(choice) => {
294 state.radio_choice = Some(choice);
295 Task::none()
296 }
297 Message::SegmentSelected(choice) => {
298 state.segment_choice = choice;
299 state.segment_state.select(choice.index(), Instant::now());
300 Task::none()
301 }
302 Message::PrimaryTabSelected(choice) => {
303 state.primary_tab = choice;
304 state.primary_tab_state.select(
305 choice.index(),
306 Instant::now(),
307 material::widget::tabs::Variant::Primary,
308 );
309 Task::none()
310 }
311 Message::SecondaryTabSelected(choice) => {
312 state.secondary_tab = choice;
313 state.secondary_tab_state.select(
314 choice.index(),
315 Instant::now(),
316 material::widget::tabs::Variant::Secondary,
317 );
318 Task::none()
319 }
320 Message::MenuPressed => {
321 state.navigation.toggle_menu_now();
322 Task::none()
323 }
324 Message::DialogOpened => {
325 state.alert_dialog.show(Instant::now());
326 Task::none()
327 }
328 Message::DialogDismissed => {
329 state.alert_dialog.dismiss(Instant::now());
330 Task::none()
331 }
332 Message::DialogConfirmed => {
333 state.alert_dialog.dismiss(Instant::now());
334 state.count += 1;
335 Task::none()
336 }
337 Message::ShowSnackbar => {
338 state.snackbar.show(Instant::now());
339 Task::none()
340 }
341 Message::SnackbarUndo => {
342 state.count -= 1;
343 state.snackbar.dismiss(Instant::now());
344 Task::none()
345 }
346 Message::WindowResized(size) => {
347 state.window_size = size;
348 Task::none()
349 }
350 Message::ThemeChanged(action) => {
351 state.theme_controller.update(
352 action,
353 state.window_size,
354 theme_picker::bottom_margin(state.adaptive_navigation_layout()),
355 Instant::now(),
356 );
357 Task::none()
358 }
359 Message::Frame(now) => {
360 let _ = state.theme_controller.advance(now);
361 let _ = state.navigation.advance(now);
362 let _ = state.segment_state.advance(now);
363 let _ = state.primary_tab_state.advance(now);
364 let _ = state.secondary_tab_state.advance(now);
365 state.progress_animation.advance(now);
366 let _ = state.alert_dialog.advance(now);
367 let _ = state.snackbar.advance(now);
368 let _ = state.date_picker.advance(now);
369 let _ = state.date_range_picker.advance(now);
370 let _ = state.time_picker.advance(now);
371 Task::none()
372 }
373 }
374}
375
376fn theme(state: &Showcase) -> Theme {
377 state.theme()
378}
379
380fn subscription(state: &Showcase) -> Subscription<Message> {
381 let mut subscriptions =
382 vec![iced::window::resize_events().map(|(_id, size)| Message::WindowResized(size))];
383
384 if state.theme_controller.is_animating()
385 || state.navigation.is_animating()
386 || state.segment_state.is_animating()
387 || state.primary_tab_state.is_animating()
388 || state.secondary_tab_state.is_animating()
389 || state.alert_dialog.is_animating()
390 || state.snackbar.is_active()
391 || state.date_picker.is_animating()
392 || state.date_range_picker.is_animating()
393 || state.time_picker.is_animating()
394 || (state.navigation.selected() == ShowcasePage::Feedback
395 && state.progress_animation.is_animating())
396 {
397 subscriptions.push(iced::window::frames().map(Message::Frame));
398 }
399
400 Subscription::batch(subscriptions)
401}
402
403fn view(state: &Showcase) -> material::Element<'_, Message> {
404 let now = Instant::now();
405 let page_content = material::widget::snackbar::host(
406 pages::view(state),
407 &state.snackbar,
408 now,
409 "Photo archived",
410 "Undo",
411 Message::SnackbarUndo,
412 );
413
414 let content = navigation::suite(&NAV_DESTINATIONS, &state.navigation)
415 .layout(state.adaptive_navigation_layout())
416 .with_menu("Showcase", Message::MenuPressed)
417 .view(Message::Navigate, page_content);
418 let content = state.theme_controller.controls_over(
419 content,
420 theme_picker::bottom_margin(state.adaptive_navigation_layout()),
421 Message::ThemeChanged,
422 );
423
424 let content = material::widget::dialog::modal_animated(
425 content,
426 &state.alert_dialog,
427 now,
428 alert_dialog(state.alert_dialog.alpha(now)),
429 );
430
431 state.theme_controller.reveal_over(content, now)
432}
433
434fn alert_dialog(alpha: f32) -> material::Element<'static, Message> {
435 let action_options = material::widget::dialog::AlphaOptions::default().alpha(alpha);
436
437 material::widget::dialog::alert_with(
438 "Discard draft?",
439 "Your current changes will be removed from this device.",
440 material::widget::dialog::actions([
441 material::widget::dialog::action_button_with(
442 "Cancel",
443 Message::DialogDismissed,
444 action_options,
445 ),
446 material::widget::dialog::action_button_with(
447 "Discard",
448 Message::DialogConfirmed,
449 action_options,
450 ),
451 ]),
452 material::widget::dialog::AlertOptions::default()
453 .icon("info")
454 .alpha(alpha),
455 )
456 .into()
457}
458
459#[cfg(test)]
460#[allow(unused_must_use)]
461mod tests {
462 use super::*;
463 use iced::Point;
464
465 #[test]
466 fn combobox_input_preserves_typed_query_and_clears_stale_selection() {
467 let mut showcase = Showcase::default();
468
469 update(&mut showcase, Message::ComboboxInputChanged("xxx".into()));
470
471 assert_eq!(showcase.combobox_choice, None);
472 assert_eq!(showcase.combobox_input, "xxx");
473
474 update(&mut showcase, Message::ComboboxSelected("Assist"));
475
476 assert_eq!(showcase.combobox_choice, Some("Assist"));
477 assert_eq!(showcase.combobox_input, "");
478 }
479
480 #[test]
481 fn date_picker_action_updates_showcase_state() {
482 let mut showcase = Showcase::default();
483 let date = material::widget::picker::Date::new(2026, 12, 25).unwrap();
484
485 update(
486 &mut showcase,
487 Message::DatePickerChanged(material::widget::picker::DatePickerAction::SelectDate(
488 date,
489 )),
490 );
491
492 assert_eq!(showcase.date_picker.selected_date(), Some(date));
493 assert_eq!(
494 showcase.date_picker.displayed_month(),
495 material::widget::picker::YearMonth::new(2026, 12).unwrap()
496 );
497 }
498
499 #[test]
500 fn date_range_picker_action_updates_showcase_state() {
501 let mut showcase = Showcase::default();
502 let start = material::widget::picker::Date::new(2026, 8, 1).unwrap();
503 let end = material::widget::picker::Date::new(2026, 8, 5).unwrap();
504
505 update(
506 &mut showcase,
507 Message::DateRangePickerChanged(
508 material::widget::picker::DateRangePickerAction::SelectDate(start),
509 ),
510 );
511 update(
512 &mut showcase,
513 Message::DateRangePickerChanged(
514 material::widget::picker::DateRangePickerAction::SelectDate(end),
515 ),
516 );
517
518 assert_eq!(
519 showcase.date_range_picker.selected_start_date(),
520 Some(start)
521 );
522 assert_eq!(showcase.date_range_picker.selected_end_date(), Some(end));
523 }
524
525 #[test]
526 fn time_picker_action_updates_showcase_state() {
527 let mut showcase = Showcase::default();
528
529 update(
530 &mut showcase,
531 Message::TimePickerChanged(material::widget::picker::TimePickerAction::SelectHour(9)),
532 );
533 update(
534 &mut showcase,
535 Message::TimePickerChanged(material::widget::picker::TimePickerAction::SelectMinute(
536 45,
537 )),
538 );
539
540 assert_eq!(showcase.time_picker.hour(), 21);
541 assert_eq!(showcase.time_picker.minute(), 45);
542 }
543
544 #[test]
545 fn navigation_starts_selection_animation() {
546 let mut showcase = Showcase::default();
547
548 update(&mut showcase, Message::Navigate(ShowcasePage::Controls));
549
550 assert_eq!(showcase.navigation.selected(), ShowcasePage::Controls);
551 assert!(showcase.navigation.is_animating());
552 assert_eq!(
553 showcase
554 .navigation
555 .selection()
556 .progress(ShowcasePage::Controls),
557 0.0
558 );
559 assert_eq!(
560 showcase
561 .navigation
562 .selection()
563 .progress(ShowcasePage::Inputs),
564 1.0
565 );
566 }
567
568 #[test]
569 fn alert_dialog_messages_toggle_modal_state() {
570 let mut showcase = Showcase::default();
571
572 update(&mut showcase, Message::DialogOpened);
573 assert_eq!(
574 showcase.alert_dialog.phase(),
575 material::widget::dialog::TransitionPhase::Showing
576 );
577 assert!(showcase.alert_dialog.is_active());
578
579 update(&mut showcase, Message::DialogDismissed);
580 assert_eq!(
581 showcase.alert_dialog.phase(),
582 material::widget::dialog::TransitionPhase::Dismissing
583 );
584
585 update(&mut showcase, Message::DialogOpened);
586 update(&mut showcase, Message::DialogConfirmed);
587 assert_eq!(
588 showcase.alert_dialog.phase(),
589 material::widget::dialog::TransitionPhase::Dismissing
590 );
591 assert_eq!(showcase.count, 1);
592 }
593
594 #[test]
595 fn snackbar_button_starts_android_transition() {
596 let mut showcase = Showcase::default();
597
598 update(&mut showcase, Message::ShowSnackbar);
599
600 assert_eq!(
601 showcase.snackbar.phase(),
602 material::widget::snackbar::TransitionPhase::Showing
603 );
604 assert!(showcase.snackbar.is_active());
605 }
606
607 #[test]
608 fn snackbar_action_dismisses_with_exit_transition() {
609 let mut showcase = Showcase::default();
610
611 update(&mut showcase, Message::ShowSnackbar);
612 update(&mut showcase, Message::SnackbarUndo);
613
614 assert_eq!(showcase.count, -1);
615 assert_eq!(
616 showcase.snackbar.phase(),
617 material::widget::snackbar::TransitionPhase::Dismissing
618 );
619 }
620
621 #[test]
622 fn theme_picker_uses_navigation_bar_clearance() {
623 assert_eq!(
624 theme_picker::bottom_margin(navigation::AdaptiveLayout::NavigationBar),
625 theme_picker::FLOATING_MARGIN
626 + material::tokens::component::navigation_bar::CONTAINER_HEIGHT
627 );
628 assert_eq!(
629 theme_picker::bottom_margin(navigation::AdaptiveLayout::NavigationRail),
630 theme_picker::FLOATING_MARGIN
631 );
632 }
633
634 #[test]
635 fn selecting_current_theme_does_not_start_animation() {
636 let mut showcase = Showcase::default();
637
638 update(
639 &mut showcase,
640 Message::ThemeChanged(theme_picker::ThemeAction::SetDarkMode {
641 dark_mode: true,
642 origin: Point::new(120.0, 360.0),
643 }),
644 );
645
646 assert!(!showcase.theme_controller.is_animating());
647 assert!(showcase.theme_controller.dark_mode());
648 }
649
650 #[test]
651 fn dark_mode_action_starts_reveal_from_switch_origin() {
652 let mut showcase = Showcase::default();
653 let origin = Point::new(120.0, 640.0);
654
655 update(
656 &mut showcase,
657 Message::ThemeChanged(theme_picker::ThemeAction::SetDarkMode {
658 dark_mode: false,
659 origin,
660 }),
661 );
662
663 let animation = showcase
664 .theme_controller
665 .transition()
666 .expect("dark mode should animate");
667
668 assert!(!showcase.theme_controller.dark_mode());
669 assert_eq!(animation.origin(), origin);
670 }
671
672 #[test]
673 fn theme_picker_selects_color_and_closes() {
674 let mut showcase = Showcase::default();
675
676 update(
677 &mut showcase,
678 Message::ThemeChanged(theme_picker::ThemeAction::TogglePicker),
679 );
680 assert!(showcase.theme_controller.is_picker_open());
681
682 update(
683 &mut showcase,
684 Message::ThemeChanged(theme_picker::ThemeAction::SelectColor(
685 theme_picker::MaterialColor::Blue,
686 )),
687 );
688
689 let expected_origin = theme_picker::swatch_center(
690 showcase.window_size,
691 theme_picker::bottom_margin(showcase.adaptive_navigation_layout()),
692 theme_picker::MaterialColor::Blue,
693 );
694 let animation = showcase
695 .theme_controller
696 .transition()
697 .expect("theme selection should animate");
698
699 assert_eq!(
700 showcase.theme_controller.selected_color(),
701 theme_picker::MaterialColor::Blue
702 );
703 assert!(!showcase.theme_controller.is_picker_open());
704 assert_eq!(animation.origin(), expected_origin);
705 }
706
707 #[test]
708 fn navigation_uses_material_symbol_icon_names() {
709 assert_eq!(material::fonts::all().len(), 5);
710 assert_eq!(
711 NAV_DESTINATIONS.map(|destination| destination.icon),
712 ["input", "tune", "info", "layers", "navigation", "layers"]
713 );
714
715 for destination in NAV_DESTINATIONS {
716 assert!(material::fonts::material_symbol_codepoint(destination.icon).is_some());
717 }
718 }
719
720 #[test]
721 fn resize_updates_adaptive_layout_inputs() {
722 let mut showcase = Showcase::default();
723
724 update(
725 &mut showcase,
726 Message::WindowResized(Size::new(500.0, 900.0)),
727 );
728
729 assert_eq!(
730 showcase.adaptive_navigation_layout(),
731 material::widget::navigation::AdaptiveLayout::NavigationBar
732 );
733
734 update(
735 &mut showcase,
736 Message::WindowResized(Size::new(900.0, 900.0)),
737 );
738
739 assert_eq!(
740 showcase.adaptive_navigation_layout(),
741 material::widget::navigation::AdaptiveLayout::NavigationRail
742 );
743 }
744}