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.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}