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}