use std::collections::BTreeSet;
use std::rc::Rc;
use frust::authoring::text::{FontWeight, LineHeight};
use frust::authoring::{Action, Role};
use frust::authoring::{
AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, ErasedArgCallback, EventCtx,
EventResult, InputEvent, LayoutCtx, PaintCtx, PaintScene, PointerPhase, SemanticsCtx,
ThemeTextColor, ThemeTextType, View, ViewSeq, Widget, any,
};
use frust::input::TOUCH_SLOP;
use frust::{FrameTime, Theme, icon, text};
use kurbo::{Point, Size};
use peniko::Color;
use crate::icon_button::icon_button;
use crate::press::presses;
const APP_BAR_HEIGHT: f64 = 64.0;
const APP_BAR_PAD_X: f64 = 4.0;
const APP_BAR_GAP: f64 = 4.0;
const ACTION_SLOT: f64 = 48.0;
const SELECT_ALL_HEIGHT: f64 = 48.0;
const COUNT_SIZE: f32 = 22.0;
const COUNT_LINE_HEIGHT: f32 = 28.0;
const COUNT_WEIGHT: FontWeight = FontWeight::MEDIUM;
const CONTEXTUAL_BACKGROUND: Color = Color::from_rgb8(0xEA, 0xDD, 0xFF);
const CLEAR_SELECTION_LABEL: &str = "Clear selection";
const LONG_PRESS_MS: f64 = 500.0;
pub fn is_selection_mode(selected: &BTreeSet<usize>) -> bool {
!selected.is_empty()
}
pub fn is_selected(selected: &BTreeSet<usize>, index: usize) -> bool {
selected.contains(&index)
}
pub fn all_selected_for(selected: &BTreeSet<usize>, item_count: usize) -> Option<bool> {
if item_count == 0 || selected.is_empty() {
return Some(false);
}
if selected.len() >= item_count {
return Some(true);
}
None
}
pub fn select(selected: &BTreeSet<usize>, index: usize) -> BTreeSet<usize> {
let mut next = selected.clone();
next.insert(index);
next
}
pub fn deselect(selected: &BTreeSet<usize>, index: usize) -> BTreeSet<usize> {
let mut next = selected.clone();
next.remove(&index);
next
}
pub fn toggle(selected: &BTreeSet<usize>, index: usize) -> BTreeSet<usize> {
let mut next = selected.clone();
if !next.remove(&index) {
next.insert(index);
}
next
}
pub fn select_all(item_count: usize) -> BTreeSet<usize> {
(0..item_count).collect()
}
pub fn cleared() -> BTreeSet<usize> {
BTreeSet::new()
}
type OnIndex<State> = Rc<dyn Fn(&mut State, usize)>;
pub struct SelectionHostView<State: 'static> {
items: Vec<AnyView<State>>,
selected: BTreeSet<usize>,
enabled: bool,
on_toggle: OnIndex<State>,
on_activate: Option<OnIndex<State>>,
}
pub fn selection_host<State: 'static, F, M>(
items: impl ViewSeq<State, M>,
selected: &BTreeSet<usize>,
on_toggle: F,
) -> SelectionHostView<State>
where
F: Fn(&mut State, usize) + 'static,
{
let mut erased = Vec::new();
items.extend_views(&mut erased);
SelectionHostView {
items: erased,
selected: selected.clone(),
enabled: true,
on_toggle: Rc::new(on_toggle),
on_activate: None,
}
}
impl<State: 'static> SelectionHostView<State> {
pub fn on_activate<F: Fn(&mut State, usize) + 'static>(mut self, on_activate: F) -> Self {
self.on_activate = Some(Rc::new(on_activate));
self
}
pub fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
}
#[derive(Debug, Default, Clone, Copy)]
struct LongPressState {
press_start: Option<FrameTime>,
elapsed: bool,
fired: bool,
}
impl LongPressState {
fn clear(&mut self) {
*self = Self::default();
}
}
pub struct SelectionHostWidget {
items: Vec<ChildPod>,
selected: BTreeSet<usize>,
enabled: bool,
rows: Vec<(f64, f64)>,
width: f64,
armed: Option<usize>,
down_pos: Point,
long_press: LongPressState,
on_toggle: ErasedArgCallback<usize>,
on_activate: Option<ErasedArgCallback<usize>>,
}
impl<State: 'static> View<State> for SelectionHostView<State> {
type Element = SelectionHostWidget;
fn build(&self, ctx: &mut BuildCtx<'_>) -> SelectionHostWidget {
let items = self
.items
.iter()
.map(|view| frust::authoring::build_child(view, ctx))
.collect();
SelectionHostWidget {
items,
selected: self.selected.clone(),
enabled: self.enabled,
rows: Vec::new(),
width: 0.0,
armed: None,
down_pos: Point::ZERO,
long_press: LongPressState::default(),
on_toggle: frust::authoring::erase_callback_arg(&self.on_toggle),
on_activate: self
.on_activate
.as_ref()
.map(frust::authoring::erase_callback_arg),
}
}
fn rebuild(
&self,
prev: &Self,
element: &mut SelectionHostWidget,
ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
let mut flags = frust::authoring::rebuild_children(
&prev.items,
&self.items,
&mut element.items,
ctx,
|view| view,
|_| None,
);
if prev.items.len() != self.items.len() {
element.armed = None;
element.long_press.clear();
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
if element.selected != self.selected {
element.selected = self.selected.clone();
flags |= ChangeFlags::PAINT;
}
if element.enabled != self.enabled {
element.enabled = self.enabled;
if !self.enabled {
element.armed = None;
element.long_press.clear();
}
flags |= ChangeFlags::PAINT;
}
element.on_toggle = frust::authoring::erase_callback_arg(&self.on_toggle);
element.on_activate = self
.on_activate
.as_ref()
.map(frust::authoring::erase_callback_arg);
flags
}
fn teardown(&self, element: &mut SelectionHostWidget, ctx: &mut BuildCtx<'_>) {
for (view, pod) in self.items.iter().zip(element.items.iter_mut()) {
frust::authoring::teardown_child(view, pod, ctx);
}
}
}
impl SelectionHostWidget {
fn row_at(&self, pos: Point) -> Option<usize> {
if pos.x < 0.0 || pos.x >= self.width {
return None;
}
self.rows
.iter()
.position(|(y, height)| pos.y >= *y && pos.y < y + height)
}
fn clear_press(&mut self) {
self.armed = None;
self.long_press.clear();
}
fn fire_long_press(&mut self, ctx: &mut EventCtx, index: usize) {
if !self.selected.contains(&index) {
(self.on_toggle)(ctx, index);
}
}
fn fire_tap(&mut self, ctx: &mut EventCtx, index: usize) {
if is_selection_mode(&self.selected) {
(self.on_toggle)(ctx, index);
} else if let Some(on_activate) = self.on_activate.as_mut() {
on_activate(ctx, index);
}
}
}
impl Widget for SelectionHostWidget {
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
let width = if bc.max().width.is_finite() {
bc.max().width
} else {
0.0
};
self.width = width;
let max_height = bc.max().height;
let child_bc = BoxConstraints::new(Size::new(width, 0.0), Size::new(width, max_height));
self.rows.clear();
let mut y = 0.0;
for pod in self.items.iter_mut() {
let size = pod.layout_child(ctx, &child_bc);
pod.set_origin(Point::new(0.0, y));
self.rows.push((y, size.height));
y += size.height;
}
bc.constrain(Size::new(width, y))
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
for pod in &mut self.items {
pod.paint_child(ctx, scene);
}
if self.enabled && self.armed.is_some() && !self.long_press.elapsed {
let start = *self.long_press.press_start.get_or_insert(ctx.frame_time());
let elapsed_ms = ctx.frame_time().saturating_sub(start).as_secs_f64() * 1000.0;
if elapsed_ms >= LONG_PRESS_MS {
self.long_press.elapsed = true;
} else {
ctx.request_frame();
}
}
}
fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
let InputEvent::Pointer(p) = event else {
return frust::authoring::route_event(&mut self.items, ctx, event);
};
if !self.enabled {
return EventResult::Ignored;
}
match p.phase {
PointerPhase::Down => {
if !presses(p) {
return EventResult::Ignored;
}
let Some(index) = self.row_at(p.position) else {
return EventResult::Ignored;
};
self.armed = Some(index);
self.down_pos = p.position;
self.long_press.clear();
ctx.capture_pointer();
ctx.request_redraw();
EventResult::Handled
}
PointerPhase::Move => {
let Some(armed) = self.armed else {
return EventResult::Ignored;
};
if (p.position - self.down_pos).hypot() > TOUCH_SLOP {
self.long_press.clear();
} else if self.long_press.elapsed && !self.long_press.fired {
self.long_press.fired = true;
self.fire_long_press(ctx, armed);
ctx.request_redraw();
}
EventResult::Handled
}
PointerPhase::Up => {
let Some(armed) = self.armed else {
return EventResult::Ignored;
};
if self.long_press.elapsed {
if !self.long_press.fired {
self.long_press.fired = true;
self.fire_long_press(ctx, armed);
}
} else if self.row_at(p.position) == Some(armed) {
self.fire_tap(ctx, armed);
}
self.clear_press();
ctx.request_redraw();
EventResult::Handled
}
PointerPhase::Cancel => {
if self.armed.is_none() {
return EventResult::Ignored;
}
self.clear_press();
ctx.request_redraw();
EventResult::Handled
}
}
}
fn semantics(&self, ctx: &mut SemanticsCtx) {
ctx.push_container(
Role::List,
|_node| {},
|ctx| {
for pod in &self.items {
pod.semantics_child(ctx);
}
},
);
}
frust::authoring::visit_children!(items);
}
type OnClear<State> = Rc<dyn Fn(&mut State)>;
type OnAllSelected<State> = Rc<dyn Fn(&mut State, bool)>;
pub struct SelectionAppBarView<State: 'static> {
idle: AnyView<State>,
selected: BTreeSet<usize>,
item_count: usize,
actions: Vec<AnyView<State>>,
show_select_all: bool,
select_all_label: String,
on_clear: OnClear<State>,
on_all_selected: Option<OnAllSelected<State>>,
safe_area: bool,
}
pub fn selection_app_bar<State: 'static, F>(
idle: impl View<State>,
selected: &BTreeSet<usize>,
item_count: usize,
on_clear: F,
) -> SelectionAppBarView<State>
where
F: Fn(&mut State) + 'static,
{
SelectionAppBarView {
idle: AnyView::new(idle),
selected: selected.clone(),
item_count,
actions: Vec::new(),
show_select_all: true,
select_all_label: "Select all".to_string(),
on_clear: Rc::new(on_clear),
on_all_selected: None,
safe_area: true,
}
}
impl<State: 'static> SelectionAppBarView<State> {
pub fn actions<M>(mut self, actions: impl ViewSeq<State, M>) -> Self {
self.actions.clear();
actions.extend_views(&mut self.actions);
self
}
pub fn show_select_all(mut self, show: bool) -> Self {
self.show_select_all = show;
self
}
pub fn select_all_label(mut self, label: impl Into<String>) -> Self {
self.select_all_label = label.into();
self
}
pub fn on_all_selected<F: Fn(&mut State, bool) + 'static>(
mut self,
on_all_selected: F,
) -> Self {
self.on_all_selected = Some(Rc::new(on_all_selected));
self
}
pub fn safe_area(mut self, enabled: bool) -> Self {
self.safe_area = enabled;
self
}
}
impl<State: 'static> SelectionAppBarView<State> {
fn shape(&self) -> (usize, bool) {
(self.actions.len(), self.show_select_all)
}
}
fn close_view<State: 'static>(on_clear: OnClear<State>) -> impl View<State> {
icon_button(icon(crate::icons::CLOSE), move |state: &mut State| {
(on_clear)(state);
})
.semantic_label(CLEAR_SELECTION_LABEL)
}
fn count_view<State: 'static>(count: usize) -> AnyView<State> {
any::<State, _>(
text(count.to_string())
.size(COUNT_SIZE)
.weight(COUNT_WEIGHT)
.line_height(LineHeight::Absolute(COUNT_LINE_HEIGHT))
.themed_role(ThemeTextColor::OnPrimaryContainer)
.themed_family(ThemeTextType::TitleLargeEmphasized),
)
}
fn select_all_checkbox_view<State: 'static>(
selected: BTreeSet<usize>,
item_count: usize,
on_all_selected: Option<OnAllSelected<State>>,
) -> impl View<State> {
let value = all_selected_for(&selected, item_count);
crate::checkbox::tristate_checkbox::<State, _>(
value,
move |state: &mut State, _next: Option<bool>| {
if let Some(cb) = &on_all_selected {
cb(state, !matches!(value, Some(true)));
}
},
)
}
fn select_all_label_view<State: 'static>(label: String) -> AnyView<State> {
any::<State, _>(
text(label)
.themed_role(ThemeTextColor::OnPrimaryContainer)
.themed_family(ThemeTextType::BodyLarge),
)
}
fn resolve_contextual_background(theme: Option<&Theme>) -> Color {
match theme {
Some(theme) => theme.scheme().primary_container,
None => CONTEXTUAL_BACKGROUND,
}
}
#[derive(Clone, Copy)]
struct ContextualSlots {
close: usize,
count_text: usize,
actions_start: usize,
actions_len: usize,
select_all: Option<(usize, usize)>,
}
fn build_contextual_children<State: 'static>(
view: &SelectionAppBarView<State>,
ctx: &mut BuildCtx<'_>,
) -> (Vec<ChildPod>, ContextualSlots) {
let mut children = Vec::new();
let close_v = close_view::<State>(view.on_clear.clone());
children.push(frust::authoring::build_child(&close_v, ctx));
let close = 0;
let count_v = count_view::<State>(view.selected.len());
children.push(frust::authoring::build_child(&count_v, ctx));
let count_text = 1;
let actions_start = children.len();
for action in &view.actions {
children.push(frust::authoring::build_child(action, ctx));
}
let actions_len = view.actions.len();
let select_all = view.show_select_all.then(|| {
let cb = select_all_checkbox_view::<State>(
view.selected.clone(),
view.item_count,
view.on_all_selected.clone(),
);
let lbl = select_all_label_view::<State>(view.select_all_label.clone());
let cb_idx = children.len();
children.push(frust::authoring::build_child(&cb, ctx));
let lbl_idx = children.len();
children.push(frust::authoring::build_child(&lbl, ctx));
(cb_idx, lbl_idx)
});
(
children,
ContextualSlots {
close,
count_text,
actions_start,
actions_len,
select_all,
},
)
}
fn teardown_contextual_children<State: 'static>(
view: &SelectionAppBarView<State>,
slots: ContextualSlots,
children: &mut [ChildPod],
ctx: &mut BuildCtx<'_>,
) {
let close_v = close_view::<State>(view.on_clear.clone());
frust::authoring::teardown_child(&close_v, &mut children[slots.close], ctx);
let count_v = count_view::<State>(view.selected.len());
frust::authoring::teardown_child(&count_v, &mut children[slots.count_text], ctx);
for i in 0..slots.actions_len {
frust::authoring::teardown_child(
&view.actions[i],
&mut children[slots.actions_start + i],
ctx,
);
}
if let Some((cb_idx, lbl_idx)) = slots.select_all {
let cb = select_all_checkbox_view::<State>(
view.selected.clone(),
view.item_count,
view.on_all_selected.clone(),
);
frust::authoring::teardown_child(&cb, &mut children[cb_idx], ctx);
let lbl = select_all_label_view::<State>(view.select_all_label.clone());
frust::authoring::teardown_child(&lbl, &mut children[lbl_idx], ctx);
}
}
pub struct SelectionAppBarWidget {
idle: ChildPod,
contextual: Vec<ChildPod>,
contextual_slots: ContextualSlots,
selected: BTreeSet<usize>,
item_count: usize,
show_select_all: bool,
select_all_label: String,
safe_area: bool,
}
impl<State: 'static> View<State> for SelectionAppBarView<State> {
type Element = SelectionAppBarWidget;
fn build(&self, ctx: &mut BuildCtx<'_>) -> SelectionAppBarWidget {
let (contextual, contextual_slots) = build_contextual_children(self, ctx);
SelectionAppBarWidget {
idle: frust::authoring::build_child(&self.idle, ctx),
contextual,
contextual_slots,
selected: self.selected.clone(),
item_count: self.item_count,
show_select_all: self.show_select_all,
select_all_label: self.select_all_label.clone(),
safe_area: self.safe_area,
}
}
fn rebuild(
&self,
prev: &Self,
element: &mut SelectionAppBarWidget,
ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
let mut flags =
frust::authoring::rebuild_child(&prev.idle, &self.idle, &mut element.idle, ctx);
if prev.shape() != self.shape() {
teardown_contextual_children(
prev,
element.contextual_slots,
&mut element.contextual,
ctx,
);
let (children, slots) = build_contextual_children(self, ctx);
element.contextual = children;
element.contextual_slots = slots;
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
} else {
let slots = element.contextual_slots;
let prev_close = close_view::<State>(prev.on_clear.clone());
let next_close = close_view::<State>(self.on_clear.clone());
flags |= frust::authoring::rebuild_child(
&prev_close,
&next_close,
&mut element.contextual[slots.close],
ctx,
);
let prev_count = count_view::<State>(prev.selected.len());
let next_count = count_view::<State>(self.selected.len());
flags |= frust::authoring::rebuild_child(
&prev_count,
&next_count,
&mut element.contextual[slots.count_text],
ctx,
);
for i in 0..slots.actions_len {
flags |= frust::authoring::rebuild_child(
&prev.actions[i],
&self.actions[i],
&mut element.contextual[slots.actions_start + i],
ctx,
);
}
if let Some((cb_idx, lbl_idx)) = slots.select_all {
let prev_cb = select_all_checkbox_view::<State>(
prev.selected.clone(),
prev.item_count,
prev.on_all_selected.clone(),
);
let next_cb = select_all_checkbox_view::<State>(
self.selected.clone(),
self.item_count,
self.on_all_selected.clone(),
);
flags |= frust::authoring::rebuild_child(
&prev_cb,
&next_cb,
&mut element.contextual[cb_idx],
ctx,
);
let prev_lbl = select_all_label_view::<State>(prev.select_all_label.clone());
let next_lbl = select_all_label_view::<State>(self.select_all_label.clone());
flags |= frust::authoring::rebuild_child(
&prev_lbl,
&next_lbl,
&mut element.contextual[lbl_idx],
ctx,
);
}
}
if element.selected != self.selected {
element.selected = self.selected.clone();
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
if element.safe_area != self.safe_area {
element.safe_area = self.safe_area;
flags |= ChangeFlags::LAYOUT;
}
element.item_count = self.item_count;
element.show_select_all = self.show_select_all;
element.select_all_label = self.select_all_label.clone();
flags
}
fn teardown(&self, element: &mut SelectionAppBarWidget, ctx: &mut BuildCtx<'_>) {
frust::authoring::teardown_child(&self.idle, &mut element.idle, ctx);
teardown_contextual_children(self, element.contextual_slots, &mut element.contextual, ctx);
}
}
impl Widget for SelectionAppBarWidget {
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
let width = if bc.max().width.is_finite() {
bc.max().width
} else {
0.0
};
if !is_selection_mode(&self.selected) {
let idle_size = self
.idle
.layout_child(ctx, &BoxConstraints::loose(Size::new(width, f64::INFINITY)));
self.idle.set_origin(Point::ZERO);
return bc.constrain(Size::new(width, idle_size.height));
}
let (pad_l, top, pad_r) = if self.safe_area {
let padding = ctx.window_insets().padding();
(
padding.left.max(0.0),
padding.top.max(0.0),
padding.right.max(0.0),
)
} else {
(0.0, 0.0, 0.0)
};
let leading_edge = pad_l + APP_BAR_PAD_X;
let trailing_edge = width - pad_r - APP_BAR_PAD_X;
let slots = self.contextual_slots;
let slot_bc = BoxConstraints::loose(Size::new(f64::INFINITY, APP_BAR_HEIGHT));
let close_size = self.contextual[slots.close].layout_child(ctx, &slot_bc);
self.contextual[slots.close].set_origin(Point::new(
leading_edge + (ACTION_SLOT - close_size.width).max(0.0) / 2.0,
top + (APP_BAR_HEIGHT - close_size.height) / 2.0,
));
let mut right = trailing_edge;
for i in (0..slots.actions_len).rev() {
let idx = slots.actions_start + i;
let size = self.contextual[idx].layout_child(ctx, &slot_bc);
right -= size.width;
self.contextual[idx].set_origin(Point::new(
right,
top + (APP_BAR_HEIGHT - size.height) / 2.0,
));
right -= APP_BAR_GAP;
}
let count_left = leading_edge + ACTION_SLOT + APP_BAR_GAP;
let count_max_width = (right - count_left).max(0.0);
let count_size = self.contextual[slots.count_text].layout_child(
ctx,
&BoxConstraints::loose(Size::new(count_max_width, APP_BAR_HEIGHT)),
);
self.contextual[slots.count_text].set_origin(Point::new(
count_left,
top + (APP_BAR_HEIGHT - count_size.height) / 2.0,
));
let mut height = top + APP_BAR_HEIGHT;
if let Some((cb_idx, lbl_idx)) = slots.select_all {
let cb_bc = BoxConstraints::loose(Size::new(f64::INFINITY, SELECT_ALL_HEIGHT));
let cb_size = self.contextual[cb_idx].layout_child(ctx, &cb_bc);
self.contextual[cb_idx].set_origin(Point::new(
leading_edge + (ACTION_SLOT - cb_size.width).max(0.0) / 2.0,
height + (SELECT_ALL_HEIGHT - cb_size.height) / 2.0,
));
let lbl_left = leading_edge + ACTION_SLOT + APP_BAR_GAP;
let lbl_max_width = (trailing_edge - lbl_left).max(0.0);
let lbl_size = self.contextual[lbl_idx].layout_child(
ctx,
&BoxConstraints::loose(Size::new(lbl_max_width, SELECT_ALL_HEIGHT)),
);
self.contextual[lbl_idx].set_origin(Point::new(
lbl_left,
height + (SELECT_ALL_HEIGHT - lbl_size.height) / 2.0,
));
height += SELECT_ALL_HEIGHT;
}
bc.constrain(Size::new(width, height))
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
if !is_selection_mode(&self.selected) {
self.idle.paint_child(ctx, scene);
return;
}
let theme = Theme::from_paint_ctx(ctx);
let fill = resolve_contextual_background(theme);
scene.fill_rect(ctx.origin(), ctx.size(), fill);
for pod in &mut self.contextual {
pod.paint_child(ctx, scene);
}
}
fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
if is_selection_mode(&self.selected) {
frust::authoring::route_event(&mut self.contextual, ctx, event)
} else {
frust::authoring::route_event_single(&mut self.idle, ctx, event)
}
}
fn semantics(&self, ctx: &mut SemanticsCtx) {
if !is_selection_mode(&self.selected) {
self.idle.semantics_child(ctx);
return;
}
let count = self.selected.len();
let label = format!("{count} selected");
let contextual = &self.contextual;
ctx.push_container(
Role::TitleBar,
|node| {
node.set_label(label.as_str());
node.add_action(Action::Click);
},
|ctx| {
for pod in contextual {
pod.semantics_child(ctx);
}
},
);
}
frust::authoring::visit_children!(contextual, idle);
}
#[cfg(test)]
mod tests {
use super::*;
use frust::authoring::PointerButton;
use frust::authoring::text::TextContext;
use kurbo::Vec2;
use std::any::Any;
#[test]
fn is_selection_mode_matches_reference_getter() {
assert!(!is_selection_mode(&BTreeSet::new()));
assert!(is_selection_mode(&BTreeSet::from([2])));
}
#[test]
fn is_selected_reads_membership() {
let selected = BTreeSet::from([1, 3]);
assert!(is_selected(&selected, 1));
assert!(!is_selected(&selected, 2));
}
#[test]
fn all_selected_for_matches_reference_tristate_formula() {
assert_eq!(all_selected_for(&BTreeSet::new(), 0), Some(false));
assert_eq!(all_selected_for(&BTreeSet::new(), 3), Some(false));
assert_eq!(all_selected_for(&BTreeSet::from([0, 1, 2]), 3), Some(true));
assert_eq!(
all_selected_for(&BTreeSet::from([0, 1, 2, 5]), 3),
Some(true)
);
assert_eq!(all_selected_for(&BTreeSet::from([0]), 3), None);
}
#[test]
fn select_deselect_toggle_and_select_all_are_pure_next_state_transforms() {
let empty = BTreeSet::new();
assert_eq!(select(&empty, 2), BTreeSet::from([2]));
assert_eq!(empty, BTreeSet::new(), "select must not mutate its input");
let some = BTreeSet::from([1, 2]);
assert_eq!(deselect(&some, 1), BTreeSet::from([2]));
assert_eq!(
deselect(&some, 9),
some,
"deselecting an absent index is a no-op value"
);
assert_eq!(toggle(&some, 1), BTreeSet::from([2]), "toggle off");
assert_eq!(toggle(&some, 5), BTreeSet::from([1, 2, 5]), "toggle on");
assert_eq!(select_all(3), BTreeSet::from([0, 1, 2]));
assert_eq!(select_all(0), BTreeSet::new());
assert_eq!(cleared(), BTreeSet::new());
}
const ROW_HEIGHT: f64 = 40.0;
const WIDTH: f64 = 200.0;
fn build<S: 'static>(view: &SelectionHostView<S>) -> SelectionHostWidget {
let mut counter = 0u64;
View::<S>::build(view, &mut BuildCtx::new(&mut counter))
}
fn layout(w: &mut SelectionHostWidget, height: 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, height)))
}
fn rows<S: 'static>(n: usize) -> Vec<AnyView<S>> {
(0..n)
.map(|_| any::<S, _>(frust::SizedBox(Some(10.0), Some(ROW_HEIGHT))))
.collect()
}
fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
InputEvent::Pointer(frust::authoring::PointerEvent {
phase,
position: Point::new(x, y),
button: PointerButton::Primary,
})
}
#[derive(Default)]
struct Log {
toggled: Vec<usize>,
activated: Vec<usize>,
}
fn dispatch(w: &mut SelectionHostWidget, state: &mut Log, event: &InputEvent) -> EventResult {
let state_any: &mut dyn Any = state;
let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(WIDTH, ROW_HEIGHT * 3.0));
w.event(&mut ctx, event)
}
#[test]
fn layout_stacks_rows_vertically_with_no_gap() {
let selected = BTreeSet::new();
let view: SelectionHostView<Log> =
selection_host(rows(3), &selected, |s: &mut Log, i| s.toggled.push(i));
let mut w = build(&view);
let size = layout(&mut w, 300.0);
assert_eq!(size, Size::new(WIDTH, ROW_HEIGHT * 3.0));
assert_eq!(
w.rows,
vec![
(0.0, ROW_HEIGHT),
(ROW_HEIGHT, ROW_HEIGHT),
(ROW_HEIGHT * 2.0, ROW_HEIGHT)
]
);
}
#[test]
fn tap_outside_selection_mode_activates_not_toggles() {
let selected = BTreeSet::new();
let view: SelectionHostView<Log> =
selection_host(rows(2), &selected, |s: &mut Log, i| s.toggled.push(i))
.on_activate(|s: &mut Log, i| s.activated.push(i));
let mut w = build(&view);
layout(&mut w, 300.0);
let mut state = Log::default();
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 5.0));
assert_eq!(state.activated, vec![0]);
assert!(state.toggled.is_empty());
}
#[test]
fn tap_in_selection_mode_toggles_not_activates() {
let selected = BTreeSet::from([1]);
let view: SelectionHostView<Log> =
selection_host(rows(2), &selected, |s: &mut Log, i| s.toggled.push(i))
.on_activate(|s: &mut Log, i| s.activated.push(i));
let mut w = build(&view);
layout(&mut w, 300.0);
let mut state = Log::default();
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 5.0));
assert_eq!(state.toggled, vec![0]);
assert!(state.activated.is_empty());
}
#[test]
fn tap_up_outside_the_armed_row_fires_nothing() {
let selected = BTreeSet::new();
let view: SelectionHostView<Log> =
selection_host(rows(2), &selected, |s: &mut Log, i| s.toggled.push(i))
.on_activate(|s: &mut Log, i| s.activated.push(i));
let mut w = build(&view);
layout(&mut w, 300.0);
let mut state = Log::default();
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Up, 5.0, ROW_HEIGHT + 5.0),
);
assert!(state.activated.is_empty());
assert!(state.toggled.is_empty());
}
#[test]
fn long_press_selects_an_unselected_row_and_suppresses_the_tap() {
let selected = BTreeSet::new();
let view: SelectionHostView<Log> =
selection_host(rows(2), &selected, |s: &mut Log, i| s.toggled.push(i))
.on_activate(|s: &mut Log, i| s.activated.push(i));
let mut w = build(&view);
layout(&mut w, 300.0);
let mut state = Log::default();
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
w.long_press.elapsed = true;
dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 6.0, 5.0));
assert_eq!(state.toggled, vec![0], "long-press fired on move-arrival");
assert!(state.activated.is_empty());
dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 6.0, 5.0));
assert_eq!(
state.toggled,
vec![0],
"the resolved long-press is not also a tap"
);
}
#[test]
fn long_press_never_deselects_an_already_selected_row() {
let selected = BTreeSet::from([0]);
let view: SelectionHostView<Log> =
selection_host(rows(2), &selected, |s: &mut Log, i| s.toggled.push(i));
let mut w = build(&view);
layout(&mut w, 300.0);
let mut state = Log::default();
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
w.long_press.elapsed = true;
dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 6.0, 5.0));
assert!(
state.toggled.is_empty(),
"a long-press on an already-selected row must not fire on_toggle"
);
}
#[test]
fn long_press_still_fires_at_up_if_no_move_arrives_first() {
let selected = BTreeSet::new();
let view: SelectionHostView<Log> =
selection_host(rows(2), &selected, |s: &mut Log, i| s.toggled.push(i));
let mut w = build(&view);
layout(&mut w, 300.0);
let mut state = Log::default();
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
w.long_press.elapsed = true;
dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 5.0));
assert_eq!(state.toggled, vec![0]);
}
#[test]
fn movement_past_touch_slop_cancels_long_press_candidacy() {
let selected = BTreeSet::new();
let view: SelectionHostView<Log> =
selection_host(rows(2), &selected, |s: &mut Log, i| s.toggled.push(i));
let mut w = build(&view);
layout(&mut w, 300.0);
let mut state = Log::default();
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
w.long_press.elapsed = true; dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Move, 5.0 + TOUCH_SLOP + 5.0, 5.0),
);
assert!(!w.long_press.elapsed, "a drag cancels the candidacy");
assert!(state.toggled.is_empty());
}
#[test]
fn cancel_clears_state_without_firing() {
let selected = BTreeSet::new();
let view: SelectionHostView<Log> =
selection_host(rows(2), &selected, |s: &mut Log, i| s.toggled.push(i));
let mut w = build(&view);
layout(&mut w, 300.0);
let mut state = Log::default();
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
assert!(w.armed.is_some());
dispatch(&mut w, &mut state, &ev(PointerPhase::Cancel, 5.0, 5.0));
assert!(w.armed.is_none());
dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 5.0));
assert!(state.toggled.is_empty());
}
#[test]
fn disabled_router_arms_nothing() {
let selected = BTreeSet::new();
let view: SelectionHostView<Log> =
selection_host(rows(2), &selected, |s: &mut Log, i| s.toggled.push(i)).enabled(false);
let mut w = build(&view);
layout(&mut w, 300.0);
let mut state = Log::default();
assert_eq!(
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0)),
EventResult::Ignored
);
assert!(w.armed.is_none());
}
#[test]
fn a_secondary_press_never_arms_a_row() {
let selected = BTreeSet::new();
let view: SelectionHostView<Log> =
selection_host(rows(2), &selected, |s: &mut Log, i| s.toggled.push(i));
let mut w = build(&view);
layout(&mut w, 300.0);
let mut state = Log::default();
let secondary = InputEvent::Pointer(frust::authoring::PointerEvent {
phase: PointerPhase::Down,
position: Point::new(5.0, 5.0),
button: PointerButton::Secondary,
});
assert_eq!(
dispatch(&mut w, &mut state, &secondary),
EventResult::Ignored
);
assert!(w.armed.is_none());
}
#[test]
fn external_clear_collapses_mode_on_the_next_dispatch() {
let mut counter = 0u64;
let prev_selected = BTreeSet::from([0]);
let prev: SelectionHostView<Log> =
selection_host(rows(2), &prev_selected, |s: &mut Log, i| s.toggled.push(i))
.on_activate(|s: &mut Log, i| s.activated.push(i));
let mut w = View::<Log>::build(&prev, &mut BuildCtx::new(&mut counter));
layout(&mut w, 300.0);
let next_selected = BTreeSet::new();
let next: SelectionHostView<Log> =
selection_host(rows(2), &next_selected, |s: &mut Log, i| s.toggled.push(i))
.on_activate(|s: &mut Log, i| s.activated.push(i));
View::<Log>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
assert!(!is_selection_mode(&w.selected));
let mut state = Log::default();
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 5.0));
assert_eq!(
state.activated,
vec![0],
"collapsed mode resumes tap-to-activate"
);
assert!(state.toggled.is_empty());
}
fn idle_view<S: 'static>() -> AnyView<S> {
any::<S, _>(frust::SizedBox(Some(0.0), Some(30.0)))
}
fn bar_build<S: 'static>(view: &SelectionAppBarView<S>) -> SelectionAppBarWidget {
let mut counter = 0u64;
View::<S>::build(view, &mut BuildCtx::new(&mut counter))
}
fn bar_layout(w: &mut SelectionAppBarWidget, 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)))
}
#[test]
fn idle_mode_uses_the_idle_contents_own_height() {
let selected = BTreeSet::new();
let view: SelectionAppBarView<()> =
selection_app_bar(idle_view(), &selected, 3, |_: &mut ()| {});
let mut w = bar_build(&view);
let size = bar_layout(&mut w, 300.0);
assert_eq!(size, Size::new(300.0, 30.0));
}
#[test]
fn contextual_mode_height_matches_app_bar_height_metric() {
let selected = BTreeSet::from([0]);
let view: SelectionAppBarView<()> =
selection_app_bar(idle_view(), &selected, 3, |_: &mut ()| {}).show_select_all(false);
let mut w = bar_build(&view);
let size = bar_layout(&mut w, 300.0);
assert_eq!(
size.height, APP_BAR_HEIGHT,
"the toolbar band matches appbar.rs's own HEIGHT metric"
);
}
fn notched() -> frust::authoring::WindowInsets {
frust::authoring::WindowInsets::new(
frust::authoring::WindowEdgeInsets::new(47.0, 24.0, 47.0, 0.0),
frust::authoring::WindowEdgeInsets::ZERO,
)
}
fn bar_layout_with(
w: &mut SelectionAppBarWidget,
width: f64,
insets: frust::authoring::WindowInsets,
) -> Size {
let mut tcx = TextContext::new();
let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
lctx.with_window_insets(insets, |ctx| {
w.layout(ctx, &BoxConstraints::loose(Size::new(width, 400.0)))
})
}
fn real_bar(selected: &BTreeSet<usize>, safe_area: bool) -> SelectionAppBarView<()> {
use frust_widgets::test_support::leaf_any;
let idle = crate::app_bar::<()>("Inbox")
.leading(leaf_any(48.0, 48.0))
.actions(vec![leaf_any(48.0, 48.0)])
.safe_area(safe_area);
selection_app_bar(idle, selected, 3, |_: &mut ()| {})
.show_select_all(false)
.actions(vec![leaf_any(48.0, 48.0)])
.safe_area(safe_area)
}
#[test]
fn idle_to_selection_keeps_height_and_slot_offsets_under_window_insets() {
let idle_set = BTreeSet::new();
let selected = BTreeSet::from([0]);
let mut idle = bar_build(&real_bar(&idle_set, true));
let idle_size = bar_layout_with(&mut idle, 800.0, notched());
let mut ctx_w = bar_build(&real_bar(&selected, true));
let ctx_size = bar_layout_with(&mut ctx_w, 800.0, notched());
assert_eq!(idle_size, Size::new(800.0, 24.0 + APP_BAR_HEIGHT));
assert_eq!(ctx_size, idle_size, "the swap keeps the bar's height");
let close = &ctx_w.contextual[ctx_w.contextual_slots.close];
assert_eq!(
close.origin().x,
47.0 + APP_BAR_PAD_X + (ACTION_SLOT - close.size().width).max(0.0) / 2.0
);
assert_eq!(
close.origin().y,
24.0 + (APP_BAR_HEIGHT - close.size().height) / 2.0
);
let action = &ctx_w.contextual[ctx_w.contextual_slots.actions_start];
assert_eq!(
action.origin().x + action.size().width,
800.0 - 47.0 - APP_BAR_PAD_X
);
assert_eq!(action.origin().y, 24.0 + (APP_BAR_HEIGHT - 48.0) / 2.0);
let count = &ctx_w.contextual[ctx_w.contextual_slots.count_text];
assert_eq!(
count.origin().x,
47.0 + APP_BAR_PAD_X + ACTION_SLOT + APP_BAR_GAP
);
let mut scene = RectRecorder::default();
let mut pctx = PaintCtx::new(Point::ZERO, ctx_size);
ctx_w.paint(&mut pctx, &mut scene);
assert_eq!(scene.rects[0], (Point::ZERO, ctx_size));
}
#[test]
fn idle_to_selection_stays_continuous_under_a_consuming_safe_area() {
let idle_set = BTreeSet::new();
let selected = BTreeSet::from([0]);
let consumed = notched().consuming(true, true, true, false);
let mut idle = bar_build(&real_bar(&idle_set, true));
let idle_size = bar_layout_with(&mut idle, 706.0, consumed);
let mut ctx_w = bar_build(&real_bar(&selected, true));
let ctx_size = bar_layout_with(&mut ctx_w, 706.0, consumed);
assert_eq!(idle_size.height, APP_BAR_HEIGHT);
assert_eq!(ctx_size, idle_size);
let close = &ctx_w.contextual[ctx_w.contextual_slots.close];
assert_eq!(
close.origin().x,
APP_BAR_PAD_X + (ACTION_SLOT - close.size().width).max(0.0) / 2.0
);
let action = &ctx_w.contextual[ctx_w.contextual_slots.actions_start];
assert_eq!(
action.origin().x + action.size().width,
706.0 - APP_BAR_PAD_X
);
let heights: Vec<f64> = [&idle_set, &selected]
.into_iter()
.map(|set| {
let wrapped = frust::safe_area(real_bar(set, true)).bottom(false);
let mut counter = 0u64;
let mut w = View::<()>::build(&wrapped, &mut BuildCtx::new(&mut counter));
let mut tcx = TextContext::new();
let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
lctx.with_window_insets(notched(), |ctx| {
w.layout(ctx, &BoxConstraints::loose(Size::new(800.0, 400.0)))
})
.height
})
.collect();
assert_eq!(heights, vec![24.0 + APP_BAR_HEIGHT; 2]);
}
#[test]
fn safe_area_false_opts_the_contextual_branch_out_like_the_idle_bar() {
let idle_set = BTreeSet::new();
let selected = BTreeSet::from([0]);
let mut idle = bar_build(&real_bar(&idle_set, false));
let idle_size = bar_layout_with(&mut idle, 800.0, notched());
let mut ctx_w = bar_build(&real_bar(&selected, false));
let ctx_size = bar_layout_with(&mut ctx_w, 800.0, notched());
assert_eq!(idle_size.height, APP_BAR_HEIGHT);
assert_eq!(ctx_size, idle_size);
let action = &ctx_w.contextual[ctx_w.contextual_slots.actions_start];
assert_eq!(
action.origin().x + action.size().width,
800.0 - APP_BAR_PAD_X
);
let mut counter = 0u64;
let prev = real_bar(&selected, false);
let next = real_bar(&selected, true);
let flags = View::<()>::rebuild(&next, &prev, &mut ctx_w, &mut BuildCtx::new(&mut counter));
assert!(flags.needs_layout());
assert!(ctx_w.safe_area);
}
#[test]
fn contextual_mode_with_select_all_adds_the_select_all_row_height() {
let selected = BTreeSet::from([0]);
let view: SelectionAppBarView<()> =
selection_app_bar(idle_view(), &selected, 3, |_: &mut ()| {});
let mut w = bar_build(&view);
let size = bar_layout(&mut w, 300.0);
assert_eq!(size.height, APP_BAR_HEIGHT + SELECT_ALL_HEIGHT);
}
#[test]
fn unthemed_paint_fills_the_contextual_background_only_in_selection_mode() {
let idle_selected = BTreeSet::new();
let idle: SelectionAppBarView<()> =
selection_app_bar(idle_view(), &idle_selected, 3, |_: &mut ()| {});
let mut idle_w = bar_build(&idle);
bar_layout(&mut idle_w, 300.0);
let mut idle_scene = RectRecorder::default();
let mut ctx = PaintCtx::new(Point::ZERO, Size::new(300.0, 30.0));
idle_w.paint(&mut ctx, &mut idle_scene);
assert!(
idle_scene.rects.is_empty(),
"idle mode paints no background of its own"
);
let contextual_selected = BTreeSet::from([0]);
let contextual: SelectionAppBarView<()> =
selection_app_bar(idle_view(), &contextual_selected, 3, |_: &mut ()| {});
let mut contextual_w = bar_build(&contextual);
let size = bar_layout(&mut contextual_w, 300.0);
let mut contextual_scene = RectRecorder::default();
let mut ctx = PaintCtx::new(Point::ZERO, size);
contextual_w.paint(&mut ctx, &mut contextual_scene);
assert_eq!(contextual_scene.rects[0].1, size);
assert!(!contextual_scene.rects.is_empty());
}
#[derive(Default)]
struct RectRecorder {
rects: Vec<(Point, Size)>,
}
impl PaintScene for RectRecorder {
fn fill_rect(&mut self, o: Point, s: Size, _c: Color) {
self.rects.push((o, s));
}
fn draw_text(&mut self, _o: Point, _t: &str) {}
}
#[test]
fn close_button_press_fires_on_clear() {
#[derive(Default)]
struct S {
cleared: u32,
}
let selected = BTreeSet::from([0]);
let view: SelectionAppBarView<S> =
selection_app_bar(idle_view::<S>(), &selected, 3, |s: &mut S| s.cleared += 1);
let mut w = bar_build(&view);
bar_layout(&mut w, 300.0);
let close_pod = &w.contextual[w.contextual_slots.close];
let center = close_pod.origin()
+ Vec2::new(close_pod.size().width / 2.0, close_pod.size().height / 2.0);
let mut state = S::default();
let state_any: &mut dyn Any = &mut state;
let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(300.0, APP_BAR_HEIGHT));
w.event(&mut ctx, &ev(PointerPhase::Down, center.x, center.y));
w.event(&mut ctx, &ev(PointerPhase::Up, center.x, center.y));
assert_eq!(state.cleared, 1);
}
#[test]
fn select_all_reports_binary_flip_treating_partial_as_not_all() {
#[derive(Default)]
struct S {
calls: Vec<bool>,
}
fn bar(selected: &BTreeSet<usize>) -> SelectionAppBarView<S> {
selection_app_bar(idle_view::<S>(), selected, 3, |_: &mut S| {})
.on_all_selected(|s: &mut S, all| s.calls.push(all))
}
fn press_checkbox(w: &mut SelectionAppBarWidget, state: &mut S) {
let (cb_idx, _) = w.contextual_slots.select_all.expect("select-all present");
let pod = &w.contextual[cb_idx];
let center = pod.origin() + Vec2::new(pod.size().width / 2.0, pod.size().height / 2.0);
let state_any: &mut dyn Any = state;
let mut ctx = EventCtx::new(
state_any,
Point::ZERO,
Size::new(300.0, APP_BAR_HEIGHT + SELECT_ALL_HEIGHT),
);
w.event(&mut ctx, &ev(PointerPhase::Down, center.x, center.y));
w.event(&mut ctx, &ev(PointerPhase::Up, center.x, center.y));
}
let none = BTreeSet::from([0]); let view = bar(&none);
let mut w = bar_build(&view);
bar_layout(&mut w, 300.0);
let mut state = S::default();
press_checkbox(&mut w, &mut state);
assert_eq!(
state.calls,
vec![true],
"a partial selection reports true (not-all)"
);
let all = BTreeSet::from([0, 1, 2]);
let view = bar(&all);
let mut w = bar_build(&view);
bar_layout(&mut w, 300.0);
let mut state = S::default();
press_checkbox(&mut w, &mut state);
assert_eq!(state.calls, vec![false]);
}
#[test]
fn external_clear_collapses_the_bar_back_to_idle() {
let mut counter = 0u64;
let prev_selected = BTreeSet::from([0]);
let prev: SelectionAppBarView<()> =
selection_app_bar(idle_view(), &prev_selected, 3, |_: &mut ()| {});
let mut w = View::<()>::build(&prev, &mut BuildCtx::new(&mut counter));
bar_layout(&mut w, 300.0);
assert!(is_selection_mode(&w.selected));
let next_selected = BTreeSet::new();
let next: SelectionAppBarView<()> =
selection_app_bar(idle_view(), &next_selected, 3, |_: &mut ()| {});
View::<()>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
assert!(!is_selection_mode(&w.selected));
let size = bar_layout(&mut w, 300.0);
assert_eq!(
size,
Size::new(300.0, 30.0),
"layout falls back to idle's own height"
);
}
#[test]
fn semantics_reports_count_selected_label_in_contextual_mode() {
fn logic(_s: &mut ()) -> SelectionAppBarView<()> {
let selected = BTreeSet::from([0, 1]);
selection_app_bar(idle_view(), &selected, 3, |_: &mut ()| {})
}
let mut root: frust_core::RenderRoot<(), SelectionAppBarView<()>> =
frust_core::RenderRoot::new();
let mut state = ();
root.rebuild(&mut logic, &mut state);
let mut tcx = TextContext::new();
root.layout_with_text(Size::new(300.0, 200.0), &mut tcx as &mut dyn Any);
let update = root.semantics();
let (_, node) = update
.nodes
.iter()
.find(|(_, n)| n.role() == Role::TitleBar)
.expect("a TitleBar node is contributed in selection mode");
assert_eq!(node.label(), Some("2 selected"));
}
}