1use std::rc::Rc;
137
138use frust::Theme;
139use frust::authoring::text::TextOverflow;
140use frust::authoring::{Action, Role};
141use frust::authoring::{
142 AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, ErasedCallback, EventCtx,
143 EventResult, InputEvent, LayoutCtx, PaintCtx, PaintScene, PointerPhase, SemanticsCtx,
144 ThemeTextColor, ThemeTextType, View, Widget, any,
145};
146use frust::text;
147use kurbo::{Point, Size};
148use peniko::Color;
149
150use crate::interaction::{DISABLED_CONTAINER_OPACITY, InteractionState};
151
152use super::press::presses;
153
154const HPAD: f64 = 16.0;
157const GAP: f64 = 16.0;
160const VPAD_THREE_LINE: f64 = 12.0;
164const SUPPORTING_SIZE: f32 = 14.0;
167const OVERLINE_SIZE: f32 = 11.0;
169const OVERLINE_TRACKING: f32 = 0.5;
171const HEADLINE_MAX_LINES: usize = 1;
173const SUPPORTING_MAX_LINES: usize = 2;
175
176pub const ONE_LINE_HEIGHT: f64 = 56.0;
178pub const TWO_LINE_HEIGHT: f64 = 72.0;
180pub const THREE_LINE_HEIGHT: f64 = 88.0;
182
183const RADIUS: f64 = 12.0;
187
188const ON_SURFACE: Color = Color::from_rgb8(0x1D, 0x1B, 0x20);
191const FILLED_CONTAINER: Color = Color::from_rgb8(0xE6, 0xE0, 0xE9);
194const SELECTED_CONTAINER: Color = Color::from_rgb8(0xE8, 0xDE, 0xF8);
197
198#[derive(Clone, Copy, Debug, PartialEq, Eq)]
200pub enum ListItemLines {
201 One,
203 Two,
205 Three,
207}
208
209impl ListItemLines {
210 pub fn height(self) -> f64 {
212 match self {
213 ListItemLines::One => ONE_LINE_HEIGHT,
214 ListItemLines::Two => TWO_LINE_HEIGHT,
215 ListItemLines::Three => THREE_LINE_HEIGHT,
216 }
217 }
218}
219
220type OnPress<State> = Rc<dyn Fn(&mut State)>;
222
223pub struct ListItem<State: 'static> {
225 headline: String,
226 supporting: Option<String>,
227 overline: Option<String>,
228 lines: ListItemLines,
229 leading: Option<AnyView<State>>,
230 trailing: Option<AnyView<State>>,
231 on_press: Option<OnPress<State>>,
232 selected: bool,
233 enabled: bool,
234 contained: bool,
235}
236
237pub fn list_item<State: 'static>(headline: impl Into<String>) -> ListItem<State> {
239 ListItem {
240 headline: headline.into(),
241 supporting: None,
242 overline: None,
243 lines: ListItemLines::One,
244 leading: None,
245 trailing: None,
246 on_press: None,
247 selected: false,
248 enabled: true,
249 contained: false,
250 }
251}
252
253impl<State: 'static> ListItem<State> {
254 pub fn supporting(mut self, supporting: impl Into<String>) -> Self {
257 self.supporting = Some(supporting.into());
258 self.promote_lines();
259 self
260 }
261
262 pub fn overline(mut self, overline: impl Into<String>) -> Self {
266 self.overline = Some(overline.into());
267 self.promote_lines();
268 self
269 }
270
271 fn promote_lines(&mut self) {
277 let implied = match (self.overline.is_some(), self.supporting.is_some()) {
278 (true, true) => ListItemLines::Three,
279 (true, false) | (false, true) => ListItemLines::Two,
280 (false, false) => ListItemLines::One,
281 };
282 if implied.height() > self.lines.height() {
283 self.lines = implied;
284 }
285 }
286
287 pub fn three_line(mut self) -> Self {
290 self.lines = ListItemLines::Three;
291 self
292 }
293
294 pub fn leading<V: View<State>>(mut self, leading: V) -> Self {
297 self.leading = Some(any(leading));
298 self
299 }
300
301 pub fn trailing<V: View<State>>(mut self, trailing: V) -> Self {
305 self.trailing = Some(any(trailing));
306 self
307 }
308
309 pub fn on_press<F: Fn(&mut State) + 'static>(mut self, on_press: F) -> Self {
312 self.on_press = Some(Rc::new(on_press));
313 self
314 }
315
316 pub fn selected(mut self, selected: bool) -> Self {
322 self.selected = selected;
323 self
324 }
325
326 pub fn contained(mut self, contained: bool) -> Self {
333 self.contained = contained;
334 self
335 }
336
337 pub fn enabled(mut self, enabled: bool) -> Self {
343 self.enabled = enabled;
344 self
345 }
346
347 fn shape(&self) -> (bool, bool, bool, bool) {
349 (
350 self.leading.is_some(),
351 self.overline.is_some(),
352 self.supporting.is_some(),
353 self.trailing.is_some(),
354 )
355 }
356
357 fn paints_container(&self) -> bool {
361 self.contained || self.selected
362 }
363
364 fn overline_view(&self) -> Option<AnyView<State>> {
367 self.overline.as_ref().map(|s| {
368 any(text(s.clone())
369 .size(OVERLINE_SIZE)
370 .letter_spacing(OVERLINE_TRACKING)
371 .max_lines(1)
372 .overflow(TextOverflow::Ellipsis)
373 .themed_role(ThemeTextColor::OnSurfaceVariant)
374 .themed_family(ThemeTextType::LabelSmall))
375 })
376 }
377
378 fn headline_view(&self) -> impl View<State> + use<State> {
381 text(self.headline.clone())
382 .max_lines(HEADLINE_MAX_LINES)
383 .overflow(TextOverflow::Ellipsis)
384 .themed_family(ThemeTextType::BodyLarge)
385 }
386
387 fn supporting_view(&self) -> Option<AnyView<State>> {
391 self.supporting.as_ref().map(|s| {
392 any(text(s.clone())
393 .size(SUPPORTING_SIZE)
394 .max_lines(SUPPORTING_MAX_LINES)
395 .overflow(TextOverflow::Ellipsis)
396 .themed_role(ThemeTextColor::OnSurfaceVariant)
397 .themed_family(ThemeTextType::BodyMedium))
398 })
399 }
400}
401
402#[derive(Clone, Copy)]
407struct Slots {
408 leading: Option<usize>,
409 overline: Option<usize>,
410 headline: usize,
411 supporting: Option<usize>,
412 trailing: Option<usize>,
413}
414
415pub struct ListItemWidget {
417 children: Vec<ChildPod>,
418 slots: Slots,
419 lines: ListItemLines,
420 interactive: bool,
421 enabled: bool,
422 selected: bool,
423 paints_container: bool,
426 captured: bool,
429 state: InteractionState,
430 on_press: Option<ErasedCallback>,
431}
432
433fn build_children<State: 'static>(
437 view: &ListItem<State>,
438 ctx: &mut BuildCtx<'_>,
439) -> (Vec<ChildPod>, Slots) {
440 let mut children = Vec::new();
441 let leading = view.leading.as_ref().map(|v| {
442 children.push(frust::authoring::build_child(v, ctx));
443 children.len() - 1
444 });
445 let overline = view.overline_view().map(|v| {
446 children.push(frust::authoring::build_child(&v, ctx));
447 children.len() - 1
448 });
449 children.push(frust::authoring::build_child(&view.headline_view(), ctx));
450 let headline = children.len() - 1;
451 let supporting = view.supporting_view().map(|v| {
452 children.push(frust::authoring::build_child(&v, ctx));
453 children.len() - 1
454 });
455 let trailing = view.trailing.as_ref().map(|v| {
456 children.push(frust::authoring::build_child(v, ctx));
457 children.len() - 1
458 });
459 (
460 children,
461 Slots {
462 leading,
463 overline,
464 headline,
465 supporting,
466 trailing,
467 },
468 )
469}
470
471fn inside(pos: Point, size: Size) -> bool {
472 pos.x >= 0.0 && pos.y >= 0.0 && pos.x < size.width && pos.y < size.height
473}
474
475fn with_alpha(color: Color, alpha: f32) -> Color {
478 let c = color.components;
479 Color::new([c[0], c[1], c[2], alpha])
480}
481
482fn resolve_content_color(theme: Option<&Theme>) -> Color {
485 match theme {
486 Some(theme) => theme.scheme().on_surface,
487 None => ON_SURFACE,
488 }
489}
490
491fn resolve_container(theme: Option<&Theme>, selected: bool, disabled: bool) -> Color {
497 if disabled {
498 return with_alpha(resolve_content_color(theme), DISABLED_CONTAINER_OPACITY);
499 }
500 match theme {
501 Some(theme) => {
502 let scheme = theme.scheme();
503 if selected {
504 scheme.secondary_container
505 } else {
506 scheme.surface_container_highest
507 }
508 }
509 None => {
510 if selected {
511 SELECTED_CONTAINER
512 } else {
513 FILLED_CONTAINER
514 }
515 }
516 }
517}
518
519fn resolve_radius(theme: Option<&Theme>) -> f64 {
522 match theme {
523 Some(theme) => theme.shape.medium,
524 None => RADIUS,
525 }
526}
527
528impl<State: 'static> View<State> for ListItem<State> {
529 type Element = ListItemWidget;
530
531 fn build(&self, ctx: &mut BuildCtx<'_>) -> ListItemWidget {
532 let (children, slots) = build_children(self, ctx);
533 ListItemWidget {
534 children,
535 slots,
536 lines: self.lines,
537 interactive: self.on_press.is_some(),
538 enabled: self.enabled,
539 selected: self.selected,
540 paints_container: self.paints_container(),
541 captured: false,
542 state: InteractionState::new(),
543 on_press: self.on_press.as_ref().map(frust::authoring::erase_callback),
544 }
545 }
546
547 fn rebuild(
548 &self,
549 prev: &Self,
550 element: &mut ListItemWidget,
551 ctx: &mut BuildCtx<'_>,
552 ) -> ChangeFlags {
553 let mut flags = ChangeFlags::NONE;
554
555 if self.shape() != prev.shape() {
556 teardown_children(prev, element.slots, &mut element.children, ctx);
560 let (children, slots) = build_children(self, ctx);
561 element.children = children;
562 element.slots = slots;
563 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
564 } else {
565 let slots = element.slots;
567 if let (Some(pi), Some(ni)) = (prev.leading.as_ref(), self.leading.as_ref())
568 && let Some(idx) = slots.leading
569 {
570 flags |= frust::authoring::rebuild_child(pi, ni, &mut element.children[idx], ctx);
571 }
572 if let (Some(pv), Some(nv)) = (prev.overline_view(), self.overline_view())
573 && let Some(idx) = slots.overline
574 {
575 flags |= frust::authoring::rebuild_child(&pv, &nv, &mut element.children[idx], ctx);
576 }
577 flags |= frust::authoring::rebuild_child(
578 &prev.headline_view(),
579 &self.headline_view(),
580 &mut element.children[slots.headline],
581 ctx,
582 );
583 if let (Some(pv), Some(nv)) = (prev.supporting_view(), self.supporting_view())
584 && let Some(idx) = slots.supporting
585 {
586 flags |= frust::authoring::rebuild_child(&pv, &nv, &mut element.children[idx], ctx);
587 }
588 if let (Some(pi), Some(ni)) = (prev.trailing.as_ref(), self.trailing.as_ref())
589 && let Some(idx) = slots.trailing
590 {
591 flags |= frust::authoring::rebuild_child(pi, ni, &mut element.children[idx], ctx);
592 }
593 }
594
595 if element.lines != self.lines {
596 element.lines = self.lines;
597 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
598 }
599 if element.selected != self.selected || element.paints_container != self.paints_container()
600 {
601 element.selected = self.selected;
602 element.paints_container = self.paints_container();
603 flags |= ChangeFlags::PAINT;
604 }
605
606 let now_interactive = self.on_press.is_some();
607 if element.interactive != now_interactive {
608 element.interactive = now_interactive;
609 if !now_interactive {
610 element.captured = false;
613 element.state.set_pressed(false);
614 element.state.set_hovered(false);
615 }
616 flags |= ChangeFlags::PAINT;
617 }
618 if element.enabled != self.enabled {
619 element.enabled = self.enabled;
620 if !self.enabled && element.interactive {
621 element.captured = false;
624 element.state.set_pressed(false);
625 element.state.set_hovered(false);
626 }
627 flags |= ChangeFlags::PAINT;
628 }
629 element.on_press = self.on_press.as_ref().map(frust::authoring::erase_callback);
630 flags
631 }
632
633 fn teardown(&self, element: &mut ListItemWidget, ctx: &mut BuildCtx<'_>) {
634 teardown_children(self, element.slots, &mut element.children, ctx);
635 }
636}
637
638fn teardown_children<State: 'static>(
643 view: &ListItem<State>,
644 slots: Slots,
645 children: &mut [ChildPod],
646 ctx: &mut BuildCtx<'_>,
647) {
648 for (index, pod) in children.iter_mut().enumerate() {
649 if Some(index) == slots.leading
650 && let Some(v) = view.leading.as_ref()
651 {
652 frust::authoring::teardown_child(v, pod, ctx);
653 } else if Some(index) == slots.overline
654 && let Some(v) = view.overline_view()
655 {
656 frust::authoring::teardown_child(&v, pod, ctx);
657 } else if index == slots.headline {
658 frust::authoring::teardown_child(&view.headline_view(), pod, ctx);
659 } else if Some(index) == slots.supporting
660 && let Some(v) = view.supporting_view()
661 {
662 frust::authoring::teardown_child(&v, pod, ctx);
663 } else if Some(index) == slots.trailing
664 && let Some(v) = view.trailing.as_ref()
665 {
666 frust::authoring::teardown_child(v, pod, ctx);
667 }
668 }
669}
670
671impl ListItemWidget {
672 fn slot_y(&self, row_height: f64, slot_height: f64) -> f64 {
675 if self.lines == ListItemLines::Three {
676 VPAD_THREE_LINE
677 } else {
678 ((row_height - slot_height) / 2.0).max(0.0)
679 }
680 }
681}
682
683impl Widget for ListItemWidget {
684 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
685 let width = if bc.max().width.is_finite() {
686 bc.max().width
687 } else {
688 0.0
689 };
690 let height = self.lines.height();
691 let slot_bc = BoxConstraints::new(Size::ZERO, Size::new(width, height));
692
693 let mut text_left = HPAD;
695 if let Some(idx) = self.slots.leading {
696 let s = self.children[idx].layout_child(ctx, &slot_bc);
697 let y = self.slot_y(height, s.height);
698 self.children[idx].set_origin(Point::new(HPAD, y));
699 text_left = HPAD + s.width + GAP;
700 }
701
702 let mut text_right = width - HPAD;
704 if let Some(idx) = self.slots.trailing {
705 let s = self.children[idx].layout_child(ctx, &slot_bc);
706 let x = width - HPAD - s.width;
707 let y = self.slot_y(height, s.height);
708 self.children[idx].set_origin(Point::new(x, y));
709 text_right = x - GAP;
710 }
711
712 let text_w = (text_right - text_left).max(0.0);
714 let text_bc = BoxConstraints::new(Size::ZERO, Size::new(text_w, height));
715 let over_size = if let Some(oi) = self.slots.overline {
716 self.children[oi].layout_child(ctx, &text_bc)
717 } else {
718 Size::ZERO
719 };
720 let hi = self.slots.headline;
721 let head_size = self.children[hi].layout_child(ctx, &text_bc);
722 let supp_size = if let Some(si) = self.slots.supporting {
723 self.children[si].layout_child(ctx, &text_bc)
724 } else {
725 Size::ZERO
726 };
727
728 let block = over_size.height + head_size.height + supp_size.height;
732 let mut y = ((height - block) / 2.0).max(0.0);
733 if let Some(oi) = self.slots.overline {
734 self.children[oi].set_origin(Point::new(text_left, y));
735 y += over_size.height;
736 }
737 self.children[hi].set_origin(Point::new(text_left, y));
738 y += head_size.height;
739 if let Some(si) = self.slots.supporting {
740 self.children[si].set_origin(Point::new(text_left, y));
741 }
742
743 bc.constrain(Size::new(width, height))
744 }
745
746 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
747 let theme = Theme::from_paint_ctx(ctx);
750 let disabled = self.interactive && !self.enabled;
751 let radius = if self.paints_container {
755 resolve_radius(theme)
756 } else {
757 0.0
758 };
759 let container = self
760 .paints_container
761 .then(|| resolve_container(theme, self.selected, disabled));
762 let content_color = resolve_content_color(theme);
763 let hovered = self.interactive && self.enabled && ctx.is_hovered();
771
772 let o = ctx.origin();
773 let size = ctx.size();
774
775 self.state.set_hovered(hovered);
776
777 if let Some(container) = container {
778 scene.fill_rounded_rect(o, size, radius, container);
779 }
780 if self.interactive && self.enabled {
781 let opacity = self.state.resolve_opacity();
782 if opacity > 0.0 {
783 scene.fill_rounded_rect(o, size, radius, with_alpha(content_color, opacity));
784 }
785 }
786 for pod in &mut self.children {
787 pod.paint_child(ctx, scene);
788 }
789 }
790
791 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
792 if !self.interactive {
793 return frust::authoring::route_event(&mut self.children, ctx, event);
794 }
795 let InputEvent::Pointer(p) = event else {
799 return frust::authoring::route_event(&mut self.children, ctx, event);
800 };
801 if !self.enabled {
802 return EventResult::Ignored;
806 }
807 let on_press = self
808 .on_press
809 .as_mut()
810 .expect("on_press is set whenever interactive is true");
811 match p.phase {
812 PointerPhase::Down => {
813 if !presses(p) {
814 return EventResult::Ignored;
815 }
816 self.state.set_pressed(true);
817 self.captured = true;
818 ctx.capture_pointer();
819 ctx.request_redraw();
820 EventResult::Handled
821 }
822 PointerPhase::Move => {
823 if !self.captured {
824 let over = inside(p.position, ctx.size());
835 if over {
836 ctx.claim_hover();
837 }
838 if self.state.set_hovered(over) {
839 ctx.request_redraw();
840 }
841 return EventResult::Ignored;
843 }
844 let inside_now = inside(p.position, ctx.size());
845 if self.state.set_pressed(inside_now) {
846 ctx.request_redraw();
847 }
848 EventResult::Handled
849 }
850 PointerPhase::Up => {
851 if !self.captured {
852 return EventResult::Ignored;
853 }
854 if inside(p.position, ctx.size()) {
855 (on_press)(ctx);
856 }
857 self.state.set_pressed(false);
858 self.captured = false;
859 ctx.request_redraw();
860 EventResult::Handled
861 }
862 PointerPhase::Cancel => {
863 if !self.captured {
864 return EventResult::Ignored;
865 }
866 self.state.set_pressed(false);
867 self.captured = false;
868 ctx.request_redraw();
869 EventResult::Handled
870 }
871 }
872 }
873
874 fn semantics(&self, ctx: &mut SemanticsCtx) {
875 ctx.push_container(
876 Role::ListItem,
877 |node| {
878 if self.interactive {
879 if self.enabled {
880 node.add_action(Action::Click);
881 } else {
882 node.set_disabled();
883 }
884 }
885 if self.selected {
889 node.set_selected(true);
890 }
891 },
892 |ctx| {
893 for pod in &self.children {
894 pod.semantics_child(ctx);
895 }
896 },
897 );
898 }
899
900 frust::authoring::visit_children!(children);
901}
902
903#[cfg(test)]
904mod tests {
905 use super::*;
906 use frust::authoring::text::TextContext;
907 use frust_core::RenderRoot;
908 use frust_widgets::test_support::leaf;
909 use kurbo::Point;
910 use std::any::Any;
911
912 fn build<S: 'static>(view: &ListItem<S>) -> ListItemWidget {
913 let mut counter = 0u64;
914 View::<S>::build(view, &mut BuildCtx::new(&mut counter))
915 }
916
917 fn layout(w: &mut ListItemWidget, width: f64) -> Size {
918 let mut tcx = TextContext::new();
919 let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
920 w.layout(&mut lctx, &BoxConstraints::loose(Size::new(width, 400.0)))
921 }
922
923 #[derive(Default)]
926 struct RRectRecorder {
927 rrects: Vec<(Point, Size, f64, Color)>,
928 }
929
930 impl PaintScene for RRectRecorder {
931 fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
932 fn draw_text(&mut self, _o: Point, _t: &str) {}
933 fn fill_rounded_rect(&mut self, o: Point, s: Size, radius: f64, color: Color) {
934 self.rrects.push((o, s, radius, color));
935 }
936 }
937
938 impl RRectRecorder {
939 fn overlays(&self) -> Vec<(Point, f32)> {
942 self.rrects
943 .iter()
944 .filter(|(_, _, _, c)| c.components[3] < 1.0)
945 .map(|(o, _, _, c)| (*o, c.components[3]))
946 .collect()
947 }
948 }
949
950 fn paint(w: &mut ListItemWidget, size: Size, theme: Option<&Theme>) -> RRectRecorder {
951 let mut rec = RRectRecorder::default();
952 let mut ctx = match theme {
953 Some(t) => PaintCtx::new(Point::ZERO, size).with_theme(t),
954 None => PaintCtx::new(Point::ZERO, size),
955 };
956 w.paint(&mut ctx, &mut rec);
957 rec
958 }
959
960 #[test]
961 fn one_line_row_is_56dp() {
962 let view: ListItem<()> = list_item("Title");
963 let mut w = build(&view);
964 let size = layout(&mut w, 300.0);
965 assert_eq!(size.height, ONE_LINE_HEIGHT);
966 }
967
968 #[test]
969 fn supporting_promotes_to_two_line_72dp() {
970 let view: ListItem<()> = list_item("Title").supporting("Subtitle");
971 assert_eq!(view.lines, ListItemLines::Two);
972 let mut w = build(&view);
973 let size = layout(&mut w, 300.0);
974 assert_eq!(size.height, TWO_LINE_HEIGHT);
975 }
976
977 #[test]
978 fn overline_alone_promotes_to_two_line_and_with_supporting_to_three() {
979 let two: ListItem<()> = list_item("Title").overline("OVERLINE");
981 assert_eq!(two.lines, ListItemLines::Two);
982
983 let three: ListItem<()> = list_item("Title").overline("OVERLINE").supporting("Sub");
984 assert_eq!(three.lines, ListItemLines::Three);
985
986 let flipped: ListItem<()> = list_item("Title").supporting("Sub").overline("OVERLINE");
988 assert_eq!(flipped.lines, ListItemLines::Three);
989 let forced: ListItem<()> = list_item("Title").three_line().supporting("Sub");
990 assert_eq!(forced.lines, ListItemLines::Three);
991 }
992
993 #[test]
994 fn three_line_is_88dp() {
995 let view: ListItem<()> = list_item("Title").supporting("Sub").three_line();
996 assert_eq!(view.lines, ListItemLines::Three);
997 let mut w = build(&view);
998 let size = layout(&mut w, 300.0);
999 assert_eq!(size.height, THREE_LINE_HEIGHT);
1000 }
1001
1002 #[test]
1003 fn leading_and_trailing_slots_are_placed_and_inset_the_text() {
1004 let view: ListItem<()> = list_item("Title")
1007 .leading(leaf(24.0, 24.0))
1008 .trailing(leaf(16.0, 16.0));
1009 let mut w = build(&view);
1010 layout(&mut w, 300.0);
1011
1012 let leading_i = w.slots.leading.expect("leading present");
1013 let trailing_i = w.slots.trailing.expect("trailing present");
1014 let headline_i = w.slots.headline;
1015
1016 assert_eq!(w.children[leading_i].origin().x, HPAD);
1018 assert_eq!(
1019 w.children[leading_i].origin().y,
1020 (ONE_LINE_HEIGHT - 24.0) / 2.0
1021 );
1022 assert_eq!(w.children[trailing_i].origin().x, 300.0 - HPAD - 16.0);
1024 assert_eq!(w.children[headline_i].origin().x, HPAD + 24.0 + GAP);
1026 }
1027
1028 #[test]
1029 fn a_three_line_rows_slots_pin_to_the_top_inset() {
1030 let view: ListItem<()> = list_item("Title")
1033 .overline("OVER")
1034 .supporting("Sub")
1035 .leading(leaf(40.0, 40.0));
1036 let mut w = build(&view);
1037 layout(&mut w, 300.0);
1038 let leading_i = w.slots.leading.expect("leading present");
1039 assert_eq!(w.children[leading_i].origin().y, VPAD_THREE_LINE);
1040 }
1041
1042 #[test]
1043 fn build_orders_children_leading_overline_headline_supporting_trailing() {
1044 let view: ListItem<()> = list_item("H")
1045 .overline("O")
1046 .supporting("S")
1047 .leading(leaf(10.0, 10.0))
1048 .trailing(leaf(10.0, 10.0));
1049 let w = build(&view);
1050 assert_eq!(w.children.len(), 5);
1051 assert_eq!(w.slots.leading, Some(0));
1052 assert_eq!(w.slots.overline, Some(1));
1053 assert_eq!(w.slots.headline, 2);
1054 assert_eq!(w.slots.supporting, Some(3));
1055 assert_eq!(w.slots.trailing, Some(4));
1056 }
1057
1058 #[test]
1059 fn the_text_column_stacks_overline_headline_supporting_in_order() {
1060 let view: ListItem<()> = list_item("Headline")
1061 .overline("OVERLINE")
1062 .supporting("Supporting");
1063 let mut w = build(&view);
1064 layout(&mut w, 300.0);
1065 let over = w.children[w.slots.overline.expect("overline")].origin().y;
1066 let head = w.children[w.slots.headline].origin().y;
1067 let supp = w.children[w.slots.supporting.expect("supporting")]
1068 .origin()
1069 .y;
1070 assert!(over < head, "overline sits above the headline");
1071 assert!(head < supp, "supporting sits below the headline");
1072 }
1073
1074 #[test]
1075 fn every_slot_combination_x_state_builds_lays_out_and_paints() {
1076 let theme = crate::baseline();
1082 for leading in [false, true] {
1083 for overline in [false, true] {
1084 for supporting in [false, true] {
1085 for trailing in [false, true] {
1086 for (selected, contained, interactive) in [
1087 (false, false, false),
1088 (false, true, false),
1089 (true, false, false),
1090 (true, true, true),
1091 (false, false, true),
1092 ] {
1093 let mut view: ListItem<()> = list_item("Headline");
1094 if leading {
1095 view = view.leading(leaf(24.0, 24.0));
1096 }
1097 if overline {
1098 view = view.overline("OVERLINE");
1099 }
1100 if supporting {
1101 view = view.supporting("Supporting text");
1102 }
1103 if trailing {
1104 view = view.trailing(leaf(16.0, 16.0));
1105 }
1106 if interactive {
1107 view = view.on_press(|_: &mut ()| {});
1108 }
1109 let expected_height = view.lines.height();
1110 let view = view.selected(selected).contained(contained);
1111
1112 let mut w = build(&view);
1113 let size = layout(&mut w, 300.0);
1114 let case = format!(
1115 "leading={leading} overline={overline} \
1116 supporting={supporting} trailing={trailing} \
1117 selected={selected} contained={contained} \
1118 interactive={interactive}"
1119 );
1120 assert_eq!(size.height, expected_height, "{case}");
1121 for pod in &w.children {
1122 assert!(
1123 pod.origin().y >= 0.0
1124 && pod.origin().y + pod.size().height <= size.height + 0.01,
1125 "a child escapes the row vertically ({case})"
1126 );
1127 }
1128 let rec = paint(&mut w, size, Some(&theme));
1129 let expected_rects = usize::from(selected || contained);
1130 assert_eq!(
1131 rec.rrects.len(),
1132 expected_rects,
1133 "container fill count ({case})"
1134 );
1135 }
1136 }
1137 }
1138 }
1139 }
1140 }
1141
1142 #[test]
1145 fn a_bare_row_paints_no_container_of_its_own() {
1146 let view: ListItem<()> = list_item("Title");
1149 let mut w = build(&view);
1150 let rec = paint(&mut w, Size::new(300.0, ONE_LINE_HEIGHT), None);
1151 assert!(rec.rrects.is_empty());
1152 }
1153
1154 #[test]
1155 fn a_contained_row_paints_the_filled_container_at_the_medium_radius() {
1156 let view: ListItem<()> = list_item("Title").contained(true);
1157 let mut w = build(&view);
1158 let rec = paint(&mut w, Size::new(300.0, ONE_LINE_HEIGHT), None);
1159 assert_eq!(rec.rrects.len(), 1, "just the container fill at rest");
1160 assert_eq!(rec.rrects[0].3, FILLED_CONTAINER);
1161 assert_eq!(rec.rrects[0].2, RADIUS);
1162 }
1163
1164 #[test]
1165 fn a_selected_row_paints_the_secondary_container_fill_with_or_without_contained() {
1166 let theme = crate::baseline();
1167 let view: ListItem<()> = list_item("Title").selected(true);
1170 let mut w = build(&view);
1171 let rec = paint(&mut w, Size::new(300.0, ONE_LINE_HEIGHT), Some(&theme));
1172 assert_eq!(rec.rrects.len(), 1);
1173 assert_eq!(rec.rrects[0].3, theme.scheme().secondary_container);
1174 assert_eq!(rec.rrects[0].2, theme.shape.medium);
1175
1176 let contained: ListItem<()> = list_item("Title").contained(true).selected(true);
1177 let mut w = build(&contained);
1178 let rec = paint(&mut w, Size::new(300.0, ONE_LINE_HEIGHT), Some(&theme));
1179 assert_eq!(rec.rrects[0].3, theme.scheme().secondary_container);
1180
1181 let unselected: ListItem<()> = list_item("Title").contained(true);
1182 let mut w = build(&unselected);
1183 let rec = paint(&mut w, Size::new(300.0, ONE_LINE_HEIGHT), Some(&theme));
1184 assert_eq!(rec.rrects[0].3, theme.scheme().surface_container_highest);
1185 }
1186
1187 #[test]
1188 fn a_disabled_interactive_row_dims_its_container_and_paints_no_overlay() {
1189 let theme = crate::baseline();
1190 let view: ListItem<()> = list_item("Title")
1191 .contained(true)
1192 .on_press(|_: &mut ()| {})
1193 .enabled(false);
1194 let mut w = build(&view);
1195 let rec = paint(&mut w, Size::new(300.0, ONE_LINE_HEIGHT), Some(&theme));
1196 assert_eq!(
1197 rec.rrects[0].3,
1198 with_alpha(theme.scheme().on_surface, DISABLED_CONTAINER_OPACITY)
1199 );
1200 assert_eq!(rec.rrects.len(), 1, "no state layer while disabled");
1203 }
1204
1205 #[derive(Default)]
1208 struct Counter {
1209 presses: u32,
1210 }
1211
1212 fn dispatch<S: 'static>(
1213 w: &mut ListItemWidget,
1214 state: &mut S,
1215 event: &InputEvent,
1216 ) -> EventResult {
1217 let state_any: &mut dyn Any = state;
1218 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(300.0, ONE_LINE_HEIGHT));
1219 w.event(&mut ctx, event)
1220 }
1221
1222 fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
1223 InputEvent::Pointer(frust::authoring::PointerEvent {
1224 phase,
1225 position: Point::new(x, y),
1226 button: frust::authoring::PointerButton::Primary,
1227 })
1228 }
1229
1230 fn secondary_ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
1232 InputEvent::Pointer(frust::authoring::PointerEvent {
1233 phase,
1234 position: Point::new(x, y),
1235 button: frust::authoring::PointerButton::Secondary,
1236 })
1237 }
1238
1239 #[test]
1240 fn interactive_row_fires_on_up_inside() {
1241 let view: ListItem<Counter> =
1242 list_item("Tap me").on_press(|s: &mut Counter| s.presses += 1);
1243 let mut w = build(&view);
1244 let mut state = Counter::default();
1245 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
1246 assert!(w.captured);
1247 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 10.0, 10.0));
1248 assert_eq!(state.presses, 1);
1249 }
1250
1251 #[test]
1252 fn a_secondary_press_never_presses_captures_or_fires() {
1253 let view: ListItem<Counter> =
1254 list_item("Tap me").on_press(|s: &mut Counter| s.presses += 1);
1255 let mut w = build(&view);
1256 let mut state = Counter::default();
1257 let r = dispatch(
1258 &mut w,
1259 &mut state,
1260 &secondary_ev(PointerPhase::Down, 10.0, 10.0),
1261 );
1262 assert_eq!(r, EventResult::Ignored);
1263 assert!(!w.state.pressed, "no pressed state layer on a right-click");
1264 assert!(!w.captured, "and no capture for the shell to wedge on");
1265 dispatch(
1266 &mut w,
1267 &mut state,
1268 &secondary_ev(PointerPhase::Up, 10.0, 10.0),
1269 );
1270 assert_eq!(state.presses, 0);
1271
1272 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
1274 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 10.0, 10.0));
1275 assert_eq!(state.presses, 1);
1276 }
1277
1278 #[test]
1279 fn interactive_row_up_outside_does_not_fire() {
1280 let view: ListItem<Counter> =
1281 list_item("Tap me").on_press(|s: &mut Counter| s.presses += 1);
1282 let mut w = build(&view);
1283 let mut state = Counter::default();
1284 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
1285 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 999.0, 999.0));
1286 assert_eq!(state.presses, 0);
1287 }
1288
1289 #[test]
1290 fn a_disabled_interactive_row_ignores_every_pointer_phase() {
1291 let view: ListItem<Counter> = list_item("Tap me")
1292 .on_press(|s: &mut Counter| s.presses += 1)
1293 .enabled(false);
1294 let mut w = build(&view);
1295 let mut state = Counter::default();
1296 assert_eq!(
1297 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0)),
1298 EventResult::Ignored
1299 );
1300 assert!(!w.captured, "a disabled row never captures");
1301 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 10.0, 10.0));
1302 assert_eq!(state.presses, 0, "and never fires");
1303 }
1304
1305 struct FocusConsumer;
1309
1310 impl FocusConsumer {
1311 fn new() -> Self {
1312 FocusConsumer
1313 }
1314 }
1315
1316 struct FocusConsumerWidget;
1317
1318 impl View<Counter> for FocusConsumer {
1319 type Element = FocusConsumerWidget;
1320 fn build(&self, _ctx: &mut BuildCtx<'_>) -> FocusConsumerWidget {
1321 FocusConsumerWidget
1322 }
1323 fn rebuild(
1324 &self,
1325 _prev: &Self,
1326 _element: &mut FocusConsumerWidget,
1327 _ctx: &mut BuildCtx<'_>,
1328 ) -> ChangeFlags {
1329 ChangeFlags::NONE
1330 }
1331 }
1332
1333 impl Widget for FocusConsumerWidget {
1334 fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1335 bc.max()
1336 }
1337 fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {}
1338 fn event(&mut self, _ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1339 if let InputEvent::Key(_) = event {
1342 return EventResult::Handled;
1343 }
1344 EventResult::Ignored
1345 }
1346 }
1347
1348 #[test]
1349 fn interactive_row_with_trailing_control_forwards_key_events() {
1350 let view: ListItem<Counter> = list_item("Item")
1351 .trailing(FocusConsumer::new())
1352 .on_press(|s: &mut Counter| s.presses += 1);
1353 let mut w = build(&view);
1354 let mut state = Counter::default();
1355
1356 let trailing_idx = w.slots.trailing.expect("trailing is present");
1360 w.children[trailing_idx].set_focused(true);
1361
1362 let key_event = InputEvent::Key(frust::authoring::KeyEvent {
1364 key: frust::authoring::Key::Named(frust::authoring::NamedKey::Backspace),
1365 modifiers: frust::authoring::Modifiers::default(),
1366 repeat: false,
1367 });
1368 let result = dispatch(&mut w, &mut state, &key_event);
1369
1370 assert_eq!(
1372 result,
1373 EventResult::Handled,
1374 "key event must be forwarded to focused child"
1375 );
1376
1377 assert_eq!(
1379 state.presses, 0,
1380 "row press callback does not fire on key event"
1381 );
1382 }
1383
1384 struct HoverHarness {
1392 root: frust_core::RenderRoot<Counter, frust::FlexView<Counter>>,
1393 state: Counter,
1394 tcx: TextContext,
1395 }
1396
1397 const HOVER_WINDOW: Size = Size::new(300.0, 200.0);
1399
1400 impl HoverHarness {
1401 fn new() -> Self {
1402 let mut h = HoverHarness {
1403 root: frust_core::RenderRoot::new(),
1404 state: Counter::default(),
1405 tcx: TextContext::new(),
1406 };
1407 h.rebuild_layout();
1408 h
1409 }
1410
1411 fn rebuild_layout(&mut self) {
1412 let mut app = |_s: &mut Counter| {
1413 frust::column()
1414 .child(list_item::<Counter>("first").on_press(|s: &mut Counter| s.presses += 1))
1415 .child(
1416 list_item::<Counter>("second").on_press(|s: &mut Counter| s.presses += 1),
1417 )
1418 };
1419 self.root.rebuild(&mut app, &mut self.state);
1420 self.root
1421 .layout_with_text(HOVER_WINDOW, &mut self.tcx as &mut dyn Any);
1422 }
1423
1424 fn dispatch(
1425 &mut self,
1426 phase: PointerPhase,
1427 x: f64,
1428 y: f64,
1429 ) -> frust::authoring::EventOutcome {
1430 self.root.event(
1431 &mut self.state,
1432 &InputEvent::Pointer(frust::authoring::PointerEvent {
1433 phase,
1434 position: Point::new(x, y),
1435 button: frust::authoring::PointerButton::Primary,
1436 }),
1437 )
1438 }
1439
1440 fn row_y(index: usize) -> f64 {
1442 ONE_LINE_HEIGHT * index as f64 + ONE_LINE_HEIGHT / 2.0
1443 }
1444
1445 fn overlay_rows(&mut self) -> Vec<usize> {
1449 let mut rec = RRectRecorder::default();
1450 self.root.paint(&mut rec, frust::FrameTime::ZERO);
1451 rec.overlays()
1452 .iter()
1453 .map(|(origin, _)| (origin.y / ONE_LINE_HEIGHT).round() as usize)
1454 .collect()
1455 }
1456
1457 fn overlay_alpha(&mut self) -> f32 {
1459 let mut rec = RRectRecorder::default();
1460 self.root.paint(&mut rec, frust::FrameTime::ZERO);
1461 rec.overlays().first().expect("one overlay painted").1
1462 }
1463 }
1464
1465 #[test]
1466 fn hovering_a_row_paints_the_hover_overlay() {
1467 let mut h = HoverHarness::new();
1468 assert!(h.overlay_rows().is_empty(), "no overlay at rest");
1469
1470 let gain = h.dispatch(PointerPhase::Move, 150.0, HoverHarness::row_y(0));
1474 assert!(gain.needs_redraw, "entering a row repaints");
1475 assert_eq!(h.overlay_rows(), vec![0], "the hovered row tints");
1476 assert_eq!(
1477 h.overlay_alpha(),
1478 crate::interaction::HOVER_OPACITY,
1479 "at the documented M3 hover opacity"
1480 );
1481
1482 let settled = h.dispatch(PointerPhase::Move, 160.0, HoverHarness::row_y(0));
1484 assert!(!settled.needs_redraw, "an unchanged flag asks for nothing");
1485 assert_eq!(h.overlay_rows(), vec![0]);
1486 }
1487
1488 #[test]
1489 fn hover_moves_to_the_row_under_the_pointer() {
1490 let mut h = HoverHarness::new();
1491 h.dispatch(PointerPhase::Move, 150.0, HoverHarness::row_y(0));
1492 assert_eq!(h.overlay_rows(), vec![0]);
1493
1494 let handoff = h.dispatch(PointerPhase::Move, 150.0, HoverHarness::row_y(1));
1501 assert!(handoff.needs_redraw, "a row-to-row handoff repaints");
1502 assert_eq!(h.overlay_rows(), vec![1]);
1503
1504 let outcome = h.dispatch(PointerPhase::Move, 150.0, 180.0);
1507 assert!(outcome.needs_redraw);
1508 assert!(h.overlay_rows().is_empty());
1509 }
1510
1511 #[test]
1512 fn a_captured_drag_paints_no_hover_overlay() {
1513 let mut h = HoverHarness::new();
1514 h.dispatch(PointerPhase::Down, 150.0, HoverHarness::row_y(0));
1517 assert_eq!(h.overlay_rows(), vec![0]);
1518 assert_eq!(h.overlay_alpha(), frust::authoring::PRESSED_OPACITY);
1519
1520 h.dispatch(PointerPhase::Move, 150.0, HoverHarness::row_y(1));
1523 assert!(
1524 h.overlay_rows().is_empty(),
1525 "dragged outside: neither pressed nor hovered"
1526 );
1527
1528 h.dispatch(PointerPhase::Up, 150.0, HoverHarness::row_y(1));
1530 assert_eq!(h.state.presses, 0);
1531 assert!(h.overlay_rows().is_empty());
1532 }
1533
1534 #[test]
1535 fn hover_survives_a_rebuild_and_clears_on_press() {
1536 let mut h = HoverHarness::new();
1537 h.dispatch(PointerPhase::Move, 150.0, HoverHarness::row_y(0));
1538 h.rebuild_layout();
1539 assert_eq!(
1540 h.overlay_rows(),
1541 vec![0],
1542 "an in-place rebuild keeps the pod that holds the link"
1543 );
1544
1545 h.dispatch(PointerPhase::Down, 150.0, HoverHarness::row_y(0));
1547 assert_eq!(h.overlay_alpha(), frust::authoring::PRESSED_OPACITY);
1548 h.dispatch(PointerPhase::Up, 150.0, HoverHarness::row_y(0));
1549 assert_eq!(h.state.presses, 1);
1550 assert!(
1551 h.overlay_rows().is_empty(),
1552 "a click leaves no hover behind until the pointer moves again"
1553 );
1554 }
1555
1556 #[test]
1557 fn an_uncaptured_drag_tints_the_row_only_until_the_lift() {
1558 let mut h = HoverHarness::new();
1564 h.dispatch(PointerPhase::Down, 150.0, 180.0);
1565 assert!(h.overlay_rows().is_empty(), "pressed empty chrome, no row");
1566
1567 h.dispatch(PointerPhase::Move, 150.0, HoverHarness::row_y(1));
1568 assert_eq!(
1569 h.overlay_rows(),
1570 vec![1],
1571 "an uncaptured drag over the row is a hover pass"
1572 );
1573
1574 let lift = h.dispatch(PointerPhase::Up, 150.0, HoverHarness::row_y(1));
1575 assert!(
1576 lift.needs_redraw,
1577 "the lift asks for the frame that clears it"
1578 );
1579 assert!(
1580 h.overlay_rows().is_empty(),
1581 "the tint is transient: the lift ends the link"
1582 );
1583 assert_eq!(h.state.presses, 0, "no press fired: the row never captured");
1584 }
1585
1586 #[test]
1589 fn semantics_row_is_a_list_item_with_the_headline_label() {
1590 fn logic(_s: &mut ()) -> ListItem<()> {
1591 list_item("Inbox")
1592 }
1593 let mut root: RenderRoot<(), ListItem<()>> = RenderRoot::new();
1594 let mut state = ();
1595 root.rebuild(&mut logic, &mut state);
1596 let mut tcx = TextContext::new();
1597 root.layout_with_text(Size::new(300.0, 100.0), &mut tcx as &mut dyn Any);
1598 let update = root.semantics();
1599
1600 let (_, item) = update
1601 .nodes
1602 .iter()
1603 .find(|(_, n)| n.role() == Role::ListItem)
1604 .expect("row contributes a Role::ListItem node");
1605 assert!(
1606 !item.children().is_empty(),
1607 "the headline text is a semantics child of the row"
1608 );
1609 assert!(
1611 update
1612 .nodes
1613 .iter()
1614 .any(|(_, n)| n.role() == Role::Label && n.value() == Some("Inbox")),
1615 "the headline text is announced"
1616 );
1617 assert_eq!(
1618 item.is_selected(),
1619 None,
1620 "an unselected row omits the flag entirely (accesskit's own guidance)"
1621 );
1622 }
1623
1624 #[test]
1625 fn interactive_row_semantics_carries_a_click_action() {
1626 fn logic(_s: &mut ()) -> ListItem<()> {
1627 list_item("Go").on_press(|_s: &mut ()| {})
1628 }
1629 let mut root: RenderRoot<(), ListItem<()>> = RenderRoot::new();
1630 let mut state = ();
1631 root.rebuild(&mut logic, &mut state);
1632 let mut tcx = TextContext::new();
1633 root.layout_with_text(Size::new(300.0, 100.0), &mut tcx as &mut dyn Any);
1634 let update = root.semantics();
1635 let (_, item) = update
1636 .nodes
1637 .iter()
1638 .find(|(_, n)| n.role() == Role::ListItem)
1639 .expect("row node");
1640 assert!(
1641 item.supports_action(Action::Click),
1642 "an interactive row exposes the Click action"
1643 );
1644 assert!(!item.is_disabled());
1645 }
1646
1647 #[test]
1648 fn semantics_disabled_row_reports_disabled_and_selected_row_reports_selection() {
1649 fn disabled_logic(_s: &mut ()) -> ListItem<()> {
1650 list_item("Go").on_press(|_s: &mut ()| {}).enabled(false)
1651 }
1652 let mut root: RenderRoot<(), ListItem<()>> = RenderRoot::new();
1653 let mut state = ();
1654 root.rebuild(&mut disabled_logic, &mut state);
1655 let mut tcx = TextContext::new();
1656 root.layout_with_text(Size::new(300.0, 100.0), &mut tcx as &mut dyn Any);
1657 let update = root.semantics();
1658 let (_, item) = update
1659 .nodes
1660 .iter()
1661 .find(|(_, n)| n.role() == Role::ListItem)
1662 .expect("row node");
1663 assert!(item.is_disabled());
1664 assert!(!item.supports_action(Action::Click));
1665
1666 fn selected_logic(_s: &mut ()) -> ListItem<()> {
1667 list_item("Go").selected(true)
1668 }
1669 let mut root: RenderRoot<(), ListItem<()>> = RenderRoot::new();
1670 root.rebuild(&mut selected_logic, &mut state);
1671 root.layout_with_text(Size::new(300.0, 100.0), &mut tcx as &mut dyn Any);
1672 let update = root.semantics();
1673 let (_, item) = update
1674 .nodes
1675 .iter()
1676 .find(|(_, n)| n.role() == Role::ListItem)
1677 .expect("row node");
1678 assert_eq!(item.is_selected(), Some(true));
1679 }
1680
1681 #[cfg(feature = "bundled-fonts")]
1682 #[test]
1683 fn every_text_slot_paints_in_roboto_flex_under_the_material_theme() {
1684 use crate::appbar::top::typeface_probe::{Face, assert_paints_only_in};
1688 let flex = crate::tokens::font_data()[0];
1689 let runs = assert_paints_only_in(
1690 "the list item's text slots",
1691 |_: &mut ()| {
1692 list_item::<()>("Headline")
1693 .overline("Overline")
1694 .supporting("Supporting text")
1695 },
1696 crate::baseline(),
1697 &[flex],
1698 Size::new(300.0, 100.0),
1699 Face {
1700 bytes: flex,
1701 name: "Roboto Flex",
1702 },
1703 );
1704 assert!(
1705 runs >= 3,
1706 "fixture sanity: expected a run per text slot, got {runs}"
1707 );
1708 }
1709}