pub mod field;
pub mod panel;
pub use field::{DropdownFieldView, DropdownFieldWidget, dropdown_field};
pub use panel::{DropdownPanelView, DropdownPanelWidget, dropdown_panel};
use std::rc::Rc;
use frust::authoring::text::{TextContext, TextLayout, TextOverflow, TextStyle};
use frust::authoring::{
Affine, BezPath, Brush, BuildCtx, ChangeFlags, LayoutCtx, PaintScene, Point, Size, View,
};
use frust::{Color, IconData, SpringDesc, Theme};
use crate::interaction::DISABLED_CONTENT_OPACITY;
use crate::overlay::{
AnchoredOverlayView, AnchoredOverlayWidget, OVERLAY_ANCHOR_GAP, OverlayAlign, OverlayAnchor,
OverlayPlacement, OverlaySide, anchored_overlay, with_alpha,
};
use crate::tokens::{MaterialDimensions, MaterialSpacing, MaterialSpring};
pub const DROPDOWN_FIELD_H_PADDING: f64 = MaterialSpacing::LG;
pub const DROPDOWN_FIELD_V_PADDING: f64 = 14.0;
pub const DROPDOWN_ICON_GAP: f64 = MaterialSpacing::SM;
pub const DROPDOWN_ICON_SIZE: f64 = 24.0;
pub const DROPDOWN_FIELD_LOADING_SIZE: f64 = 18.0;
pub const DROPDOWN_FIELD_LOADING_STROKE: f64 = 2.0;
pub const DROPDOWN_PANEL_MAX_HEIGHT: f64 = 350.0;
pub const DROPDOWN_PANEL_GAP: f64 = OVERLAY_ANCHOR_GAP;
pub const DROPDOWN_CONTENT_PADDING: f64 = MaterialSpacing::SM;
pub const DROPDOWN_MESSAGE_PADDING: f64 = MaterialSpacing::LG;
pub const DROPDOWN_LOADING_PADDING: f64 = MaterialSpacing::XL;
pub const DROPDOWN_ITEM_H_PADDING: f64 = MaterialSpacing::LG;
pub const DROPDOWN_ITEM_V_PADDING: f64 = MaterialSpacing::MD;
pub const DROPDOWN_ITEM_GAP: f64 = 3.0;
pub const DROPDOWN_ITEM_OUTER_RADIUS: f64 = MaterialDimensions::RADIUS_MEDIUM;
pub const DROPDOWN_ITEM_INNER_RADIUS: f64 = 6.0;
pub const DROPDOWN_ITEM_HOVER_RADIUS: f64 = MaterialDimensions::RADIUS_SMALL;
pub const DROPDOWN_ITEM_PRESSED_RADIUS: f64 = MaterialDimensions::RADIUS_EXTRA_SMALL;
pub const DROPDOWN_CHIP_H_PADDING: f64 = MaterialSpacing::SM;
pub const DROPDOWN_CHIP_V_PADDING: f64 = MaterialSpacing::XS;
pub const DROPDOWN_CHIP_RADIUS: f64 = 20.0;
pub const DROPDOWN_CHIP_SPACING: f64 = 6.0;
pub const DROPDOWN_CHIP_RUN_SPACING: f64 = 6.0;
pub const DROPDOWN_CHIP_LABEL_GAP: f64 = MaterialSpacing::XS;
pub const DROPDOWN_CHIP_ICON_SIZE: f64 = 18.0;
pub const DROPDOWN_HINT: &str = "Select";
pub const DROPDOWN_EMPTY_TEXT: &str = "No items found";
pub const DROPDOWN_SEARCH_HINT: &str = "Search…";
pub const DROPDOWN_FIELD_HOVER_ALPHA: f32 = 0.05;
pub const DROPDOWN_FIELD_PRESSED_ALPHA: f32 = 0.10;
pub const DROPDOWN_HINT_OPACITY: f32 = 0.6;
pub const DROPDOWN_EMPTY_OPACITY: f32 = 0.5;
pub const DROPDOWN_DISABLED_CONTAINER_OPACITY: f32 = 0.04;
pub(crate) const DROPDOWN_SPRING: SpringDesc = SpringDesc {
mass: 1.0,
stiffness: MaterialSpring::EXPRESSIVE_SPATIAL_DEFAULT.stiffness,
damping_ratio: MaterialSpring::EXPRESSIVE_SPATIAL_DEFAULT.damping_ratio,
};
pub(crate) const DROPDOWN_FLING_VELOCITY: f64 = 4.0;
pub(crate) const DROPDOWN_PROGRESS_EPSILON: f64 = 1e-3;
pub(crate) const SHAPING_INK: Color = Color::BLACK;
const FALLBACK_CONTAINER_HIGHEST: Color = Color::from_rgb8(0xE6, 0xE0, 0xE9);
const FALLBACK_CONTAINER_HIGH: Color = Color::from_rgb8(0xEC, 0xE6, 0xF0);
const FALLBACK_ON_SURFACE: Color = Color::from_rgb8(0x1D, 0x1B, 0x20);
const FALLBACK_SECONDARY_CONTAINER: Color = Color::from_rgb8(0xE8, 0xDE, 0xF8);
const FALLBACK_ON_SECONDARY_CONTAINER: Color = Color::from_rgb8(0x1D, 0x19, 0x2B);
const FALLBACK_ERROR: Color = Color::from_rgb8(0xB3, 0x26, 0x1E);
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct DropdownColors {
pub field_container: Color,
pub field_content: Color,
pub panel_container: Color,
pub chip_container: Color,
pub chip_content: Color,
pub item_container: Color,
pub item_selected_container: Color,
pub item_content: Color,
pub item_selected_content: Color,
pub hint: Color,
pub empty: Color,
pub error: Color,
}
impl DropdownColors {
pub fn resolve(theme: Option<&Theme>) -> Self {
let (
container_highest,
container_high,
on_surface,
secondary_container,
on_secondary_container,
error,
) = match theme {
Some(theme) => {
let s = theme.scheme();
(
s.surface_container_highest,
s.surface_container_high,
s.on_surface,
s.secondary_container,
s.on_secondary_container,
s.error,
)
}
None => (
FALLBACK_CONTAINER_HIGHEST,
FALLBACK_CONTAINER_HIGH,
FALLBACK_ON_SURFACE,
FALLBACK_SECONDARY_CONTAINER,
FALLBACK_ON_SECONDARY_CONTAINER,
FALLBACK_ERROR,
),
};
DropdownColors {
field_container: container_highest,
field_content: on_surface,
panel_container: container_highest,
chip_container: secondary_container,
chip_content: on_secondary_container,
item_container: container_high,
item_selected_container: secondary_container,
item_content: on_surface,
item_selected_content: on_secondary_container,
hint: with_alpha(on_surface, DROPDOWN_HINT_OPACITY),
empty: with_alpha(on_surface, DROPDOWN_EMPTY_OPACITY),
error,
}
}
pub fn item_background(&self, enabled: bool, selected: bool) -> Color {
if !enabled {
return with_alpha(self.item_content, DROPDOWN_DISABLED_CONTAINER_OPACITY);
}
if selected {
self.item_selected_container
} else {
self.item_container
}
}
pub fn item_foreground(&self, enabled: bool, selected: bool) -> Color {
if !enabled {
return with_alpha(self.item_content, DISABLED_CONTENT_OPACITY);
}
if selected {
self.item_selected_content
} else {
self.item_content
}
}
}
pub(crate) fn container_radius(theme: Option<&Theme>) -> f64 {
crate::overlay::radius(theme)
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DropdownItem {
label: String,
value: String,
disabled: bool,
}
pub fn dropdown_item(label: impl Into<String>, value: impl Into<String>) -> DropdownItem {
DropdownItem {
label: label.into(),
value: value.into(),
disabled: false,
}
}
impl DropdownItem {
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
pub fn label(&self) -> &str {
&self.label
}
pub fn value(&self) -> &str {
&self.value
}
pub fn is_disabled(&self) -> bool {
self.disabled
}
}
pub fn dropdown_matches(item: &DropdownItem, query: &str) -> bool {
if query.is_empty() {
return true;
}
item.label.to_lowercase().contains(&query.to_lowercase())
}
pub fn dropdown_filter(items: &[DropdownItem], query: &str) -> Vec<DropdownItem> {
items
.iter()
.filter(|item| dropdown_matches(item, query))
.cloned()
.collect()
}
pub(crate) fn ordered_selection(items: &[DropdownItem], values: &[String]) -> Vec<String> {
items
.iter()
.filter(|item| values.iter().any(|v| v == &item.value))
.map(|item| item.value.clone())
.collect()
}
pub(crate) struct Run {
pub(crate) content: String,
layout: Option<TextLayout>,
shaped_for: Option<(TextStyle, Option<f64>)>,
natural: Option<(TextStyle, f64)>,
}
impl Run {
pub(crate) fn new(content: impl Into<String>) -> Self {
Run {
content: content.into(),
layout: None,
shaped_for: None,
natural: None,
}
}
pub(crate) fn natural_width(&mut self, ctx: &mut LayoutCtx, style: &TextStyle) -> f64 {
if let Some((cached, width)) = &self.natural
&& cached == style
{
return *width;
}
let text_ctx = ctx.text_context::<TextContext>();
let width = text_ctx.layout(&self.content, style, None).size().width;
self.natural = Some((style.clone(), width));
width
}
pub(crate) fn shape(&mut self, ctx: &mut LayoutCtx, style: &TextStyle, max_width: f64) -> Size {
let key = (style.clone(), Some(max_width));
if let Some(cached) = &self.layout
&& self.shaped_for.as_ref() == Some(&key)
{
return cached.size();
}
let text_ctx = ctx.text_context::<TextContext>();
let laid = text_ctx.layout_bounded(
&self.content,
style,
Some(max_width.max(0.0) as f32),
Some(1),
TextOverflow::Ellipsis,
);
let size = laid.size();
self.layout = Some(laid);
self.shaped_for = Some(key);
size
}
pub(crate) fn size(&self) -> Size {
self.layout.as_ref().map_or(Size::ZERO, TextLayout::size)
}
pub(crate) fn paint(&self, origin: Point, color: Color, scene: &mut dyn PaintScene) {
self.paint_transformed(origin, color, Affine::IDENTITY, scene);
}
pub(crate) fn paint_transformed(
&self,
origin: Point,
color: Color,
transform: Affine,
scene: &mut dyn PaintScene,
) {
let Some(layout) = &self.layout else {
return;
};
for mut run in layout.to_scene_runs(origin) {
run.brush = Brush::Solid(color);
if transform != Affine::IDENTITY {
run.transform = transform * run.transform;
}
scene.draw_glyph_run(run);
}
}
}
pub(crate) struct Glyph {
path: BezPath,
design: f64,
pub(crate) extent: f64,
}
impl Glyph {
pub(crate) fn new(icon: frust::IconSource, extent: f64) -> Self {
let (path, design) = IconData::from(icon).resolve();
Glyph {
path,
design,
extent,
}
}
fn scale(&self) -> f64 {
if self.design > 0.0 {
self.extent / self.design
} else {
1.0
}
}
pub(crate) fn paint(&self, origin: Point, color: Color, scene: &mut dyn PaintScene) {
let scaled = Affine::scale(self.scale()) * self.path.clone();
scene.fill_path(origin, &scaled, &Brush::Solid(color));
}
pub(crate) fn paint_rotated(
&self,
origin: Point,
color: Color,
radians: f64,
scene: &mut dyn PaintScene,
) {
let half = self.extent / 2.0;
let pivot = kurbo::Vec2::new(half, half);
let transform = Affine::translate(pivot)
* Affine::rotate(radians)
* Affine::translate(-pivot)
* Affine::scale(self.scale());
scene.fill_path(
origin,
&(transform * self.path.clone()),
&Brush::Solid(color),
);
}
}
pub(crate) type OnSelection<State> = Rc<dyn Fn(&mut State, Vec<String>)>;
pub(crate) type OnOpen<State> = Rc<dyn Fn(&mut State, bool)>;
pub(crate) type OnQuery<State> = Rc<dyn Fn(&mut State, String)>;
pub struct DropdownView<State: 'static> {
items: Vec<DropdownItem>,
selected: Vec<String>,
multi: bool,
open: bool,
query: String,
searchable: bool,
loading: bool,
error: Option<String>,
empty_text: String,
max_selections: usize,
max_height: f64,
placement: OverlayPlacement,
anchor: OverlayAnchor,
on_change: OnSelection<State>,
on_open: Option<OnOpen<State>>,
on_query: Option<OnQuery<State>>,
}
pub fn dropdown<State: 'static, F: Fn(&mut State, Vec<String>) + 'static>(
items: Vec<DropdownItem>,
on_change: F,
) -> DropdownView<State> {
DropdownView {
items,
selected: Vec::new(),
multi: false,
open: true,
query: String::new(),
searchable: false,
loading: false,
error: None,
empty_text: DROPDOWN_EMPTY_TEXT.to_string(),
max_selections: 0,
max_height: DROPDOWN_PANEL_MAX_HEIGHT,
placement: OverlayPlacement::default()
.align(OverlayAlign::Start)
.offset(DROPDOWN_PANEL_GAP),
anchor: OverlayAnchor::new(),
on_change: Rc::new(on_change),
on_open: None,
on_query: None,
}
}
impl<State: 'static> DropdownView<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 multi(mut self, multi: bool) -> Self {
self.multi = multi;
self
}
pub fn max_selections(mut self, max: usize) -> Self {
self.max_selections = max;
self
}
pub fn open(mut self, open: bool) -> Self {
self.open = open;
self
}
pub fn searchable(mut self, searchable: bool) -> Self {
self.searchable = searchable;
self
}
pub fn query(mut self, query: impl Into<String>) -> Self {
self.query = query.into();
self
}
pub fn on_query<F: Fn(&mut State, String) + 'static>(mut self, on_query: F) -> Self {
self.on_query = Some(Rc::new(on_query));
self
}
pub fn loading(mut self, loading: bool) -> Self {
self.loading = loading;
self
}
pub fn error(mut self, error: Option<String>) -> Self {
self.error = error;
self
}
pub fn empty_text(mut self, text: impl Into<String>) -> Self {
self.empty_text = text.into();
self
}
pub fn max_height(mut self, max_height: f64) -> Self {
self.max_height = max_height;
self
}
pub fn side(mut self, side: OverlaySide) -> Self {
self.placement.side = side;
self
}
pub fn align(mut self, align: OverlayAlign) -> Self {
self.placement.align = align;
self
}
pub fn offset(mut self, offset: f64) -> Self {
self.placement.offset = offset;
self
}
pub fn on_open<F: Fn(&mut State, bool) + 'static>(mut self, on_open: F) -> Self {
self.on_open = Some(Rc::new(on_open));
self
}
fn host(&self) -> AnchoredOverlayView<State> {
let on_change = self.on_change.clone();
let mut panel = dropdown_panel(self.items.clone(), move |state: &mut State, values| {
on_change(state, values)
})
.anchor(&self.anchor)
.selected(self.selected.clone())
.multi(self.multi)
.max_selections(self.max_selections)
.query(self.query.clone())
.searchable(self.searchable)
.loading(self.loading)
.error(self.error.clone())
.empty_text(self.empty_text.clone())
.max_height(self.max_height)
.open(self.open);
if let Some(on_open) = self.on_open.clone() {
panel = panel.on_open(move |state: &mut State, open| on_open(state, open));
}
if let Some(on_query) = self.on_query.clone() {
panel = panel.on_query(move |state: &mut State, query| on_query(state, query));
}
let mut host = anchored_overlay(panel)
.anchor(&self.anchor)
.placement(self.placement)
.open(self.open);
if let Some(on_open) = self.on_open.clone() {
host = host.on_dismiss(move |state: &mut State| on_open(state, false));
}
host
}
}
impl<State: 'static> View<State> for DropdownView<State> {
type Element = AnchoredOverlayWidget;
fn build(&self, ctx: &mut BuildCtx<'_>) -> AnchoredOverlayWidget {
View::build(&self.host(), ctx)
}
fn rebuild(
&self,
prev: &Self,
element: &mut AnchoredOverlayWidget,
ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
View::rebuild(&self.host(), &prev.host(), element, ctx)
}
fn teardown(&self, element: &mut AnchoredOverlayWidget, ctx: &mut BuildCtx<'_>) {
View::teardown(&self.host(), element, ctx);
}
}
#[cfg(test)]
mod tests {
use super::*;
use frust::Brightness;
fn fruit() -> Vec<DropdownItem> {
vec![
dropdown_item("Apple", "apple"),
dropdown_item("Banana", "banana"),
dropdown_item("Blackcurrant", "blackcurrant").disabled(true),
]
}
#[test]
fn the_metrics_are_the_reference_s_own_defaults() {
assert_eq!(DROPDOWN_FIELD_H_PADDING, 16.0);
assert_eq!(DROPDOWN_FIELD_V_PADDING, 14.0);
assert_eq!(DROPDOWN_ICON_GAP, 8.0);
assert_eq!(DROPDOWN_ICON_SIZE, 24.0);
assert_eq!(DROPDOWN_FIELD_LOADING_SIZE, 18.0);
assert_eq!(DROPDOWN_FIELD_LOADING_STROKE, 2.0);
assert_eq!(DROPDOWN_PANEL_MAX_HEIGHT, 350.0);
assert_eq!(DROPDOWN_PANEL_GAP, 4.0);
assert_eq!(DROPDOWN_CONTENT_PADDING, 8.0);
assert_eq!(DROPDOWN_MESSAGE_PADDING, 16.0);
assert_eq!(DROPDOWN_LOADING_PADDING, 24.0);
assert_eq!(DROPDOWN_ITEM_H_PADDING, 16.0);
assert_eq!(DROPDOWN_ITEM_V_PADDING, 12.0);
assert_eq!(DROPDOWN_ITEM_GAP, 3.0);
assert_eq!(DROPDOWN_ITEM_OUTER_RADIUS, 12.0);
assert_eq!(DROPDOWN_ITEM_INNER_RADIUS, 6.0);
assert_eq!(DROPDOWN_ITEM_HOVER_RADIUS, 8.0);
assert_eq!(DROPDOWN_ITEM_PRESSED_RADIUS, 4.0);
assert_eq!(DROPDOWN_CHIP_H_PADDING, 8.0);
assert_eq!(DROPDOWN_CHIP_V_PADDING, 4.0);
assert_eq!(DROPDOWN_CHIP_RADIUS, 20.0);
assert_eq!(DROPDOWN_CHIP_SPACING, 6.0);
assert_eq!(DROPDOWN_CHIP_RUN_SPACING, 6.0);
assert_eq!(DROPDOWN_CHIP_LABEL_GAP, 4.0);
assert_eq!(DROPDOWN_HINT, "Select");
assert_eq!(DROPDOWN_EMPTY_TEXT, "No items found");
}
#[test]
fn the_container_radius_is_the_theme_s_extra_large_shape_token() {
let theme = crate::baseline();
assert_eq!(container_radius(Some(&theme)), theme.shape.extra_large);
assert_eq!(container_radius(None), 28.0);
}
#[test]
fn every_color_folds_onto_its_material_role() {
let theme = crate::baseline().with_brightness(Brightness::Light);
let s = theme.scheme();
let c = DropdownColors::resolve(Some(&theme));
assert_eq!(c.field_container, s.surface_container_highest);
assert_eq!(c.field_content, s.on_surface);
assert_eq!(c.panel_container, s.surface_container_highest);
assert_eq!(c.chip_container, s.secondary_container);
assert_eq!(c.chip_content, s.on_secondary_container);
assert_eq!(c.item_container, s.surface_container_high);
assert_eq!(c.item_selected_container, s.secondary_container);
assert_eq!(c.item_content, s.on_surface);
assert_eq!(c.item_selected_content, s.on_secondary_container);
assert_eq!(c.hint, with_alpha(s.on_surface, DROPDOWN_HINT_OPACITY));
assert_eq!(c.empty, with_alpha(s.on_surface, DROPDOWN_EMPTY_OPACITY));
assert_eq!(c.error, s.error);
}
#[test]
fn the_unthemed_fallbacks_are_the_m3_baseline_light_values() {
let light = crate::baseline().with_brightness(Brightness::Light);
assert_eq!(
DropdownColors::resolve(None),
DropdownColors::resolve(Some(&light))
);
}
#[test]
fn the_colors_follow_a_live_brightness_flip() {
let light = crate::baseline().with_brightness(Brightness::Light);
let dark = crate::baseline().with_brightness(Brightness::Dark);
assert_ne!(
DropdownColors::resolve(Some(&light)).panel_container,
DropdownColors::resolve(Some(&dark)).panel_container
);
}
#[test]
fn a_non_material_theme_still_resolves_every_role() {
let bare = Theme::neutral();
let _ = DropdownColors::resolve(Some(&bare));
let _ = container_radius(Some(&bare));
}
#[test]
fn the_item_ladder_is_disabled_then_selected() {
let c = DropdownColors::resolve(None);
assert_eq!(
c.item_background(false, true),
with_alpha(c.item_content, DROPDOWN_DISABLED_CONTAINER_OPACITY),
"disabled outranks selected"
);
assert_eq!(
c.item_foreground(false, true),
with_alpha(c.item_content, DISABLED_CONTENT_OPACITY)
);
assert_eq!(c.item_background(true, true), c.item_selected_container);
assert_eq!(c.item_background(true, false), c.item_container);
assert_eq!(c.item_foreground(true, true), c.item_selected_content);
assert_eq!(c.item_foreground(true, false), c.item_content);
}
#[test]
fn an_empty_query_admits_every_item() {
let items = fruit();
assert_eq!(dropdown_filter(&items, "").len(), 3);
}
#[test]
fn the_filter_is_a_case_insensitive_substring_of_the_label() {
let items = fruit();
let narrowed = dropdown_filter(&items, "nan");
assert_eq!(narrowed.len(), 1);
assert_eq!(narrowed[0].label(), "Banana");
assert_eq!(dropdown_filter(&items, "APPLE").len(), 1);
assert_eq!(dropdown_filter(&items, "apple").len(), 1);
let b = dropdown_filter(&items, "b");
assert_eq!(
b.iter().map(DropdownItem::label).collect::<Vec<_>>(),
["Banana", "Blackcurrant"]
);
}
#[test]
fn the_filter_never_matches_a_value_only_the_label() {
let items = vec![dropdown_item("Apple", "pomme")];
assert!(dropdown_filter(&items, "pomme").is_empty());
assert_eq!(dropdown_filter(&items, "app").len(), 1);
}
#[test]
fn a_disabled_item_still_matches_the_filter() {
let items = fruit();
let narrowed = dropdown_filter(&items, "black");
assert_eq!(narrowed.len(), 1);
assert!(narrowed[0].is_disabled());
}
mod composite {
use super::super::*;
use frust::FrameTime;
use frust::authoring::{
InputEvent, Key, KeyEvent, Modifiers, NamedKey, Point, PointerButton, PointerEvent,
PointerPhase, Size, text::TextContext,
};
use frust_core::RenderRoot;
use std::any::Any;
const WINDOW: Size = Size::new(320.0, 480.0);
#[derive(Default)]
struct AppState {
open: bool,
selected: Vec<String>,
query: String,
}
fn items() -> Vec<DropdownItem> {
vec![
dropdown_item("Apple", "apple"),
dropdown_item("Banana", "banana"),
dropdown_item("Cherry", "cherry"),
]
}
fn tree(
anchor: &OverlayAnchor,
multi: bool,
searchable: bool,
state: &AppState,
) -> frust::StackView<AppState> {
let selected = state.selected.clone();
let open = state.open;
let query = state.query.clone();
frust::stack()
.child(
dropdown_field::<AppState>(items())
.anchor(anchor)
.multi(multi)
.selected(selected.clone())
.open(open)
.on_open(|s: &mut AppState, next| s.open = next)
.on_change(|s: &mut AppState, values| s.selected = values),
)
.child(
dropdown(items(), |s: &mut AppState, values| s.selected = values)
.anchor(anchor)
.multi(multi)
.selected(selected)
.open(open)
.searchable(searchable)
.query(query)
.on_query(|s: &mut AppState, text| s.query = text)
.on_open(|s: &mut AppState, next| s.open = next),
)
}
struct Harness {
root: RenderRoot<AppState, frust::StackView<AppState>>,
state: AppState,
tcx: TextContext,
anchor: OverlayAnchor,
multi: bool,
searchable: bool,
}
impl Harness {
fn new(multi: bool) -> Self {
Harness::with(multi, false)
}
fn with(multi: bool, searchable: bool) -> Self {
let mut h = Harness {
root: RenderRoot::new(),
state: AppState::default(),
tcx: TextContext::new(),
anchor: OverlayAnchor::new(),
multi,
searchable,
};
h.pass();
h
}
fn pass(&mut self) {
let anchor = self.anchor.clone();
let multi = self.multi;
let searchable = self.searchable;
let mut logic = move |state: &mut AppState| tree(&anchor, multi, searchable, state);
self.root.rebuild(&mut logic, &mut self.state);
self.root
.layout_with_text(WINDOW, &mut self.tcx as &mut dyn Any);
self.paint();
}
fn paint(&mut self) {
struct Sink;
impl frust::authoring::PaintScene for Sink {
fn fill_rect(
&mut self,
_origin: Point,
_size: Size,
_color: frust::authoring::Color,
) {
}
fn draw_text(&mut self, _origin: Point, _text: &str) {}
}
self.root.paint(&mut Sink, FrameTime::ZERO);
}
fn pointer(&mut self, phase: PointerPhase, x: f64, y: f64) {
self.root.event(
&mut self.state,
&InputEvent::Pointer(PointerEvent {
phase,
position: Point::new(x, y),
button: PointerButton::Primary,
}),
);
}
fn click(&mut self, x: f64, y: f64) {
self.pointer(PointerPhase::Down, x, y);
self.pointer(PointerPhase::Up, x, y);
}
fn key(&mut self, named: NamedKey) {
self.send(Key::Named(named));
}
fn typed(&mut self, text: &str) {
self.send(Key::Character(text.to_string()));
}
fn send(&mut self, key: Key) {
self.root.event(
&mut self.state,
&InputEvent::Key(KeyEvent {
key,
modifiers: Modifiers::default(),
repeat: false,
}),
);
}
fn field(&self) -> frust::authoring::Rect {
self.anchor.rect()
}
fn row_y(&self, i: usize) -> f64 {
let band = 2.0 * DROPDOWN_ITEM_V_PADDING + DROPDOWN_ICON_SIZE;
self.list_top() + i as f64 * (band + DROPDOWN_ITEM_GAP) + band / 2.0
}
fn panel_top(&self) -> f64 {
self.field().y1 + DROPDOWN_PANEL_GAP
}
fn list_top(&self) -> f64 {
let header = if self.searchable {
panel::SEARCH_MARGIN_TOP
+ panel::SEARCH_FIELD_HEIGHT
+ panel::SEARCH_MARGIN_BOTTOM
} else {
0.0
};
self.panel_top() + header + DROPDOWN_CONTENT_PADDING
}
fn search_point(&self) -> Point {
Point::new(
panel::SEARCH_MARGIN_H + 20.0,
self.panel_top() + panel::SEARCH_MARGIN_TOP + panel::SEARCH_FIELD_HEIGHT / 2.0,
)
}
}
#[test]
fn open_then_pick_then_close_is_one_round_trip_through_the_app_s_own_state() {
let mut h = Harness::new(false);
assert!(!h.state.open);
assert_eq!(h.field().width(), WINDOW.width, "the trigger is captured");
let field = h.field();
h.click(40.0, field.center().y);
assert!(h.state.open, "the field requested the open state");
h.pass();
let y = h.row_y(1);
h.click(40.0, y);
assert_eq!(h.state.selected, vec!["banana".to_string()]);
assert!(!h.state.open, "a single-select pick closes the dropdown");
h.pass();
let center = h.field().center().y;
h.click(40.0, center);
assert!(h.state.open);
}
#[test]
fn a_press_outside_the_panel_light_dismisses_through_the_host() {
let mut h = Harness::new(false);
let field = h.field();
h.click(40.0, field.center().y);
h.pass();
assert!(h.state.open);
h.pointer(PointerPhase::Down, 10.0, WINDOW.height - 10.0);
assert!(!h.state.open);
assert!(h.state.selected.is_empty());
}
#[test]
fn the_arrows_reach_the_panel_once_a_press_inside_it_has_claimed_focus() {
let mut h = Harness::new(false);
let field = h.field();
h.click(40.0, field.center().y);
h.pass();
let panel_top = h.panel_top();
h.click(4.0, panel_top + 2.0);
assert!(h.state.selected.is_empty());
assert!(h.state.open, "a background press never dismisses");
h.key(NamedKey::ArrowDown);
h.key(NamedKey::ArrowDown);
h.key(NamedKey::Enter);
assert_eq!(h.state.selected, vec!["banana".to_string()]);
assert!(!h.state.open);
}
#[test]
fn escape_dismisses_through_the_host_once_it_holds_focus() {
let mut h = Harness::new(false);
let field = h.field();
h.click(40.0, field.center().y);
h.pass();
let panel_top = h.panel_top();
h.click(4.0, panel_top + 2.0);
h.key(NamedKey::Escape);
assert!(!h.state.open);
}
#[test]
fn typing_in_the_search_field_narrows_the_options_it_offers() {
let mut h = Harness::with(false, true);
let field = h.field();
h.click(40.0, field.center().y);
h.pass();
let at = h.search_point();
h.click(at.x, at.y);
h.typed("b");
assert_eq!(h.state.query, "b");
h.pass();
let y = h.row_y(0);
h.click(40.0, y);
assert_eq!(h.state.selected, vec!["banana".to_string()]);
}
#[test]
fn a_multi_select_round_trip_keeps_the_panel_open_and_grows_the_field() {
let mut h = Harness::new(true);
let field = h.field();
h.click(40.0, field.center().y);
h.pass();
let first = h.row_y(0);
h.click(40.0, first);
assert_eq!(h.state.selected, vec!["apple".to_string()]);
assert!(h.state.open, "a multi-select pick keeps the panel open");
h.pass();
let third = h.row_y(2);
h.click(40.0, third);
assert_eq!(
h.state.selected,
vec!["apple".to_string(), "cherry".to_string()],
"item order, not click order"
);
for _ in 0..120 {
h.pass();
}
assert!(
h.field().height() > field.height(),
"two chips grew the trigger past its hint-only height ({} vs {})",
h.field().height(),
field.height()
);
}
}
#[test]
fn a_selection_is_normalized_into_item_order_and_drops_unknown_values() {
let items = fruit();
let requested = vec![
"banana".to_string(),
"nope".to_string(),
"apple".to_string(),
];
assert_eq!(
ordered_selection(&items, &requested),
vec!["apple".to_string(), "banana".to_string()],
"item order wins over the order the values arrived in"
);
assert!(ordered_selection(&items, &[]).is_empty());
}
}