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