use crate::core::{Align, Anchor, Axis, Cx, Element, KeyInput, OverlaySpec, Role};
use crate::geometry::Insets;
use crate::platform::native::NativeControl;
use crate::style::Theme;
use super::{native_size, scroll};
const MAX_VISIBLE: usize = 8;
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Select {
selected: Option<usize>,
open: bool,
highlighted: usize,
}
impl Select {
pub fn new(selected: Option<usize>) -> Self {
Self {
selected,
open: false,
highlighted: selected.unwrap_or(0),
}
}
pub fn selected(&self) -> Option<usize> {
self.selected
}
pub fn set_selected(&mut self, index: Option<usize>) {
self.selected = index;
}
pub fn is_open(&self) -> bool {
self.open
}
pub fn highlighted(&self) -> usize {
self.highlighted
}
pub fn open(&mut self) {
self.open = true;
self.highlighted = self.selected.unwrap_or(0);
}
pub fn close(&mut self) {
self.open = false;
}
pub fn toggle(&mut self) {
if self.open {
self.close();
} else {
self.open();
}
}
pub fn choose(&mut self, index: usize) {
self.selected = Some(index);
self.highlighted = index;
self.open = false;
}
pub fn handle_key<T: AsRef<str>>(&mut self, key: &KeyInput, options: &[T]) -> bool {
let n = options.len();
if n == 0 {
self.close();
return false;
}
let before = self.selected;
self.highlighted = self.highlighted.min(n - 1);
if self.open {
match key {
KeyInput::Down => self.highlighted = (self.highlighted + 1).min(n - 1),
KeyInput::Up => self.highlighted = self.highlighted.saturating_sub(1),
KeyInput::Home => self.highlighted = 0,
KeyInput::End => self.highlighted = n - 1,
KeyInput::Enter => self.choose(self.highlighted),
KeyInput::Text(t) if t == " " => self.choose(self.highlighted),
KeyInput::Escape => self.close(),
KeyInput::Text(t) => {
if let Some(i) = next_match(options, self.highlighted, t) {
self.highlighted = i;
}
}
_ => {}
}
} else {
match key {
KeyInput::Down | KeyInput::Up | KeyInput::Enter => self.open(),
KeyInput::Text(t) if t == " " => self.open(),
KeyInput::Home => self.choose(0),
KeyInput::End => self.choose(n - 1),
KeyInput::Text(t) => {
let from = self.selected.unwrap_or(n - 1);
if let Some(i) = next_match(options, from, t) {
self.choose(i);
}
}
_ => {}
}
}
self.selected != before
}
}
fn next_match<T: AsRef<str>>(options: &[T], from: usize, typed: &str) -> Option<usize> {
let typed = typed.to_lowercase();
if typed.trim().is_empty() {
return None;
}
let n = options.len();
(1..=n)
.map(|k| (from + k) % n)
.find(|&i| options[i].as_ref().to_lowercase().starts_with(&typed))
}
pub fn select<S, T: AsRef<str>>(
cx: &mut Cx<S>,
theme: &Theme,
options: &[T],
state: &Select,
field: impl Fn(&mut S) -> &mut Select + Copy + 'static,
) -> Element {
let p = &theme.palette;
let labels: Vec<String> = options.iter().map(|o| o.as_ref().to_string()).collect();
let tap = cx.register(move |s: &mut S| field(s).toggle());
if state.open && !labels.is_empty() {
let list = option_list(cx, theme, &labels, state, field);
let dismiss = cx.register(move |s: &mut S| field(s).close());
cx.overlay(OverlaySpec {
content: list,
anchor: Anchor::Below(tap),
modal: false,
dismiss: Some(dismiss),
});
}
let (text, ink) = match state.selected.and_then(|i| labels.get(i)) {
Some(label) => (label.clone(), p.text),
None => ("\u{2014}".to_string(), p.text_muted),
};
let body = vec![
Element::text(text, theme.typography.body, ink),
super::spacer(),
Element::text("\u{25BE}", theme.typography.body, p.text_muted),
];
let control = NativeControl::Button { primary: false };
let mut el = Element::stack(Axis::Horizontal, body)
.fill(p.surface)
.border(p.border, 1.0)
.radius(theme.radius)
.padding(Insets::symmetric(theme.spacing.md, theme.spacing.sm))
.gap(theme.spacing.sm)
.align(Align::Start, Align::Center)
.width(200.0);
if let Some(deco) = theme.native_deco(control) {
el = native_size(el.native(deco), control).width(200.0);
}
el.action = Some(tap);
el.role(Role::ComboBox).on_key(cx, move |s: &mut S, k| {
field(s).handle_key(k, &labels);
})
}
fn option_list<S>(
cx: &mut Cx<S>,
theme: &Theme,
labels: &[String],
state: &Select,
field: impl Fn(&mut S) -> &mut Select + Copy + 'static,
) -> Element {
let p = &theme.palette;
let row_height = (theme.typography.body * 1.4 + 2.0 * theme.spacing.sm).round();
let mut rows = Vec::with_capacity(labels.len());
for (i, text) in labels.iter().enumerate() {
let mark = if state.selected == Some(i) {
"\u{2713}"
} else {
""
};
let mut row = Element::stack(
Axis::Horizontal,
vec![
Element::text(mark, theme.typography.body, p.primary).width(16.0),
Element::text(text.clone(), theme.typography.body, p.text),
],
)
.radius(theme.radius / 2.0)
.padding(Insets::symmetric(theme.spacing.sm, 0.0))
.gap(theme.spacing.xs)
.align(Align::Start, Align::Center)
.height(row_height)
.role(Role::ListItem)
.a11y_label(text.clone());
if i == state.highlighted {
row = row.fill(p.selection);
}
rows.push(row.on_tap(cx, move |s: &mut S| field(s).choose(i)));
}
let many = rows.len() > MAX_VISIBLE;
let mut list = Element::stack(Axis::Vertical, rows)
.gap(2.0)
.align(Align::Start, Align::Stretch);
if many {
let height = MAX_VISIBLE as f64 * (row_height + 2.0);
list = scroll(cx, height, list);
}
Element::stack(Axis::Vertical, vec![list])
.fill(p.surface)
.border(p.border, 1.0)
.radius(theme.radius)
.padding(Insets::uniform(theme.spacing.xs))
.align(Align::Start, Align::Stretch)
.clip()
.role(Role::List)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::App;
use crate::core::Role;
use crate::geometry::{Point, Size};
const OPTIONS: [&str; 4] = ["Apple", "Banana", "Blueberry", "Cherry"];
#[test]
fn keyboard_opens_moves_and_chooses() {
let mut s = Select::new(Some(1));
assert!(!s.handle_key(&KeyInput::Down, &OPTIONS));
assert!(s.is_open());
assert_eq!(s.highlighted(), 1, "opens on the chosen option");
s.handle_key(&KeyInput::Down, &OPTIONS);
s.handle_key(&KeyInput::Down, &OPTIONS);
s.handle_key(&KeyInput::Down, &OPTIONS); assert_eq!(s.highlighted(), 3);
s.handle_key(&KeyInput::Home, &OPTIONS);
assert_eq!(s.highlighted(), 0);
s.handle_key(&KeyInput::Up, &OPTIONS); assert_eq!(s.highlighted(), 0);
assert!(s.handle_key(&KeyInput::Enter, &OPTIONS));
assert_eq!((s.selected(), s.is_open()), (Some(0), false));
}
#[test]
fn escape_closes_without_choosing() {
let mut s = Select::new(Some(2));
s.handle_key(&KeyInput::Text(" ".into()), &OPTIONS);
assert!(s.is_open());
s.handle_key(&KeyInput::End, &OPTIONS);
assert!(!s.handle_key(&KeyInput::Escape, &OPTIONS));
assert_eq!((s.selected(), s.is_open()), (Some(2), false));
}
#[test]
fn typing_jumps_to_the_next_match() {
let mut s = Select::new(None);
assert!(s.handle_key(&KeyInput::Text("b".into()), &OPTIONS));
assert_eq!(s.selected(), Some(1));
s.handle_key(&KeyInput::Text("B".into()), &OPTIONS); assert_eq!(s.selected(), Some(2));
s.handle_key(&KeyInput::Text("b".into()), &OPTIONS); assert_eq!(s.selected(), Some(1));
assert!(!s.handle_key(&KeyInput::Text("z".into()), &OPTIONS));
s.handle_key(&KeyInput::End, &OPTIONS);
assert_eq!(s.selected(), Some(3));
}
#[test]
fn empty_options_do_nothing() {
let mut s = Select::new(None);
let none: [&str; 0] = [];
assert!(!s.handle_key(&KeyInput::Down, &none));
assert!(!s.is_open());
}
struct State {
fruit: Select,
}
fn app() -> App<State> {
App::new(
State {
fruit: Select::new(Some(0)),
},
|s: &State, cx: &mut Cx<State>| {
let theme = *cx.theme();
super::super::column(vec![select(
cx,
&theme,
&OPTIONS,
&s.fruit,
|s: &mut State| &mut s.fruit,
)])
.padding(Insets::uniform(10.0))
},
)
.logical_size(Size::new(300.0, 300.0))
}
#[test]
fn clicks_open_the_list_below_and_choose() {
let mut app = app();
app.render_once();
assert!(app.click_at(Point::new(100.0, 20.0)));
assert!(app.state().fruit.is_open());
app.render_once();
let tree = app.accessibility_tree().unwrap();
let nodes = tree.descendants();
let list = nodes
.iter()
.find(|n| n.role == Role::List)
.expect("the open list is in the accessibility tree");
let items: Vec<_> = list
.descendants()
.into_iter()
.filter(|n| n.role == Role::ListItem)
.collect();
assert_eq!(items.len(), OPTIONS.len());
assert_eq!(items[2].name, "Blueberry");
assert!(list.bounds.min_y() > 20.0, "dropped below the button");
assert!(
list.bounds.width() >= 200.0,
"at least as wide as the button"
);
let target = items[2].bounds.center();
assert!(app.click_at(target));
assert_eq!(app.state().fruit.selected(), Some(2));
assert!(!app.state().fruit.is_open());
assert!(app.focused().is_some());
assert!(app.press_key(KeyInput::Down));
assert!(app.state().fruit.is_open());
}
#[test]
fn an_outside_click_closes_the_list() {
let mut app = app();
app.render_once();
app.click_at(Point::new(100.0, 20.0));
app.render_once();
assert!(app.click_at(Point::new(280.0, 280.0)));
assert!(!app.state().fruit.is_open());
assert_eq!(app.state().fruit.selected(), Some(0));
}
#[test]
fn reports_a_combo_box_named_by_its_value() {
let mut app = app();
app.render_once();
let tree = app.accessibility_tree().unwrap();
let combo = tree
.descendants()
.into_iter()
.find(|n| n.role == Role::ComboBox)
.unwrap()
.clone();
assert_eq!(combo.name, "Apple");
}
}