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();
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 content = navigation::suite(&NAV_DESTINATIONS, &state.navigation)
610 .layout(state.adaptive_navigation_layout())
611 .with_menu("Showcase", Message::MenuPressed)
612 .view(Message::Navigate, page_content);
613 let content = state.theme_controller.controls_over(
614 content,
615 theme_picker::bottom_margin(state.adaptive_navigation_layout()),
616 Message::ThemeChanged,
617 );
618
619 let content = material::widget::dialog::modal_animated(
620 content,
621 &state.alert_dialog,
622 now,
623 alert_dialog(state.alert_dialog.alpha(now)),
624 );
625
626 state.theme_controller.reveal_over(content, now)
627}
628
629fn alert_dialog(alpha: f32) -> material::Element<'static, Message> {
630 let action_options = material::widget::dialog::AlphaOptions::default().alpha(alpha);
631
632 material::widget::dialog::alert_with(
633 "Discard draft?",
634 "Your current changes will be removed from this device.",
635 material::widget::dialog::actions([
636 material::widget::dialog::action_button_with(
637 "Cancel",
638 Message::DialogDismissed,
639 action_options,
640 ),
641 material::widget::dialog::action_button_with(
642 "Discard",
643 Message::DialogConfirmed,
644 action_options,
645 ),
646 ]),
647 material::widget::dialog::AlertOptions::default()
648 .icon("info")
649 .alpha(alpha),
650 )
651 .into()
652}
653
654#[cfg(test)]
655#[allow(unused_must_use)]
656mod tests {
657 use super::*;
658 use iced::Point;
659
660 #[test]
661 fn combobox_input_preserves_typed_query_and_clears_stale_selection() {
662 let mut showcase = Showcase::default();
663
664 update(&mut showcase, Message::ComboboxInputChanged("xxx".into()));
665
666 assert_eq!(showcase.combobox_choice, None);
667 assert_eq!(showcase.combobox_input, "xxx");
668
669 update(&mut showcase, Message::ComboboxSelected("Assist"));
670
671 assert_eq!(showcase.combobox_choice, Some("Assist"));
672 assert_eq!(showcase.combobox_input, "");
673 }
674
675 #[test]
676 fn date_picker_action_updates_showcase_state() {
677 let mut showcase = Showcase::default();
678 let date = material::widget::picker::Date::new(2026, 12, 25).unwrap();
679
680 update(
681 &mut showcase,
682 Message::DatePickerChanged(material::widget::picker::DatePickerAction::SelectDate(
683 date,
684 )),
685 );
686
687 assert_eq!(showcase.date_picker.selected_date(), Some(date));
688 assert_eq!(
689 showcase.date_picker.displayed_month(),
690 material::widget::picker::YearMonth::new(2026, 12).unwrap()
691 );
692 }
693
694 #[test]
695 fn date_range_picker_action_updates_showcase_state() {
696 let mut showcase = Showcase::default();
697 let start = material::widget::picker::Date::new(2026, 8, 1).unwrap();
698 let end = material::widget::picker::Date::new(2026, 8, 5).unwrap();
699
700 update(
701 &mut showcase,
702 Message::DateRangePickerChanged(
703 material::widget::picker::DateRangePickerAction::SelectDate(start),
704 ),
705 );
706 update(
707 &mut showcase,
708 Message::DateRangePickerChanged(
709 material::widget::picker::DateRangePickerAction::SelectDate(end),
710 ),
711 );
712
713 assert_eq!(
714 showcase.date_range_picker.selected_start_date(),
715 Some(start)
716 );
717 assert_eq!(showcase.date_range_picker.selected_end_date(), Some(end));
718 }
719
720 #[test]
721 fn time_picker_action_updates_showcase_state() {
722 let mut showcase = Showcase::default();
723
724 update(
725 &mut showcase,
726 Message::TimePickerChanged(material::widget::picker::TimePickerAction::SelectHour(9)),
727 );
728 update(
729 &mut showcase,
730 Message::TimePickerChanged(material::widget::picker::TimePickerAction::SelectMinute(
731 45,
732 )),
733 );
734
735 assert_eq!(showcase.time_picker.hour(), 21);
736 assert_eq!(showcase.time_picker.minute(), 45);
737 }
738
739 #[test]
740 fn navigation_starts_selection_animation() {
741 let mut showcase = Showcase::default();
742
743 update(&mut showcase, Message::Navigate(ShowcasePage::Controls));
744
745 assert_eq!(showcase.navigation.selected(), ShowcasePage::Controls);
746 assert!(showcase.navigation.is_animating());
747 assert_eq!(
748 showcase
749 .navigation
750 .selection()
751 .progress(ShowcasePage::Controls),
752 0.0
753 );
754 assert_eq!(
755 showcase
756 .navigation
757 .selection()
758 .progress(ShowcasePage::Inputs),
759 1.0
760 );
761 }
762
763 #[test]
764 fn alert_dialog_messages_toggle_modal_state() {
765 let mut showcase = Showcase::default();
766
767 update(&mut showcase, Message::DialogOpened);
768 assert_eq!(
769 showcase.alert_dialog.phase(),
770 material::widget::dialog::TransitionPhase::Showing
771 );
772 assert!(showcase.alert_dialog.is_active());
773
774 update(&mut showcase, Message::DialogDismissed);
775 assert_eq!(
776 showcase.alert_dialog.phase(),
777 material::widget::dialog::TransitionPhase::Dismissing
778 );
779
780 update(&mut showcase, Message::DialogOpened);
781 update(&mut showcase, Message::DialogConfirmed);
782 assert_eq!(
783 showcase.alert_dialog.phase(),
784 material::widget::dialog::TransitionPhase::Dismissing
785 );
786 assert_eq!(showcase.count, 1);
787 }
788
789 #[test]
790 fn snackbar_button_starts_android_transition() {
791 let mut showcase = Showcase::default();
792
793 update(&mut showcase, Message::ShowSnackbar);
794
795 assert_eq!(
796 showcase.snackbar.phase(),
797 material::widget::snackbar::TransitionPhase::Showing
798 );
799 assert!(showcase.snackbar.is_active());
800 }
801
802 #[test]
803 fn snackbar_action_dismisses_with_exit_transition() {
804 let mut showcase = Showcase::default();
805
806 update(&mut showcase, Message::ShowSnackbar);
807 update(&mut showcase, Message::SnackbarUndo);
808
809 assert_eq!(showcase.count, -1);
810 assert_eq!(
811 showcase.snackbar.phase(),
812 material::widget::snackbar::TransitionPhase::Dismissing
813 );
814 }
815
816 #[test]
817 fn theme_picker_uses_navigation_bar_clearance() {
818 assert_eq!(
819 theme_picker::bottom_margin(navigation::AdaptiveLayout::NavigationBar),
820 theme_picker::FLOATING_MARGIN
821 + material::tokens::component::navigation_bar::CONTAINER_HEIGHT
822 );
823 assert_eq!(
824 theme_picker::bottom_margin(navigation::AdaptiveLayout::NavigationRail),
825 theme_picker::FLOATING_MARGIN
826 );
827 }
828
829 #[test]
830 fn selecting_current_theme_does_not_start_animation() {
831 let mut showcase = Showcase::default();
832
833 update(
834 &mut showcase,
835 Message::ThemeChanged(theme_picker::ThemeAction::SetDarkMode {
836 dark_mode: true,
837 origin: Point::new(120.0, 360.0),
838 }),
839 );
840
841 assert!(!showcase.theme_controller.is_animating());
842 assert!(showcase.theme_controller.dark_mode());
843 }
844
845 #[test]
846 fn dark_mode_action_starts_reveal_from_switch_origin() {
847 let mut showcase = Showcase::default();
848 let origin = Point::new(120.0, 640.0);
849
850 update(
851 &mut showcase,
852 Message::ThemeChanged(theme_picker::ThemeAction::SetDarkMode {
853 dark_mode: false,
854 origin,
855 }),
856 );
857
858 let animation = showcase
859 .theme_controller
860 .transition()
861 .expect("dark mode should animate");
862
863 assert!(!showcase.theme_controller.dark_mode());
864 assert_eq!(animation.origin(), origin);
865 }
866
867 #[test]
868 fn theme_picker_selects_color_and_closes() {
869 let mut showcase = Showcase::default();
870
871 update(
872 &mut showcase,
873 Message::ThemeChanged(theme_picker::ThemeAction::TogglePicker),
874 );
875 assert!(showcase.theme_controller.is_picker_open());
876
877 update(
878 &mut showcase,
879 Message::ThemeChanged(theme_picker::ThemeAction::SelectColor(
880 theme_picker::MaterialColor::Blue,
881 )),
882 );
883
884 let expected_origin = theme_picker::swatch_center(
885 showcase.window_size,
886 theme_picker::bottom_margin(showcase.adaptive_navigation_layout()),
887 theme_picker::MaterialColor::Blue,
888 );
889 let animation = showcase
890 .theme_controller
891 .transition()
892 .expect("theme selection should animate");
893
894 assert_eq!(
895 showcase.theme_controller.selected_color(),
896 theme_picker::MaterialColor::Blue
897 );
898 assert!(!showcase.theme_controller.is_picker_open());
899 assert_eq!(animation.origin(), expected_origin);
900 }
901
902 #[test]
903 fn navigation_uses_material_symbol_icon_names() {
904 assert_eq!(material::fonts::all().len(), 5);
905 assert_eq!(
906 NAV_DESTINATIONS.map(|destination| destination.icon),
907 ["input", "tune", "info", "layers", "navigation", "layers"]
908 );
909
910 for destination in NAV_DESTINATIONS {
911 assert!(material::fonts::material_symbol_codepoint(destination.icon).is_some());
912 }
913 }
914
915 #[test]
916 fn cjk_fonts_load_serially_from_boot_without_input_trigger() {
917 let (mut showcase, core_load) = boot();
918 assert!(core_load.units() > 0);
919
920 let input_update = update(&mut showcase, Message::TextChanged("中文".into()));
921 assert_eq!(input_update.units(), 0);
922 assert_eq!(showcase.note, "中文");
923
924 let regional_load = update(&mut showcase, Message::CjkCoreFontFinished);
925 assert!(regional_load.units() > 0);
926 assert_eq!(showcase.note, "中文");
927
928 let finished = update(&mut showcase, Message::CjkRegionalFontFinished);
929 assert_eq!(finished.units(), 0);
930 assert_eq!(showcase.note, "中文");
931 }
932
933 #[test]
934 fn every_free_text_surface_preserves_cjk_input_during_font_loading() {
935 let mut note = Showcase::default();
936 let note_update = update(&mut note, Message::TextChanged("中文".into()));
937 assert_eq!(note_update.units(), 0);
938 assert_eq!(note.note, "中文");
939
940 let mut editor = Showcase::default();
941 let editor_update = update(
942 &mut editor,
943 Message::EditorAction(material::widget::text_editor::Action::Edit(
944 iced::widget::text_editor::Edit::Insert('中'),
945 )),
946 );
947 assert_eq!(editor_update.units(), 0);
948 assert!(editor.editor_content.text().contains('中'));
949
950 let mut combobox = Showcase::default();
951 let combobox_update = update(&mut combobox, Message::ComboboxInputChanged("中文".into()));
952 assert_eq!(combobox_update.units(), 0);
953 assert_eq!(combobox.combobox_input, "中文");
954
955 let mut search = Showcase::default();
956 let search_update = update(&mut search, Message::SearchChanged("中文".into()));
957 assert_eq!(search_update.units(), 0);
958 assert_eq!(search.search_query, "中文");
959 }
960
961 #[test]
962 fn resize_updates_adaptive_layout_inputs() {
963 let mut showcase = Showcase::default();
964
965 update(
966 &mut showcase,
967 Message::WindowResized(Size::new(500.0, 900.0)),
968 );
969
970 assert_eq!(
971 showcase.adaptive_navigation_layout(),
972 material::widget::navigation::AdaptiveLayout::NavigationBar
973 );
974
975 update(
976 &mut showcase,
977 Message::WindowResized(Size::new(900.0, 900.0)),
978 );
979
980 assert_eq!(
981 showcase.adaptive_navigation_layout(),
982 material::widget::navigation::AdaptiveLayout::NavigationRail
983 );
984 }
985}