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(boot, 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#[cfg(any(target_arch = "wasm32", test))]
27const CJK_CORE_FONT_URL: &str = "fonts/NotoSansSC-Core-0a7ff25a.otf";
28#[cfg(any(target_arch = "wasm32", test))]
29const CJK_REGIONAL_FONT_URL: &str = "fonts/NotoSansSC-faa6c9df.otf";
30
31fn boot() -> (Showcase, Task<Message>) {
32    let state = Showcase::default();
33
34    #[cfg(any(target_arch = "wasm32", test))]
35    let load_cjk_core =
36        material::fonts::load_web_font(CJK_CORE_FONT_URL).map(|_| Message::CjkCoreFontFinished);
37    #[cfg(not(any(target_arch = "wasm32", test)))]
38    let load_cjk_core = Task::none();
39
40    (state, load_cjk_core)
41}
42
43#[derive(Debug, Clone)]
44enum Message {
45    #[cfg(any(target_arch = "wasm32", test))]
46    CjkCoreFontFinished,
47    #[cfg(any(target_arch = "wasm32", test))]
48    CjkRegionalFontFinished,
49    Navigate(ShowcasePage),
50    Increment,
51    Decrement,
52    TextChanged(String),
53    EditorAction(material::widget::text_editor::Action),
54    SelectChanged(&'static str),
55    ComboboxSelected(&'static str),
56    ComboboxInputChanged(String),
57    SearchChanged(String),
58    DatePickerChanged(material::widget::picker::DatePickerAction),
59    DateRangePickerChanged(material::widget::picker::DateRangePickerAction),
60    TimePickerChanged(material::widget::picker::TimePickerAction),
61    SliderChanged(f32),
62    EnabledChanged(bool),
63    ThemeChanged(theme_picker::ThemeAction),
64    ChoiceSelected(RadioChoice),
65    SegmentSelected(SegmentChoice),
66    PrimaryTabSelected(TabChoice),
67    SecondaryTabSelected(TabChoice),
68    LogViewer(material::widget::log_viewer::Action<u64>),
69    MenuPressed,
70    DialogOpened,
71    DialogDismissed,
72    DialogConfirmed,
73    DialogAccountChanged(String),
74    DialogPasswordChanged(String),
75    DialogRememberPasswordChanged(bool),
76    ShowSnackbar,
77    SnackbarUndo,
78    WindowResized(Size),
79    Frame(Instant),
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83enum ShowcasePage {
84    Inputs,
85    Controls,
86    Feedback,
87    Surfaces,
88    Navigation,
89    Structure,
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93enum RadioChoice {
94    Standard,
95    Expressive,
96    Dense,
97}
98
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100enum SegmentChoice {
101    List,
102    Grid,
103    Map,
104}
105
106impl SegmentChoice {
107    const fn index(self) -> usize {
108        match self {
109            Self::List => 0,
110            Self::Grid => 1,
111            Self::Map => 2,
112        }
113    }
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117enum TabChoice {
118    Inputs,
119    Controls,
120    Feedback,
121}
122
123impl TabChoice {
124    const fn index(self) -> usize {
125        match self {
126            Self::Inputs => 0,
127            Self::Controls => 1,
128            Self::Feedback => 2,
129        }
130    }
131}
132
133#[derive(Debug, Clone, Copy)]
134struct InventoryRow {
135    component: &'static str,
136    status: &'static str,
137    count: u32,
138}
139
140const NAV_DESTINATIONS: [navigation::Destination<ShowcasePage>; 6] = [
141    navigation::Destination::new(ShowcasePage::Inputs, "input", "Inputs"),
142    navigation::Destination::new(ShowcasePage::Controls, "tune", "Controls"),
143    navigation::Destination::new(ShowcasePage::Feedback, "info", "Feedback").badge("3"),
144    navigation::Destination::new(ShowcasePage::Surfaces, "layers", "Surfaces").small_badge(),
145    navigation::Destination::new(ShowcasePage::Navigation, "navigation", "Navigation"),
146    navigation::Destination::new(ShowcasePage::Structure, "layers", "Structure"),
147];
148
149const INVENTORY_ROWS: [InventoryRow; 3] = [
150    InventoryRow {
151        component: "Buttons",
152        status: "Enabled",
153        count: 4,
154    },
155    InventoryRow {
156        component: "Selection",
157        status: "Animated",
158        count: 3,
159    },
160    InventoryRow {
161        component: "Inputs",
162        status: "Focused",
163        count: 5,
164    },
165];
166
167#[derive(Debug)]
168struct Showcase {
169    navigation: navigation::NavigationState<ShowcasePage>,
170    window_size: Size,
171    count: i32,
172    note: String,
173    editor_content: material::widget::text_editor::Content,
174    select_choice: Option<&'static str>,
175    combobox_options: material::widget::combobox::State<&'static str>,
176    combobox_choice: Option<&'static str>,
177    combobox_input: String,
178    search_query: String,
179    date_picker: material::widget::picker::DatePickerState,
180    date_range_picker: material::widget::picker::DateRangePickerState,
181    time_picker: material::widget::picker::TimePickerState,
182    progress: f32,
183    enabled: bool,
184    radio_choice: Option<RadioChoice>,
185    segment_choice: SegmentChoice,
186    segment_state: material::widget::segmented_button::State,
187    primary_tab: TabChoice,
188    primary_tab_state: material::widget::tabs::State,
189    secondary_tab: TabChoice,
190    secondary_tab_state: material::widget::tabs::State,
191    log_viewer: material::widget::log_viewer::State<u64>,
192    log_entries: Vec<material::widget::log_viewer::LogEntry<u64>>,
193    progress_animation: material::widget::progress_bar::IndeterminateState,
194    login_dialog: material::widget::dialog::Transition,
195    dialog_account: String,
196    dialog_password: String,
197    dialog_remember_password: bool,
198    snackbar: material::widget::snackbar::Transition,
199    theme_controller: theme_picker::ThemeController,
200}
201
202impl Default for Showcase {
203    fn default() -> Self {
204        Self {
205            navigation: navigation::NavigationState::new(ShowcasePage::Inputs),
206            window_size: Size::new(1080.0, 980.0),
207            count: 0,
208            note: String::new(),
209            editor_content: material::widget::text_editor::Content::with_text(
210                "Material 3 multi-line text editor",
211            ),
212            select_choice: Some("Assist"),
213            combobox_options: material::widget::combobox::State::with_selection(
214                vec!["Assist", "Suggestion", "Filter"],
215                Some(&"Suggestion"),
216            ),
217            combobox_choice: Some("Suggestion"),
218            combobox_input: String::new(),
219            search_query: String::new(),
220            date_picker: material::widget::picker::DatePickerState::new(
221                material::widget::picker::Date::new(2026, 7, 4),
222            ),
223            date_range_picker: material::widget::picker::DateRangePickerState::new(
224                material::widget::picker::Date::new(2026, 7, 4),
225                material::widget::picker::Date::new(2026, 7, 10),
226            ),
227            time_picker: material::widget::picker::TimePickerState::new(14, 30, false),
228            progress: 42.0,
229            enabled: true,
230            radio_choice: Some(RadioChoice::Standard),
231            segment_choice: SegmentChoice::List,
232            segment_state: material::widget::segmented_button::State::new(
233                SegmentChoice::List.index(),
234            ),
235            primary_tab: TabChoice::Inputs,
236            primary_tab_state: material::widget::tabs::State::new(TabChoice::Inputs.index()),
237            secondary_tab: TabChoice::Controls,
238            secondary_tab_state: material::widget::tabs::State::new(TabChoice::Controls.index()),
239            log_viewer: material::widget::log_viewer::State::new(),
240            log_entries: sample_log_entries(),
241            progress_animation: material::widget::progress_bar::IndeterminateState::new(
242                Instant::now(),
243            ),
244            login_dialog: material::widget::dialog::Transition::default(),
245            dialog_account: String::new(),
246            dialog_password: String::new(),
247            dialog_remember_password: false,
248            snackbar: material::widget::snackbar::Transition::default(),
249            theme_controller: theme_picker::ThemeController::default(),
250        }
251    }
252}
253
254impl Showcase {
255    fn theme(&self) -> Theme {
256        self.theme_controller.theme("Material 3 animated")
257    }
258
259    fn navigation_selection(&self) -> navigation::Selection<ShowcasePage> {
260        self.navigation.selection()
261    }
262
263    fn adaptive_navigation_layout(&self) -> navigation::AdaptiveLayout {
264        navigation::adaptive_layout(self.window_size.width, self.window_size.height)
265    }
266}
267
268fn update(state: &mut Showcase, message: Message) -> Task<Message> {
269    match message {
270        #[cfg(any(target_arch = "wasm32", test))]
271        Message::CjkCoreFontFinished => load_cjk_regional_font(),
272        #[cfg(any(target_arch = "wasm32", test))]
273        Message::CjkRegionalFontFinished => Task::none(),
274        Message::Navigate(page) => {
275            state
276                .navigation
277                .select(page, Instant::now(), state.adaptive_navigation_layout());
278            Task::none()
279        }
280        Message::Increment => {
281            state.count += 1;
282            Task::none()
283        }
284        Message::Decrement => {
285            state.count -= 1;
286            Task::none()
287        }
288        Message::TextChanged(note) => {
289            state.note = note;
290            Task::none()
291        }
292        Message::EditorAction(action) => {
293            state.editor_content.perform(action);
294            Task::none()
295        }
296        Message::SelectChanged(choice) => {
297            state.select_choice = Some(choice);
298            Task::none()
299        }
300        Message::ComboboxSelected(choice) => {
301            state.combobox_choice = Some(choice);
302            state.combobox_input.clear();
303            state.combobox_options.set_selection(Some(&choice));
304            Task::none()
305        }
306        Message::ComboboxInputChanged(input) => {
307            state.combobox_options.set_input(input.clone());
308            state.combobox_input = input;
309            state.combobox_choice = None;
310            Task::none()
311        }
312        Message::SearchChanged(query) => {
313            state.search_query = query;
314            Task::none()
315        }
316        Message::DatePickerChanged(action) => state.date_picker.update_and_scroll(action),
317        Message::DateRangePickerChanged(action) => {
318            state.date_range_picker.update_and_scroll(action)
319        }
320        Message::TimePickerChanged(action) => {
321            state.time_picker.update(action);
322            Task::none()
323        }
324        Message::SliderChanged(progress) => {
325            state.progress = progress;
326            Task::none()
327        }
328        Message::EnabledChanged(enabled) => {
329            state.enabled = enabled;
330            Task::none()
331        }
332        Message::ChoiceSelected(choice) => {
333            state.radio_choice = Some(choice);
334            Task::none()
335        }
336        Message::SegmentSelected(choice) => {
337            state.segment_choice = choice;
338            state.segment_state.select(choice.index(), Instant::now());
339            Task::none()
340        }
341        Message::PrimaryTabSelected(choice) => {
342            state.primary_tab = choice;
343            state.primary_tab_state.select(
344                choice.index(),
345                Instant::now(),
346                material::widget::tabs::Variant::Primary,
347            );
348            Task::none()
349        }
350        Message::SecondaryTabSelected(choice) => {
351            state.secondary_tab = choice;
352            state.secondary_tab_state.select(
353                choice.index(),
354                Instant::now(),
355                material::widget::tabs::Variant::Secondary,
356            );
357            Task::none()
358        }
359        Message::LogViewer(action) => state.log_viewer.update(action, &state.log_entries),
360        Message::MenuPressed => {
361            state.navigation.toggle_menu_now_for_size(state.window_size);
362            Task::none()
363        }
364        Message::DialogOpened => {
365            state.login_dialog.show(Instant::now());
366            iced::widget::operation::focus(dialog_account_input_id())
367        }
368        Message::DialogDismissed => {
369            state.login_dialog.dismiss(Instant::now());
370            Task::none()
371        }
372        Message::DialogConfirmed => {
373            state.login_dialog.dismiss(Instant::now());
374            state.count += 1;
375            Task::none()
376        }
377        Message::DialogAccountChanged(account) => {
378            state.dialog_account = account;
379            Task::none()
380        }
381        Message::DialogPasswordChanged(password) => {
382            state.dialog_password = password;
383            Task::none()
384        }
385        Message::DialogRememberPasswordChanged(remember_password) => {
386            state.dialog_remember_password = remember_password;
387            Task::none()
388        }
389        Message::ShowSnackbar => {
390            state.snackbar.show(Instant::now());
391            Task::none()
392        }
393        Message::SnackbarUndo => {
394            state.count -= 1;
395            state.snackbar.dismiss(Instant::now());
396            Task::none()
397        }
398        Message::WindowResized(size) => {
399            state.window_size = size;
400            Task::none()
401        }
402        Message::ThemeChanged(action) => {
403            state.theme_controller.update(
404                action,
405                state.window_size,
406                showcase_floating_bottom_margin(state.adaptive_navigation_layout()),
407                Instant::now(),
408            );
409            Task::none()
410        }
411        Message::Frame(now) => {
412            let _ = state.theme_controller.advance(now);
413            let _ = state.navigation.advance(now);
414            let _ = state.segment_state.advance(now);
415            let _ = state.primary_tab_state.advance(now);
416            let _ = state.secondary_tab_state.advance(now);
417            let _ = state.log_viewer.advance(now);
418            state.progress_animation.advance(now);
419            let _ = state.login_dialog.advance(now);
420            let _ = state.snackbar.advance(now);
421            let _ = state.date_picker.advance(now);
422            let _ = state.date_range_picker.advance(now);
423            let _ = state.time_picker.advance(now);
424            Task::none()
425        }
426    }
427}
428
429fn sample_log_entries() -> Vec<material::widget::log_viewer::LogEntry<u64>> {
430    use material::widget::log_viewer::{LogEntry, LogLevel};
431
432    vec![
433        LogEntry::new(
434            1,
435            LogLevel::Info,
436            "[0005] [354884390 0ms] inbound/tun[tun-in]: inbound redirect connection from 172.19.0.1:47892",
437        ),
438        LogEntry::new(
439            2,
440            LogLevel::Info,
441            "[0005] [354884390 0ms] inbound/tun[tun-in]: inbound connection to 81.69.216.240:443",
442        ),
443        LogEntry::new(
444            3,
445            LogLevel::Info,
446            "[0005] [354884390 0ms] router: found user id: 10404",
447        ),
448        LogEntry::new(
449            4,
450            LogLevel::Info,
451            "[0005] [354884390 6ms] outbound/direct[direct]: outbound connection to 81.69.216.240:443",
452        ),
453        LogEntry::new(
454            5,
455            LogLevel::Error,
456            "[0005] [953254993 5.0s] connection: open connection to 172.19.0.2:853 using outbound/direct[direct]: dial tcp 172.19.0.2:853: i/o timeout",
457        ),
458        LogEntry::new(
459            6,
460            LogLevel::Error,
461            "[0005] [2920815984 5.4s] connection: open connection to 172.19.0.2:853 using outbound/direct[direct]: dial tcp 172.19.0.2:853: i/o timeout",
462        ),
463        LogEntry::new(
464            7,
465            LogLevel::Warn,
466            "[0005] router: fallback route selected for user id: 10325",
467        ),
468        LogEntry::new(
469            8,
470            LogLevel::Debug,
471            "[0005] [83404445 0ms] inbound/tun[tun-in]: inbound packet connection from 172.19.0.1:55755",
472        ),
473        LogEntry::new(
474            9,
475            LogLevel::Info,
476            "[0005] [83404445 0ms] inbound/tun[tun-in]: inbound packet connection to 198.18.0.16:443",
477        ),
478        LogEntry::new(
479            10,
480            LogLevel::Trace,
481            "[0005] [83404445 0ms] router: matching route rules",
482        ),
483        LogEntry::new(
484            11,
485            LogLevel::Trace,
486            "[0005] [83404445 0ms] router: rule[3] domain_suffix=.example.com did not match",
487        ),
488        LogEntry::new(
489            12,
490            LogLevel::Debug,
491            "[0005] [83404445 1ms] router: rule[7] ip_cidr=198.18.0.0/15 matched outbound/proxy[edge]",
492        ),
493        LogEntry::new(
494            13,
495            LogLevel::Info,
496            "[0005] [83404445 1ms] outbound/proxy[edge]: dialing 198.18.0.16:443 through 203.0.113.8:8443",
497        ),
498        LogEntry::new(
499            14,
500            LogLevel::Debug,
501            "[0006] dns: query A api.example.com from 172.19.0.1:53044",
502        ),
503        LogEntry::new(
504            15,
505            LogLevel::Trace,
506            "[0006] dns: cache miss for api.example.com IN A",
507        ),
508        LogEntry::new(
509            16,
510            LogLevel::Info,
511            "[0006] dns/doh[remote]: exchange query with https://dns.example/dns-query",
512        ),
513        LogEntry::new(
514            17,
515            LogLevel::Info,
516            "[0006] dns: resolved api.example.com to 198.51.100.42 ttl=300",
517        ),
518        LogEntry::new(
519            18,
520            LogLevel::Debug,
521            "[0005] [83404445 42ms] outbound/proxy[edge]: tunnel established with cipher aes-256-gcm",
522        ),
523        LogEntry::new(
524            19,
525            LogLevel::Info,
526            "[0005] [83404445 43ms] connection: connected to 198.18.0.16:443",
527        ),
528        LogEntry::new(
529            20,
530            LogLevel::Trace,
531            "[0005] [83404445 44ms] connection: uploaded 517 bytes, downloaded 1.8 KiB",
532        ),
533        LogEntry::new(
534            21,
535            LogLevel::Warn,
536            "[0007] inbound/tun[tun-in]: TCP handshake from 172.19.0.1:49102 exceeded 750ms",
537        ),
538        LogEntry::new(
539            22,
540            LogLevel::Info,
541            "[0007] [1653028021 811ms] inbound/tun[tun-in]: inbound connection to 192.0.2.80:80",
542        ),
543        LogEntry::new(
544            23,
545            LogLevel::Debug,
546            "[0007] [1653028021 812ms] router: protocol=http host=updates.example.com method=GET",
547        ),
548        LogEntry::new(
549            24,
550            LogLevel::Info,
551            "[0007] [1653028021 814ms] outbound/direct[direct]: outbound connection to 192.0.2.80:80",
552        ),
553        LogEntry::new(
554            25,
555            LogLevel::Warn,
556            "[0008] inbound/tun[tun-in]: dropped malformed UDP packet from 172.19.0.1:60418",
557        ),
558        LogEntry::new(
559            26,
560            LogLevel::Error,
561            "[0009] outbound/proxy[edge]: authentication failed for 203.0.113.8:8443: invalid server response",
562        ),
563        LogEntry::new(
564            27,
565            LogLevel::Info,
566            "[0009] outbound/proxy[edge]: retrying with secondary endpoint 203.0.113.9:8443",
567        ),
568        LogEntry::new(
569            28,
570            LogLevel::Debug,
571            "[0009] outbound/proxy[edge]: secondary endpoint connected in 68ms",
572        ),
573        LogEntry::new(
574            29,
575            LogLevel::Info,
576            "[0009] connection: traffic recovered after 1 retry",
577        ),
578        LogEntry::new(
579            30,
580            LogLevel::Trace,
581            "[0000] stats: connections=12 upload=4.2 MiB download=38.7 MiB memory=24.6 MiB goroutines=31",
582        ),
583    ]
584}
585
586#[cfg(any(target_arch = "wasm32", test))]
587fn load_cjk_regional_font() -> Task<Message> {
588    material::fonts::load_web_font(CJK_REGIONAL_FONT_URL).map(|_| Message::CjkRegionalFontFinished)
589}
590
591fn theme(state: &Showcase) -> Theme {
592    state.theme()
593}
594
595fn subscription(state: &Showcase) -> Subscription<Message> {
596    let mut subscriptions =
597        vec![iced::window::resize_events().map(|(_id, size)| Message::WindowResized(size))];
598
599    if state.theme_controller.is_animating()
600        || state.navigation.is_animating()
601        || state.segment_state.is_animating()
602        || state.primary_tab_state.is_animating()
603        || state.secondary_tab_state.is_animating()
604        || state.log_viewer.is_animating()
605        || state.login_dialog.is_animating()
606        || state.snackbar.is_active()
607        || state.date_picker.is_animating()
608        || state.date_range_picker.is_animating()
609        || state.time_picker.is_animating()
610        || (state.navigation.selected() == ShowcasePage::Feedback
611            && state.progress_animation.is_animating())
612    {
613        subscriptions.push(iced::window::frames().map(Message::Frame));
614    }
615
616    Subscription::batch(subscriptions)
617}
618
619fn view(state: &Showcase) -> material::Element<'_, Message> {
620    let now = Instant::now();
621    let navigation_layout = state.adaptive_navigation_layout();
622    let page_content = material::widget::snackbar::host_with(
623        pages::view(state),
624        &state.snackbar,
625        now,
626        "Photo archived",
627        "Undo",
628        Message::SnackbarUndo,
629        snackbar_host_options(&state.theme_controller),
630    );
631
632    let navigation_suite = navigation::suite(&NAV_DESTINATIONS, &state.navigation)
633        .layout(navigation_layout)
634        .with_menu("Showcase", Message::MenuPressed)
635        .compact_navigation(showcase_compact_navigation());
636    let content = navigation_suite.view(Message::Navigate, page_content);
637    let content = state.theme_controller.controls_over(
638        content,
639        showcase_floating_bottom_margin(navigation_layout),
640        Message::ThemeChanged,
641    );
642
643    let content = material::widget::dialog::modal_animated(
644        content,
645        &state.login_dialog,
646        now,
647        login_dialog(state, state.login_dialog.alpha(now)),
648    );
649
650    state.theme_controller.reveal_over(content, now)
651}
652
653fn snackbar_host_options(
654    theme_controller: &theme_picker::ThemeController,
655) -> material::widget::snackbar::HostOptions {
656    material::widget::snackbar::HostOptions::default().bottom_margin(
657        theme_picker::FLOATING_MARGIN
658            + theme_controller.floating_clearance()
659            + material::tokens::component::snackbar::BOTTOM_MARGIN,
660    )
661}
662
663fn showcase_floating_bottom_margin(layout: navigation::AdaptiveLayout) -> f32 {
664    theme_picker::bottom_margin_for(layout, showcase_compact_navigation())
665}
666
667fn showcase_compact_navigation() -> navigation::CompactNavigation {
668    if cfg!(target_os = "android") {
669        navigation::CompactNavigation::ModalDrawer
670    } else {
671        navigation::CompactNavigation::NavigationBar
672    }
673}
674
675fn dialog_account_input_id() -> iced::widget::Id {
676    iced::widget::Id::new("showcase-login-account")
677}
678
679fn login_dialog(state: &Showcase, alpha: f32) -> material::Element<'_, Message> {
680    let body = iced::widget::Column::new()
681        .spacing(material::widget::page::STACK_SPACING)
682        .push(
683            material::widget::text_input::outlined("Account", &state.dialog_account)
684                .id(dialog_account_input_id())
685                .on_input(Message::DialogAccountChanged)
686                .alpha(alpha),
687        )
688        .push(
689            material::widget::text_input::outlined("Password", &state.dialog_password)
690                .secure(true)
691                .on_input(Message::DialogPasswordChanged)
692                .on_submit(Message::DialogConfirmed)
693                .alpha(alpha),
694        )
695        .push(material::widget::checkbox::standard_with_alpha(
696            state.dialog_remember_password,
697            "Remember password",
698            Message::DialogRememberPasswordChanged,
699            alpha,
700        ));
701
702    let options = material::widget::dialog::AlphaOptions::default().alpha(alpha);
703
704    material::widget::dialog::content_with(
705        "Sign in",
706        body,
707        material::widget::dialog::actions([
708            material::widget::dialog::action_button_with(
709                "Cancel",
710                Message::DialogDismissed,
711                options,
712            ),
713            material::widget::dialog::action_button_with(
714                "Log in",
715                Message::DialogConfirmed,
716                options,
717            ),
718        ]),
719        options,
720    )
721    .into()
722}
723
724#[cfg(test)]
725#[allow(unused_must_use)]
726mod tests {
727    use super::*;
728    use iced::Point;
729
730    #[test]
731    fn combobox_input_preserves_typed_query_and_clears_stale_selection() {
732        let mut showcase = Showcase::default();
733
734        update(&mut showcase, Message::ComboboxInputChanged("xxx".into()));
735
736        assert_eq!(showcase.combobox_choice, None);
737        assert_eq!(showcase.combobox_input, "xxx");
738
739        update(&mut showcase, Message::ComboboxSelected("Assist"));
740
741        assert_eq!(showcase.combobox_choice, Some("Assist"));
742        assert_eq!(showcase.combobox_input, "");
743    }
744
745    #[test]
746    fn date_picker_action_updates_showcase_state() {
747        let mut showcase = Showcase::default();
748        let date = material::widget::picker::Date::new(2026, 12, 25).unwrap();
749
750        update(
751            &mut showcase,
752            Message::DatePickerChanged(material::widget::picker::DatePickerAction::SelectDate(
753                date,
754            )),
755        );
756
757        assert_eq!(showcase.date_picker.selected_date(), Some(date));
758        assert_eq!(
759            showcase.date_picker.displayed_month(),
760            material::widget::picker::YearMonth::new(2026, 12).unwrap()
761        );
762    }
763
764    #[test]
765    fn date_range_picker_action_updates_showcase_state() {
766        let mut showcase = Showcase::default();
767        let start = material::widget::picker::Date::new(2026, 8, 1).unwrap();
768        let end = material::widget::picker::Date::new(2026, 8, 5).unwrap();
769
770        update(
771            &mut showcase,
772            Message::DateRangePickerChanged(
773                material::widget::picker::DateRangePickerAction::SelectDate(start),
774            ),
775        );
776        update(
777            &mut showcase,
778            Message::DateRangePickerChanged(
779                material::widget::picker::DateRangePickerAction::SelectDate(end),
780            ),
781        );
782
783        assert_eq!(
784            showcase.date_range_picker.selected_start_date(),
785            Some(start)
786        );
787        assert_eq!(showcase.date_range_picker.selected_end_date(), Some(end));
788    }
789
790    #[test]
791    fn time_picker_action_updates_showcase_state() {
792        let mut showcase = Showcase::default();
793
794        update(
795            &mut showcase,
796            Message::TimePickerChanged(material::widget::picker::TimePickerAction::SelectHour(9)),
797        );
798        update(
799            &mut showcase,
800            Message::TimePickerChanged(material::widget::picker::TimePickerAction::SelectMinute(
801                45,
802            )),
803        );
804
805        assert_eq!(showcase.time_picker.hour(), 21);
806        assert_eq!(showcase.time_picker.minute(), 45);
807    }
808
809    #[test]
810    fn navigation_starts_selection_animation() {
811        let mut showcase = Showcase::default();
812
813        update(&mut showcase, Message::Navigate(ShowcasePage::Controls));
814
815        assert_eq!(showcase.navigation.selected(), ShowcasePage::Controls);
816        assert!(showcase.navigation.is_animating());
817        assert_eq!(
818            showcase
819                .navigation
820                .selection()
821                .progress(ShowcasePage::Controls),
822            0.0
823        );
824        assert_eq!(
825            showcase
826                .navigation
827                .selection()
828                .progress(ShowcasePage::Inputs),
829            1.0
830        );
831    }
832
833    #[test]
834    fn login_dialog_messages_update_form_and_toggle_modal_state() {
835        let mut showcase = Showcase::default();
836
837        update(&mut showcase, Message::DialogOpened);
838        assert_eq!(
839            showcase.login_dialog.phase(),
840            material::widget::dialog::TransitionPhase::Showing
841        );
842        assert!(showcase.login_dialog.is_active());
843
844        update(
845            &mut showcase,
846            Message::DialogAccountChanged("material-user".into()),
847        );
848        update(
849            &mut showcase,
850            Message::DialogPasswordChanged("secret".into()),
851        );
852        update(&mut showcase, Message::DialogRememberPasswordChanged(true));
853        assert_eq!(showcase.dialog_account, "material-user");
854        assert_eq!(showcase.dialog_password, "secret");
855        assert!(showcase.dialog_remember_password);
856
857        update(&mut showcase, Message::DialogDismissed);
858        assert_eq!(
859            showcase.login_dialog.phase(),
860            material::widget::dialog::TransitionPhase::Dismissing
861        );
862
863        update(&mut showcase, Message::DialogOpened);
864        update(&mut showcase, Message::DialogConfirmed);
865        assert_eq!(
866            showcase.login_dialog.phase(),
867            material::widget::dialog::TransitionPhase::Dismissing
868        );
869        assert_eq!(showcase.count, 1);
870    }
871
872    #[test]
873    fn snackbar_button_starts_android_transition() {
874        let mut showcase = Showcase::default();
875
876        update(&mut showcase, Message::ShowSnackbar);
877
878        assert_eq!(
879            showcase.snackbar.phase(),
880            material::widget::snackbar::TransitionPhase::Showing
881        );
882        assert!(showcase.snackbar.is_active());
883    }
884
885    #[test]
886    fn snackbar_action_dismisses_with_exit_transition() {
887        let mut showcase = Showcase::default();
888
889        update(&mut showcase, Message::ShowSnackbar);
890        update(&mut showcase, Message::SnackbarUndo);
891
892        assert_eq!(showcase.count, -1);
893        assert_eq!(
894            showcase.snackbar.phase(),
895            material::widget::snackbar::TransitionPhase::Dismissing
896        );
897    }
898
899    #[test]
900    fn theme_picker_uses_navigation_bar_clearance() {
901        assert_eq!(
902            theme_picker::bottom_margin(navigation::AdaptiveLayout::NavigationBar),
903            theme_picker::FLOATING_MARGIN
904                + material::tokens::component::navigation_bar::CONTAINER_HEIGHT
905        );
906        assert_eq!(
907            theme_picker::bottom_margin(navigation::AdaptiveLayout::NavigationRail),
908            theme_picker::FLOATING_MARGIN
909        );
910        assert_eq!(
911            theme_picker::bottom_margin_for(
912                navigation::AdaptiveLayout::NavigationBar,
913                navigation::CompactNavigation::ModalDrawer,
914            ),
915            theme_picker::FLOATING_MARGIN
916        );
917        assert_eq!(
918            snackbar_host_options(&theme_picker::ThemeController::default()).bottom_margin,
919            theme_picker::FLOATING_MARGIN
920                + material::tokens::component::fab::CONTAINER_HEIGHT
921                + material::tokens::component::snackbar::BOTTOM_MARGIN
922        );
923    }
924
925    #[test]
926    fn snackbar_stays_above_closed_and_open_theme_controls() {
927        let mut controller = theme_picker::ThemeController::default();
928        let assert_clearance = |controller: &theme_picker::ThemeController| {
929            let floating_top_from_content_bottom =
930                theme_picker::FLOATING_MARGIN + controller.floating_clearance();
931
932            assert_eq!(
933                snackbar_host_options(controller).bottom_margin - floating_top_from_content_bottom,
934                material::tokens::component::snackbar::BOTTOM_MARGIN
935            );
936        };
937
938        assert_clearance(&controller);
939
940        let start = Instant::now();
941        controller.update(
942            theme_picker::ThemeAction::TogglePicker,
943            Size::new(360.0, 800.0),
944            theme_picker::FLOATING_MARGIN,
945            start,
946        );
947        let _ = controller.advance(start + iced::time::Duration::from_secs(1));
948
949        assert_clearance(&controller);
950    }
951
952    #[test]
953    fn scrollable_pages_reserve_the_floating_controls_safe_area() {
954        let mut showcase = Showcase::default();
955
956        let closed_inset = pages::floating_content_inset(&showcase);
957        assert_eq!(
958            closed_inset,
959            theme_picker::FLOATING_MARGIN
960                + material::tokens::component::fab::CONTAINER_HEIGHT
961                + material::tokens::component::snackbar::BOTTOM_MARGIN
962                - material::widget::page::PADDING
963        );
964
965        let start = Instant::now();
966        showcase.theme_controller.update(
967            theme_picker::ThemeAction::TogglePicker,
968            showcase.window_size,
969            showcase_floating_bottom_margin(showcase.adaptive_navigation_layout()),
970            start,
971        );
972        let _ = showcase
973            .theme_controller
974            .advance(start + iced::time::Duration::from_secs(1));
975
976        assert!(pages::floating_content_inset(&showcase) > closed_inset);
977    }
978
979    #[test]
980    fn selecting_current_theme_does_not_start_animation() {
981        let mut showcase = Showcase::default();
982
983        update(
984            &mut showcase,
985            Message::ThemeChanged(theme_picker::ThemeAction::SetDarkMode {
986                dark_mode: true,
987                origin: Point::new(120.0, 360.0),
988            }),
989        );
990
991        assert!(!showcase.theme_controller.is_animating());
992        assert!(showcase.theme_controller.dark_mode());
993    }
994
995    #[test]
996    fn dark_mode_action_starts_reveal_from_switch_origin() {
997        let mut showcase = Showcase::default();
998        let origin = Point::new(120.0, 640.0);
999
1000        update(
1001            &mut showcase,
1002            Message::ThemeChanged(theme_picker::ThemeAction::SetDarkMode {
1003                dark_mode: false,
1004                origin,
1005            }),
1006        );
1007
1008        let animation = showcase
1009            .theme_controller
1010            .transition()
1011            .expect("dark mode should animate");
1012
1013        assert!(!showcase.theme_controller.dark_mode());
1014        assert_eq!(animation.origin(), origin);
1015    }
1016
1017    #[test]
1018    fn theme_picker_selects_color_and_closes() {
1019        let mut showcase = Showcase::default();
1020
1021        update(
1022            &mut showcase,
1023            Message::ThemeChanged(theme_picker::ThemeAction::TogglePicker),
1024        );
1025        assert!(showcase.theme_controller.is_picker_open());
1026
1027        update(
1028            &mut showcase,
1029            Message::ThemeChanged(theme_picker::ThemeAction::SelectColor(
1030                theme_picker::MaterialColor::Blue,
1031            )),
1032        );
1033
1034        let expected_origin = theme_picker::swatch_center(
1035            showcase.window_size,
1036            showcase_floating_bottom_margin(showcase.adaptive_navigation_layout()),
1037            theme_picker::MaterialColor::Blue,
1038        );
1039        let animation = showcase
1040            .theme_controller
1041            .transition()
1042            .expect("theme selection should animate");
1043
1044        assert_eq!(
1045            showcase.theme_controller.selected_color(),
1046            theme_picker::MaterialColor::Blue
1047        );
1048        assert!(!showcase.theme_controller.is_picker_open());
1049        assert_eq!(animation.origin(), expected_origin);
1050    }
1051
1052    #[test]
1053    fn navigation_uses_material_symbol_icon_names() {
1054        assert_eq!(material::fonts::all().len(), 5);
1055        assert_eq!(
1056            NAV_DESTINATIONS.map(|destination| destination.icon),
1057            ["input", "tune", "info", "layers", "navigation", "layers"]
1058        );
1059
1060        for destination in NAV_DESTINATIONS {
1061            assert!(material::fonts::material_symbol_codepoint(destination.icon).is_some());
1062        }
1063    }
1064
1065    #[test]
1066    fn cjk_fonts_load_serially_from_boot_without_input_trigger() {
1067        let (mut showcase, core_load) = boot();
1068        assert!(core_load.units() > 0);
1069
1070        let input_update = update(&mut showcase, Message::TextChanged("中文".into()));
1071        assert_eq!(input_update.units(), 0);
1072        assert_eq!(showcase.note, "中文");
1073
1074        let regional_load = update(&mut showcase, Message::CjkCoreFontFinished);
1075        assert!(regional_load.units() > 0);
1076        assert_eq!(showcase.note, "中文");
1077
1078        let finished = update(&mut showcase, Message::CjkRegionalFontFinished);
1079        assert_eq!(finished.units(), 0);
1080        assert_eq!(showcase.note, "中文");
1081    }
1082
1083    #[test]
1084    fn every_free_text_surface_preserves_cjk_input_during_font_loading() {
1085        let mut note = Showcase::default();
1086        let note_update = update(&mut note, Message::TextChanged("中文".into()));
1087        assert_eq!(note_update.units(), 0);
1088        assert_eq!(note.note, "中文");
1089
1090        let mut editor = Showcase::default();
1091        let editor_update = update(
1092            &mut editor,
1093            Message::EditorAction(material::widget::text_editor::Action::Edit(
1094                iced::widget::text_editor::Edit::Insert('中'),
1095            )),
1096        );
1097        assert_eq!(editor_update.units(), 0);
1098        assert!(editor.editor_content.text().contains('中'));
1099
1100        let mut combobox = Showcase::default();
1101        let combobox_update = update(&mut combobox, Message::ComboboxInputChanged("中文".into()));
1102        assert_eq!(combobox_update.units(), 0);
1103        assert_eq!(combobox.combobox_input, "中文");
1104
1105        let mut search = Showcase::default();
1106        let search_update = update(&mut search, Message::SearchChanged("中文".into()));
1107        assert_eq!(search_update.units(), 0);
1108        assert_eq!(search.search_query, "中文");
1109    }
1110
1111    #[test]
1112    fn resize_updates_adaptive_layout_inputs() {
1113        let mut showcase = Showcase::default();
1114
1115        update(
1116            &mut showcase,
1117            Message::WindowResized(Size::new(500.0, 900.0)),
1118        );
1119
1120        assert_eq!(
1121            showcase.adaptive_navigation_layout(),
1122            material::widget::navigation::AdaptiveLayout::NavigationBar
1123        );
1124
1125        update(
1126            &mut showcase,
1127            Message::WindowResized(Size::new(900.0, 900.0)),
1128        );
1129
1130        assert_eq!(
1131            showcase.adaptive_navigation_layout(),
1132            material::widget::navigation::AdaptiveLayout::NavigationRail
1133        );
1134    }
1135}