1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
//! [`Chip`]: a compact element for an attribute, a filter or an entry — pressable,
//! selectable, and removable.
//!
//! The reference has four of these (assist, filter, input, suggestion), and what separates
//! them is not shape but **affordance**: whether the chip can be selected, whether it
//! carries a leading icon, whether it can be deleted. They share one set of measurements —
//! 32 px high, an 8 px radius, a `label_large` label, an outline that gives way to a filled
//! surface once selected — so there is one `Chip` here with those affordances as builders:
//!
//! ```ignore
//! Chip::new("Draft") // an attribute
//! Chip::new("Unread").selected(on).on_press(Msg::Toggle) // a filter
//! Chip::new(name).leading(Icons::STAR).on_remove(Msg::Drop(id)) // an entry
//! ```
//!
//! Every measurement and colour is overridable, per call or through
//! [`ChipTheme`](crate::ChipTheme).
use frus_core::{BorderRadius, Color, Insets, Point, Rect, Scene, ShapeBorder, TextStyle};
use frus_layout::{Align, Dimension, FlexDirection, Style};
use crate::disabled::{disabled_container, disabled_content};
use crate::icons::{IconData, Icons};
use crate::interaction::Status;
use crate::theme::Theme;
use crate::widget::Widget;
/// A chip's height.
pub const CHIP_HEIGHT: f32 = 32.0;
/// Its corner radius — a rounded rectangle, not a stadium.
pub const CHIP_RADIUS: f32 = 8.0;
/// The room inside the outline, on every side.
pub const CHIP_PADDING: f32 = 8.0;
/// The room on either side of the label, inside that.
pub const CHIP_LABEL_PADDING: f32 = 8.0;
/// A leading icon, a checkmark or a delete cross.
pub const CHIP_ICON_SIZE: f32 = 18.0;
/// The outline's thickness.
pub const CHIP_BORDER_WIDTH: f32 = 1.0;
/// Everything about a chip's appearance, each part `None` until someone says otherwise.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub(crate) struct ChipStyle {
pub color: Option<Color>,
pub selected_color: Option<Color>,
pub label_color: Option<Color>,
pub selected_label_color: Option<Color>,
pub label_style: Option<TextStyle>,
pub border_color: Option<Color>,
pub border_width: Option<f32>,
/// What shape it is, over the theme's. A radius named through
/// [`Chip::radius`](crate::Chip::radius) arrives here as a rounded rectangle.
pub shape: Option<ShapeBorder>,
pub padding: Option<f32>,
pub label_padding: Option<f32>,
pub height: Option<f32>,
pub icon_size: Option<f32>,
pub show_checkmark: Option<bool>,
}
impl ChipStyle {
/// The surface under the label. A chip at rest has **none**: the outline is what marks
/// it out, and a fill would make an attribute look like a button.
fn background(&self, theme: &Theme, selected: bool) -> Color {
if selected {
self.selected_color
.or(theme.widgets.chip.selected_color)
.unwrap_or(theme.scheme.secondary_container)
} else {
self.color
.or(theme.widgets.chip.color)
.unwrap_or(Color::TRANSPARENT)
}
}
fn label_color(&self, theme: &Theme, selected: bool) -> Color {
if selected {
self.selected_label_color
.or(theme.widgets.chip.selected_label_color)
.unwrap_or(theme.scheme.on_secondary_container)
} else {
self.label_color
.or(theme.widgets.chip.label_color)
.unwrap_or(theme.scheme.on_surface_variant)
}
}
fn label_style(&self, theme: &Theme) -> TextStyle {
self.label_style
.or(theme.widgets.chip.label_style)
.unwrap_or(theme.text.label_large)
}
/// The outline. A **selected** chip has none by default — it is filled, and an outline
/// around a filled surface reads as a second border.
fn border(&self, theme: &Theme, selected: bool) -> (f32, Color) {
let width = self
.border_width
.or(theme.widgets.chip.border_width)
.unwrap_or(if selected { 0.0 } else { CHIP_BORDER_WIDTH });
let color = self
.border_color
.or(theme.widgets.chip.border_color)
.unwrap_or(theme.scheme.outline_variant);
(width, color)
}
/// **What shape this chip is**: its own word, then the theme's shape, then the
/// theme's plain radius, then the reference's 8.
fn shape(&self, theme: &Theme) -> ShapeBorder {
crate::resolve_shape(
self.shape,
theme.widgets.chip.shape,
theme.widgets.chip.radius,
ShapeBorder::rounded(CHIP_RADIUS),
)
}
/// The corners that shape resolves to in a box of this chip's height — what the ink
/// is clipped to, and what a shape with no rounded form has none of.
fn radius(&self, theme: &Theme) -> BorderRadius {
let side = self.height(theme);
self.shape(theme)
.as_rounded(Rect::new(0.0, 0.0, side, side))
.map(|(_, radius)| radius)
.unwrap_or(BorderRadius::ZERO)
}
fn padding(&self, theme: &Theme) -> f32 {
self.padding
.or(theme.widgets.chip.padding)
.unwrap_or(CHIP_PADDING)
}
fn label_padding(&self, theme: &Theme) -> f32 {
self.label_padding
.or(theme.widgets.chip.label_padding)
.unwrap_or(CHIP_LABEL_PADDING)
}
fn height(&self, theme: &Theme) -> f32 {
self.height
.or(theme.widgets.chip.height)
.unwrap_or(CHIP_HEIGHT)
}
fn icon_size(&self, theme: &Theme) -> f32 {
self.icon_size
.or(theme.widgets.chip.icon_size)
.unwrap_or(CHIP_ICON_SIZE)
}
fn show_checkmark(&self, theme: &Theme) -> bool {
self.show_checkmark
.or(theme.widgets.chip.show_checkmark)
.unwrap_or(true)
}
}
/// A chip's delete cross: its own hit target, so that removing a chip and pressing it are
/// two different gestures.
struct Remove<Msg> {
message: Msg,
/// The chip's style and state, so that the cross resolves its size and colour from the
/// same theme the chip does — a cross on a selected chip has to read against the fill.
style: ChipStyle,
selected: bool,
/// The chip's availability. A cross that still answered on a disabled chip would be
/// the one live control on an inert thing.
enabled: bool,
}
impl<Msg: Clone> Widget<Msg> for Remove<Msg> {
fn style(&self) -> Style {
Widget::<Msg>::style_themed(self, &Theme::default())
}
fn style_themed(&self, theme: &Theme) -> Style {
let size = self.style.icon_size(theme);
Style {
width: Dimension::Length(size),
height: Dimension::Length(size),
..Default::default()
}
}
fn children(&self) -> &[Box<dyn Widget<Msg>>] {
&[]
}
fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
// Brighter under the pointer: the cross is the one part of a chip that does
// something different from the chip, and hovering has to say so.
let size = self.style.icon_size(theme);
let color = if self.enabled {
self.style
.label_color(theme, self.selected)
.lerp(theme.scheme.on_surface, status.hover_progress)
} else {
disabled_content(theme)
}
.fade(status.opacity);
let path = Icons::CLOSE.placed(
size,
bounds.x,
bounds.y + (bounds.height - size) * 0.5,
theme.direction,
);
scene.fill_path(&path, color);
}
fn on_click(&self) -> Option<Msg> {
if !self.enabled {
return None;
}
Some(self.message.clone())
}
fn focusable(&self) -> bool {
self.enabled
}
fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
let semantics =
frus_core::SemanticsProperties::new(frus_core::Role::Button).label("Remove");
// Milestone 320 stopped this cross answering a tap and stopped there. Tab still
// landed on it and a reader was still told it could be pressed — the same control
// reported three different ways, and the two that were wrong are the two nobody
// looks at in a screenshot. Milestone 322's guard is what found it.
Some(if self.enabled {
semantics.clickable()
} else {
semantics.disabled(true)
})
}
}
/// A compact label — an attribute, a filter or an entry.
pub struct Chip<Msg = crate::callback::Callback> {
label: String,
selected: bool,
leading: Option<IconData>,
on_press: Option<Msg>,
on_remove: Option<Msg>,
/// A chip that is shown but cannot be acted on: greyed out and inert, its label still
/// legible. The reference dims a disabled control rather than hiding it — it is often
/// the answer to why the rest of a row looks the way it does.
enabled: bool,
style: ChipStyle,
/// The delete cross, if there is one — the chip's only child. The label and the
/// leading icon are painted by the chip, so that their colour can follow its state.
children: Vec<Box<dyn Widget<Msg>>>,
}
impl<Msg: Clone + 'static> Chip<Msg> {
/// Creates a chip with the given label.
pub fn new(label: impl Into<String>) -> Self {
Self {
label: label.into(),
selected: false,
leading: None,
on_press: None,
on_remove: None,
enabled: true,
style: ChipStyle::default(),
children: Vec::new(),
}
}
/// (Re)builds the delete cross, which carries a copy of the chip's style and state.
///
/// Every builder calls this, because a builder run after `on_remove(…)` would
/// otherwise be written into a cross that had already been built — the order of a
/// builder chain is not something a caller should have to think about.
fn rebuild(mut self) -> Self {
self.children.clear();
if let Some(message) = self.on_remove.clone() {
self.children.push(Box::new(Remove {
message,
style: self.style,
selected: self.selected,
enabled: self.enabled,
}));
}
self
}
/// Marks the chip **selected**: it fills, its label takes the colour that goes with
/// the fill, and a checkmark appears in the leading slot unless there is an icon there
/// already (or [`Chip::show_checkmark`] says otherwise).
pub fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self.rebuild()
}
/// A leading icon — the avatar or glyph an entry chip carries.
pub fn leading(mut self, icon: IconData) -> Self {
self.leading = Some(icon);
self.rebuild()
}
/// Greys the chip out and makes it inert: no press, no ink, out of the tab order,
/// and announced to a reader as disabled rather than simply going quiet. Its label
/// stays legible — the reference dims a disabled control rather than hiding it.
pub fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self.rebuild()
}
/// The message the chip emits when pressed. A chip with none is inert, which is what
/// an attribute chip is.
pub fn on_press(mut self, message: impl Into<Msg>) -> Self {
self.on_press = Some(message.into());
self
}
/// Adds a delete cross that emits `message`.
pub fn on_remove(mut self, message: impl Into<Msg>) -> Self {
self.on_remove = Some(message.into());
self.rebuild()
}
/// Whether a selected chip shows a checkmark. On by default.
pub fn show_checkmark(mut self, show: bool) -> Self {
self.style.show_checkmark = Some(show);
self.rebuild()
}
/// The surface under an **unselected** chip. Transparent by default.
pub fn color(mut self, color: Color) -> Self {
self.style.color = Some(color);
self.rebuild()
}
/// The surface under a **selected** one.
pub fn selected_color(mut self, color: Color) -> Self {
self.style.selected_color = Some(color);
self.rebuild()
}
/// The label's colour when unselected.
pub fn label_color(mut self, color: Color) -> Self {
self.style.label_color = Some(color);
self.rebuild()
}
/// The label's colour when selected.
pub fn selected_label_color(mut self, color: Color) -> Self {
self.style.selected_label_color = Some(color);
self.rebuild()
}
/// The label's type. Defaults to the theme's `label_large` step.
pub fn label_style(mut self, style: TextStyle) -> Self {
self.style.label_style = Some(style);
self.rebuild()
}
/// The outline's colour.
pub fn border_color(mut self, color: Color) -> Self {
self.style.border_color = Some(color);
self.rebuild()
}
/// The outline's thickness; `0.0` removes it.
pub fn border_width(mut self, width: f32) -> Self {
self.style.border_width = Some(width);
self.rebuild()
}
/// The corner radii (uniform via `f32`, per corner via [`BorderRadius`]) — the
/// shorthand for a rounded rectangle. See [`Chip::shape`].
pub fn radius(mut self, radius: impl Into<BorderRadius>) -> Self {
self.style.shape = Some(ShapeBorder::rounded(radius));
self.rebuild()
}
/// **What shape it is** (`chip.dart`), over the corners named above. The last of the
/// two to be called is the one that counts: they are one property.
pub fn shape(mut self, shape: ShapeBorder) -> Self {
self.style.shape = Some(shape);
self.rebuild()
}
/// The room inside the outline.
pub fn padding(mut self, padding: f32) -> Self {
self.style.padding = Some(padding);
self.rebuild()
}
/// The room on either side of the label.
pub fn label_padding(mut self, padding: f32) -> Self {
self.style.label_padding = Some(padding);
self.rebuild()
}
/// The chip's height.
pub fn height(mut self, height: f32) -> Self {
self.style.height = Some(height);
self.rebuild()
}
/// The size of the leading icon, the checkmark and the delete cross.
pub fn icon_size(mut self, size: f32) -> Self {
self.style.icon_size = Some(size);
self.rebuild()
}
/// Whether anything is drawn in the leading slot, and what: an icon if there is one,
/// otherwise the checkmark of a selected chip.
fn leading_glyph(&self, theme: &Theme) -> Option<IconData> {
self.leading
.or_else(|| (self.selected && self.style.show_checkmark(theme)).then_some(Icons::CHECK))
}
/// Where the label starts, measured from the chip's left edge.
fn label_offset(&self, theme: &Theme) -> f32 {
let leading = self
.leading_glyph(theme)
.map(|_| self.style.icon_size(theme))
.unwrap_or(0.0);
self.style.padding(theme) + leading + self.style.label_padding(theme)
}
fn label_size(&self, theme: &Theme) -> frus_core::Size {
frus_text::measure_resolved(&self.label, &self.style.label_style(theme).resolved())
}
/// The chip's height: the one it was given, **or what its label needs** when the reader
/// asked for type that does not fit inside it.
///
/// The reference's chip does the same arithmetic —
/// `max(chipHeight - padding.vertical, labelHeight) + padding.vertical` — because a
/// component's default height is a **floor** and not a ceiling. Ours was a ceiling, so
/// at a text scale of 2 a chip's glyphs needed 34 px inside a 32 px box and were cut.
///
/// The room asked for around the line is `0`, not the chip's padding: that padding is
/// applied on the horizontal axis only (the label is painted, not laid out, and is
/// centred in the box), so counting it here would move every chip that fits perfectly
/// well today.
fn box_height(&self, theme: &Theme) -> f32 {
frus_text::line_box(
self.style.height(theme),
&self.style.label_style(theme).resolved(),
0.0,
)
}
}
impl<Msg: Clone + 'static> Widget<Msg> for Chip<Msg> {
fn style(&self) -> Style {
Widget::<Msg>::style_themed(self, &Theme::default())
}
fn style_themed(&self, theme: &Theme) -> Style {
// The chip is sized here rather than by its content, because its content is
// mostly painted rather than laid out: the label and the leading glyph belong to
// the chip, so that their colour can follow its state. What is left is the delete
// cross, and the padding is what puts it after the label.
let pad = self.style.padding(theme);
let label_pad = self.style.label_padding(theme);
let icon = self.style.icon_size(theme);
let after_label = self.label_offset(theme) + self.label_size(theme).width + label_pad;
let removable = !self.children.is_empty();
Style {
width: Dimension::Length(after_label + if removable { icon } else { 0.0 } + pad),
height: Dimension::Length(self.box_height(theme)),
flex_direction: FlexDirection::Row,
align: Align::Center,
padding: Insets::new(0.0, pad, 0.0, after_label),
..Default::default()
}
}
fn children(&self) -> &[Box<dyn Widget<Msg>>] {
&self.children
}
fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
let o = status.opacity;
let selected = self.selected;
let (border_width, border_color) = self.style.border(theme, selected);
// Disabled flattens the same way `Button` does, and for the same reason: the
// reference collapses every variant to `on_surface` at 12 % under a label at 38 %,
// so unavailable reads as unavailable rather than as a quieter kind of chip.
// Selected or not stops mattering, which is the point — a disabled filter is not
// offering to tell you whether it is on.
let (background, border_color, label_color) = if self.enabled {
(
self.style.background(theme, selected),
border_color,
self.style.label_color(theme, selected),
)
} else {
(
if selected {
disabled_container(theme)
} else {
Color::TRANSPARENT
},
disabled_container(theme),
disabled_content(theme),
)
};
scene.draw_shape(
bounds,
self.style
.shape(theme)
.with_side(frus_core::BorderSide::new(
if border_width > 0.0 {
border_color.fade(o)
} else {
Color::TRANSPARENT
},
border_width,
)),
background.fade(o),
);
// The leading slot: an icon if the chip has one, otherwise a selected chip's
// checkmark. An icon takes the accent when the chip is at rest, and the colour of
// the fill once it is selected — the reference's icon theme, which is a different
// colour from the label's only in the first case.
let icon_size = self.style.icon_size(theme);
if let Some(name) = self.leading_glyph(theme) {
let color = if selected {
label_color
} else {
theme.scheme.primary
};
let path = name.placed(
icon_size,
bounds.x + self.style.padding(theme),
bounds.y + (bounds.height - icon_size) * 0.5,
theme.direction,
);
scene.fill_path(&path, color.fade(o));
}
let label_style = self.style.label_style(theme);
let measured = self.label_size(theme);
scene.text(
Point::new(
bounds.x + self.label_offset(theme),
bounds.y + (bounds.height - measured.height) * 0.5,
),
self.label.clone(),
&label_style.resolved(),
label_color.fade(o),
);
}
fn on_click(&self) -> Option<Msg> {
self.enabled.then(|| self.on_press.clone()).flatten()
}
fn ink(&self, theme: &Theme) -> Option<crate::InkStyle> {
// Only where there is something to press: ink on an inert attribute chip — or on
// a disabled one — would promise an action it does not have.
if !self.enabled {
return None;
}
self.on_press.as_ref()?;
Some(crate::InkStyle::of(theme).radius(self.style.radius(theme)))
}
fn focusable(&self) -> bool {
self.enabled && self.on_press.is_some()
}
fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
let semantics =
frus_core::SemanticsProperties::new(frus_core::Role::Button).label(self.label.clone());
// A chip that can be selected says whether it is: a filter that is on and one that
// is off are the same words otherwise.
let semantics = if self.on_press.is_some() && self.enabled {
semantics.toggled(self.selected).clickable()
} else {
semantics
};
// Still announced, and announced as unavailable: a reader that simply stopped
// hearing about a chip would be told the filter had gone away.
let semantics = if self.enabled {
semantics
} else {
semantics.disabled(true)
};
Some(semantics)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{build_ui, Point as P, Runtime, Size};
use frus_core::Primitive;
#[derive(Clone, Debug, PartialEq)]
enum Msg {
Press,
Remove,
}
fn frame(chip: Chip<Msg>) -> crate::Ui<Msg> {
let root = crate::flex::Flex::column()
.width(200.0)
.height(60.0)
.child(chip);
build_ui(
&root,
Size::new(200.0, 60.0),
&Runtime::default(),
&Theme::default(),
)
}
fn primitives(chip: Chip<Msg>) -> Vec<Primitive> {
frame(chip).scene().primitives().to_vec()
}
/// The chip's own box: the first rectangle painted.
fn surface(chip: Chip<Msg>) -> (Rect, Color, BorderRadius, f32) {
primitives(chip)
.iter()
.find_map(|p| match p {
Primitive::Rect {
rect,
color,
radius,
border_width,
..
} => Some((*rect, *color, *radius, *border_width)),
_ => None,
})
.expect("a chip paints its box")
}
/// The same gap, in the widget that recorded it first (milestone 312). A disabled
/// chip is greyed out **and** inert: no press, no ink, out of the tab order, its
/// delete cross dead too — a live cross on an inert chip would be the one thing on it
/// that still answered.
#[test]
fn a_disabled_chip_is_inert_including_its_cross() {
let live = Chip::new("Filter")
.on_press(Msg::Press)
.on_remove(Msg::Remove);
let dead = Chip::new("Filter")
.on_press(Msg::Press)
.on_remove(Msg::Remove)
.enabled(false);
assert_eq!(Widget::on_click(&live), Some(Msg::Press));
assert_eq!(Widget::on_click(&dead), None, "the press goes nowhere");
assert!(Widget::<Msg>::focusable(&live));
assert!(!Widget::<Msg>::focusable(&dead), "out of the tab order");
assert!(Widget::<Msg>::ink(&live, &Theme::default()).is_some());
assert!(
Widget::<Msg>::ink(&dead, &Theme::default()).is_none(),
"nothing to splash for"
);
// The cross is a child, and it has to follow the chip it sits on.
let cross = |c: &Chip<Msg>| Widget::<Msg>::children(c)[0].on_click();
assert_eq!(cross(&live), Some(Msg::Remove));
assert_eq!(cross(&dead), None, "the cross follows the chip");
// Announced, and announced as unavailable: a reader that stopped hearing about a
// chip would be told the filter had gone away.
let semantics = Widget::<Msg>::semantics(&dead).unwrap();
assert!(semantics.disabled);
assert_eq!(semantics.label.as_deref(), Some("Filter"));
}
/// Disabled must never draw the eye more than live. The measure is **contrast
/// against the surface**, not brightness: on a light theme a quieter colour is
/// closer to white, on a dark one closer to black, and a test that compared raw
/// luminance would pass on one and fail on the other for no real reason.
///
/// It is not obvious from the rule that this holds: a live unselected chip's label
/// is `on_surface_variant` and a disabled one's is `on_surface` at 38 % — two
/// different tokens, and which reads louder depends on the palette.
#[test]
fn disabled_is_never_louder_than_live() {
for (name, theme) in [("dark", Theme::dark()), ("light", Theme::light())] {
let bg = theme.scheme.surface;
let lum = |r: f32, g: f32, b: f32| 0.2126 * r + 0.7152 * g + 0.0722 * b;
let ground = lum(bg.r, bg.g, bg.b);
// How far the colour ends up from the surface behind it, alpha included.
let contrast = |c: frus_core::Color| {
let over = |f: f32, b: f32| f * c.a + b * (1.0 - c.a);
(lum(over(c.r, bg.r), over(c.g, bg.g), over(c.b, bg.b)) - ground).abs()
};
let live = ChipStyle::default().label_color(&theme, false);
let dead = theme.scheme.on_surface.fade(0.38);
assert!(
contrast(dead) < contrast(live),
"{name}: the disabled label must be quieter, got {} against {}",
contrast(dead),
contrast(live)
);
// The **outline** now clears the same bar. Milestone 320 left this assertion
// out rather than weaken it: the rule was the reference's exactly, but both
// shipped palettes put `outline_variant` on top of `on_surface` at 12 %, so a
// disabled hairline carried marginally *more* contrast than a live one. J325
// moved the two outline roles to their reference tones, which is what lets the
// claim be asserted instead of written up.
let live_edge = ChipStyle::default().border(&theme, false).1;
let dead_edge = theme.scheme.on_surface.fade(0.12);
assert!(
contrast(dead_edge) < contrast(live_edge),
"{name}: the disabled outline must be quieter, got {} against {}",
contrast(dead_edge),
contrast(live_edge)
);
}
}
/// Disabled **flattens** rather than fades, following `Button`: the reference
/// collapses every state to one grey, so unavailable does not read as a quieter kind
/// of selected.
#[test]
fn a_disabled_chip_flattens_rather_than_fades() {
let theme = Theme::default();
let accent = ChipStyle::default().background(&theme, true);
let fills = |chip: Chip<Msg>| -> Vec<frus_core::Color> {
frame(chip)
.scene()
.primitives()
.iter()
.filter_map(|p| match p {
Primitive::Rect { color, .. } if color.a > 0.0 => Some(*color),
_ => None,
})
.collect()
};
let selected = || Chip::new("On").on_press(Msg::Press).selected(true);
assert!(
fills(selected()).contains(&accent),
"a live selected chip fills with the accent"
);
assert!(
!fills(selected().enabled(false)).contains(&accent),
"a disabled one never does"
);
}
#[test]
fn a_chip_is_the_references_size_and_shape() {
// Not a stadium: the reference's chip is a 32 px rounded rectangle with an 8 px
// radius, and a pill is a different component.
let (rect, _, radius, _) = surface(Chip::new("Draft"));
assert_eq!(rect.height, CHIP_HEIGHT);
assert_eq!(radius, BorderRadius::uniform(CHIP_RADIUS));
assert!(
radius.top_left < rect.height / 2.0,
"a rounded rectangle, not a pill"
);
}
#[test]
fn at_rest_it_is_an_outline_and_nothing_else() {
let theme = Theme::default();
let (_, color, _, border) = surface(Chip::new("Draft"));
assert_eq!(
color,
Color::TRANSPARENT,
"no fill under an unselected chip"
);
assert_eq!(border, CHIP_BORDER_WIDTH);
// Selected: it fills, and the outline goes.
let (_, color, _, border) = surface(Chip::new("Draft").selected(true));
assert_eq!(color, theme.scheme.secondary_container);
assert_eq!(border, 0.0, "an outline around a fill reads as two borders");
}
#[test]
fn a_selected_chip_shows_a_checkmark_and_the_label_makes_room_for_it() {
let count_paths = |chip: Chip<Msg>| {
primitives(chip)
.iter()
.filter(|p| matches!(p, Primitive::Path { .. }))
.count()
};
assert_eq!(count_paths(Chip::new("Unread")), 0);
assert_eq!(count_paths(Chip::new("Unread").selected(true)), 1);
assert_eq!(
count_paths(Chip::new("Unread").selected(true).show_checkmark(false)),
0,
"and it can be refused"
);
// The label moves aside for it rather than sitting under it.
let text_x = |chip: Chip<Msg>| {
primitives(chip)
.iter()
.find_map(|p| match p {
Primitive::Text { position, .. } => Some(position.x),
_ => None,
})
.expect("a label")
};
assert_eq!(
text_x(Chip::new("Unread").selected(true)) - text_x(Chip::new("Unread")),
CHIP_ICON_SIZE
);
}
#[test]
fn a_leading_icon_takes_the_slot_the_checkmark_would_have() {
// Both at once would be two glyphs in a 32 px chip; the icon wins, as it does in
// the reference for an input chip with an avatar.
let paths = |chip: Chip<Msg>| {
primitives(chip)
.iter()
.filter(|p| matches!(p, Primitive::Path { .. }))
.count()
};
assert_eq!(paths(Chip::new("Ada").leading(Icons::STAR)), 1);
assert_eq!(
paths(Chip::new("Ada").leading(Icons::STAR).selected(true)),
1,
"the icon, not the icon and a checkmark"
);
}
#[test]
fn the_cross_is_a_target_of_its_own() {
// Pressing a chip and removing it are two gestures, so the cross has to be its own
// hit target rather than a region of the chip's.
let chip = || Chip::new("Ada").on_press(Msg::Press).on_remove(Msg::Remove);
let ui = frame(chip());
let at = |x: f32| ui.hit(P::new(x, 16.0)).and_then(|id| ui.msg_for(id));
let width = surface(chip()).0.width;
assert_eq!(at(20.0), Some(Msg::Press), "the chip presses");
assert_eq!(at(width - 14.0), Some(Msg::Remove), "the cross removes");
}
#[test]
fn the_cross_is_visible_before_anyone_hovers_it() {
// It was built with a transparent colour and only appeared once the pointer
// reached it, which is a delete affordance nobody can find. It resolves its colour
// against the theme now — and against the chip's **state**, set after it or not.
let theme = Theme::default();
let cross = |chip: Chip<Msg>| {
primitives(chip)
.iter()
.rev()
.find_map(|p| match p {
Primitive::Path { fill, .. } => *fill,
_ => None,
})
.expect("the cross is painted")
};
assert_eq!(
cross(Chip::new("Ada").on_remove(Msg::Remove)),
theme.scheme.on_surface_variant
);
assert_eq!(
cross(Chip::new("Ada").on_remove(Msg::Remove).selected(true)),
theme.scheme.on_secondary_container,
"a builder after `on_remove` still reaches the cross"
);
}
#[test]
fn an_inert_chip_neither_splashes_nor_takes_focus() {
// An attribute chip is a label. Ink on it would promise an action it has not got.
let plain = Chip::<Msg>::new("Draft");
assert!(Widget::<Msg>::ink(&plain, &Theme::default()).is_none());
assert!(!Widget::<Msg>::focusable(&plain));
let pressable = Chip::new("Draft").on_press(Msg::Press);
assert!(Widget::<Msg>::ink(&pressable, &Theme::default()).is_some());
assert!(Widget::<Msg>::focusable(&pressable));
}
#[test]
fn a_filter_chip_announces_whether_it_is_on() {
let on = Chip::new("Unread").selected(true).on_press(Msg::Press);
let semantics = Widget::<Msg>::semantics(&on).expect("semantics");
assert_eq!(semantics.label.as_deref(), Some("Unread"));
assert_eq!(semantics.toggled, frus_core::Toggled::True);
let off = Chip::new("Unread").on_press(Msg::Press);
assert_eq!(
Widget::<Msg>::semantics(&off).unwrap().toggled,
frus_core::Toggled::False
);
}
#[test]
fn every_measurement_is_the_callers_and_then_the_themes() {
let mut theme = Theme::default();
theme.widgets.chip.height = Some(40.0);
let height =
|chip: Chip<Msg>, theme: &Theme| match Widget::<Msg>::style_themed(&chip, theme).height
{
Dimension::Length(h) => h,
other => panic!("{other:?}"),
};
assert_eq!(
height(Chip::new("x"), &Theme::default()),
CHIP_HEIGHT,
"the framework's"
);
assert_eq!(height(Chip::new("x"), &theme), 40.0, "the theme's");
assert_eq!(
height(Chip::new("x").height(24.0), &theme),
24.0,
"the caller's, over the theme's"
);
}
}