use frust::authoring::text::TextStyle;
use frust::authoring::{
Action, Affine, AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, CursorIcon,
ErasedArgCallback, EventCtx, EventResult, InputEvent, LayoutCtx, PaintCtx, PaintScene, Point,
PointerEvent, PointerPhase, Rect, Role, SemanticsCtx, Size, View, Widget, any, build_child,
erase_callback_arg, rebuild_children, route_event_single, teardown_child, visit_children,
};
use frust::{AnimationController, Theme};
use super::{
DROPDOWN_CHIP_H_PADDING, DROPDOWN_CHIP_ICON_SIZE, DROPDOWN_CHIP_LABEL_GAP,
DROPDOWN_CHIP_RADIUS, DROPDOWN_CHIP_RUN_SPACING, DROPDOWN_CHIP_SPACING,
DROPDOWN_CHIP_V_PADDING, DROPDOWN_FIELD_H_PADDING, DROPDOWN_FIELD_HOVER_ALPHA,
DROPDOWN_FIELD_LOADING_SIZE, DROPDOWN_FIELD_LOADING_STROKE, DROPDOWN_FIELD_PRESSED_ALPHA,
DROPDOWN_FIELD_V_PADDING, DROPDOWN_FLING_VELOCITY, DROPDOWN_HINT, DROPDOWN_ICON_GAP,
DROPDOWN_ICON_SIZE, DROPDOWN_PROGRESS_EPSILON, DROPDOWN_SPRING, DropdownColors, DropdownItem,
Glyph, OnOpen, OnSelection, Run, SHAPING_INK, container_radius, ordered_selection,
};
use crate::icons;
use crate::overlay::{OverlayAnchor, with_alpha};
use crate::press::presses;
use crate::progress::{ProgressValue, circular_progress};
const UNBOUNDED_WIDTH: f64 = 200.0;
const ARROW_TURN: f64 = std::f64::consts::PI;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Trailing {
Busy,
Clear,
Arrow,
}
struct Chip {
value: String,
label: Run,
scale: AnimationController,
progress: f64,
removing: bool,
started: bool,
rect: Rect,
delete: Rect,
}
impl Chip {
fn new(value: &str, label: &str) -> Self {
Chip {
value: value.to_string(),
label: Run::new(label),
scale: AnimationController::new(std::time::Duration::ZERO),
progress: 0.0,
removing: false,
started: false,
rect: Rect::ZERO,
delete: Rect::ZERO,
}
}
fn layout_scale(&self) -> f64 {
self.progress.clamp(0.0, 1.0)
}
}
pub struct DropdownFieldView<State: 'static> {
items: Vec<DropdownItem>,
selected: Vec<String>,
hint: String,
multi: bool,
open: bool,
enabled: bool,
loading: bool,
error: bool,
show_clear: bool,
max_chips: Option<usize>,
anchor: OverlayAnchor,
on_open: Option<OnOpen<State>>,
on_change: Option<OnSelection<State>>,
}
pub fn dropdown_field<State: 'static>(items: Vec<DropdownItem>) -> DropdownFieldView<State> {
DropdownFieldView {
items,
selected: Vec::new(),
hint: DROPDOWN_HINT.to_string(),
multi: false,
open: false,
enabled: true,
loading: false,
error: false,
show_clear: false,
max_chips: None,
anchor: OverlayAnchor::new(),
on_open: None,
on_change: None,
}
}
impl<State: 'static> DropdownFieldView<State> {
pub fn anchor(mut self, anchor: &OverlayAnchor) -> Self {
self.anchor = anchor.clone();
self
}
pub fn selected(mut self, selected: Vec<String>) -> Self {
self.selected = selected;
self
}
pub fn hint(mut self, hint: impl Into<String>) -> Self {
self.hint = hint.into();
self
}
pub fn multi(mut self, multi: bool) -> Self {
self.multi = multi;
self
}
pub fn open(mut self, open: bool) -> Self {
self.open = open;
self
}
pub fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
pub fn loading(mut self, loading: bool) -> Self {
self.loading = loading;
self
}
pub fn error(mut self, error: bool) -> Self {
self.error = error;
self
}
pub fn show_clear(mut self, show_clear: bool) -> Self {
self.show_clear = show_clear;
self
}
pub fn max_chips(mut self, max: usize) -> Self {
self.max_chips = Some(max);
self
}
pub fn on_open<F: Fn(&mut State, bool) + 'static>(mut self, on_open: F) -> Self {
self.on_open = Some(std::rc::Rc::new(on_open));
self
}
pub fn on_change<F: Fn(&mut State, Vec<String>) + 'static>(mut self, on_change: F) -> Self {
self.on_change = Some(std::rc::Rc::new(on_change));
self
}
fn displayed(&self) -> (Vec<String>, usize) {
let ordered = ordered_selection(&self.items, &self.selected);
match self.max_chips {
Some(max) if ordered.len() > max => {
let hidden = ordered.len() - max;
(ordered.into_iter().take(max).collect(), hidden)
}
_ => (ordered, 0),
}
}
fn label_of(&self, value: &str) -> String {
self.items
.iter()
.find(|item| item.value() == value)
.map_or_else(|| value.to_string(), |item| item.label().to_string())
}
fn busy_views(&self) -> Vec<AnyView<State>> {
if !self.loading {
return Vec::new();
}
vec![any(circular_progress(ProgressValue::Indeterminate)
.diameter(DROPDOWN_FIELD_LOADING_SIZE)
.stroke_width(DROPDOWN_FIELD_LOADING_STROKE))]
}
fn single_label(&self, ordered: &[String]) -> Option<String> {
if self.multi || ordered.is_empty() {
return None;
}
Some(self.label_of(&ordered[0]))
}
fn trailing(&self) -> Trailing {
if self.loading {
Trailing::Busy
} else if self.show_clear && self.enabled && !self.selected.is_empty() {
Trailing::Clear
} else {
Trailing::Arrow
}
}
}
pub struct DropdownFieldWidget {
hint: Run,
value_label: Option<Run>,
chips: Vec<Chip>,
overflow: Option<(Run, usize)>,
arrow: Glyph,
clear: Glyph,
delete: Glyph,
trailing: Trailing,
busy: Vec<ChildPod>,
multi: bool,
open: bool,
enabled: bool,
error: bool,
loading: bool,
selected: Vec<String>,
anchor: OverlayAnchor,
on_open: Option<ErasedArgCallback<bool>>,
on_change: Option<ErasedArgCallback<Vec<String>>>,
arrow_anim: AnimationController,
arrow_progress: f64,
arrow_started: bool,
hovered: bool,
pressed: bool,
press_inside: bool,
captured: bool,
armed_delete: Option<usize>,
armed_clear: bool,
width: f64,
height: f64,
trailing_rect: Rect,
overflow_rect: Rect,
}
impl DropdownFieldWidget {
pub fn width(&self) -> f64 {
self.width
}
pub fn height(&self) -> f64 {
self.height
}
pub fn arrow_radians(&self) -> f64 {
self.arrow_progress * ARROW_TURN
}
pub fn chip_count(&self) -> usize {
self.chips.len()
}
fn sync_chips(&mut self, next: &[String], labels: &[String]) -> bool {
let mut changed = false;
for chip in self.chips.iter_mut() {
let present = next.iter().any(|v| v == &chip.value);
if present && chip.removing {
chip.removing = false;
chip.scale.fling(DROPDOWN_FLING_VELOCITY, DROPDOWN_SPRING);
changed = true;
} else if !present && !chip.removing {
chip.removing = true;
chip.scale.fling(-DROPDOWN_FLING_VELOCITY, DROPDOWN_SPRING);
changed = true;
}
}
let mut fading: Vec<(usize, Chip)> = Vec::new();
let mut present: Vec<Chip> = Vec::new();
for (i, chip) in std::mem::take(&mut self.chips).into_iter().enumerate() {
if chip.removing {
fading.push((i, chip));
} else {
present.push(chip);
}
}
let mut ordered: Vec<Chip> = Vec::with_capacity(next.len() + fading.len());
for (value, label) in next.iter().zip(labels.iter()) {
match present.iter().position(|chip| &chip.value == value) {
Some(i) => ordered.push(present.remove(i)),
None => {
ordered.push(Chip::new(value, label));
changed = true;
}
}
}
ordered.append(&mut present);
for (index, chip) in fading {
let at = index.min(ordered.len());
ordered.insert(at, chip);
}
self.chips = ordered;
changed
}
fn advance(&mut self, ctx: &mut PaintCtx, reduce: bool) -> bool {
let mut animating = false;
let now = ctx.frame_time();
if !self.arrow_started {
self.arrow_started = true;
self.arrow_progress = if self.open { 1.0 } else { 0.0 };
} else if reduce {
if self.arrow_anim.is_animating() {
self.arrow_anim.stop();
}
self.arrow_progress = if self.open { 1.0 } else { 0.0 };
} else if self.arrow_anim.is_animating() {
if self.arrow_anim.advance(now) {
animating = true;
}
self.arrow_progress = self.arrow_anim.value_clamped();
}
let mut settled: Vec<usize> = Vec::new();
let mut chips_moved = false;
for (i, chip) in self.chips.iter_mut().enumerate() {
let progress_before = chip.progress;
if !chip.started {
chip.started = true;
if reduce {
chip.progress = 1.0;
} else {
chip.scale.fling(DROPDOWN_FLING_VELOCITY, DROPDOWN_SPRING);
}
}
if reduce {
if chip.scale.is_animating() {
chip.scale.stop();
}
chip.progress = if chip.removing { 0.0 } else { 1.0 };
if chip.progress != progress_before {
chips_moved = true;
}
} else if chip.scale.is_animating() {
if chip.scale.advance(now) {
animating = true;
}
chip.progress = chip.scale.value();
if chip.progress != progress_before {
chips_moved = true;
}
}
if chip.removing && chip.progress <= DROPDOWN_PROGRESS_EPSILON {
settled.push(i);
}
}
for i in settled.into_iter().rev() {
self.chips.remove(i);
chips_moved = true;
}
if chips_moved {
ctx.request_layout();
}
animating
}
fn has_chip_strip(&self) -> bool {
self.multi && (!self.chips.is_empty() || self.overflow.is_some())
}
fn delete_chip(&mut self, ctx: &mut EventCtx, value: &str) {
let Some(on_change) = self.on_change.as_mut() else {
return;
};
let next: Vec<String> = self
.selected
.iter()
.filter(|v| v.as_str() != value)
.cloned()
.collect();
on_change(ctx, next);
}
fn delete_at(&self, pos: Point) -> Option<usize> {
if !self.enabled {
return None;
}
self.chips
.iter()
.position(|chip| !chip.removing && chip.delete.contains(pos))
}
fn handle_pointer(&mut self, ctx: &mut EventCtx, p: &PointerEvent) -> EventResult {
let inside =
|pos: Point| pos.x >= 0.0 && pos.x < self.width && pos.y >= 0.0 && pos.y < self.height;
match p.phase {
PointerPhase::Move if !self.captured => {
let over = inside(p.position);
if over {
ctx.claim_hover();
if self.enabled {
ctx.set_cursor(CursorIcon::Pointer);
}
}
if self.hovered != over {
self.hovered = over;
ctx.request_redraw();
}
EventResult::Ignored
}
PointerPhase::Move => {
let still = inside(p.position);
if self.press_inside != still {
self.press_inside = still;
ctx.request_redraw();
}
EventResult::Handled
}
PointerPhase::Down => {
if !presses(p) || !inside(p.position) {
return EventResult::Ignored;
}
self.armed_delete = self.delete_at(p.position);
self.armed_clear = self.armed_delete.is_none()
&& self.trailing == Trailing::Clear
&& self.trailing_rect.contains(p.position);
self.pressed = true;
self.press_inside = true;
self.captured = true;
ctx.capture_pointer();
ctx.request_redraw();
EventResult::Handled
}
PointerPhase::Up => {
if !self.captured {
return EventResult::Ignored;
}
let released_inside = inside(p.position);
let armed_delete = self.armed_delete;
let armed_clear = self.armed_clear;
self.pressed = false;
self.press_inside = false;
self.captured = false;
self.armed_delete = None;
self.armed_clear = false;
ctx.request_redraw();
if !released_inside {
return EventResult::Handled;
}
if let Some(i) = armed_delete {
if self.delete_at(p.position) == Some(i) {
let value = self.chips[i].value.clone();
self.delete_chip(ctx, &value);
}
return EventResult::Handled;
}
if armed_clear {
if self.trailing_rect.contains(p.position)
&& let Some(on_change) = self.on_change.as_mut()
{
on_change(ctx, Vec::new());
}
return EventResult::Handled;
}
if self.enabled
&& !self.loading
&& let Some(on_open) = self.on_open.as_mut()
{
let open = self.open;
on_open(ctx, !open);
}
EventResult::Handled
}
PointerPhase::Cancel => {
if !self.captured {
return EventResult::Ignored;
}
self.pressed = false;
self.press_inside = false;
self.captured = false;
self.armed_delete = None;
self.armed_clear = false;
ctx.request_redraw();
EventResult::Handled
}
}
}
}
impl<State: 'static> View<State> for DropdownFieldView<State> {
type Element = DropdownFieldWidget;
fn build(&self, ctx: &mut BuildCtx<'_>) -> DropdownFieldWidget {
let (displayed, hidden) = self.displayed();
let labels: Vec<String> = displayed.iter().map(|v| self.label_of(v)).collect();
let chips = if self.multi {
displayed
.iter()
.zip(labels.iter())
.map(|(value, label)| Chip::new(value, label))
.collect()
} else {
Vec::new()
};
let ordered = ordered_selection(&self.items, &self.selected);
DropdownFieldWidget {
hint: Run::new(self.hint.clone()),
value_label: self.single_label(&ordered).map(Run::new),
chips,
overflow: (self.multi && hidden > 0).then(|| (Run::new(format!("+{hidden}")), hidden)),
arrow: Glyph::new(icons::KEYBOARD_ARROW_DOWN, DROPDOWN_ICON_SIZE),
clear: Glyph::new(icons::CLOSE, DROPDOWN_ICON_SIZE),
delete: Glyph::new(icons::CLOSE, DROPDOWN_CHIP_ICON_SIZE),
trailing: self.trailing(),
busy: self
.busy_views()
.iter()
.map(|view| build_child(view, ctx))
.collect(),
multi: self.multi,
open: self.open,
enabled: self.enabled,
error: self.error,
loading: self.loading,
selected: ordered,
anchor: self.anchor.clone(),
on_open: self.on_open.as_ref().map(erase_callback_arg),
on_change: self.on_change.as_ref().map(erase_callback_arg),
arrow_anim: AnimationController::new(std::time::Duration::ZERO),
arrow_progress: 0.0,
arrow_started: false,
hovered: false,
pressed: false,
press_inside: false,
captured: false,
armed_delete: None,
armed_clear: false,
width: 0.0,
height: 0.0,
trailing_rect: Rect::ZERO,
overflow_rect: Rect::ZERO,
}
}
fn rebuild(
&self,
prev: &Self,
element: &mut DropdownFieldWidget,
ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
let mut flags = rebuild_children(
&prev.busy_views(),
&self.busy_views(),
&mut element.busy,
ctx,
|view: &AnyView<State>| view,
|_| None,
);
if prev.hint != self.hint {
element.hint = Run::new(self.hint.clone());
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
let ordered = ordered_selection(&self.items, &self.selected);
let (displayed, hidden) = self.displayed();
let labels: Vec<String> = displayed.iter().map(|v| self.label_of(v)).collect();
if element.selected != ordered || prev.items != self.items || element.multi != self.multi {
element.selected = ordered.clone();
element.multi = self.multi;
element.value_label = self.single_label(&ordered).map(Run::new);
if self.multi {
if element.sync_chips(&displayed, &labels) {
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
} else if !element.chips.is_empty() {
element.chips.clear();
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
let overflow = (self.multi && hidden > 0).then_some(hidden);
if element.overflow.as_ref().map(|(_, n)| *n) != overflow {
element.overflow = overflow.map(|n| (Run::new(format!("+{n}")), n));
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
if element.open != self.open {
element.open = self.open;
element.arrow_anim.fling(
if self.open {
DROPDOWN_FLING_VELOCITY
} else {
-DROPDOWN_FLING_VELOCITY
},
DROPDOWN_SPRING,
);
flags |= ChangeFlags::PAINT;
}
let trailing = self.trailing();
if element.trailing != trailing {
element.trailing = trailing;
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
if element.enabled != self.enabled
|| element.error != self.error
|| element.loading != self.loading
{
element.enabled = self.enabled;
element.error = self.error;
element.loading = self.loading;
flags |= ChangeFlags::PAINT;
}
element.anchor = self.anchor.clone();
element.on_open = self.on_open.as_ref().map(erase_callback_arg);
element.on_change = self.on_change.as_ref().map(erase_callback_arg);
flags
}
fn teardown(&self, element: &mut DropdownFieldWidget, ctx: &mut BuildCtx<'_>) {
for (view, pod) in self.busy_views().iter().zip(element.busy.iter_mut()) {
teardown_child(view, pod, ctx);
}
}
}
struct Styles {
body: TextStyle,
chip: TextStyle,
}
impl Styles {
fn resolve(theme: Option<&Theme>) -> Self {
let (mut body, mut chip) = match theme {
Some(theme) => (
theme.type_scale.body_large.clone(),
theme.type_scale.label_medium.clone(),
),
None => {
let body = TextStyle::new(16.0, SHAPING_INK);
let mut chip = TextStyle::new(12.0, SHAPING_INK);
chip.letter_spacing = 0.5;
(body, chip)
}
};
body.color = SHAPING_INK;
chip.color = SHAPING_INK;
Styles { body, chip }
}
}
impl Widget for DropdownFieldWidget {
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
let styles = Styles::resolve(Theme::from_layout_ctx(ctx));
self.width = if bc.max().width.is_finite() {
bc.max().width
} else {
UNBOUNDED_WIDTH
};
let slot = DROPDOWN_ICON_GAP + DROPDOWN_ICON_SIZE;
let content_width = (self.width - 2.0 * DROPDOWN_FIELD_H_PADDING - slot).max(0.0);
let content_height = if self.has_chip_strip() {
self.layout_chips(ctx, &styles, content_width)
} else if let Some(label) = self.value_label.as_mut() {
label.shape(ctx, &styles.body, content_width).height
} else {
self.hint.shape(ctx, &styles.body, content_width).height
};
self.height = 2.0 * DROPDOWN_FIELD_V_PADDING + content_height.max(DROPDOWN_ICON_SIZE);
self.trailing_rect = Rect::from_origin_size(
Point::new(
self.width - DROPDOWN_FIELD_H_PADDING - DROPDOWN_ICON_SIZE,
(self.height - DROPDOWN_ICON_SIZE) / 2.0,
),
Size::new(DROPDOWN_ICON_SIZE, DROPDOWN_ICON_SIZE),
);
if let Some(pod) = self.busy.first_mut() {
let extent = DROPDOWN_FIELD_LOADING_SIZE;
pod.layout_child(ctx, &BoxConstraints::tight(Size::new(extent, extent)));
pod.set_origin(Point::new(
self.width - DROPDOWN_FIELD_H_PADDING - extent,
(self.height - extent) / 2.0,
));
}
bc.constrain(Size::new(self.width, self.height))
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
if !ctx.is_hovered() {
self.hovered = false;
}
let theme = Theme::from_paint_ctx(ctx);
let colors = DropdownColors::resolve(theme);
let radius = container_radius(theme);
let reduce = crate::overlay::reduce_motion(theme);
if self.advance(ctx, reduce) {
ctx.request_frame();
}
let origin = ctx.origin();
self.anchor.set(Rect::from_origin_size(origin, ctx.size()));
let size = Size::new(self.width, self.height);
scene.fill_rounded_rect(origin, size, radius, colors.field_container);
let ink = if self.error {
colors.error
} else {
colors.field_content
};
let overlay = if self.pressed && self.press_inside {
DROPDOWN_FIELD_PRESSED_ALPHA
} else if self.hovered {
DROPDOWN_FIELD_HOVER_ALPHA
} else {
0.0
};
if overlay > 0.0 && self.enabled {
scene.fill_rounded_rect(origin, size, radius, with_alpha(ink, overlay));
}
let content_x = origin.x + DROPDOWN_FIELD_H_PADDING;
if self.has_chip_strip() {
self.paint_chips(origin, &colors, scene);
} else if let Some(label) = &self.value_label {
let height = label.size().height;
label.paint(
Point::new(content_x, origin.y + (self.height - height) / 2.0),
ink,
scene,
);
} else {
let height = self.hint.size().height;
self.hint.paint(
Point::new(content_x, origin.y + (self.height - height) / 2.0),
colors.hint,
scene,
);
}
match self.trailing {
Trailing::Busy => {
if let Some(pod) = self.busy.first_mut() {
pod.paint_child(ctx, scene);
}
}
Trailing::Clear => {
self.clear
.paint(self.trailing_rect.origin() + origin.to_vec2(), ink, scene)
}
Trailing::Arrow => self.arrow.paint_rotated(
self.trailing_rect.origin() + origin.to_vec2(),
ink,
self.arrow_radians(),
scene,
),
}
}
fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
if event.is_broadcast() {
for pod in self.busy.iter_mut() {
pod.event_child(ctx, event);
}
return EventResult::Ignored;
}
if let Some(pod) = self.busy.first_mut()
&& pod.is_active()
{
return route_event_single(pod, ctx, event);
}
match event {
InputEvent::Pointer(p) => self.handle_pointer(ctx, p),
_ => EventResult::Ignored,
}
}
fn semantics(&self, ctx: &mut SemanticsCtx) {
let label = if let Some(value) = &self.value_label {
value.content.clone()
} else {
self.hint.content.clone()
};
let open = self.open;
let enabled = self.enabled;
ctx.push_node(Role::ComboBox, |node| {
node.set_label(label.as_str());
node.set_expanded(open);
if enabled {
node.add_action(Action::Click);
}
});
}
visit_children!(busy);
}
impl DropdownFieldWidget {
fn layout_chips(&mut self, ctx: &mut LayoutCtx, styles: &Styles, content_width: f64) -> f64 {
let chip_width = |label: Size, deletable: bool| {
let mut width = 2.0 * DROPDOWN_CHIP_H_PADDING + label.width;
if deletable {
width += DROPDOWN_CHIP_LABEL_GAP + DROPDOWN_CHIP_ICON_SIZE;
}
width
};
let deletable = self.enabled;
let mut sizes: Vec<Size> = Vec::with_capacity(self.chips.len());
for chip in self.chips.iter_mut() {
let natural = chip.label.natural_width(ctx, &styles.chip);
sizes.push(chip.label.shape(ctx, &styles.chip, natural));
}
let overflow_size = self
.overflow
.as_mut()
.map(|(run, _)| {
let natural = run.natural_width(ctx, &styles.chip);
run.shape(ctx, &styles.chip, natural)
})
.unwrap_or(Size::ZERO);
let row_height = sizes
.iter()
.map(|size| size.height)
.fold(overflow_size.height, f64::max)
.max(if deletable {
DROPDOWN_CHIP_ICON_SIZE
} else {
0.0
})
+ 2.0 * DROPDOWN_CHIP_V_PADDING;
let mut x = 0.0;
let mut y = 0.0;
for (i, chip) in self.chips.iter_mut().enumerate() {
let width = chip_width(sizes[i], deletable) * chip.layout_scale();
if x > 0.0 && x + width > content_width {
x = 0.0;
y += row_height + DROPDOWN_CHIP_RUN_SPACING;
}
chip.rect = Rect::from_origin_size(
Point::new(DROPDOWN_FIELD_H_PADDING + x, DROPDOWN_FIELD_V_PADDING + y),
Size::new(width, row_height),
);
chip.delete = if deletable && width > 0.0 {
Rect::from_origin_size(
Point::new(
chip.rect.x1 - DROPDOWN_CHIP_H_PADDING - DROPDOWN_CHIP_ICON_SIZE,
chip.rect.y0 + (row_height - DROPDOWN_CHIP_ICON_SIZE) / 2.0,
),
Size::new(DROPDOWN_CHIP_ICON_SIZE, DROPDOWN_CHIP_ICON_SIZE),
)
} else {
Rect::ZERO
};
x += width + DROPDOWN_CHIP_SPACING;
}
if self.overflow.is_some() {
let width = 2.0 * DROPDOWN_CHIP_H_PADDING + overflow_size.width;
if x > 0.0 && x + width > content_width {
x = 0.0;
y += row_height + DROPDOWN_CHIP_RUN_SPACING;
}
self.overflow_rect = Rect::from_origin_size(
Point::new(DROPDOWN_FIELD_H_PADDING + x, DROPDOWN_FIELD_V_PADDING + y),
Size::new(width, row_height),
);
} else {
self.overflow_rect = Rect::ZERO;
}
y + row_height
}
fn paint_chips(&self, origin: Point, colors: &DropdownColors, scene: &mut dyn PaintScene) {
for chip in self.chips.iter() {
if chip.progress <= DROPDOWN_PROGRESS_EPSILON {
continue;
}
let rect = chip.rect + origin.to_vec2();
scene.fill_rounded_rect(
rect.origin(),
rect.size(),
DROPDOWN_CHIP_RADIUS,
colors.chip_container,
);
let pivot = kurbo::Vec2::new(rect.x0, rect.y0 + rect.height() / 2.0);
let scale = chip.progress.clamp(0.0, 1.0);
let transform = Affine::translate(pivot)
* Affine::scale_non_uniform(scale, 1.0)
* Affine::translate(-pivot);
let label_size = chip.label.size();
chip.label.paint_transformed(
Point::new(
rect.x0 + DROPDOWN_CHIP_H_PADDING,
rect.y0 + (rect.height() - label_size.height) / 2.0,
),
colors.chip_content,
transform,
scene,
);
if chip.delete.width() > 0.0 {
self.delete.paint(
chip.delete.origin() + origin.to_vec2(),
colors.chip_content,
scene,
);
}
}
if let Some((run, _)) = &self.overflow {
let size = run.size();
let rect = self.overflow_rect + origin.to_vec2();
scene.fill_rounded_rect(
rect.origin(),
rect.size(),
DROPDOWN_CHIP_RADIUS,
colors.chip_container,
);
run.paint(
Point::new(
rect.x0 + DROPDOWN_CHIP_H_PADDING,
rect.y0 + (rect.height() - size.height) / 2.0,
),
colors.chip_content,
scene,
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dropdown::dropdown_item;
use frust::authoring::scene::GlyphRun;
use frust::authoring::{BezPath, Brush, Color, PointerButton, text::TextContext};
use frust::{Brightness, FrameTime};
use std::any::Any;
const AREA: Size = Size::new(320.0, 200.0);
#[derive(Default)]
struct AppState {
opens: Vec<bool>,
selections: Vec<Vec<String>>,
}
#[derive(Default)]
struct Recorder {
rrects: Vec<(Point, Size, f64, Color)>,
paths: Vec<Point>,
runs: usize,
}
impl PaintScene for Recorder {
fn fill_rect(&mut self, _origin: Point, _size: Size, _color: Color) {}
fn fill_rounded_rect(&mut self, origin: Point, size: Size, radius: f64, color: Color) {
self.rrects.push((origin, size, radius, color));
}
fn fill_path(&mut self, origin: Point, _path: &BezPath, _brush: &Brush) {
self.paths.push(origin);
}
fn draw_text(&mut self, _origin: Point, _text: &str) {}
fn draw_glyph_run(&mut self, _run: GlyphRun) {
self.runs += 1;
}
}
impl Recorder {
fn washes(&self) -> Vec<f32> {
self.rrects
.iter()
.map(|(_, _, _, color)| color.components[3])
.filter(|alpha| *alpha < 1.0)
.collect()
}
}
fn fruit() -> Vec<DropdownItem> {
vec![
dropdown_item("Apple", "apple"),
dropdown_item("Banana", "banana"),
dropdown_item("Cherry", "cherry"),
]
}
fn view() -> DropdownFieldView<AppState> {
dropdown_field(fruit())
.on_open(|state: &mut AppState, open| state.opens.push(open))
.on_change(|state: &mut AppState, values| state.selections.push(values))
}
fn build(view: &DropdownFieldView<AppState>) -> DropdownFieldWidget {
let mut counter = 0u64;
View::<AppState>::build(view, &mut BuildCtx::new(&mut counter))
}
fn layout(w: &mut DropdownFieldWidget, area: Size) -> 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(area))
}
fn ready(view: &DropdownFieldView<AppState>) -> DropdownFieldWidget {
let mut w = build(view);
layout(&mut w, AREA);
w
}
fn rebuild(
w: &mut DropdownFieldWidget,
prev: &DropdownFieldView<AppState>,
next: &DropdownFieldView<AppState>,
) {
let mut counter = 0u64;
View::<AppState>::rebuild(next, prev, w, &mut BuildCtx::new(&mut counter));
}
fn paint_at(w: &mut DropdownFieldWidget, now: FrameTime) -> Recorder {
let theme = crate::baseline().with_brightness(Brightness::Light);
let mut rec = Recorder::default();
let size = Size::new(w.width, w.height);
let mut ctx = PaintCtx::for_test(Point::ORIGIN, size, now).with_theme(&theme as &dyn Any);
w.paint(&mut ctx, &mut rec);
rec
}
fn paint(w: &mut DropdownFieldWidget) -> Recorder {
paint_at(w, FrameTime::ZERO)
}
fn settle(w: &mut DropdownFieldWidget) {
for step in 1..200 {
paint_at(w, FrameTime::from_nanos(step * 16_000_000));
}
}
fn pointer(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
InputEvent::Pointer(PointerEvent {
phase,
position: Point::new(x, y),
button: PointerButton::Primary,
})
}
fn dispatch(
w: &mut DropdownFieldWidget,
state: &mut AppState,
event: &InputEvent,
) -> EventResult {
let any: &mut dyn Any = state;
let mut ctx = EventCtx::new(any, Point::ZERO, Size::new(w.width, w.height));
w.event(&mut ctx, event)
}
fn click(w: &mut DropdownFieldWidget, state: &mut AppState, at: Point) {
dispatch(w, state, &pointer(PointerPhase::Down, at.x, at.y));
dispatch(w, state, &pointer(PointerPhase::Up, at.x, at.y));
}
#[test]
fn the_field_captures_its_own_rect_into_the_anchor_on_paint() {
let anchor = OverlayAnchor::new();
let mut w = ready(&view().anchor(&anchor));
assert_eq!(anchor.rect(), Rect::ZERO, "nothing captured before paint");
paint(&mut w);
assert_eq!(
anchor.rect(),
Rect::from_origin_size(Point::ORIGIN, Size::new(w.width(), w.height()))
);
}
#[test]
fn an_empty_selection_shows_the_hint() {
let mut w = ready(&view());
assert!(w.value_label.is_none());
let rec = paint(&mut w);
assert!(rec.runs > 0);
}
#[test]
fn a_single_selection_shows_its_label_not_its_value() {
let w = ready(&view().selected(vec!["banana".to_string()]));
assert_eq!(
w.value_label.as_ref().map(|run| run.content.as_str()),
Some("Banana")
);
}
#[test]
fn a_value_naming_no_item_is_dropped_rather_than_shown_raw() {
let w = ready(&view().selected(vec!["durian".to_string()]));
assert!(
w.value_label.is_none(),
"an unknown value normalizes away, leaving the hint"
);
}
#[test]
fn the_trailing_slot_follows_the_reference_s_precedence() {
assert_eq!(ready(&view()).trailing, Trailing::Arrow);
assert_eq!(
ready(&view().show_clear(true).selected(vec!["apple".to_string()])).trailing,
Trailing::Clear
);
assert_eq!(
ready(
&view()
.show_clear(true)
.selected(vec!["apple".to_string()])
.loading(true)
)
.trailing,
Trailing::Busy,
"loading outranks the clear affordance"
);
assert_eq!(
ready(&view().show_clear(true)).trailing,
Trailing::Arrow,
"nothing selected, nothing to clear"
);
assert_eq!(
ready(
&view()
.show_clear(true)
.enabled(false)
.selected(vec!["apple".to_string()])
)
.trailing,
Trailing::Arrow,
"a disabled field offers no clear affordance"
);
}
#[test]
fn a_busy_field_mounts_the_indicator_pod() {
let w = ready(&view().loading(true));
assert_eq!(w.busy.len(), 1);
let plain = ready(&view());
assert_eq!(plain.busy.len(), 0);
}
#[test]
fn the_arrow_rests_at_zero_closed_and_a_half_turn_open() {
let mut closed = ready(&view());
paint(&mut closed);
assert_eq!(closed.arrow_radians(), 0.0);
let mut open = ready(&view().open(true));
paint(&mut open);
assert!((open.arrow_radians() - ARROW_TURN).abs() < 1e-9);
}
#[test]
fn opening_springs_the_arrow_round_and_closing_springs_it_back() {
let prev = view();
let mut w = ready(&prev);
paint(&mut w);
assert_eq!(w.arrow_radians(), 0.0);
let next = view().open(true);
rebuild(&mut w, &prev, &next);
settle(&mut w);
assert!(
(w.arrow_radians() - ARROW_TURN).abs() < 1e-3,
"the arrow settled at a half turn, got {}",
w.arrow_radians()
);
rebuild(&mut w, &next, &prev);
settle(&mut w);
assert!(w.arrow_radians().abs() < 1e-3);
}
#[test]
fn a_press_requests_the_opposite_open_state_and_never_flips_its_own() {
let mut w = ready(&view());
let mut state = AppState::default();
let at = Point::new(40.0, w.height() / 2.0);
click(&mut w, &mut state, at);
assert_eq!(state.opens, vec![true]);
assert!(!w.open, "controlled: the field waits to be told");
}
#[test]
fn an_open_field_requests_a_close() {
let mut w = ready(&view().open(true));
let mut state = AppState::default();
let at = Point::new(40.0, w.height() / 2.0);
click(&mut w, &mut state, at);
assert_eq!(state.opens, vec![false]);
}
#[test]
fn a_disabled_or_loading_field_opens_nothing() {
for v in [view().enabled(false), view().loading(true)] {
let mut w = ready(&v);
let mut state = AppState::default();
let at = Point::new(40.0, w.height() / 2.0);
click(&mut w, &mut state, at);
assert!(state.opens.is_empty());
}
}
#[test]
fn a_release_outside_the_field_reports_nothing() {
let mut w = ready(&view());
let mut state = AppState::default();
let below = w.height() + 40.0;
dispatch(&mut w, &mut state, &pointer(PointerPhase::Down, 40.0, 10.0));
dispatch(&mut w, &mut state, &pointer(PointerPhase::Up, 40.0, below));
assert!(state.opens.is_empty());
}
#[test]
fn only_a_primary_press_arms_the_field() {
let mut w = ready(&view());
let mut state = AppState::default();
let secondary = InputEvent::Pointer(PointerEvent {
phase: PointerPhase::Down,
position: Point::new(40.0, w.height() / 2.0),
button: PointerButton::Secondary,
});
assert_eq!(
dispatch(&mut w, &mut state, &secondary),
EventResult::Ignored
);
}
#[test]
fn a_cancel_clears_the_press_without_reporting() {
let mut w = ready(&view());
let mut state = AppState::default();
let at = Point::new(40.0, w.height() / 2.0);
dispatch(&mut w, &mut state, &pointer(PointerPhase::Down, at.x, at.y));
dispatch(
&mut w,
&mut state,
&pointer(PointerPhase::Cancel, at.x, at.y),
);
assert!(state.opens.is_empty());
assert!(!w.pressed);
}
#[test]
fn the_clear_affordance_requests_an_empty_selection_instead_of_opening() {
let mut w = ready(
&view()
.show_clear(true)
.selected(vec!["apple".to_string(), "banana".to_string()]),
);
let mut state = AppState::default();
let at = w.trailing_rect.center();
click(&mut w, &mut state, at);
assert_eq!(state.selections, vec![Vec::<String>::new()]);
assert!(state.opens.is_empty(), "the clear press never toggles");
}
#[test]
fn a_multi_select_renders_one_chip_per_selected_option() {
let w = ready(
&view()
.multi(true)
.selected(vec!["apple".to_string(), "cherry".to_string()]),
);
assert_eq!(w.chip_count(), 2);
assert_eq!(w.chips[0].label.content, "Apple");
assert_eq!(w.chips[1].label.content, "Cherry");
}
#[test]
fn a_single_select_never_renders_a_chip() {
let w = ready(&view().selected(vec!["apple".to_string()]));
assert_eq!(w.chip_count(), 0);
}
fn long_items() -> Vec<DropdownItem> {
vec![
dropdown_item("Passionfruit sorbet", "a"),
dropdown_item("Blackcurrant cordial", "b"),
dropdown_item("Clementine marmalade", "c"),
]
}
#[test]
fn chips_grow_the_field_past_the_hint_only_row() {
let plain = ready(&view());
let field = |selected: Vec<String>| {
dropdown_field::<AppState>(long_items())
.multi(true)
.selected(selected)
};
let mut one = ready(&field(vec!["a".to_string()]));
settle(&mut one);
layout(&mut one, AREA);
let mut many = ready(&field(vec![
"a".to_string(),
"b".to_string(),
"c".to_string(),
]));
settle(&mut many);
layout(&mut many, AREA);
assert!(one.height() >= plain.height() - 1e-6);
assert!(
many.height() > one.height(),
"three long chips wrap past one row in a {}px field ({} vs {})",
AREA.width,
many.height(),
one.height()
);
}
#[test]
fn a_chip_springs_in_from_nothing() {
let mut w = ready(&view().multi(true).selected(vec!["apple".to_string()]));
paint(&mut w);
assert!(w.chips[0].progress < 1.0, "the entrance starts from zero");
settle(&mut w);
assert!((w.chips[0].progress - 1.0).abs() < 1e-2);
}
#[test]
fn a_freshly_added_chip_lands_its_box_exactly_on_the_settle_frame() {
let prev = view().multi(true);
let mut w = ready(&prev);
let next = view().multi(true).selected(vec!["apple".to_string()]);
rebuild(&mut w, &prev, &next);
assert_eq!(w.chip_count(), 1);
let theme = crate::baseline().with_brightness(Brightness::Light);
let mut needs_layout = true;
let mut needs_frame = true;
let mut t = 0u64;
let mut frames = 0u32;
let mut last_width = w.chips[0].rect.width();
loop {
if !needs_frame {
break;
}
if needs_layout {
layout(&mut w, AREA);
last_width = w.chips[0].rect.width();
}
let mut rec = Recorder::default();
let size = Size::new(w.width, w.height);
let mut ctx = PaintCtx::for_test(Point::ORIGIN, size, FrameTime::from_nanos(t))
.with_theme(&theme as &dyn Any);
w.paint(&mut ctx, &mut rec);
needs_layout = ctx.needs_layout();
needs_frame = ctx.needs_frame();
frames += 1;
assert!(
frames < 600,
"the chip entrance should settle well inside 600 frames"
);
t += 16_000_000;
}
assert!(frames > 1, "the chip entrance spans more than one frame");
settle(&mut w);
layout(&mut w, AREA);
let target_width = w.chips[0].rect.width();
assert_eq!(
last_width, target_width,
"the last layout a shell-honest driver runs lands exactly on the chip's settled box width"
);
}
#[test]
fn reduce_motion_gives_a_freshly_added_chip_a_real_box_after_relayout() {
let mut theme = crate::baseline().with_brightness(Brightness::Light);
theme.motion.reduce_motion = true;
let prev = view().multi(true);
let mut w = ready(&prev);
let next = view().multi(true).selected(vec!["apple".to_string()]);
rebuild(&mut w, &prev, &next);
assert_eq!(w.chip_count(), 1);
assert_eq!(w.chips[0].rect, Rect::ZERO, "not yet laid out");
let mut rec = Recorder::default();
let size = Size::new(w.width, w.height);
let mut ctx =
PaintCtx::for_test(Point::ORIGIN, size, FrameTime::ZERO).with_theme(&theme as &dyn Any);
w.paint(&mut ctx, &mut rec);
assert_eq!(w.chips[0].progress, 1.0, "snapped straight to visible");
assert!(
ctx.needs_layout(),
"the snap changed a layout-relevant value, so it must request a relayout"
);
layout(&mut w, AREA);
assert!(
w.chips[0].rect.width() > 0.0,
"the chip's box reflects the snapped-in progress after the requested relayout"
);
assert_ne!(
w.chips[0].delete,
Rect::ZERO,
"the delete glyph gets a real hit box, not an empty one"
);
}
#[test]
fn a_removed_chip_outlives_the_rebuild_that_dropped_it_and_then_settles_away() {
let prev = view()
.multi(true)
.selected(vec!["apple".to_string(), "banana".to_string()]);
let mut w = ready(&prev);
settle(&mut w);
assert_eq!(w.chip_count(), 2);
let next = view().multi(true).selected(vec!["banana".to_string()]);
rebuild(&mut w, &prev, &next);
assert_eq!(
w.chip_count(),
2,
"the removed chip is retained for its exit ramp"
);
assert!(w.chips[0].removing);
settle(&mut w);
assert_eq!(w.chip_count(), 1);
assert_eq!(w.chips[0].value, "banana");
}
#[test]
fn a_chip_re_added_mid_exit_is_revived_rather_than_duplicated() {
let prev = view().multi(true).selected(vec!["apple".to_string()]);
let mut w = ready(&prev);
settle(&mut w);
let gone = view().multi(true);
rebuild(&mut w, &prev, &gone);
assert!(w.chips[0].removing);
rebuild(&mut w, &gone, &prev);
assert_eq!(w.chip_count(), 1);
assert!(!w.chips[0].removing);
}
#[test]
fn a_chip_s_delete_glyph_requests_the_selection_without_that_value() {
let mut w = ready(
&view()
.multi(true)
.selected(vec!["apple".to_string(), "banana".to_string()]),
);
settle(&mut w);
layout(&mut w, AREA);
let mut state = AppState::default();
let at = w.chips[0].delete.center();
click(&mut w, &mut state, at);
assert_eq!(state.selections, vec![vec!["banana".to_string()]]);
assert!(state.opens.is_empty(), "a delete never toggles the panel");
}
#[test]
fn a_press_on_the_chip_body_falls_through_to_the_field_s_own_toggle() {
let mut w = ready(&view().multi(true).selected(vec!["apple".to_string()]));
settle(&mut w);
layout(&mut w, AREA);
let mut state = AppState::default();
let chip = w.chips[0].rect;
let at = Point::new(chip.x0 + 2.0, chip.center().y);
click(&mut w, &mut state, at);
assert!(state.selections.is_empty());
assert_eq!(state.opens, vec![true]);
}
#[test]
fn a_disabled_field_s_chips_offer_no_delete_region() {
let mut w = ready(
&view()
.multi(true)
.enabled(false)
.selected(vec!["apple".to_string()]),
);
settle(&mut w);
layout(&mut w, AREA);
assert_eq!(w.chips[0].delete, Rect::ZERO);
}
#[test]
fn max_chips_hides_the_tail_behind_an_overflow_indicator() {
let mut w = ready(&view().multi(true).max_chips(2).selected(vec![
"apple".to_string(),
"banana".to_string(),
"cherry".to_string(),
]));
assert_eq!(w.chip_count(), 2);
assert_eq!(w.overflow.as_ref().map(|(_, n)| *n), Some(1));
assert_eq!(
w.overflow.as_ref().map(|(run, _)| run.content.as_str()),
Some("+1")
);
settle(&mut w);
layout(&mut w, AREA);
let rec = paint(&mut w);
assert!(rec.rrects.len() >= 4);
}
#[test]
fn the_container_fills_with_surface_container_highest_at_the_panel_radius() {
let theme = crate::baseline().with_brightness(Brightness::Light);
let mut w = ready(&view());
let rec = paint(&mut w);
let (origin, size, radius, color) = rec.rrects[0];
assert_eq!(origin, Point::ORIGIN);
assert_eq!(size, Size::new(w.width(), w.height()));
assert_eq!(radius, theme.shape.extra_large);
assert_eq!(color, theme.scheme().surface_container_highest);
}
#[test]
fn a_press_washes_the_container_at_the_reference_s_pressed_alpha() {
let mut w = ready(&view());
let mut state = AppState::default();
let at = Point::new(40.0, w.height() / 2.0);
dispatch(&mut w, &mut state, &pointer(PointerPhase::Down, at.x, at.y));
let pressed = paint(&mut w);
assert_eq!(
pressed.rrects[1].3.components[3],
DROPDOWN_FIELD_PRESSED_ALPHA
);
}
#[test]
fn hovering_the_field_washes_it_at_the_reference_s_hover_alpha() {
use frust_core::RenderRoot;
let mut root: RenderRoot<AppState, DropdownFieldView<AppState>> = RenderRoot::new();
let mut state = AppState::default();
let mut tcx = TextContext::new();
let mut logic = move |_s: &mut AppState| view();
root.rebuild(&mut logic, &mut state);
root.layout_with_text(AREA, &mut tcx as &mut dyn Any);
let mut rest = Recorder::default();
root.paint(&mut rest, FrameTime::ZERO);
assert_eq!(rest.washes(), Vec::<f32>::new(), "no wash at rest");
root.event(&mut state, &pointer(PointerPhase::Move, 40.0, 20.0));
let mut hovered = Recorder::default();
root.paint(&mut hovered, FrameTime::ZERO);
assert_eq!(hovered.washes(), vec![DROPDOWN_FIELD_HOVER_ALPHA]);
}
#[test]
fn a_disabled_field_never_washes() {
let mut w = ready(&view().enabled(false));
let mut state = AppState::default();
let at = Point::new(40.0, w.height() / 2.0);
dispatch(&mut w, &mut state, &pointer(PointerPhase::Move, at.x, at.y));
let rec = paint(&mut w);
assert_eq!(rec.rrects.len(), 1, "the container and nothing else");
}
#[test]
fn an_error_field_inks_its_content_in_the_error_role() {
let theme = crate::baseline().with_brightness(Brightness::Light);
let mut w = ready(&view().error(true).selected(vec!["apple".to_string()]));
let mut state = AppState::default();
let at = Point::new(40.0, w.height() / 2.0);
dispatch(&mut w, &mut state, &pointer(PointerPhase::Down, at.x, at.y));
let rec = paint(&mut w);
let wash = rec.rrects[1].3;
let error = theme.scheme().error;
assert_eq!(wash.components[..3], error.components[..3]);
}
#[test]
fn the_arrow_is_the_one_glyph_a_resting_field_paints() {
let mut w = ready(&view());
let rec = paint(&mut w);
assert_eq!(rec.paths.len(), 1);
}
#[test]
fn a_paint_with_no_hover_link_clears_the_latched_flag() {
let mut w = ready(&view());
let mut state = AppState::default();
let at = Point::new(40.0, w.height() / 2.0);
dispatch(&mut w, &mut state, &pointer(PointerPhase::Move, at.x, at.y));
assert!(w.hovered);
paint(&mut w);
assert!(!w.hovered);
}
#[test]
fn the_semantics_node_is_a_combo_box_carrying_the_open_state() {
use frust_core::RenderRoot;
let mut root: RenderRoot<AppState, DropdownFieldView<AppState>> = RenderRoot::new();
let mut state = AppState::default();
let mut tcx = TextContext::new();
let mut logic = move |_s: &mut AppState| view().open(true);
root.rebuild(&mut logic, &mut state);
root.layout_with_text(AREA, &mut tcx as &mut dyn Any);
let tree = root.semantics();
let node = tree
.nodes
.iter()
.find(|(_, node)| node.role() == Role::ComboBox)
.expect("a combo-box node");
assert!(node.1.is_expanded().unwrap_or(false));
}
}