use std::rc::Rc;
use frust::Theme;
use frust::authoring::text::TextOverflow;
use frust::authoring::{Action, Role};
use frust::authoring::{
AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, ErasedCallback, EventCtx,
EventResult, InputEvent, LayoutCtx, PaintCtx, PaintScene, PointerPhase, SemanticsCtx,
ThemeTextColor, ThemeTextType, View, Widget, any,
};
use frust::text;
use kurbo::{Point, Size};
use peniko::Color;
use crate::interaction::{DISABLED_CONTAINER_OPACITY, InteractionState};
use super::press::presses;
const HPAD: f64 = 16.0;
const GAP: f64 = 16.0;
const VPAD_THREE_LINE: f64 = 12.0;
const SUPPORTING_SIZE: f32 = 14.0;
const OVERLINE_SIZE: f32 = 11.0;
const OVERLINE_TRACKING: f32 = 0.5;
const HEADLINE_MAX_LINES: usize = 1;
const SUPPORTING_MAX_LINES: usize = 2;
pub const ONE_LINE_HEIGHT: f64 = 56.0;
pub const TWO_LINE_HEIGHT: f64 = 72.0;
pub const THREE_LINE_HEIGHT: f64 = 88.0;
const RADIUS: f64 = 12.0;
const ON_SURFACE: Color = Color::from_rgb8(0x1D, 0x1B, 0x20);
const FILLED_CONTAINER: Color = Color::from_rgb8(0xE6, 0xE0, 0xE9);
const SELECTED_CONTAINER: Color = Color::from_rgb8(0xE8, 0xDE, 0xF8);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ListItemLines {
One,
Two,
Three,
}
impl ListItemLines {
pub fn height(self) -> f64 {
match self {
ListItemLines::One => ONE_LINE_HEIGHT,
ListItemLines::Two => TWO_LINE_HEIGHT,
ListItemLines::Three => THREE_LINE_HEIGHT,
}
}
}
type OnPress<State> = Rc<dyn Fn(&mut State)>;
pub struct ListItem<State: 'static> {
headline: String,
supporting: Option<String>,
overline: Option<String>,
lines: ListItemLines,
leading: Option<AnyView<State>>,
trailing: Option<AnyView<State>>,
on_press: Option<OnPress<State>>,
selected: bool,
enabled: bool,
contained: bool,
}
pub fn list_item<State: 'static>(headline: impl Into<String>) -> ListItem<State> {
ListItem {
headline: headline.into(),
supporting: None,
overline: None,
lines: ListItemLines::One,
leading: None,
trailing: None,
on_press: None,
selected: false,
enabled: true,
contained: false,
}
}
impl<State: 'static> ListItem<State> {
pub fn supporting(mut self, supporting: impl Into<String>) -> Self {
self.supporting = Some(supporting.into());
self.promote_lines();
self
}
pub fn overline(mut self, overline: impl Into<String>) -> Self {
self.overline = Some(overline.into());
self.promote_lines();
self
}
fn promote_lines(&mut self) {
let implied = match (self.overline.is_some(), self.supporting.is_some()) {
(true, true) => ListItemLines::Three,
(true, false) | (false, true) => ListItemLines::Two,
(false, false) => ListItemLines::One,
};
if implied.height() > self.lines.height() {
self.lines = implied;
}
}
pub fn three_line(mut self) -> Self {
self.lines = ListItemLines::Three;
self
}
pub fn leading<V: View<State>>(mut self, leading: V) -> Self {
self.leading = Some(any(leading));
self
}
pub fn trailing<V: View<State>>(mut self, trailing: V) -> Self {
self.trailing = Some(any(trailing));
self
}
pub fn on_press<F: Fn(&mut State) + 'static>(mut self, on_press: F) -> Self {
self.on_press = Some(Rc::new(on_press));
self
}
pub fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
pub fn contained(mut self, contained: bool) -> Self {
self.contained = contained;
self
}
pub fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
fn shape(&self) -> (bool, bool, bool, bool) {
(
self.leading.is_some(),
self.overline.is_some(),
self.supporting.is_some(),
self.trailing.is_some(),
)
}
fn paints_container(&self) -> bool {
self.contained || self.selected
}
fn overline_view(&self) -> Option<AnyView<State>> {
self.overline.as_ref().map(|s| {
any(text(s.clone())
.size(OVERLINE_SIZE)
.letter_spacing(OVERLINE_TRACKING)
.max_lines(1)
.overflow(TextOverflow::Ellipsis)
.themed_role(ThemeTextColor::OnSurfaceVariant)
.themed_family(ThemeTextType::LabelSmall))
})
}
fn headline_view(&self) -> impl View<State> + use<State> {
text(self.headline.clone())
.max_lines(HEADLINE_MAX_LINES)
.overflow(TextOverflow::Ellipsis)
.themed_family(ThemeTextType::BodyLarge)
}
fn supporting_view(&self) -> Option<AnyView<State>> {
self.supporting.as_ref().map(|s| {
any(text(s.clone())
.size(SUPPORTING_SIZE)
.max_lines(SUPPORTING_MAX_LINES)
.overflow(TextOverflow::Ellipsis)
.themed_role(ThemeTextColor::OnSurfaceVariant)
.themed_family(ThemeTextType::BodyMedium))
})
}
}
#[derive(Clone, Copy)]
struct Slots {
leading: Option<usize>,
overline: Option<usize>,
headline: usize,
supporting: Option<usize>,
trailing: Option<usize>,
}
pub struct ListItemWidget {
children: Vec<ChildPod>,
slots: Slots,
lines: ListItemLines,
interactive: bool,
enabled: bool,
selected: bool,
paints_container: bool,
captured: bool,
state: InteractionState,
on_press: Option<ErasedCallback>,
}
fn build_children<State: 'static>(
view: &ListItem<State>,
ctx: &mut BuildCtx<'_>,
) -> (Vec<ChildPod>, Slots) {
let mut children = Vec::new();
let leading = view.leading.as_ref().map(|v| {
children.push(frust::authoring::build_child(v, ctx));
children.len() - 1
});
let overline = view.overline_view().map(|v| {
children.push(frust::authoring::build_child(&v, ctx));
children.len() - 1
});
children.push(frust::authoring::build_child(&view.headline_view(), ctx));
let headline = children.len() - 1;
let supporting = view.supporting_view().map(|v| {
children.push(frust::authoring::build_child(&v, ctx));
children.len() - 1
});
let trailing = view.trailing.as_ref().map(|v| {
children.push(frust::authoring::build_child(v, ctx));
children.len() - 1
});
(
children,
Slots {
leading,
overline,
headline,
supporting,
trailing,
},
)
}
fn inside(pos: Point, size: Size) -> bool {
pos.x >= 0.0 && pos.y >= 0.0 && pos.x < size.width && pos.y < size.height
}
fn with_alpha(color: Color, alpha: f32) -> Color {
let c = color.components;
Color::new([c[0], c[1], c[2], alpha])
}
fn resolve_content_color(theme: Option<&Theme>) -> Color {
match theme {
Some(theme) => theme.scheme().on_surface,
None => ON_SURFACE,
}
}
fn resolve_container(theme: Option<&Theme>, selected: bool, disabled: bool) -> Color {
if disabled {
return with_alpha(resolve_content_color(theme), DISABLED_CONTAINER_OPACITY);
}
match theme {
Some(theme) => {
let scheme = theme.scheme();
if selected {
scheme.secondary_container
} else {
scheme.surface_container_highest
}
}
None => {
if selected {
SELECTED_CONTAINER
} else {
FILLED_CONTAINER
}
}
}
}
fn resolve_radius(theme: Option<&Theme>) -> f64 {
match theme {
Some(theme) => theme.shape.medium,
None => RADIUS,
}
}
impl<State: 'static> View<State> for ListItem<State> {
type Element = ListItemWidget;
fn build(&self, ctx: &mut BuildCtx<'_>) -> ListItemWidget {
let (children, slots) = build_children(self, ctx);
ListItemWidget {
children,
slots,
lines: self.lines,
interactive: self.on_press.is_some(),
enabled: self.enabled,
selected: self.selected,
paints_container: self.paints_container(),
captured: false,
state: InteractionState::new(),
on_press: self.on_press.as_ref().map(frust::authoring::erase_callback),
}
}
fn rebuild(
&self,
prev: &Self,
element: &mut ListItemWidget,
ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
let mut flags = ChangeFlags::NONE;
if self.shape() != prev.shape() {
teardown_children(prev, element.slots, &mut element.children, ctx);
let (children, slots) = build_children(self, ctx);
element.children = children;
element.slots = slots;
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
} else {
let slots = element.slots;
if let (Some(pi), Some(ni)) = (prev.leading.as_ref(), self.leading.as_ref())
&& let Some(idx) = slots.leading
{
flags |= frust::authoring::rebuild_child(pi, ni, &mut element.children[idx], ctx);
}
if let (Some(pv), Some(nv)) = (prev.overline_view(), self.overline_view())
&& let Some(idx) = slots.overline
{
flags |= frust::authoring::rebuild_child(&pv, &nv, &mut element.children[idx], ctx);
}
flags |= frust::authoring::rebuild_child(
&prev.headline_view(),
&self.headline_view(),
&mut element.children[slots.headline],
ctx,
);
if let (Some(pv), Some(nv)) = (prev.supporting_view(), self.supporting_view())
&& let Some(idx) = slots.supporting
{
flags |= frust::authoring::rebuild_child(&pv, &nv, &mut element.children[idx], ctx);
}
if let (Some(pi), Some(ni)) = (prev.trailing.as_ref(), self.trailing.as_ref())
&& let Some(idx) = slots.trailing
{
flags |= frust::authoring::rebuild_child(pi, ni, &mut element.children[idx], ctx);
}
}
if element.lines != self.lines {
element.lines = self.lines;
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
if element.selected != self.selected || element.paints_container != self.paints_container()
{
element.selected = self.selected;
element.paints_container = self.paints_container();
flags |= ChangeFlags::PAINT;
}
let now_interactive = self.on_press.is_some();
if element.interactive != now_interactive {
element.interactive = now_interactive;
if !now_interactive {
element.captured = false;
element.state.set_pressed(false);
element.state.set_hovered(false);
}
flags |= ChangeFlags::PAINT;
}
if element.enabled != self.enabled {
element.enabled = self.enabled;
if !self.enabled && element.interactive {
element.captured = false;
element.state.set_pressed(false);
element.state.set_hovered(false);
}
flags |= ChangeFlags::PAINT;
}
element.on_press = self.on_press.as_ref().map(frust::authoring::erase_callback);
flags
}
fn teardown(&self, element: &mut ListItemWidget, ctx: &mut BuildCtx<'_>) {
teardown_children(self, element.slots, &mut element.children, ctx);
}
}
fn teardown_children<State: 'static>(
view: &ListItem<State>,
slots: Slots,
children: &mut [ChildPod],
ctx: &mut BuildCtx<'_>,
) {
for (index, pod) in children.iter_mut().enumerate() {
if Some(index) == slots.leading
&& let Some(v) = view.leading.as_ref()
{
frust::authoring::teardown_child(v, pod, ctx);
} else if Some(index) == slots.overline
&& let Some(v) = view.overline_view()
{
frust::authoring::teardown_child(&v, pod, ctx);
} else if index == slots.headline {
frust::authoring::teardown_child(&view.headline_view(), pod, ctx);
} else if Some(index) == slots.supporting
&& let Some(v) = view.supporting_view()
{
frust::authoring::teardown_child(&v, pod, ctx);
} else if Some(index) == slots.trailing
&& let Some(v) = view.trailing.as_ref()
{
frust::authoring::teardown_child(v, pod, ctx);
}
}
}
impl ListItemWidget {
fn slot_y(&self, row_height: f64, slot_height: f64) -> f64 {
if self.lines == ListItemLines::Three {
VPAD_THREE_LINE
} else {
((row_height - slot_height) / 2.0).max(0.0)
}
}
}
impl Widget for ListItemWidget {
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
let width = if bc.max().width.is_finite() {
bc.max().width
} else {
0.0
};
let height = self.lines.height();
let slot_bc = BoxConstraints::new(Size::ZERO, Size::new(width, height));
let mut text_left = HPAD;
if let Some(idx) = self.slots.leading {
let s = self.children[idx].layout_child(ctx, &slot_bc);
let y = self.slot_y(height, s.height);
self.children[idx].set_origin(Point::new(HPAD, y));
text_left = HPAD + s.width + GAP;
}
let mut text_right = width - HPAD;
if let Some(idx) = self.slots.trailing {
let s = self.children[idx].layout_child(ctx, &slot_bc);
let x = width - HPAD - s.width;
let y = self.slot_y(height, s.height);
self.children[idx].set_origin(Point::new(x, y));
text_right = x - GAP;
}
let text_w = (text_right - text_left).max(0.0);
let text_bc = BoxConstraints::new(Size::ZERO, Size::new(text_w, height));
let over_size = if let Some(oi) = self.slots.overline {
self.children[oi].layout_child(ctx, &text_bc)
} else {
Size::ZERO
};
let hi = self.slots.headline;
let head_size = self.children[hi].layout_child(ctx, &text_bc);
let supp_size = if let Some(si) = self.slots.supporting {
self.children[si].layout_child(ctx, &text_bc)
} else {
Size::ZERO
};
let block = over_size.height + head_size.height + supp_size.height;
let mut y = ((height - block) / 2.0).max(0.0);
if let Some(oi) = self.slots.overline {
self.children[oi].set_origin(Point::new(text_left, y));
y += over_size.height;
}
self.children[hi].set_origin(Point::new(text_left, y));
y += head_size.height;
if let Some(si) = self.slots.supporting {
self.children[si].set_origin(Point::new(text_left, y));
}
bc.constrain(Size::new(width, height))
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
let theme = Theme::from_paint_ctx(ctx);
let disabled = self.interactive && !self.enabled;
let radius = if self.paints_container {
resolve_radius(theme)
} else {
0.0
};
let container = self
.paints_container
.then(|| resolve_container(theme, self.selected, disabled));
let content_color = resolve_content_color(theme);
let hovered = self.interactive && self.enabled && ctx.is_hovered();
let o = ctx.origin();
let size = ctx.size();
self.state.set_hovered(hovered);
if let Some(container) = container {
scene.fill_rounded_rect(o, size, radius, container);
}
if self.interactive && self.enabled {
let opacity = self.state.resolve_opacity();
if opacity > 0.0 {
scene.fill_rounded_rect(o, size, radius, with_alpha(content_color, opacity));
}
}
for pod in &mut self.children {
pod.paint_child(ctx, scene);
}
}
fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
if !self.interactive {
return frust::authoring::route_event(&mut self.children, ctx, event);
}
let InputEvent::Pointer(p) = event else {
return frust::authoring::route_event(&mut self.children, ctx, event);
};
if !self.enabled {
return EventResult::Ignored;
}
let on_press = self
.on_press
.as_mut()
.expect("on_press is set whenever interactive is true");
match p.phase {
PointerPhase::Down => {
if !presses(p) {
return EventResult::Ignored;
}
self.state.set_pressed(true);
self.captured = true;
ctx.capture_pointer();
ctx.request_redraw();
EventResult::Handled
}
PointerPhase::Move => {
if !self.captured {
let over = inside(p.position, ctx.size());
if over {
ctx.claim_hover();
}
if self.state.set_hovered(over) {
ctx.request_redraw();
}
return EventResult::Ignored;
}
let inside_now = inside(p.position, ctx.size());
if self.state.set_pressed(inside_now) {
ctx.request_redraw();
}
EventResult::Handled
}
PointerPhase::Up => {
if !self.captured {
return EventResult::Ignored;
}
if inside(p.position, ctx.size()) {
(on_press)(ctx);
}
self.state.set_pressed(false);
self.captured = false;
ctx.request_redraw();
EventResult::Handled
}
PointerPhase::Cancel => {
if !self.captured {
return EventResult::Ignored;
}
self.state.set_pressed(false);
self.captured = false;
ctx.request_redraw();
EventResult::Handled
}
}
}
fn semantics(&self, ctx: &mut SemanticsCtx) {
ctx.push_container(
Role::ListItem,
|node| {
if self.interactive {
if self.enabled {
node.add_action(Action::Click);
} else {
node.set_disabled();
}
}
if self.selected {
node.set_selected(true);
}
},
|ctx| {
for pod in &self.children {
pod.semantics_child(ctx);
}
},
);
}
frust::authoring::visit_children!(children);
}
#[cfg(test)]
mod tests {
use super::*;
use frust::authoring::text::TextContext;
use frust_core::RenderRoot;
use frust_widgets::test_support::leaf;
use kurbo::Point;
use std::any::Any;
fn build<S: 'static>(view: &ListItem<S>) -> ListItemWidget {
let mut counter = 0u64;
View::<S>::build(view, &mut BuildCtx::new(&mut counter))
}
fn layout(w: &mut ListItemWidget, width: f64) -> Size {
let mut tcx = TextContext::new();
let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
w.layout(&mut lctx, &BoxConstraints::loose(Size::new(width, 400.0)))
}
#[derive(Default)]
struct RRectRecorder {
rrects: Vec<(Point, Size, f64, Color)>,
}
impl PaintScene for RRectRecorder {
fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
fn draw_text(&mut self, _o: Point, _t: &str) {}
fn fill_rounded_rect(&mut self, o: Point, s: Size, radius: f64, color: Color) {
self.rrects.push((o, s, radius, color));
}
}
impl RRectRecorder {
fn overlays(&self) -> Vec<(Point, f32)> {
self.rrects
.iter()
.filter(|(_, _, _, c)| c.components[3] < 1.0)
.map(|(o, _, _, c)| (*o, c.components[3]))
.collect()
}
}
fn paint(w: &mut ListItemWidget, size: Size, theme: Option<&Theme>) -> RRectRecorder {
let mut rec = RRectRecorder::default();
let mut ctx = match theme {
Some(t) => PaintCtx::new(Point::ZERO, size).with_theme(t),
None => PaintCtx::new(Point::ZERO, size),
};
w.paint(&mut ctx, &mut rec);
rec
}
#[test]
fn one_line_row_is_56dp() {
let view: ListItem<()> = list_item("Title");
let mut w = build(&view);
let size = layout(&mut w, 300.0);
assert_eq!(size.height, ONE_LINE_HEIGHT);
}
#[test]
fn supporting_promotes_to_two_line_72dp() {
let view: ListItem<()> = list_item("Title").supporting("Subtitle");
assert_eq!(view.lines, ListItemLines::Two);
let mut w = build(&view);
let size = layout(&mut w, 300.0);
assert_eq!(size.height, TWO_LINE_HEIGHT);
}
#[test]
fn overline_alone_promotes_to_two_line_and_with_supporting_to_three() {
let two: ListItem<()> = list_item("Title").overline("OVERLINE");
assert_eq!(two.lines, ListItemLines::Two);
let three: ListItem<()> = list_item("Title").overline("OVERLINE").supporting("Sub");
assert_eq!(three.lines, ListItemLines::Three);
let flipped: ListItem<()> = list_item("Title").supporting("Sub").overline("OVERLINE");
assert_eq!(flipped.lines, ListItemLines::Three);
let forced: ListItem<()> = list_item("Title").three_line().supporting("Sub");
assert_eq!(forced.lines, ListItemLines::Three);
}
#[test]
fn three_line_is_88dp() {
let view: ListItem<()> = list_item("Title").supporting("Sub").three_line();
assert_eq!(view.lines, ListItemLines::Three);
let mut w = build(&view);
let size = layout(&mut w, 300.0);
assert_eq!(size.height, THREE_LINE_HEIGHT);
}
#[test]
fn leading_and_trailing_slots_are_placed_and_inset_the_text() {
let view: ListItem<()> = list_item("Title")
.leading(leaf(24.0, 24.0))
.trailing(leaf(16.0, 16.0));
let mut w = build(&view);
layout(&mut w, 300.0);
let leading_i = w.slots.leading.expect("leading present");
let trailing_i = w.slots.trailing.expect("trailing present");
let headline_i = w.slots.headline;
assert_eq!(w.children[leading_i].origin().x, HPAD);
assert_eq!(
w.children[leading_i].origin().y,
(ONE_LINE_HEIGHT - 24.0) / 2.0
);
assert_eq!(w.children[trailing_i].origin().x, 300.0 - HPAD - 16.0);
assert_eq!(w.children[headline_i].origin().x, HPAD + 24.0 + GAP);
}
#[test]
fn a_three_line_rows_slots_pin_to_the_top_inset() {
let view: ListItem<()> = list_item("Title")
.overline("OVER")
.supporting("Sub")
.leading(leaf(40.0, 40.0));
let mut w = build(&view);
layout(&mut w, 300.0);
let leading_i = w.slots.leading.expect("leading present");
assert_eq!(w.children[leading_i].origin().y, VPAD_THREE_LINE);
}
#[test]
fn build_orders_children_leading_overline_headline_supporting_trailing() {
let view: ListItem<()> = list_item("H")
.overline("O")
.supporting("S")
.leading(leaf(10.0, 10.0))
.trailing(leaf(10.0, 10.0));
let w = build(&view);
assert_eq!(w.children.len(), 5);
assert_eq!(w.slots.leading, Some(0));
assert_eq!(w.slots.overline, Some(1));
assert_eq!(w.slots.headline, 2);
assert_eq!(w.slots.supporting, Some(3));
assert_eq!(w.slots.trailing, Some(4));
}
#[test]
fn the_text_column_stacks_overline_headline_supporting_in_order() {
let view: ListItem<()> = list_item("Headline")
.overline("OVERLINE")
.supporting("Supporting");
let mut w = build(&view);
layout(&mut w, 300.0);
let over = w.children[w.slots.overline.expect("overline")].origin().y;
let head = w.children[w.slots.headline].origin().y;
let supp = w.children[w.slots.supporting.expect("supporting")]
.origin()
.y;
assert!(over < head, "overline sits above the headline");
assert!(head < supp, "supporting sits below the headline");
}
#[test]
fn every_slot_combination_x_state_builds_lays_out_and_paints() {
let theme = crate::baseline();
for leading in [false, true] {
for overline in [false, true] {
for supporting in [false, true] {
for trailing in [false, true] {
for (selected, contained, interactive) in [
(false, false, false),
(false, true, false),
(true, false, false),
(true, true, true),
(false, false, true),
] {
let mut view: ListItem<()> = list_item("Headline");
if leading {
view = view.leading(leaf(24.0, 24.0));
}
if overline {
view = view.overline("OVERLINE");
}
if supporting {
view = view.supporting("Supporting text");
}
if trailing {
view = view.trailing(leaf(16.0, 16.0));
}
if interactive {
view = view.on_press(|_: &mut ()| {});
}
let expected_height = view.lines.height();
let view = view.selected(selected).contained(contained);
let mut w = build(&view);
let size = layout(&mut w, 300.0);
let case = format!(
"leading={leading} overline={overline} \
supporting={supporting} trailing={trailing} \
selected={selected} contained={contained} \
interactive={interactive}"
);
assert_eq!(size.height, expected_height, "{case}");
for pod in &w.children {
assert!(
pod.origin().y >= 0.0
&& pod.origin().y + pod.size().height <= size.height + 0.01,
"a child escapes the row vertically ({case})"
);
}
let rec = paint(&mut w, size, Some(&theme));
let expected_rects = usize::from(selected || contained);
assert_eq!(
rec.rrects.len(),
expected_rects,
"container fill count ({case})"
);
}
}
}
}
}
}
#[test]
fn a_bare_row_paints_no_container_of_its_own() {
let view: ListItem<()> = list_item("Title");
let mut w = build(&view);
let rec = paint(&mut w, Size::new(300.0, ONE_LINE_HEIGHT), None);
assert!(rec.rrects.is_empty());
}
#[test]
fn a_contained_row_paints_the_filled_container_at_the_medium_radius() {
let view: ListItem<()> = list_item("Title").contained(true);
let mut w = build(&view);
let rec = paint(&mut w, Size::new(300.0, ONE_LINE_HEIGHT), None);
assert_eq!(rec.rrects.len(), 1, "just the container fill at rest");
assert_eq!(rec.rrects[0].3, FILLED_CONTAINER);
assert_eq!(rec.rrects[0].2, RADIUS);
}
#[test]
fn a_selected_row_paints_the_secondary_container_fill_with_or_without_contained() {
let theme = crate::baseline();
let view: ListItem<()> = list_item("Title").selected(true);
let mut w = build(&view);
let rec = paint(&mut w, Size::new(300.0, ONE_LINE_HEIGHT), Some(&theme));
assert_eq!(rec.rrects.len(), 1);
assert_eq!(rec.rrects[0].3, theme.scheme().secondary_container);
assert_eq!(rec.rrects[0].2, theme.shape.medium);
let contained: ListItem<()> = list_item("Title").contained(true).selected(true);
let mut w = build(&contained);
let rec = paint(&mut w, Size::new(300.0, ONE_LINE_HEIGHT), Some(&theme));
assert_eq!(rec.rrects[0].3, theme.scheme().secondary_container);
let unselected: ListItem<()> = list_item("Title").contained(true);
let mut w = build(&unselected);
let rec = paint(&mut w, Size::new(300.0, ONE_LINE_HEIGHT), Some(&theme));
assert_eq!(rec.rrects[0].3, theme.scheme().surface_container_highest);
}
#[test]
fn a_disabled_interactive_row_dims_its_container_and_paints_no_overlay() {
let theme = crate::baseline();
let view: ListItem<()> = list_item("Title")
.contained(true)
.on_press(|_: &mut ()| {})
.enabled(false);
let mut w = build(&view);
let rec = paint(&mut w, Size::new(300.0, ONE_LINE_HEIGHT), Some(&theme));
assert_eq!(
rec.rrects[0].3,
with_alpha(theme.scheme().on_surface, DISABLED_CONTAINER_OPACITY)
);
assert_eq!(rec.rrects.len(), 1, "no state layer while disabled");
}
#[derive(Default)]
struct Counter {
presses: u32,
}
fn dispatch<S: 'static>(
w: &mut ListItemWidget,
state: &mut S,
event: &InputEvent,
) -> EventResult {
let state_any: &mut dyn Any = state;
let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(300.0, ONE_LINE_HEIGHT));
w.event(&mut ctx, event)
}
fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
InputEvent::Pointer(frust::authoring::PointerEvent {
phase,
position: Point::new(x, y),
button: frust::authoring::PointerButton::Primary,
})
}
fn secondary_ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
InputEvent::Pointer(frust::authoring::PointerEvent {
phase,
position: Point::new(x, y),
button: frust::authoring::PointerButton::Secondary,
})
}
#[test]
fn interactive_row_fires_on_up_inside() {
let view: ListItem<Counter> =
list_item("Tap me").on_press(|s: &mut Counter| s.presses += 1);
let mut w = build(&view);
let mut state = Counter::default();
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
assert!(w.captured);
dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 10.0, 10.0));
assert_eq!(state.presses, 1);
}
#[test]
fn a_secondary_press_never_presses_captures_or_fires() {
let view: ListItem<Counter> =
list_item("Tap me").on_press(|s: &mut Counter| s.presses += 1);
let mut w = build(&view);
let mut state = Counter::default();
let r = dispatch(
&mut w,
&mut state,
&secondary_ev(PointerPhase::Down, 10.0, 10.0),
);
assert_eq!(r, EventResult::Ignored);
assert!(!w.state.pressed, "no pressed state layer on a right-click");
assert!(!w.captured, "and no capture for the shell to wedge on");
dispatch(
&mut w,
&mut state,
&secondary_ev(PointerPhase::Up, 10.0, 10.0),
);
assert_eq!(state.presses, 0);
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 10.0, 10.0));
assert_eq!(state.presses, 1);
}
#[test]
fn interactive_row_up_outside_does_not_fire() {
let view: ListItem<Counter> =
list_item("Tap me").on_press(|s: &mut Counter| s.presses += 1);
let mut w = build(&view);
let mut state = Counter::default();
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 999.0, 999.0));
assert_eq!(state.presses, 0);
}
#[test]
fn a_disabled_interactive_row_ignores_every_pointer_phase() {
let view: ListItem<Counter> = list_item("Tap me")
.on_press(|s: &mut Counter| s.presses += 1)
.enabled(false);
let mut w = build(&view);
let mut state = Counter::default();
assert_eq!(
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0)),
EventResult::Ignored
);
assert!(!w.captured, "a disabled row never captures");
dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 10.0, 10.0));
assert_eq!(state.presses, 0, "and never fires");
}
struct FocusConsumer;
impl FocusConsumer {
fn new() -> Self {
FocusConsumer
}
}
struct FocusConsumerWidget;
impl View<Counter> for FocusConsumer {
type Element = FocusConsumerWidget;
fn build(&self, _ctx: &mut BuildCtx<'_>) -> FocusConsumerWidget {
FocusConsumerWidget
}
fn rebuild(
&self,
_prev: &Self,
_element: &mut FocusConsumerWidget,
_ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
ChangeFlags::NONE
}
}
impl Widget for FocusConsumerWidget {
fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
bc.max()
}
fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {}
fn event(&mut self, _ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
if let InputEvent::Key(_) = event {
return EventResult::Handled;
}
EventResult::Ignored
}
}
#[test]
fn interactive_row_with_trailing_control_forwards_key_events() {
let view: ListItem<Counter> = list_item("Item")
.trailing(FocusConsumer::new())
.on_press(|s: &mut Counter| s.presses += 1);
let mut w = build(&view);
let mut state = Counter::default();
let trailing_idx = w.slots.trailing.expect("trailing is present");
w.children[trailing_idx].set_focused(true);
let key_event = InputEvent::Key(frust::authoring::KeyEvent {
key: frust::authoring::Key::Named(frust::authoring::NamedKey::Backspace),
modifiers: frust::authoring::Modifiers::default(),
repeat: false,
});
let result = dispatch(&mut w, &mut state, &key_event);
assert_eq!(
result,
EventResult::Handled,
"key event must be forwarded to focused child"
);
assert_eq!(
state.presses, 0,
"row press callback does not fire on key event"
);
}
struct HoverHarness {
root: frust_core::RenderRoot<Counter, frust::FlexView<Counter>>,
state: Counter,
tcx: TextContext,
}
const HOVER_WINDOW: Size = Size::new(300.0, 200.0);
impl HoverHarness {
fn new() -> Self {
let mut h = HoverHarness {
root: frust_core::RenderRoot::new(),
state: Counter::default(),
tcx: TextContext::new(),
};
h.rebuild_layout();
h
}
fn rebuild_layout(&mut self) {
let mut app = |_s: &mut Counter| {
frust::column()
.child(list_item::<Counter>("first").on_press(|s: &mut Counter| s.presses += 1))
.child(
list_item::<Counter>("second").on_press(|s: &mut Counter| s.presses += 1),
)
};
self.root.rebuild(&mut app, &mut self.state);
self.root
.layout_with_text(HOVER_WINDOW, &mut self.tcx as &mut dyn Any);
}
fn dispatch(
&mut self,
phase: PointerPhase,
x: f64,
y: f64,
) -> frust::authoring::EventOutcome {
self.root.event(
&mut self.state,
&InputEvent::Pointer(frust::authoring::PointerEvent {
phase,
position: Point::new(x, y),
button: frust::authoring::PointerButton::Primary,
}),
)
}
fn row_y(index: usize) -> f64 {
ONE_LINE_HEIGHT * index as f64 + ONE_LINE_HEIGHT / 2.0
}
fn overlay_rows(&mut self) -> Vec<usize> {
let mut rec = RRectRecorder::default();
self.root.paint(&mut rec, frust::FrameTime::ZERO);
rec.overlays()
.iter()
.map(|(origin, _)| (origin.y / ONE_LINE_HEIGHT).round() as usize)
.collect()
}
fn overlay_alpha(&mut self) -> f32 {
let mut rec = RRectRecorder::default();
self.root.paint(&mut rec, frust::FrameTime::ZERO);
rec.overlays().first().expect("one overlay painted").1
}
}
#[test]
fn hovering_a_row_paints_the_hover_overlay() {
let mut h = HoverHarness::new();
assert!(h.overlay_rows().is_empty(), "no overlay at rest");
let gain = h.dispatch(PointerPhase::Move, 150.0, HoverHarness::row_y(0));
assert!(gain.needs_redraw, "entering a row repaints");
assert_eq!(h.overlay_rows(), vec![0], "the hovered row tints");
assert_eq!(
h.overlay_alpha(),
crate::interaction::HOVER_OPACITY,
"at the documented M3 hover opacity"
);
let settled = h.dispatch(PointerPhase::Move, 160.0, HoverHarness::row_y(0));
assert!(!settled.needs_redraw, "an unchanged flag asks for nothing");
assert_eq!(h.overlay_rows(), vec![0]);
}
#[test]
fn hover_moves_to_the_row_under_the_pointer() {
let mut h = HoverHarness::new();
h.dispatch(PointerPhase::Move, 150.0, HoverHarness::row_y(0));
assert_eq!(h.overlay_rows(), vec![0]);
let handoff = h.dispatch(PointerPhase::Move, 150.0, HoverHarness::row_y(1));
assert!(handoff.needs_redraw, "a row-to-row handoff repaints");
assert_eq!(h.overlay_rows(), vec![1]);
let outcome = h.dispatch(PointerPhase::Move, 150.0, 180.0);
assert!(outcome.needs_redraw);
assert!(h.overlay_rows().is_empty());
}
#[test]
fn a_captured_drag_paints_no_hover_overlay() {
let mut h = HoverHarness::new();
h.dispatch(PointerPhase::Down, 150.0, HoverHarness::row_y(0));
assert_eq!(h.overlay_rows(), vec![0]);
assert_eq!(h.overlay_alpha(), frust::authoring::PRESSED_OPACITY);
h.dispatch(PointerPhase::Move, 150.0, HoverHarness::row_y(1));
assert!(
h.overlay_rows().is_empty(),
"dragged outside: neither pressed nor hovered"
);
h.dispatch(PointerPhase::Up, 150.0, HoverHarness::row_y(1));
assert_eq!(h.state.presses, 0);
assert!(h.overlay_rows().is_empty());
}
#[test]
fn hover_survives_a_rebuild_and_clears_on_press() {
let mut h = HoverHarness::new();
h.dispatch(PointerPhase::Move, 150.0, HoverHarness::row_y(0));
h.rebuild_layout();
assert_eq!(
h.overlay_rows(),
vec![0],
"an in-place rebuild keeps the pod that holds the link"
);
h.dispatch(PointerPhase::Down, 150.0, HoverHarness::row_y(0));
assert_eq!(h.overlay_alpha(), frust::authoring::PRESSED_OPACITY);
h.dispatch(PointerPhase::Up, 150.0, HoverHarness::row_y(0));
assert_eq!(h.state.presses, 1);
assert!(
h.overlay_rows().is_empty(),
"a click leaves no hover behind until the pointer moves again"
);
}
#[test]
fn an_uncaptured_drag_tints_the_row_only_until_the_lift() {
let mut h = HoverHarness::new();
h.dispatch(PointerPhase::Down, 150.0, 180.0);
assert!(h.overlay_rows().is_empty(), "pressed empty chrome, no row");
h.dispatch(PointerPhase::Move, 150.0, HoverHarness::row_y(1));
assert_eq!(
h.overlay_rows(),
vec![1],
"an uncaptured drag over the row is a hover pass"
);
let lift = h.dispatch(PointerPhase::Up, 150.0, HoverHarness::row_y(1));
assert!(
lift.needs_redraw,
"the lift asks for the frame that clears it"
);
assert!(
h.overlay_rows().is_empty(),
"the tint is transient: the lift ends the link"
);
assert_eq!(h.state.presses, 0, "no press fired: the row never captured");
}
#[test]
fn semantics_row_is_a_list_item_with_the_headline_label() {
fn logic(_s: &mut ()) -> ListItem<()> {
list_item("Inbox")
}
let mut root: RenderRoot<(), ListItem<()>> = RenderRoot::new();
let mut state = ();
root.rebuild(&mut logic, &mut state);
let mut tcx = TextContext::new();
root.layout_with_text(Size::new(300.0, 100.0), &mut tcx as &mut dyn Any);
let update = root.semantics();
let (_, item) = update
.nodes
.iter()
.find(|(_, n)| n.role() == Role::ListItem)
.expect("row contributes a Role::ListItem node");
assert!(
!item.children().is_empty(),
"the headline text is a semantics child of the row"
);
assert!(
update
.nodes
.iter()
.any(|(_, n)| n.role() == Role::Label && n.value() == Some("Inbox")),
"the headline text is announced"
);
assert_eq!(
item.is_selected(),
None,
"an unselected row omits the flag entirely (accesskit's own guidance)"
);
}
#[test]
fn interactive_row_semantics_carries_a_click_action() {
fn logic(_s: &mut ()) -> ListItem<()> {
list_item("Go").on_press(|_s: &mut ()| {})
}
let mut root: RenderRoot<(), ListItem<()>> = RenderRoot::new();
let mut state = ();
root.rebuild(&mut logic, &mut state);
let mut tcx = TextContext::new();
root.layout_with_text(Size::new(300.0, 100.0), &mut tcx as &mut dyn Any);
let update = root.semantics();
let (_, item) = update
.nodes
.iter()
.find(|(_, n)| n.role() == Role::ListItem)
.expect("row node");
assert!(
item.supports_action(Action::Click),
"an interactive row exposes the Click action"
);
assert!(!item.is_disabled());
}
#[test]
fn semantics_disabled_row_reports_disabled_and_selected_row_reports_selection() {
fn disabled_logic(_s: &mut ()) -> ListItem<()> {
list_item("Go").on_press(|_s: &mut ()| {}).enabled(false)
}
let mut root: RenderRoot<(), ListItem<()>> = RenderRoot::new();
let mut state = ();
root.rebuild(&mut disabled_logic, &mut state);
let mut tcx = TextContext::new();
root.layout_with_text(Size::new(300.0, 100.0), &mut tcx as &mut dyn Any);
let update = root.semantics();
let (_, item) = update
.nodes
.iter()
.find(|(_, n)| n.role() == Role::ListItem)
.expect("row node");
assert!(item.is_disabled());
assert!(!item.supports_action(Action::Click));
fn selected_logic(_s: &mut ()) -> ListItem<()> {
list_item("Go").selected(true)
}
let mut root: RenderRoot<(), ListItem<()>> = RenderRoot::new();
root.rebuild(&mut selected_logic, &mut state);
root.layout_with_text(Size::new(300.0, 100.0), &mut tcx as &mut dyn Any);
let update = root.semantics();
let (_, item) = update
.nodes
.iter()
.find(|(_, n)| n.role() == Role::ListItem)
.expect("row node");
assert_eq!(item.is_selected(), Some(true));
}
#[cfg(feature = "bundled-fonts")]
#[test]
fn every_text_slot_paints_in_roboto_flex_under_the_material_theme() {
use crate::appbar::top::typeface_probe::{Face, assert_paints_only_in};
let flex = crate::tokens::font_data()[0];
let runs = assert_paints_only_in(
"the list item's text slots",
|_: &mut ()| {
list_item::<()>("Headline")
.overline("Overline")
.supporting("Supporting text")
},
crate::baseline(),
&[flex],
Size::new(300.0, 100.0),
Face {
bytes: flex,
name: "Roboto Flex",
},
);
assert!(
runs >= 3,
"fixture sanity: expected a run per text slot, got {runs}"
);
}
}