1use std::rc::Rc;
103
104use frust::authoring::{Action, Role, Toggled};
105use frust::authoring::{
106 BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent, LayoutCtx,
107 PaintCtx, PaintScene, PointerPhase, SemanticsCtx, View, Widget, any,
108};
109use frust::authoring::{ThemeTextColor, ThemeTextType};
110use frust::{IconData, ShapeScale, Theme};
111use kurbo::{Affine, Point, Rect, RoundedRect, Shape, Size};
112use peniko::{Brush, Color};
113
114use super::icons;
115use super::press::presses;
116use super::state_layer::StateLayer;
117
118const CHIP_HEIGHT: f64 = 32.0;
120const CHIP_ICON_SIZE: f64 = 18.0;
122const CHIP_LABEL_START_PAD: f64 = 16.0;
124const CHIP_ICON_START_PAD: f64 = 8.0;
126const CHIP_END_PAD: f64 = 12.0;
130const CHIP_ICON_LABEL_GAP: f64 = 8.0;
133const CHIP_RADIUS: f64 = 8.0;
136const CHIP_STROKE_WIDTH: f64 = 1.0;
138const CHIP_STROKE_TOLERANCE: f64 = 0.1;
141
142const CHIP_SELECTED_CONTAINER: Color = Color::from_rgb8(0xE8, 0xDE, 0xF8);
145const CHIP_ELEVATED_CONTAINER: Color = Color::from_rgb8(0xF7, 0xF2, 0xFA);
148const CHIP_OUTLINE_VARIANT: Color = Color::from_rgb8(0xCA, 0xC4, 0xD0);
151const CHIP_LABEL_SELECTED: Color = Color::from_rgb8(0x1D, 0x19, 0x2B);
159const CHIP_LABEL_UNSELECTED: Color = Color::from_rgb8(0x49, 0x45, 0x4F);
162
163const CHIP_SHADOW_Y_OFFSET: f64 = 1.5;
167const CHIP_SHADOW_BLUR: f64 = 1.0;
170const CHIP_SHADOW_COLOR: Color = Color::new([0.0, 0.0, 0.0, 0.3]);
173
174fn with_alpha(color: Color, alpha: f32) -> Color {
177 let c = color.components;
178 Color::new([c[0], c[1], c[2], alpha])
179}
180
181fn inside(pos: Point, size: Size) -> bool {
182 pos.x >= 0.0 && pos.y >= 0.0 && pos.x < size.width && pos.y < size.height
183}
184
185fn resolve_radius(theme: Option<&Theme>, size: Size) -> f64 {
188 match theme {
189 Some(theme) => ShapeScale::resolve(theme.shape.small, size.width, size.height),
190 None => CHIP_RADIUS,
191 }
192}
193
194fn resolve_chip_container(theme: Option<&Theme>, selected: bool, elevated: bool) -> (Color, bool) {
199 let outlined = !selected && !elevated;
200 let container = if let Some(theme) = theme {
201 let scheme = theme.scheme();
202 if selected {
203 scheme.secondary_container
204 } else if elevated {
205 scheme.surface_container_low
206 } else {
207 Color::TRANSPARENT
208 }
209 } else if selected {
210 CHIP_SELECTED_CONTAINER
211 } else if elevated {
212 CHIP_ELEVATED_CONTAINER
213 } else {
214 Color::TRANSPARENT
215 };
216 (container, outlined)
217}
218
219fn resolve_chip_foreground(theme: Option<&Theme>, selected: bool) -> Color {
223 match theme {
224 Some(theme) => {
225 let scheme = theme.scheme();
226 if selected {
227 scheme.on_secondary_container
228 } else {
229 scheme.on_surface_variant
230 }
231 }
232 None => {
233 if selected {
234 CHIP_LABEL_SELECTED
235 } else {
236 CHIP_LABEL_UNSELECTED
237 }
238 }
239 }
240}
241
242fn resolve_chip_outline(theme: Option<&Theme>) -> Color {
245 match theme {
246 Some(theme) => theme.scheme().outline_variant,
247 None => CHIP_OUTLINE_VARIANT,
248 }
249}
250
251fn resolve_chip_shadow(theme: Option<&Theme>) -> (f64, f64, Color) {
255 match theme {
256 Some(theme) => {
257 let level = theme.elevation.level1;
258 let shadow = level.shadow(theme.brightness);
259 let color = with_alpha(theme.scheme().shadow, shadow.color_alpha);
260 (shadow.blur_std_dev, shadow.y_offset, color)
261 }
262 None => (CHIP_SHADOW_BLUR, CHIP_SHADOW_Y_OFFSET, CHIP_SHADOW_COLOR),
263 }
264}
265
266fn chip_label_view<State: 'static>(
272 text: String,
273 selected: bool,
274) -> frust::authoring::AnyView<State> {
275 let role = if selected {
276 ThemeTextColor::OnSurface
277 } else {
278 ThemeTextColor::OnSurfaceVariant
279 };
280 any::<State, _>(
281 frust::text(text)
282 .themed_role(role)
283 .themed_family(ThemeTextType::LabelLarge),
284 )
285}
286
287fn paint_chip_surface(
299 ctx: &mut PaintCtx,
300 scene: &mut dyn PaintScene,
301 selected: bool,
302 elevated: bool,
303 state_layer: &StateLayer,
304) -> Color {
305 let theme = Theme::from_paint_ctx(ctx);
306 let radius = resolve_radius(theme, ctx.size());
307 let (container, outlined) = resolve_chip_container(theme, selected, elevated);
308 let foreground = resolve_chip_foreground(theme, selected);
309 let o = ctx.origin();
310 let size = ctx.size();
311
312 if elevated {
313 let (blur, y_offset, shadow_color) = resolve_chip_shadow(theme);
314 scene.draw_shadow(
315 Point::new(o.x, o.y + y_offset),
316 size,
317 radius,
318 blur,
319 shadow_color,
320 );
321 }
322
323 if container != Color::TRANSPARENT {
324 scene.fill_rounded_rect(o, size, radius, container);
325 }
326
327 if outlined {
328 let outline = resolve_chip_outline(theme);
329 let half = CHIP_STROKE_WIDTH / 2.0;
333 let rr = RoundedRect::new(
334 half,
335 half,
336 size.width - half,
337 size.height - half,
338 (radius - half).max(0.0),
339 );
340 let path = rr.to_path(CHIP_STROKE_TOLERANCE);
341 scene.stroke_path(o, &path, CHIP_STROKE_WIDTH, &Brush::Solid(outline));
342 }
343
344 state_layer.paint(
348 ctx,
349 scene,
350 Rect::from_origin_size(o, size),
351 radius,
352 foreground,
353 );
354 foreground
355}
356
357type OnPress<State> = Rc<dyn Fn(&mut State)>;
360
361pub struct AssistChipView<State: 'static> {
367 label: String,
368 leading: Option<String>,
369 elevated: bool,
370 on_press: OnPress<State>,
371}
372
373pub fn assist_chip<State: 'static, F: Fn(&mut State) + 'static>(
376 label: impl Into<String>,
377 on_press: F,
378) -> AssistChipView<State> {
379 AssistChipView {
380 label: label.into(),
381 leading: None,
382 elevated: false,
383 on_press: Rc::new(on_press),
384 }
385}
386
387#[allow(non_snake_case)]
389pub fn AssistChip<State: 'static, F: Fn(&mut State) + 'static>(
390 label: impl Into<String>,
391 on_press: F,
392) -> AssistChipView<State> {
393 assist_chip(label, on_press)
394}
395
396impl<State: 'static> AssistChipView<State> {
397 pub fn leading(mut self, glyph: impl Into<String>) -> Self {
401 self.leading = Some(glyph.into());
402 self
403 }
404
405 pub fn elevated(mut self, elevated: bool) -> Self {
408 self.elevated = elevated;
409 self
410 }
411}
412
413pub struct AssistChipWidget {
415 leading: Option<ChildPod>,
416 leading_text: Option<String>,
417 label: ChildPod,
418 label_text: String,
419 elevated: bool,
420 pressed: bool,
422 captured: bool,
424 state_layer: StateLayer,
425 on_press: frust::authoring::ErasedCallback,
426}
427
428impl<State: 'static> View<State> for AssistChipView<State> {
429 type Element = AssistChipWidget;
430
431 fn build(&self, ctx: &mut BuildCtx<'_>) -> AssistChipWidget {
432 let label_view = chip_label_view::<State>(self.label.clone(), false);
433 let leading = self.leading.as_ref().map(|glyph| {
434 frust::authoring::build_child(&chip_label_view::<State>(glyph.clone(), false), ctx)
435 });
436 AssistChipWidget {
437 leading,
438 leading_text: self.leading.clone(),
439 label: frust::authoring::build_child(&label_view, ctx),
440 label_text: self.label.clone(),
441 elevated: self.elevated,
442 pressed: false,
443 captured: false,
444 state_layer: StateLayer::new(),
445 on_press: frust::authoring::erase_callback(&self.on_press),
446 }
447 }
448
449 fn rebuild(
450 &self,
451 prev: &Self,
452 element: &mut AssistChipWidget,
453 ctx: &mut BuildCtx<'_>,
454 ) -> ChangeFlags {
455 element.on_press = frust::authoring::erase_callback(&self.on_press);
456 let mut flags = ChangeFlags::NONE;
457
458 if prev.elevated != self.elevated {
459 element.elevated = self.elevated;
460 flags |= ChangeFlags::PAINT;
461 }
462
463 if prev.label != self.label {
464 element.label_text = self.label.clone();
465 let prev_view = chip_label_view::<State>(prev.label.clone(), false);
466 let next_view = chip_label_view::<State>(self.label.clone(), false);
467 flags |=
468 frust::authoring::rebuild_child(&prev_view, &next_view, &mut element.label, ctx);
469 }
470
471 match (&prev.leading, &self.leading) {
472 (None, None) => {}
473 (Some(prev_glyph), Some(next_glyph)) => {
474 element.leading_text = self.leading.clone();
475 let prev_view = chip_label_view::<State>(prev_glyph.clone(), false);
476 let next_view = chip_label_view::<State>(next_glyph.clone(), false);
477 let pod = element
478 .leading
479 .as_mut()
480 .expect("leading pod present when prev.leading is Some");
481 flags |= frust::authoring::rebuild_child(&prev_view, &next_view, pod, ctx);
482 }
483 (None, Some(next_glyph)) => {
484 element.leading_text = self.leading.clone();
485 element.leading = Some(frust::authoring::build_child(
486 &chip_label_view::<State>(next_glyph.clone(), false),
487 ctx,
488 ));
489 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
490 }
491 (Some(prev_glyph), None) => {
492 element.leading_text = None;
493 let prev_view = chip_label_view::<State>(prev_glyph.clone(), false);
494 let mut pod = element
495 .leading
496 .take()
497 .expect("leading pod present when prev.leading is Some");
498 frust::authoring::teardown_child(&prev_view, &mut pod, ctx);
499 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
500 }
501 }
502
503 flags
504 }
505
506 fn teardown(&self, element: &mut AssistChipWidget, ctx: &mut BuildCtx<'_>) {
507 let label_view = chip_label_view::<State>(self.label.clone(), false);
508 frust::authoring::teardown_child(&label_view, &mut element.label, ctx);
509 if let (Some(glyph), Some(pod)) = (&self.leading, &mut element.leading) {
510 let leading_view = chip_label_view::<State>(glyph.clone(), false);
511 frust::authoring::teardown_child(&leading_view, pod, ctx);
512 }
513 }
514}
515
516impl Widget for AssistChipWidget {
517 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
518 let inner_max = Size::new(f64::INFINITY, CHIP_HEIGHT);
519 let mut x = if self.leading.is_some() {
520 CHIP_ICON_START_PAD
521 } else {
522 CHIP_LABEL_START_PAD
523 };
524 if let Some(leading) = &mut self.leading {
525 let size = leading.layout_child(ctx, &BoxConstraints::loose(inner_max));
526 leading.set_origin(Point::new(x, (CHIP_HEIGHT - size.height) / 2.0));
527 x += size.width + CHIP_ICON_LABEL_GAP;
528 }
529 let label_size = self
530 .label
531 .layout_child(ctx, &BoxConstraints::loose(inner_max));
532 self.label
533 .set_origin(Point::new(x, (CHIP_HEIGHT - label_size.height) / 2.0));
534 x += label_size.width + CHIP_END_PAD;
535 bc.constrain(Size::new(x, CHIP_HEIGHT))
536 }
537
538 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
539 paint_chip_surface(ctx, scene, false, self.elevated, &self.state_layer);
540 if let Some(leading) = &mut self.leading {
541 leading.paint_child(ctx, scene);
542 }
543 self.label.paint_child(ctx, scene);
544 }
545
546 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
547 let InputEvent::Pointer(p) = event else {
548 return EventResult::Ignored;
549 };
550 match p.phase {
551 PointerPhase::Down => {
552 if !presses(p) {
553 return EventResult::Ignored;
554 }
555 self.pressed = true;
556 self.captured = true;
557 self.state_layer.set_pressed(true);
558 ctx.capture_pointer();
559 ctx.request_redraw();
560 EventResult::Handled
561 }
562 PointerPhase::Move => {
563 if !self.captured {
564 return EventResult::Ignored;
565 }
566 let inside_now = inside(p.position, ctx.size());
567 self.pressed = inside_now;
568 self.state_layer.set_pressed(inside_now);
569 ctx.request_redraw();
570 EventResult::Handled
571 }
572 PointerPhase::Up => {
573 if !self.captured {
574 return EventResult::Ignored;
575 }
576 if inside(p.position, ctx.size()) {
577 (self.on_press)(ctx);
578 }
579 self.pressed = false;
580 self.captured = false;
581 self.state_layer.set_pressed(false);
582 ctx.request_redraw();
583 EventResult::Handled
584 }
585 PointerPhase::Cancel => {
586 if !self.captured {
587 return EventResult::Ignored;
588 }
589 self.pressed = false;
590 self.captured = false;
591 self.state_layer.set_pressed(false);
592 ctx.request_redraw();
593 EventResult::Handled
594 }
595 }
596 }
597
598 fn semantics(&self, ctx: &mut SemanticsCtx) {
599 ctx.push_node(Role::Button, |node| {
600 node.set_label(self.label_text.as_str());
601 node.add_action(Action::Click);
602 });
603 }
604
605 frust::authoring::visit_children!(leading, label);
606}
607
608type OnSelect<State> = Rc<dyn Fn(&mut State, bool)>;
614
615pub struct FilterChipView<State: 'static> {
617 label: String,
618 selected: bool,
619 elevated: bool,
620 on_select: OnSelect<State>,
621}
622
623pub fn filter_chip<State: 'static, F: Fn(&mut State, bool) + 'static>(
626 label: impl Into<String>,
627 selected: bool,
628 on_select: F,
629) -> FilterChipView<State> {
630 FilterChipView {
631 label: label.into(),
632 selected,
633 elevated: false,
634 on_select: Rc::new(on_select),
635 }
636}
637
638#[allow(non_snake_case)]
640pub fn FilterChip<State: 'static, F: Fn(&mut State, bool) + 'static>(
641 label: impl Into<String>,
642 selected: bool,
643 on_select: F,
644) -> FilterChipView<State> {
645 filter_chip(label, selected, on_select)
646}
647
648impl<State: 'static> FilterChipView<State> {
649 pub fn elevated(mut self, elevated: bool) -> Self {
652 self.elevated = elevated;
653 self
654 }
655}
656
657pub struct FilterChipWidget {
659 selected: bool,
661 elevated: bool,
662 label: ChildPod,
663 label_text: String,
664 pressed: bool,
666 captured: bool,
668 state_layer: StateLayer,
669 on_select: frust::authoring::ErasedArgCallback<bool>,
670}
671
672impl<State: 'static> View<State> for FilterChipView<State> {
673 type Element = FilterChipWidget;
674
675 fn build(&self, ctx: &mut BuildCtx<'_>) -> FilterChipWidget {
676 let label_view = chip_label_view::<State>(self.label.clone(), self.selected);
677 FilterChipWidget {
678 selected: self.selected,
679 elevated: self.elevated,
680 label: frust::authoring::build_child(&label_view, ctx),
681 label_text: self.label.clone(),
682 pressed: false,
683 captured: false,
684 state_layer: StateLayer::new(),
685 on_select: frust::authoring::erase_callback_arg(&self.on_select),
686 }
687 }
688
689 fn rebuild(
690 &self,
691 prev: &Self,
692 element: &mut FilterChipWidget,
693 ctx: &mut BuildCtx<'_>,
694 ) -> ChangeFlags {
695 element.on_select = frust::authoring::erase_callback_arg(&self.on_select);
696 let mut flags = ChangeFlags::NONE;
697 if prev.selected != self.selected {
698 element.selected = self.selected;
700 flags |= ChangeFlags::PAINT;
701 }
702 if prev.elevated != self.elevated {
703 element.elevated = self.elevated;
704 flags |= ChangeFlags::PAINT;
705 }
706 if prev.label != self.label || prev.selected != self.selected {
707 element.label_text = self.label.clone();
711 let prev_view = chip_label_view::<State>(prev.label.clone(), prev.selected);
712 let next_view = chip_label_view::<State>(self.label.clone(), self.selected);
713 flags |=
714 frust::authoring::rebuild_child(&prev_view, &next_view, &mut element.label, ctx);
715 }
716 flags
717 }
718
719 fn teardown(&self, element: &mut FilterChipWidget, ctx: &mut BuildCtx<'_>) {
720 let label_view = chip_label_view::<State>(self.label.clone(), self.selected);
721 frust::authoring::teardown_child(&label_view, &mut element.label, ctx);
722 }
723}
724
725impl Widget for FilterChipWidget {
726 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
727 let inner_max = Size::new(f64::INFINITY, CHIP_HEIGHT);
728 let label_size = self
729 .label
730 .layout_child(ctx, &BoxConstraints::loose(inner_max));
731 self.label.set_origin(Point::new(
732 CHIP_LABEL_START_PAD,
733 (CHIP_HEIGHT - label_size.height) / 2.0,
734 ));
735 bc.constrain(Size::new(
736 label_size.width + CHIP_LABEL_START_PAD + CHIP_END_PAD,
737 CHIP_HEIGHT,
738 ))
739 }
740
741 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
742 paint_chip_surface(ctx, scene, self.selected, self.elevated, &self.state_layer);
743 self.label.paint_child(ctx, scene);
744 }
745
746 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
747 let InputEvent::Pointer(p) = event else {
748 return EventResult::Ignored;
749 };
750 match p.phase {
751 PointerPhase::Down => {
752 if !presses(p) {
753 return EventResult::Ignored;
754 }
755 self.pressed = true;
756 self.captured = true;
757 self.state_layer.set_pressed(true);
758 ctx.capture_pointer();
759 ctx.request_redraw();
760 EventResult::Handled
761 }
762 PointerPhase::Move => {
763 if !self.captured {
764 return EventResult::Ignored;
765 }
766 let inside_now = inside(p.position, ctx.size());
767 self.pressed = inside_now;
768 self.state_layer.set_pressed(inside_now);
769 ctx.request_redraw();
770 EventResult::Handled
771 }
772 PointerPhase::Up => {
773 if !self.captured {
774 return EventResult::Ignored;
775 }
776 if inside(p.position, ctx.size()) {
777 let requested = !self.selected;
779 (self.on_select)(ctx, requested);
780 }
781 self.pressed = false;
782 self.captured = false;
783 self.state_layer.set_pressed(false);
784 ctx.request_redraw();
785 EventResult::Handled
786 }
787 PointerPhase::Cancel => {
788 if !self.captured {
789 return EventResult::Ignored;
790 }
791 self.pressed = false;
792 self.captured = false;
793 self.state_layer.set_pressed(false);
794 ctx.request_redraw();
795 EventResult::Handled
796 }
797 }
798 }
799
800 fn semantics(&self, ctx: &mut SemanticsCtx) {
801 ctx.push_node(Role::Button, |node| {
806 node.set_label(self.label_text.as_str());
807 node.set_toggled(Toggled::from(self.selected));
808 node.add_action(Action::Click);
809 });
810 }
811
812 frust::authoring::visit_children!(label);
813}
814
815pub struct SuggestionChipView<State: 'static> {
824 label: String,
825 leading: Option<String>,
826 elevated: bool,
827 on_press: OnPress<State>,
828}
829
830pub fn suggestion_chip<State: 'static, F: Fn(&mut State) + 'static>(
833 label: impl Into<String>,
834 on_press: F,
835) -> SuggestionChipView<State> {
836 SuggestionChipView {
837 label: label.into(),
838 leading: None,
839 elevated: false,
840 on_press: Rc::new(on_press),
841 }
842}
843
844#[allow(non_snake_case)]
846pub fn SuggestionChip<State: 'static, F: Fn(&mut State) + 'static>(
847 label: impl Into<String>,
848 on_press: F,
849) -> SuggestionChipView<State> {
850 suggestion_chip(label, on_press)
851}
852
853impl<State: 'static> SuggestionChipView<State> {
854 pub fn leading(mut self, glyph: impl Into<String>) -> Self {
858 self.leading = Some(glyph.into());
859 self
860 }
861
862 pub fn elevated(mut self, elevated: bool) -> Self {
865 self.elevated = elevated;
866 self
867 }
868}
869
870pub struct SuggestionChipWidget {
872 leading: Option<ChildPod>,
873 leading_text: Option<String>,
874 label: ChildPod,
875 label_text: String,
876 elevated: bool,
877 pressed: bool,
878 captured: bool,
879 state_layer: StateLayer,
880 on_press: frust::authoring::ErasedCallback,
881}
882
883impl<State: 'static> View<State> for SuggestionChipView<State> {
884 type Element = SuggestionChipWidget;
885
886 fn build(&self, ctx: &mut BuildCtx<'_>) -> SuggestionChipWidget {
887 let label_view = chip_label_view::<State>(self.label.clone(), false);
888 let leading = self.leading.as_ref().map(|glyph| {
889 frust::authoring::build_child(&chip_label_view::<State>(glyph.clone(), false), ctx)
890 });
891 SuggestionChipWidget {
892 leading,
893 leading_text: self.leading.clone(),
894 label: frust::authoring::build_child(&label_view, ctx),
895 label_text: self.label.clone(),
896 elevated: self.elevated,
897 pressed: false,
898 captured: false,
899 state_layer: StateLayer::new(),
900 on_press: frust::authoring::erase_callback(&self.on_press),
901 }
902 }
903
904 fn rebuild(
905 &self,
906 prev: &Self,
907 element: &mut SuggestionChipWidget,
908 ctx: &mut BuildCtx<'_>,
909 ) -> ChangeFlags {
910 element.on_press = frust::authoring::erase_callback(&self.on_press);
911 let mut flags = ChangeFlags::NONE;
912
913 if prev.elevated != self.elevated {
914 element.elevated = self.elevated;
915 flags |= ChangeFlags::PAINT;
916 }
917
918 if prev.label != self.label {
919 element.label_text = self.label.clone();
920 let prev_view = chip_label_view::<State>(prev.label.clone(), false);
921 let next_view = chip_label_view::<State>(self.label.clone(), false);
922 flags |=
923 frust::authoring::rebuild_child(&prev_view, &next_view, &mut element.label, ctx);
924 }
925
926 match (&prev.leading, &self.leading) {
927 (None, None) => {}
928 (Some(prev_glyph), Some(next_glyph)) => {
929 element.leading_text = self.leading.clone();
930 let prev_view = chip_label_view::<State>(prev_glyph.clone(), false);
931 let next_view = chip_label_view::<State>(next_glyph.clone(), false);
932 let pod = element
933 .leading
934 .as_mut()
935 .expect("leading pod present when prev.leading is Some");
936 flags |= frust::authoring::rebuild_child(&prev_view, &next_view, pod, ctx);
937 }
938 (None, Some(next_glyph)) => {
939 element.leading_text = self.leading.clone();
940 element.leading = Some(frust::authoring::build_child(
941 &chip_label_view::<State>(next_glyph.clone(), false),
942 ctx,
943 ));
944 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
945 }
946 (Some(prev_glyph), None) => {
947 element.leading_text = None;
948 let prev_view = chip_label_view::<State>(prev_glyph.clone(), false);
949 let mut pod = element
950 .leading
951 .take()
952 .expect("leading pod present when prev.leading is Some");
953 frust::authoring::teardown_child(&prev_view, &mut pod, ctx);
954 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
955 }
956 }
957
958 flags
959 }
960
961 fn teardown(&self, element: &mut SuggestionChipWidget, ctx: &mut BuildCtx<'_>) {
962 let label_view = chip_label_view::<State>(self.label.clone(), false);
963 frust::authoring::teardown_child(&label_view, &mut element.label, ctx);
964 if let (Some(glyph), Some(pod)) = (&self.leading, &mut element.leading) {
965 let leading_view = chip_label_view::<State>(glyph.clone(), false);
966 frust::authoring::teardown_child(&leading_view, pod, ctx);
967 }
968 }
969}
970
971impl Widget for SuggestionChipWidget {
972 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
973 let inner_max = Size::new(f64::INFINITY, CHIP_HEIGHT);
974 let mut x = if self.leading.is_some() {
975 CHIP_ICON_START_PAD
976 } else {
977 CHIP_LABEL_START_PAD
978 };
979 if let Some(leading) = &mut self.leading {
980 let size = leading.layout_child(ctx, &BoxConstraints::loose(inner_max));
981 leading.set_origin(Point::new(x, (CHIP_HEIGHT - size.height) / 2.0));
982 x += size.width + CHIP_ICON_LABEL_GAP;
983 }
984 let label_size = self
985 .label
986 .layout_child(ctx, &BoxConstraints::loose(inner_max));
987 self.label
988 .set_origin(Point::new(x, (CHIP_HEIGHT - label_size.height) / 2.0));
989 x += label_size.width + CHIP_END_PAD;
990 bc.constrain(Size::new(x, CHIP_HEIGHT))
991 }
992
993 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
994 paint_chip_surface(ctx, scene, false, self.elevated, &self.state_layer);
995 if let Some(leading) = &mut self.leading {
996 leading.paint_child(ctx, scene);
997 }
998 self.label.paint_child(ctx, scene);
999 }
1000
1001 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1002 let InputEvent::Pointer(p) = event else {
1003 return EventResult::Ignored;
1004 };
1005 match p.phase {
1006 PointerPhase::Down => {
1007 if !presses(p) {
1008 return EventResult::Ignored;
1009 }
1010 self.pressed = true;
1011 self.captured = true;
1012 self.state_layer.set_pressed(true);
1013 ctx.capture_pointer();
1014 ctx.request_redraw();
1015 EventResult::Handled
1016 }
1017 PointerPhase::Move => {
1018 if !self.captured {
1019 return EventResult::Ignored;
1020 }
1021 let inside_now = inside(p.position, ctx.size());
1022 self.pressed = inside_now;
1023 self.state_layer.set_pressed(inside_now);
1024 ctx.request_redraw();
1025 EventResult::Handled
1026 }
1027 PointerPhase::Up => {
1028 if !self.captured {
1029 return EventResult::Ignored;
1030 }
1031 if inside(p.position, ctx.size()) {
1032 (self.on_press)(ctx);
1033 }
1034 self.pressed = false;
1035 self.captured = false;
1036 self.state_layer.set_pressed(false);
1037 ctx.request_redraw();
1038 EventResult::Handled
1039 }
1040 PointerPhase::Cancel => {
1041 if !self.captured {
1042 return EventResult::Ignored;
1043 }
1044 self.pressed = false;
1045 self.captured = false;
1046 self.state_layer.set_pressed(false);
1047 ctx.request_redraw();
1048 EventResult::Handled
1049 }
1050 }
1051 }
1052
1053 fn semantics(&self, ctx: &mut SemanticsCtx) {
1054 ctx.push_node(Role::Button, |node| {
1055 node.set_label(self.label_text.as_str());
1056 node.add_action(Action::Click);
1057 });
1058 }
1059
1060 frust::authoring::visit_children!(leading, label);
1061}
1062
1063#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1073enum InputRegion {
1074 Body,
1075 Delete,
1076}
1077
1078pub struct InputChipView<State: 'static> {
1081 label: String,
1082 leading: Option<String>,
1083 elevated: bool,
1084 on_press: OnPress<State>,
1085 on_deleted: Option<OnPress<State>>,
1086}
1087
1088pub fn input_chip<State: 'static, F: Fn(&mut State) + 'static>(
1092 label: impl Into<String>,
1093 on_press: F,
1094) -> InputChipView<State> {
1095 InputChipView {
1096 label: label.into(),
1097 leading: None,
1098 elevated: false,
1099 on_press: Rc::new(on_press),
1100 on_deleted: None,
1101 }
1102}
1103
1104#[allow(non_snake_case)]
1106pub fn InputChip<State: 'static, F: Fn(&mut State) + 'static>(
1107 label: impl Into<String>,
1108 on_press: F,
1109) -> InputChipView<State> {
1110 input_chip(label, on_press)
1111}
1112
1113impl<State: 'static> InputChipView<State> {
1114 pub fn leading(mut self, glyph: impl Into<String>) -> Self {
1118 self.leading = Some(glyph.into());
1119 self
1120 }
1121
1122 pub fn elevated(mut self, elevated: bool) -> Self {
1125 self.elevated = elevated;
1126 self
1127 }
1128
1129 pub fn on_deleted<F: Fn(&mut State) + 'static>(mut self, on_deleted: F) -> Self {
1134 self.on_deleted = Some(Rc::new(on_deleted));
1135 self
1136 }
1137}
1138
1139pub struct InputChipWidget {
1141 leading: Option<ChildPod>,
1142 leading_text: Option<String>,
1143 label: ChildPod,
1144 label_text: String,
1145 elevated: bool,
1146 has_delete: bool,
1147 delete_rect: Rect,
1150 armed: Option<InputRegion>,
1152 pressed_inside: bool,
1154 state_layer: StateLayer,
1155 on_press: frust::authoring::ErasedCallback,
1156 on_deleted: Option<frust::authoring::ErasedCallback>,
1157}
1158
1159impl<State: 'static> View<State> for InputChipView<State> {
1160 type Element = InputChipWidget;
1161
1162 fn build(&self, ctx: &mut BuildCtx<'_>) -> InputChipWidget {
1163 let label_view = chip_label_view::<State>(self.label.clone(), false);
1164 let leading = self.leading.as_ref().map(|glyph| {
1165 frust::authoring::build_child(&chip_label_view::<State>(glyph.clone(), false), ctx)
1166 });
1167 InputChipWidget {
1168 leading,
1169 leading_text: self.leading.clone(),
1170 label: frust::authoring::build_child(&label_view, ctx),
1171 label_text: self.label.clone(),
1172 elevated: self.elevated,
1173 has_delete: self.on_deleted.is_some(),
1174 delete_rect: Rect::ZERO,
1175 armed: None,
1176 pressed_inside: false,
1177 state_layer: StateLayer::new(),
1178 on_press: frust::authoring::erase_callback(&self.on_press),
1179 on_deleted: self
1180 .on_deleted
1181 .as_ref()
1182 .map(frust::authoring::erase_callback),
1183 }
1184 }
1185
1186 fn rebuild(
1187 &self,
1188 prev: &Self,
1189 element: &mut InputChipWidget,
1190 ctx: &mut BuildCtx<'_>,
1191 ) -> ChangeFlags {
1192 element.on_press = frust::authoring::erase_callback(&self.on_press);
1193 element.on_deleted = self
1194 .on_deleted
1195 .as_ref()
1196 .map(frust::authoring::erase_callback);
1197 let mut flags = ChangeFlags::NONE;
1198
1199 if prev.elevated != self.elevated {
1200 element.elevated = self.elevated;
1201 flags |= ChangeFlags::PAINT;
1202 }
1203
1204 let now_has_delete = self.on_deleted.is_some();
1205 if element.has_delete != now_has_delete {
1206 element.has_delete = now_has_delete;
1207 if !now_has_delete && element.armed == Some(InputRegion::Delete) {
1211 element.armed = None;
1212 element.pressed_inside = false;
1213 }
1214 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1215 }
1216
1217 if prev.label != self.label {
1218 element.label_text = self.label.clone();
1219 let prev_view = chip_label_view::<State>(prev.label.clone(), false);
1220 let next_view = chip_label_view::<State>(self.label.clone(), false);
1221 flags |=
1222 frust::authoring::rebuild_child(&prev_view, &next_view, &mut element.label, ctx);
1223 }
1224
1225 match (&prev.leading, &self.leading) {
1226 (None, None) => {}
1227 (Some(prev_glyph), Some(next_glyph)) => {
1228 element.leading_text = self.leading.clone();
1229 let prev_view = chip_label_view::<State>(prev_glyph.clone(), false);
1230 let next_view = chip_label_view::<State>(next_glyph.clone(), false);
1231 let pod = element
1232 .leading
1233 .as_mut()
1234 .expect("leading pod present when prev.leading is Some");
1235 flags |= frust::authoring::rebuild_child(&prev_view, &next_view, pod, ctx);
1236 }
1237 (None, Some(next_glyph)) => {
1238 element.leading_text = self.leading.clone();
1239 element.leading = Some(frust::authoring::build_child(
1240 &chip_label_view::<State>(next_glyph.clone(), false),
1241 ctx,
1242 ));
1243 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1244 }
1245 (Some(prev_glyph), None) => {
1246 element.leading_text = None;
1247 let prev_view = chip_label_view::<State>(prev_glyph.clone(), false);
1248 let mut pod = element
1249 .leading
1250 .take()
1251 .expect("leading pod present when prev.leading is Some");
1252 frust::authoring::teardown_child(&prev_view, &mut pod, ctx);
1253 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1254 }
1255 }
1256
1257 flags
1258 }
1259
1260 fn teardown(&self, element: &mut InputChipWidget, ctx: &mut BuildCtx<'_>) {
1261 let label_view = chip_label_view::<State>(self.label.clone(), false);
1262 frust::authoring::teardown_child(&label_view, &mut element.label, ctx);
1263 if let (Some(glyph), Some(pod)) = (&self.leading, &mut element.leading) {
1264 let leading_view = chip_label_view::<State>(glyph.clone(), false);
1265 frust::authoring::teardown_child(&leading_view, pod, ctx);
1266 }
1267 }
1268}
1269
1270impl Widget for InputChipWidget {
1271 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1272 let inner_max = Size::new(f64::INFINITY, CHIP_HEIGHT);
1273 let mut x = if self.leading.is_some() {
1274 CHIP_ICON_START_PAD
1275 } else {
1276 CHIP_LABEL_START_PAD
1277 };
1278 if let Some(leading) = &mut self.leading {
1279 let size = leading.layout_child(ctx, &BoxConstraints::loose(inner_max));
1280 leading.set_origin(Point::new(x, (CHIP_HEIGHT - size.height) / 2.0));
1281 x += size.width + CHIP_ICON_LABEL_GAP;
1282 }
1283 let label_size = self
1284 .label
1285 .layout_child(ctx, &BoxConstraints::loose(inner_max));
1286 self.label
1287 .set_origin(Point::new(x, (CHIP_HEIGHT - label_size.height) / 2.0));
1288 x += label_size.width;
1289 if self.has_delete {
1290 x += CHIP_ICON_LABEL_GAP;
1291 let icon_y = (CHIP_HEIGHT - CHIP_ICON_SIZE) / 2.0;
1292 self.delete_rect = Rect::new(x, icon_y, x + CHIP_ICON_SIZE, icon_y + CHIP_ICON_SIZE);
1293 x += CHIP_ICON_SIZE;
1294 } else {
1295 self.delete_rect = Rect::ZERO;
1296 }
1297 x += CHIP_END_PAD;
1298 bc.constrain(Size::new(x, CHIP_HEIGHT))
1299 }
1300
1301 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
1302 let foreground = paint_chip_surface(ctx, scene, false, self.elevated, &self.state_layer);
1305 if let Some(leading) = &mut self.leading {
1306 leading.paint_child(ctx, scene);
1307 }
1308 self.label.paint_child(ctx, scene);
1309 if self.has_delete {
1310 let (base_path, design) = IconData::from(icons::CLOSE).resolve();
1311 let scale = if design > 0.0 {
1312 CHIP_ICON_SIZE / design
1313 } else {
1314 1.0
1315 };
1316 let scaled = Affine::scale(scale) * base_path;
1317 let icon_origin = Point::new(
1318 ctx.origin().x + self.delete_rect.x0,
1319 ctx.origin().y + self.delete_rect.y0,
1320 );
1321 scene.fill_path(icon_origin, &scaled, &Brush::Solid(foreground));
1322 }
1323 }
1324
1325 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1326 let InputEvent::Pointer(p) = event else {
1327 return EventResult::Ignored;
1328 };
1329 match p.phase {
1330 PointerPhase::Down => {
1331 if !presses(p) {
1332 return EventResult::Ignored;
1333 }
1334 let region = if self.has_delete && self.delete_rect.contains(p.position) {
1335 InputRegion::Delete
1336 } else if inside(p.position, ctx.size()) {
1337 InputRegion::Body
1338 } else {
1339 return EventResult::Ignored;
1340 };
1341 self.armed = Some(region);
1342 self.pressed_inside = true;
1343 if region == InputRegion::Body {
1344 self.state_layer.set_pressed(true);
1349 }
1350 ctx.capture_pointer();
1351 ctx.request_redraw();
1352 EventResult::Handled
1353 }
1354 PointerPhase::Move => {
1355 let Some(region) = self.armed else {
1356 return EventResult::Ignored;
1357 };
1358 let now_inside = match region {
1359 InputRegion::Delete => self.delete_rect.contains(p.position),
1360 InputRegion::Body => inside(p.position, ctx.size()),
1361 };
1362 self.pressed_inside = now_inside;
1363 if region == InputRegion::Body {
1364 self.state_layer.set_pressed(now_inside);
1365 }
1366 ctx.request_redraw();
1367 EventResult::Handled
1368 }
1369 PointerPhase::Up => {
1370 let Some(region) = self.armed else {
1371 return EventResult::Ignored;
1372 };
1373 if self.pressed_inside {
1374 match region {
1375 InputRegion::Body => (self.on_press)(ctx),
1376 InputRegion::Delete => {
1377 if let Some(on_deleted) = &mut self.on_deleted {
1378 (on_deleted)(ctx);
1379 }
1380 }
1381 }
1382 }
1383 self.armed = None;
1384 self.pressed_inside = false;
1385 self.state_layer.set_pressed(false);
1386 ctx.request_redraw();
1387 EventResult::Handled
1388 }
1389 PointerPhase::Cancel => {
1390 if self.armed.is_none() {
1391 return EventResult::Ignored;
1392 }
1393 self.armed = None;
1394 self.pressed_inside = false;
1395 self.state_layer.set_pressed(false);
1396 ctx.request_redraw();
1397 EventResult::Handled
1398 }
1399 }
1400 }
1401
1402 fn semantics(&self, ctx: &mut SemanticsCtx) {
1403 if self.has_delete {
1404 ctx.push_container(
1407 Role::Group,
1408 |_| {},
1409 |ctx| {
1410 ctx.push_node(Role::Button, |node| {
1411 node.set_label(self.label_text.as_str());
1412 node.add_action(Action::Click);
1413 });
1414 ctx.push_node(Role::Button, |node| {
1415 node.set_label("Remove");
1416 node.add_action(Action::Click);
1417 });
1418 },
1419 );
1420 } else {
1421 ctx.push_node(Role::Button, |node| {
1422 node.set_label(self.label_text.as_str());
1423 node.add_action(Action::Click);
1424 });
1425 }
1426 }
1427
1428 frust::authoring::visit_children!(leading, label);
1429}
1430
1431#[cfg(test)]
1432mod tests {
1433 use super::*;
1434 use std::any::Any;
1435
1436 fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
1437 InputEvent::Pointer(frust::authoring::PointerEvent {
1438 phase,
1439 position: Point::new(x, y),
1440 button: frust::authoring::PointerButton::Primary,
1441 })
1442 }
1443
1444 #[derive(Default)]
1449 struct ChipRecorder {
1450 rrects: Vec<(Point, Size, f64, Color)>,
1451 shadows: Vec<(Point, Size, f64, f64, Color)>,
1452 strokes: Vec<(Point, f64, Color)>,
1453 fills: Vec<(Point, Color)>,
1454 }
1455
1456 impl PaintScene for ChipRecorder {
1457 fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
1458 fn draw_text(&mut self, _o: Point, _t: &str) {}
1459 fn fill_rounded_rect(&mut self, o: Point, s: Size, radius: f64, color: Color) {
1460 self.rrects.push((o, s, radius, color));
1461 }
1462 fn draw_shadow(&mut self, o: Point, s: Size, radius: f64, std_dev: f64, color: Color) {
1463 self.shadows.push((o, s, radius, std_dev, color));
1464 }
1465 fn stroke_path(
1466 &mut self,
1467 origin: Point,
1468 _path: &kurbo::BezPath,
1469 width: f64,
1470 brush: &Brush,
1471 ) {
1472 if let Brush::Solid(color) = brush {
1473 self.strokes.push((origin, width, *color));
1474 }
1475 }
1476 fn fill_path(&mut self, origin: Point, _path: &kurbo::BezPath, brush: &Brush) {
1477 if let Brush::Solid(color) = brush {
1478 self.fills.push((origin, *color));
1479 }
1480 }
1481 }
1482
1483 fn paint_rec(w: &mut dyn Widget, size: Size, theme: Option<&Theme>) -> ChipRecorder {
1484 let mut rec = ChipRecorder::default();
1485 let mut ctx = match theme {
1486 Some(t) => PaintCtx::new(Point::ZERO, size).with_theme(t),
1487 None => PaintCtx::new(Point::ZERO, size),
1488 };
1489 w.paint(&mut ctx, &mut rec);
1490 rec
1491 }
1492
1493 mod resolution_matrix {
1496 use super::*;
1497
1498 #[test]
1499 fn unthemed_unselected_unelevated_is_transparent_and_outlined() {
1500 let (container, outlined) = resolve_chip_container(None, false, false);
1501 assert_eq!(container, Color::TRANSPARENT);
1502 assert!(outlined);
1503 }
1504
1505 #[test]
1506 fn unthemed_elevated_unselected_is_elevated_container_not_outlined() {
1507 let (container, outlined) = resolve_chip_container(None, false, true);
1508 assert_eq!(container, CHIP_ELEVATED_CONTAINER);
1509 assert!(!outlined);
1510 }
1511
1512 #[test]
1513 fn unthemed_selected_is_selected_container_not_outlined_regardless_of_elevated() {
1514 let (container, outlined) = resolve_chip_container(None, true, false);
1515 assert_eq!(container, CHIP_SELECTED_CONTAINER);
1516 assert!(!outlined);
1517
1518 let (container, outlined) = resolve_chip_container(None, true, true);
1519 assert_eq!(container, CHIP_SELECTED_CONTAINER);
1520 assert!(!outlined);
1521 }
1522
1523 #[test]
1524 fn themed_matrix_resolves_exact_scheme_roles() {
1525 let theme = crate::baseline();
1526 let scheme = theme.scheme();
1527
1528 let (unselected_unelevated, outlined) =
1529 resolve_chip_container(Some(&theme), false, false);
1530 assert_eq!(unselected_unelevated, Color::TRANSPARENT);
1531 assert!(outlined);
1532
1533 let (elevated, outlined) = resolve_chip_container(Some(&theme), false, true);
1534 assert_eq!(elevated, scheme.surface_container_low);
1535 assert!(!outlined);
1536
1537 let (selected, outlined) = resolve_chip_container(Some(&theme), true, false);
1538 assert_eq!(selected, scheme.secondary_container);
1539 assert!(!outlined);
1540
1541 let (selected_elevated, outlined) = resolve_chip_container(Some(&theme), true, true);
1545 assert_eq!(selected_elevated, scheme.secondary_container);
1546 assert!(!outlined);
1547 }
1548
1549 #[test]
1550 fn themed_foreground_resolves_on_surface_variant_unselected_on_secondary_container_selected()
1551 {
1552 let theme = crate::baseline();
1553 let scheme = theme.scheme();
1554 assert_eq!(
1555 resolve_chip_foreground(Some(&theme), false),
1556 scheme.on_surface_variant
1557 );
1558 assert_eq!(
1565 resolve_chip_foreground(Some(&theme), true),
1566 scheme.on_secondary_container
1567 );
1568 }
1569
1570 #[test]
1571 fn unthemed_foreground_matches_fallback_constants() {
1572 assert_eq!(resolve_chip_foreground(None, false), CHIP_LABEL_UNSELECTED);
1573 assert_eq!(resolve_chip_foreground(None, true), CHIP_LABEL_SELECTED);
1574 }
1575
1576 #[test]
1577 fn themed_outline_resolves_outline_variant() {
1578 let theme = crate::baseline();
1579 assert_eq!(
1580 resolve_chip_outline(Some(&theme)),
1581 theme.scheme().outline_variant
1582 );
1583 assert_eq!(resolve_chip_outline(None), CHIP_OUTLINE_VARIANT);
1584 }
1585 }
1586
1587 mod assist {
1590 use super::*;
1591
1592 #[derive(Default)]
1593 struct Presses(u32);
1594
1595 fn widget() -> AssistChipWidget {
1596 let view = assist_chip::<Presses, _>("Go", |s: &mut Presses| s.0 += 1);
1597 let mut counter = 0u64;
1598 View::<Presses>::build(&view, &mut BuildCtx::new(&mut counter))
1599 }
1600
1601 fn dispatch(w: &mut AssistChipWidget, state: &mut Presses, event: &InputEvent) {
1602 let state_any: &mut dyn Any = state;
1603 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(100.0, CHIP_HEIGHT));
1604 w.event(&mut ctx, event);
1605 }
1606
1607 #[test]
1608 fn up_inside_fires_once() {
1609 let mut w = widget();
1610 let mut state = Presses::default();
1611 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
1612 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 12.0));
1613 assert_eq!(state.0, 1);
1614 }
1615
1616 #[test]
1617 fn up_outside_does_not_fire() {
1618 let mut w = widget();
1619 let mut state = Presses::default();
1620 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
1621 dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 500.0, 12.0));
1622 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 500.0, 12.0));
1623 assert_eq!(state.0, 0);
1624 }
1625
1626 #[test]
1627 fn cancel_clears_armed_state_without_firing() {
1628 let mut w = widget();
1629 let mut state = Presses::default();
1630 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
1631 assert!(w.captured);
1632 dispatch(&mut w, &mut state, &ev(PointerPhase::Cancel, 5.0, 12.0));
1633 assert!(!w.captured);
1634 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 12.0));
1635 assert_eq!(state.0, 0);
1636 }
1637
1638 #[test]
1639 fn hover_move_without_down_is_ignored_noop() {
1640 let mut w = widget();
1641 let mut state = Presses::default();
1642 let state_any: &mut dyn Any = &mut state;
1643 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(100.0, CHIP_HEIGHT));
1644 let result = w.event(&mut ctx, &ev(PointerPhase::Move, 5.0, 12.0));
1645 assert!(matches!(result, EventResult::Ignored));
1646 assert!(!ctx.needs_redraw());
1647 assert_eq!(state.0, 0);
1648 }
1649
1650 #[test]
1655 fn unthemed_unelevated_paints_transparent_and_outlined() {
1656 let mut w = widget();
1657 let rec = paint_rec(&mut w, Size::new(100.0, CHIP_HEIGHT), None);
1658 assert!(
1659 rec.rrects.is_empty(),
1660 "a transparent fill is skipped entirely, not painted"
1661 );
1662 assert_eq!(rec.strokes.len(), 1);
1663 assert_eq!(rec.strokes[0].1, CHIP_STROKE_WIDTH);
1664 assert_eq!(rec.strokes[0].2, CHIP_OUTLINE_VARIANT);
1665 }
1666
1667 #[test]
1668 fn elevated_paints_elevated_container_and_shadow_no_outline() {
1669 let view = assist_chip::<Presses, _>("Go", |_s: &mut Presses| {}).elevated(true);
1670 let mut counter = 0u64;
1671 let mut w = View::<Presses>::build(&view, &mut BuildCtx::new(&mut counter));
1672 let rec = paint_rec(&mut w, Size::new(100.0, CHIP_HEIGHT), None);
1673 assert_eq!(rec.rrects[0].3, CHIP_ELEVATED_CONTAINER);
1674 assert_eq!(rec.shadows.len(), 1);
1675 assert!(rec.strokes.is_empty(), "an elevated chip is never outlined");
1676 }
1677
1678 #[test]
1679 fn themed_unelevated_paint_resolves_outline_variant_stroke() {
1680 let theme = crate::baseline();
1681 let mut w = widget();
1682 let rec = paint_rec(&mut w, Size::new(100.0, CHIP_HEIGHT), Some(&theme));
1683 assert!(rec.rrects.is_empty());
1684 assert_eq!(rec.strokes[0].2, theme.scheme().outline_variant);
1685 }
1686
1687 #[test]
1688 fn themed_elevated_paint_resolves_surface_container_low() {
1689 let theme = crate::baseline();
1690 let view = assist_chip::<Presses, _>("Go", |_s: &mut Presses| {}).elevated(true);
1691 let mut counter = 0u64;
1692 let mut w = View::<Presses>::build(&view, &mut BuildCtx::new(&mut counter));
1693 let rec = paint_rec(&mut w, Size::new(100.0, CHIP_HEIGHT), Some(&theme));
1694 assert_eq!(rec.rrects[0].3, theme.scheme().surface_container_low);
1695 assert_eq!(rec.rrects[0].2, theme.shape.small);
1696 }
1697
1698 #[test]
1699 fn leading_glyph_is_optional_and_shifts_label_layout() {
1700 use frust::authoring::text::TextContext;
1701 use std::any::Any;
1702
1703 let mut counter = 0u64;
1704 let plain = assist_chip::<Presses, _>("Go", |_s: &mut Presses| {});
1705 let mut w_plain = View::<Presses>::build(&plain, &mut BuildCtx::new(&mut counter));
1706 let mut tcx = TextContext::new();
1707 let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1708 let size_plain =
1709 w_plain.layout(&mut lctx, &BoxConstraints::loose(Size::new(400.0, 100.0)));
1710
1711 let with_leading = assist_chip::<Presses, _>("Go", |_s: &mut Presses| {}).leading("*");
1712 let mut w_leading =
1713 View::<Presses>::build(&with_leading, &mut BuildCtx::new(&mut counter));
1714 let mut lctx2 = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1715 let size_leading =
1716 w_leading.layout(&mut lctx2, &BoxConstraints::loose(Size::new(400.0, 100.0)));
1717
1718 assert!(
1719 size_leading.width > size_plain.width,
1720 "a leading glyph widens the chip"
1721 );
1722 assert_eq!(size_plain.height, CHIP_HEIGHT);
1723 assert_eq!(size_leading.height, CHIP_HEIGHT);
1724 }
1725
1726 #[test]
1727 fn rebuild_elevated_toggle_flags_paint_only() {
1728 let mut counter = 0u64;
1729 let prev = assist_chip::<Presses, _>("Go", |_s: &mut Presses| {});
1730 let mut w = View::<Presses>::build(&prev, &mut BuildCtx::new(&mut counter));
1731 let next = assist_chip::<Presses, _>("Go", |_s: &mut Presses| {}).elevated(true);
1732 let flags =
1733 View::<Presses>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
1734 assert!(w.elevated);
1735 assert!(flags.needs_paint());
1736 }
1737
1738 #[test]
1739 fn semantics_reports_button_role_and_label() {
1740 use frust::authoring::text::TextContext;
1741 use std::any::Any;
1742
1743 fn logic(_s: &mut Presses) -> AssistChipView<Presses> {
1744 assist_chip::<Presses, _>("Go", |_s: &mut Presses| {})
1745 }
1746 let mut root: frust_core::RenderRoot<Presses, AssistChipView<Presses>> =
1747 frust_core::RenderRoot::new();
1748 let mut state = Presses::default();
1749 root.rebuild(&mut logic, &mut state);
1750 let mut tcx = TextContext::new();
1751 root.layout_with_text(Size::new(200.0, 200.0), &mut tcx as &mut dyn Any);
1752 let update = root.semantics();
1753 let (_, node) = update
1754 .nodes
1755 .iter()
1756 .find(|(_, n)| n.role() == Role::Button)
1757 .expect("assist chip contributes a Role::Button node");
1758 assert_eq!(node.label(), Some("Go"));
1759 assert!(node.supports_action(Action::Click));
1760 }
1761 }
1762
1763 mod filter {
1766 use super::*;
1767
1768 #[derive(Default)]
1769 struct SelectState {
1770 last: Option<bool>,
1771 selects: u32,
1772 }
1773
1774 fn widget(selected: bool) -> FilterChipWidget {
1775 let view =
1776 filter_chip::<SelectState, _>("Vegan", selected, |s: &mut SelectState, v: bool| {
1777 s.last = Some(v);
1778 s.selects += 1;
1779 });
1780 let mut counter = 0u64;
1781 View::<SelectState>::build(&view, &mut BuildCtx::new(&mut counter))
1782 }
1783
1784 fn dispatch(w: &mut FilterChipWidget, state: &mut SelectState, event: &InputEvent) {
1785 let state_any: &mut dyn Any = state;
1786 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(100.0, CHIP_HEIGHT));
1787 w.event(&mut ctx, event);
1788 }
1789
1790 #[test]
1791 fn unselected_fires_true_and_does_not_self_mutate() {
1792 let mut w = widget(false);
1793 let mut state = SelectState::default();
1794 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
1795 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 12.0));
1796 assert_eq!(state.last, Some(true));
1797 assert_eq!(state.selects, 1);
1798 assert!(!w.selected, "chip must not mutate its own selected flag");
1799 }
1800
1801 #[test]
1802 fn selected_fires_false() {
1803 let mut w = widget(true);
1804 let mut state = SelectState::default();
1805 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
1806 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 12.0));
1807 assert_eq!(state.last, Some(false));
1808 assert!(w.selected, "still selected until the app rebuilds it");
1809 }
1810
1811 #[test]
1812 fn up_outside_does_not_fire() {
1813 let mut w = widget(false);
1814 let mut state = SelectState::default();
1815 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
1816 dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 500.0, 12.0));
1817 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 500.0, 12.0));
1818 assert_eq!(state.selects, 0);
1819 }
1820
1821 #[test]
1822 fn rebuild_adopts_new_selected_value_without_self_mutation() {
1823 let mut counter = 0u64;
1824 let prev = filter_chip::<SelectState, _>("Vegan", false, |_s, _v| {});
1825 let mut w = View::<SelectState>::build(&prev, &mut BuildCtx::new(&mut counter));
1826 assert!(!w.selected);
1827 let next = filter_chip::<SelectState, _>("Vegan", true, |_s, _v| {});
1828 let flags = View::<SelectState>::rebuild(
1829 &next,
1830 &prev,
1831 &mut w,
1832 &mut BuildCtx::new(&mut counter),
1833 );
1834 assert!(w.selected);
1835 assert!(flags.needs_paint());
1836 }
1837
1838 #[test]
1844 fn unthemed_paint_matches_the_matrix() {
1845 let mut off = widget(false);
1846 let rec = paint_rec(&mut off, Size::new(100.0, CHIP_HEIGHT), None);
1847 assert!(
1848 rec.rrects.is_empty(),
1849 "unselected+unelevated is transparent"
1850 );
1851 assert_eq!(rec.strokes.len(), 1);
1852 assert_eq!(rec.strokes[0].2, CHIP_OUTLINE_VARIANT);
1853
1854 let mut on = widget(true);
1855 let rec = paint_rec(&mut on, Size::new(100.0, CHIP_HEIGHT), None);
1856 assert_eq!(rec.rrects[0].3, CHIP_SELECTED_CONTAINER);
1857 assert!(rec.strokes.is_empty(), "a selected chip is never outlined");
1858 }
1859
1860 #[test]
1861 fn themed_paint_resolves_secondary_container_when_selected() {
1862 let theme = crate::baseline();
1863 let mut off = widget(false);
1864 let rec = paint_rec(&mut off, Size::new(100.0, CHIP_HEIGHT), Some(&theme));
1865 assert!(rec.rrects.is_empty());
1866 assert_eq!(rec.strokes[0].2, theme.scheme().outline_variant);
1867
1868 let mut on = widget(true);
1869 let rec = paint_rec(&mut on, Size::new(100.0, CHIP_HEIGHT), Some(&theme));
1870 assert_eq!(rec.rrects[0].3, theme.scheme().secondary_container);
1871 }
1872
1873 #[test]
1874 fn elevated_and_selected_paints_both_shadow_and_selected_container() {
1875 let view = filter_chip::<SelectState, _>("Vegan", true, |_s, _v| {}).elevated(true);
1876 let mut counter = 0u64;
1877 let mut w = View::<SelectState>::build(&view, &mut BuildCtx::new(&mut counter));
1878 let rec = paint_rec(&mut w, Size::new(100.0, CHIP_HEIGHT), None);
1879 assert_eq!(rec.rrects[0].3, CHIP_SELECTED_CONTAINER);
1880 assert_eq!(
1881 rec.shadows.len(),
1882 1,
1883 "elevated is gated independently of selected"
1884 );
1885 assert!(rec.strokes.is_empty());
1886 }
1887
1888 #[test]
1889 fn semantics_reports_toggled_state() {
1890 use frust::authoring::text::TextContext;
1891 use std::any::Any;
1892
1893 fn logic(_s: &mut SelectState) -> FilterChipView<SelectState> {
1894 filter_chip::<SelectState, _>("Vegan", true, |_s, _v| {})
1895 }
1896 let mut root: frust_core::RenderRoot<SelectState, FilterChipView<SelectState>> =
1897 frust_core::RenderRoot::new();
1898 let mut state = SelectState::default();
1899 root.rebuild(&mut logic, &mut state);
1900 let mut tcx = TextContext::new();
1901 root.layout_with_text(Size::new(200.0, 200.0), &mut tcx as &mut dyn Any);
1902 let update = root.semantics();
1903 let (_, node) = update
1904 .nodes
1905 .iter()
1906 .find(|(_, n)| n.role() == Role::Button)
1907 .expect("filter chip contributes a Role::Button node");
1908 assert_eq!(node.label(), Some("Vegan"));
1909 assert_eq!(node.toggled(), Some(Toggled::True));
1910 }
1911 }
1912
1913 mod suggestion {
1916 use super::*;
1917
1918 #[derive(Default)]
1919 struct Presses(u32);
1920
1921 fn widget() -> SuggestionChipWidget {
1922 let view = suggestion_chip::<Presses, _>("Try this", |s: &mut Presses| s.0 += 1);
1923 let mut counter = 0u64;
1924 View::<Presses>::build(&view, &mut BuildCtx::new(&mut counter))
1925 }
1926
1927 fn dispatch(w: &mut SuggestionChipWidget, state: &mut Presses, event: &InputEvent) {
1928 let state_any: &mut dyn Any = state;
1929 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(100.0, CHIP_HEIGHT));
1930 w.event(&mut ctx, event);
1931 }
1932
1933 #[test]
1934 fn up_inside_fires_once() {
1935 let mut w = widget();
1936 let mut state = Presses::default();
1937 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
1938 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 12.0));
1939 assert_eq!(state.0, 1);
1940 }
1941
1942 #[test]
1943 fn up_outside_does_not_fire() {
1944 let mut w = widget();
1945 let mut state = Presses::default();
1946 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
1947 dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 500.0, 12.0));
1948 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 500.0, 12.0));
1949 assert_eq!(state.0, 0);
1950 }
1951
1952 #[test]
1953 fn unthemed_unelevated_paints_transparent_and_outlined() {
1954 let mut w = widget();
1955 let rec = paint_rec(&mut w, Size::new(100.0, CHIP_HEIGHT), None);
1956 assert!(rec.rrects.is_empty());
1957 assert_eq!(rec.strokes[0].2, CHIP_OUTLINE_VARIANT);
1958 }
1959
1960 #[test]
1961 fn elevated_paints_elevated_container_and_shadow() {
1962 let view =
1963 suggestion_chip::<Presses, _>("Try this", |_s: &mut Presses| {}).elevated(true);
1964 let mut counter = 0u64;
1965 let mut w = View::<Presses>::build(&view, &mut BuildCtx::new(&mut counter));
1966 let rec = paint_rec(&mut w, Size::new(100.0, CHIP_HEIGHT), None);
1967 assert_eq!(rec.rrects[0].3, CHIP_ELEVATED_CONTAINER);
1968 assert_eq!(rec.shadows.len(), 1);
1969 }
1970
1971 #[test]
1972 fn semantics_reports_button_role_and_label() {
1973 use frust::authoring::text::TextContext;
1974 use std::any::Any;
1975
1976 fn logic(_s: &mut Presses) -> SuggestionChipView<Presses> {
1977 suggestion_chip::<Presses, _>("Try this", |_s: &mut Presses| {})
1978 }
1979 let mut root: frust_core::RenderRoot<Presses, SuggestionChipView<Presses>> =
1980 frust_core::RenderRoot::new();
1981 let mut state = Presses::default();
1982 root.rebuild(&mut logic, &mut state);
1983 let mut tcx = TextContext::new();
1984 root.layout_with_text(Size::new(200.0, 200.0), &mut tcx as &mut dyn Any);
1985 let update = root.semantics();
1986 let (_, node) = update
1987 .nodes
1988 .iter()
1989 .find(|(_, n)| n.role() == Role::Button)
1990 .expect("suggestion chip contributes a Role::Button node");
1991 assert_eq!(node.label(), Some("Try this"));
1992 assert!(node.supports_action(Action::Click));
1993 }
1994 }
1995
1996 mod input {
1999 use super::*;
2000
2001 #[derive(Default)]
2002 struct Log {
2003 presses: u32,
2004 deletes: u32,
2005 }
2006
2007 fn widget_no_delete() -> InputChipWidget {
2008 let view = input_chip::<Log, _>("Tag", |s: &mut Log| s.presses += 1);
2009 let mut counter = 0u64;
2010 View::<Log>::build(&view, &mut BuildCtx::new(&mut counter))
2011 }
2012
2013 fn widget_with_delete() -> InputChipWidget {
2014 let view = input_chip::<Log, _>("Tag", |s: &mut Log| s.presses += 1)
2015 .on_deleted(|s: &mut Log| s.deletes += 1);
2016 let mut counter = 0u64;
2017 View::<Log>::build(&view, &mut BuildCtx::new(&mut counter))
2018 }
2019
2020 fn with_delete_rect(w: &mut InputChipWidget) {
2023 w.delete_rect = Rect::new(70.0, 7.0, 88.0, 25.0);
2024 }
2025
2026 fn dispatch(w: &mut InputChipWidget, state: &mut Log, event: &InputEvent) {
2027 let state_any: &mut dyn Any = state;
2028 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(100.0, CHIP_HEIGHT));
2029 w.event(&mut ctx, event);
2030 }
2031
2032 #[test]
2033 fn no_delete_configured_means_no_hit_region_and_body_press_fires() {
2034 let mut w = widget_no_delete();
2035 assert!(!w.has_delete);
2036 let mut state = Log::default();
2037 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
2038 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 12.0));
2039 assert_eq!(state.presses, 1);
2040 assert_eq!(state.deletes, 0);
2041 }
2042
2043 #[test]
2044 fn body_press_fires_on_press_only() {
2045 let mut w = widget_with_delete();
2046 with_delete_rect(&mut w);
2047 let mut state = Log::default();
2048 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
2049 assert_eq!(w.armed, Some(InputRegion::Body));
2050 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 12.0));
2051 assert_eq!(state.presses, 1);
2052 assert_eq!(state.deletes, 0, "a body press must not fire on_deleted");
2053 }
2054
2055 #[test]
2056 fn delete_press_fires_on_deleted_only() {
2057 let mut w = widget_with_delete();
2058 with_delete_rect(&mut w);
2059 let mut state = Log::default();
2060 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 75.0, 12.0));
2061 assert_eq!(w.armed, Some(InputRegion::Delete));
2062 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 75.0, 12.0));
2063 assert_eq!(state.deletes, 1);
2064 assert_eq!(state.presses, 0, "a delete press must not fire on_press");
2065 }
2066
2067 #[test]
2068 fn delete_press_does_not_arm_the_state_layer() {
2069 let mut w = widget_with_delete();
2070 with_delete_rect(&mut w);
2071 let mut state = Log::default();
2072 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 75.0, 12.0));
2073 assert!(
2074 !w.state_layer.is_active(),
2075 "the delete icon carries no state-layer feedback (see the module docs)"
2076 );
2077 }
2078
2079 #[test]
2080 fn body_press_does_arm_the_state_layer() {
2081 let mut w = widget_with_delete();
2082 with_delete_rect(&mut w);
2083 let mut state = Log::default();
2084 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
2085 assert!(w.state_layer.is_active());
2086 }
2087
2088 #[test]
2089 fn release_outside_the_armed_region_fires_nothing() {
2090 let mut w = widget_with_delete();
2091 with_delete_rect(&mut w);
2092 let mut state = Log::default();
2093 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
2094 dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 500.0, 12.0));
2095 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 500.0, 12.0));
2096 assert_eq!(state.presses, 0);
2097 assert_eq!(state.deletes, 0);
2098 }
2099
2100 #[test]
2101 fn cancel_disarms_without_firing() {
2102 let mut w = widget_with_delete();
2103 with_delete_rect(&mut w);
2104 let mut state = Log::default();
2105 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 75.0, 12.0));
2106 dispatch(&mut w, &mut state, &ev(PointerPhase::Cancel, 75.0, 12.0));
2107 assert!(w.armed.is_none());
2108 assert_eq!(state.deletes, 0);
2109 }
2110
2111 #[test]
2112 fn down_outside_both_regions_is_ignored() {
2113 let mut w = widget_with_delete();
2114 with_delete_rect(&mut w);
2115 let mut state = Log::default();
2116 let state_any: &mut dyn Any = &mut state;
2117 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(100.0, CHIP_HEIGHT));
2118 let result = w.event(&mut ctx, &ev(PointerPhase::Down, 300.0, 12.0));
2119 assert!(matches!(result, EventResult::Ignored));
2120 assert!(w.armed.is_none());
2121 }
2122
2123 #[test]
2124 fn layout_sizes_the_delete_icon_at_18dp_after_the_label() {
2125 use frust::authoring::text::TextContext;
2126
2127 let view = input_chip::<Log, _>("Tag", |_s: &mut Log| {}).on_deleted(|_s: &mut Log| {});
2128 let mut counter = 0u64;
2129 let mut w = View::<Log>::build(&view, &mut BuildCtx::new(&mut counter));
2130 let mut tcx = TextContext::new();
2131 let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
2132 let total = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(400.0, 100.0)));
2133
2134 assert_eq!(w.delete_rect.width(), CHIP_ICON_SIZE);
2135 assert_eq!(w.delete_rect.height(), CHIP_ICON_SIZE);
2136 assert_eq!(w.delete_rect.x1 + CHIP_END_PAD, total.width);
2139 assert_eq!(total.height, CHIP_HEIGHT);
2140 }
2141
2142 #[test]
2143 fn layout_without_delete_has_a_zero_rect_and_no_trailing_icon_gap() {
2144 use frust::authoring::text::TextContext;
2145
2146 let with_delete =
2147 input_chip::<Log, _>("Tag", |_s: &mut Log| {}).on_deleted(|_s: &mut Log| {});
2148 let without_delete = input_chip::<Log, _>("Tag", |_s: &mut Log| {});
2149 let mut counter = 0u64;
2150 let mut w_with = View::<Log>::build(&with_delete, &mut BuildCtx::new(&mut counter));
2151 let mut w_without =
2152 View::<Log>::build(&without_delete, &mut BuildCtx::new(&mut counter));
2153 let mut tcx = TextContext::new();
2154 let mut lctx1 = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
2155 let size_with =
2156 w_with.layout(&mut lctx1, &BoxConstraints::loose(Size::new(400.0, 100.0)));
2157 let mut lctx2 = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
2158 let size_without =
2159 w_without.layout(&mut lctx2, &BoxConstraints::loose(Size::new(400.0, 100.0)));
2160
2161 assert_eq!(w_without.delete_rect, Rect::ZERO);
2162 assert!(
2163 size_with.width > size_without.width,
2164 "a delete icon widens the chip"
2165 );
2166 }
2167
2168 #[test]
2169 fn paints_a_close_glyph_fill_at_the_delete_rect_origin() {
2170 let mut w = widget_with_delete();
2171 with_delete_rect(&mut w);
2172 let rec = paint_rec(&mut w, Size::new(100.0, CHIP_HEIGHT), None);
2173 let (origin, color) = rec
2174 .fills
2175 .last()
2176 .copied()
2177 .expect("the delete icon paints one filled path");
2178 assert_eq!(origin, Point::new(70.0, 7.0));
2179 assert_eq!(color, CHIP_LABEL_UNSELECTED);
2180 }
2181
2182 #[test]
2183 fn rebuild_dropping_on_deleted_disarms_a_pending_delete_press() {
2184 let mut counter = 0u64;
2185 let prev = input_chip::<Log, _>("Tag", |_s: &mut Log| {}).on_deleted(|_s: &mut Log| {});
2186 let mut w = View::<Log>::build(&prev, &mut BuildCtx::new(&mut counter));
2187 with_delete_rect(&mut w);
2188 w.armed = Some(InputRegion::Delete);
2189 w.pressed_inside = true;
2190
2191 let next = input_chip::<Log, _>("Tag", |_s: &mut Log| {});
2192 let flags =
2193 View::<Log>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
2194 assert!(!w.has_delete);
2195 assert!(w.armed.is_none());
2196 assert!(!w.pressed_inside);
2197 assert!(flags.needs_layout());
2198 }
2199
2200 #[test]
2201 fn semantics_with_delete_reports_a_group_with_two_buttons() {
2202 fn logic(_s: &mut ()) -> InputChipView<()> {
2203 input_chip::<(), _>("Tag", |_s: &mut ()| {}).on_deleted(|_s: &mut ()| {})
2204 }
2205 let mut root: frust_core::RenderRoot<(), InputChipView<()>> =
2206 frust_core::RenderRoot::new();
2207 let mut state = ();
2208 root.rebuild(&mut logic, &mut state);
2209 let mut tcx = frust::authoring::text::TextContext::new();
2210 root.layout_with_text(Size::new(200.0, 200.0), &mut tcx as &mut dyn Any);
2211 let update = root.semantics();
2212
2213 let group = update
2214 .nodes
2215 .iter()
2216 .find(|(_, n)| n.role() == Role::Group)
2217 .expect("a Group container node is contributed");
2218 assert_eq!(group.1.children().len(), 2);
2219
2220 let label_node = update
2221 .nodes
2222 .iter()
2223 .find(|(_, n)| n.label() == Some("Tag"))
2224 .expect("the label button node is present");
2225 assert!(label_node.1.supports_action(Action::Click));
2226
2227 let delete_node = update
2228 .nodes
2229 .iter()
2230 .find(|(_, n)| n.label() == Some("Remove"))
2231 .expect("the delete button node is present");
2232 assert!(delete_node.1.supports_action(Action::Click));
2233 }
2234
2235 #[test]
2236 fn semantics_without_delete_reports_a_single_button() {
2237 fn logic(_s: &mut ()) -> InputChipView<()> {
2238 input_chip::<(), _>("Tag", |_s: &mut ()| {})
2239 }
2240 let mut root: frust_core::RenderRoot<(), InputChipView<()>> =
2241 frust_core::RenderRoot::new();
2242 let mut state = ();
2243 root.rebuild(&mut logic, &mut state);
2244 let mut tcx = frust::authoring::text::TextContext::new();
2245 root.layout_with_text(Size::new(200.0, 200.0), &mut tcx as &mut dyn Any);
2246 let update = root.semantics();
2247 assert!(
2248 update.nodes.iter().all(|(_, n)| n.role() != Role::Group),
2249 "no delete affordance means no Group container"
2250 );
2251 let (_, node) = update
2252 .nodes
2253 .iter()
2254 .find(|(_, n)| n.role() == Role::Button)
2255 .expect("input chip without delete contributes a single Role::Button node");
2256 assert_eq!(node.label(), Some("Tag"));
2257 }
2258 }
2259}