Skip to main content

showcase/
app.rs

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}