use std::rc::Rc;
use frus_core::{Point, Rect, ResolvedTextStyle, Scene, TextStyle};
use frus_layout::{Dimension, FlexDirection, Style};
use crate::flex::Flex;
use crate::interaction::Status;
use crate::portal::Placement;
use crate::scroll::SingleChildScrollView;
use crate::textinput::TextField;
use crate::theme::Theme;
use crate::widget::Widget;
const DEFAULT_WIDTH: f32 = 260.0;
const ROW_H: f32 = 32.0;
const PAD_X: f32 = 10.0;
const ROW_GAP: f32 = 2.0;
fn label_style(over: Option<TextStyle>, theme: Option<&Theme>) -> ResolvedTextStyle {
over.or(theme.and_then(|t| t.widgets.autocomplete.text_style))
.unwrap_or_else(|| crate::theme::type_scale(theme).body_large)
.resolved()
}
fn row_height(style: &ResolvedTextStyle) -> f32 {
frus_text::line_box(ROW_H, style, 0.0)
}
fn match_range(label: &str, query: &str) -> Option<(usize, usize)> {
if query.trim().is_empty() {
return None;
}
let ll: Vec<char> = label.to_lowercase().chars().collect();
let ql: Vec<char> = query.to_lowercase().chars().collect();
if ql.is_empty() || ql.len() > ll.len() {
return None;
}
(0..=ll.len() - ql.len())
.find(|&i| ll[i..i + ql.len()] == ql[..])
.map(|i| (i, i + ql.len()))
}
struct Suggestion<Msg> {
label: String,
query: String,
width: f32,
active: bool,
text_style: Option<TextStyle>,
message: Msg,
}
impl<Msg> Suggestion<Msg> {
fn sizing(&self, theme: Option<&Theme>) -> Style {
Style {
width: Dimension::Length(self.width),
height: Dimension::Length(row_height(&label_style(self.text_style, theme))),
..Default::default()
}
}
}
impl<Msg: Clone> Widget<Msg> for Suggestion<Msg> {
fn style(&self) -> Style {
self.sizing(None)
}
fn style_themed(&self, theme: &Theme) -> Style {
self.sizing(Some(theme))
}
fn children(&self) -> &[Box<dyn Widget<Msg>>] {
&[]
}
fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
let o = status.opacity;
let panel = theme.scheme.surface_container;
let base = if self.active {
panel.lerp(theme.primary, 0.14)
} else {
panel
};
let bg = theme.state_layer(base, theme.on_surface, &status);
scene.draw_rect(bounds, bg.fade(o), theme.radius, 1.0, theme.border.fade(o));
let style = label_style(self.text_style, Some(theme));
let ty = bounds.y + (bounds.height - style.line_height()) * 0.5;
let chars: Vec<char> = self.label.chars().collect();
let normal = theme.on_surface.fade(o);
let hilite = theme.primary.fade(o);
let mut x = bounds.x + PAD_X;
let segments: [(std::ops::Range<usize>, frus_core::Color); 3] =
match match_range(&self.label, &self.query) {
Some((i, j)) => [(0..i, normal), (i..j, hilite), (j..chars.len(), normal)],
None => [(0..chars.len(), normal), (0..0, normal), (0..0, normal)],
};
for (range, color) in segments {
if range.is_empty() {
continue;
}
let text: String = chars[range].iter().collect();
let width = frus_text::measure_resolved(&text, &style).width;
scene.text(Point::new(x, ty), text, &style, color);
x += width;
}
}
fn on_click(&self) -> Option<Msg> {
Some(self.message.clone())
}
fn focusable(&self) -> bool {
true
}
}
pub struct Autocomplete<Msg = crate::callback::Callback> {
value: String,
width: f32,
active: Option<usize>,
max_visible: Option<usize>,
on_input: Rc<dyn Fn(String) -> Msg>,
on_pick: Rc<dyn Fn(String) -> Msg>,
labels: Vec<String>,
text_style: Option<TextStyle>,
children: Vec<Box<dyn Widget<Msg>>>,
}
impl<Msg: Clone + 'static> Autocomplete<Msg> {
pub fn new(
value: impl Into<String>,
on_input: impl Fn(String) -> Msg + 'static,
on_pick: impl Fn(String) -> Msg + 'static,
) -> Self {
let mut ac = Self {
value: value.into(),
width: DEFAULT_WIDTH,
active: None,
max_visible: None,
on_input: Rc::new(on_input),
on_pick: Rc::new(on_pick),
labels: Vec::new(),
text_style: None,
children: Vec::new(),
};
ac.rebuild();
ac
}
#[must_use]
pub fn text_style(mut self, style: TextStyle) -> Self {
self.text_style = Some(style);
self.rebuild();
self
}
pub fn width(mut self, width: f32) -> Self {
self.width = width;
self.rebuild();
self
}
pub fn active(mut self, index: usize) -> Self {
self.active = Some(index);
self.rebuild();
self
}
pub fn max_visible(mut self, rows: usize) -> Self {
self.max_visible = Some(rows.max(1));
self.rebuild();
self
}
pub fn suggestion(mut self, label: impl Into<String>) -> Self {
self.labels.push(label.into());
self.rebuild();
self
}
fn rebuild(&mut self) {
let on_input = self.on_input.clone();
let input = TextField::new(self.value.clone())
.width(self.width)
.on_input(move |text| on_input(text));
self.children = vec![Box::new(input)];
if !self.labels.is_empty() {
let mut list = Flex::column().gap(ROW_GAP);
for (index, label) in self.labels.iter().enumerate() {
list = list.child(Suggestion {
label: label.clone(),
query: self.value.clone(),
width: self.width,
active: self.active == Some(index),
text_style: self.text_style,
message: (self.on_pick)(label.clone()),
});
}
match self.max_visible {
Some(n) if self.labels.len() > n => {
let row = row_height(&label_style(self.text_style, None));
let viewport = n as f32 * row + (n as f32 - 1.0) * ROW_GAP;
self.children.push(Box::new(
SingleChildScrollView::new()
.width(self.width)
.height(viewport)
.child(list),
));
}
_ => self.children.push(Box::new(list)),
}
}
}
}
impl<Msg: Clone> Widget<Msg> for Autocomplete<Msg> {
fn style(&self) -> Style {
Style {
flex_direction: FlexDirection::Column,
..Default::default()
}
}
fn children(&self) -> &[Box<dyn Widget<Msg>>] {
&self.children
}
fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
fn on_click(&self) -> Option<Msg> {
None
}
fn overlay(&self) -> Option<(&dyn Widget<Msg>, Placement)> {
self.children
.get(1)
.map(|list| (list.as_ref(), Placement::Below))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{build_ui, Runtime, Size};
use frus_core::Primitive;
#[derive(Clone, Debug, PartialEq)]
enum Msg {
Input(String),
Pick(String),
}
#[test]
fn no_suggestions_no_overlay() {
let ac = Autocomplete::new("a", Msg::Input, Msg::Pick);
assert!(Widget::<Msg>::overlay(&ac).is_none());
}
#[test]
fn suggestions_float_and_pick() {
let ac = Autocomplete::new("a", Msg::Input, Msg::Pick)
.suggestion("apple")
.suggestion("apricot");
assert!(Widget::<Msg>::overlay(&ac).is_some());
let list = &Widget::<Msg>::children(&ac)[1];
assert_eq!(list.children().len(), 2);
assert_eq!(
list.children()[0].on_click(),
Some(Msg::Pick("apple".to_string()))
);
}
#[test]
fn match_range_is_case_insensitive_substring() {
assert_eq!(match_range("Apricot", "ap"), Some((0, 2)));
assert_eq!(match_range("pineapple", "APPLE"), Some((4, 9)));
assert_eq!(match_range("apple", ""), None);
assert_eq!(match_range("apple", "xyz"), None);
}
#[test]
fn matched_portion_is_drawn_as_its_own_segment() {
let ac = Autocomplete::new("ap", Msg::Input, Msg::Pick).suggestion("apricot");
let (list, _) = Widget::<Msg>::overlay(&ac).unwrap();
let ui = build_ui(
list,
Size::new(280.0, 80.0),
&Runtime::default(),
&Theme::default(),
);
let texts: Vec<String> = ui
.scene()
.primitives()
.iter()
.filter_map(|p| match p {
Primitive::Text { text, .. } => Some(text.clone()),
_ => None,
})
.collect();
assert!(
texts.contains(&"ap".to_string()),
"the matching portion is isolated: {texts:?}"
);
assert!(
texts.contains(&"ricot".to_string()),
"the remainder is isolated: {texts:?}"
);
}
#[test]
fn active_suggestion_is_highlighted() {
let ac = Autocomplete::new("ap", Msg::Input, Msg::Pick)
.suggestion("apple")
.suggestion("apricot")
.active(1);
let (list, _) = Widget::<Msg>::overlay(&ac).unwrap();
let ui = build_ui(
list,
Size::new(280.0, 120.0),
&Runtime::default(),
&Theme::default(),
);
let theme = Theme::default();
let tint = theme.scheme.surface_container.lerp(theme.primary, 0.14);
let has_tint = ui.scene().primitives().iter().any(|p| {
matches!(
p,
Primitive::Rect { color, .. } if color.fade(1.0) == tint.fade(1.0)
)
});
assert!(has_tint, "the active suggestion is highlighted");
}
#[test]
fn long_list_scrolls_when_capped() {
let ac = Autocomplete::new("a", Msg::Input, Msg::Pick)
.max_visible(2)
.suggestion("a1")
.suggestion("a2")
.suggestion("a3")
.suggestion("a4");
let (overlay, _) = Widget::<Msg>::overlay(&ac).unwrap();
let expected = 2.0 * ROW_H + ROW_GAP;
assert!(
matches!(Widget::<Msg>::style(overlay).height, Dimension::Length(v) if (v - expected).abs() < 0.5),
"viewport bounded to 2 rows",
);
assert_eq!(overlay.children()[0].children().len(), 4);
}
#[test]
fn short_list_is_not_wrapped_in_scroll() {
let ac = Autocomplete::new("a", Msg::Input, Msg::Pick)
.max_visible(5)
.suggestion("a1")
.suggestion("a2");
let (overlay, _) = Widget::<Msg>::overlay(&ac).unwrap();
assert_eq!(overlay.children().len(), 2);
assert_eq!(
overlay.children()[0].on_click(),
Some(Msg::Pick("a1".to_string()))
);
}
#[test]
fn field_and_suggestions_are_keyboard_reachable() {
let ac = Autocomplete::new("ap", Msg::Input, Msg::Pick)
.suggestion("apple")
.suggestion("apricot");
let ui = build_ui(
&ac,
Size::new(280.0, 200.0),
&Runtime::default(),
&Theme::default(),
);
let first = ui.focus_next(None, true);
assert!(first.is_some(), "the field is focusable");
let second = ui.focus_next(first, true);
assert!(
second.is_some() && second != first,
"a suggestion follows the field in the cycle"
);
}
}