use std::sync::Arc;
use rosace_core::types::{Point, Size};
use rosace_render::Color;
use rosace_state::Atom;
use super::menu::Menu;
use super::overlay::{push_overlay, FocusBehavior, InputBehavior, LayerPosition, OverlayEntry, ScrimConfig};
use super::{Children, LayoutCtx, PaintCtx, Widget};
pub struct Autocomplete {
value: String,
placeholder: String,
options: Vec<String>,
open: Atom<bool>,
width: Option<f32>,
height: f32,
max_visible: usize,
on_change: Option<Arc<dyn Fn(String) + Send + Sync>>,
on_select: Option<Arc<dyn Fn(String) + Send + Sync>>,
}
impl Autocomplete {
pub fn new(options: Vec<impl Into<String>>, open: Atom<bool>) -> Self {
Self {
value: String::new(),
placeholder: "Search\u{2026}".to_string(),
options: options.into_iter().map(Into::into).collect(),
open,
width: None,
height: 36.0,
max_visible: 6,
on_change: None,
on_select: None,
}
}
pub fn value(mut self, v: impl Into<String>) -> Self { self.value = v.into(); self }
pub fn placeholder(mut self, p: impl Into<String>) -> Self { self.placeholder = p.into(); self }
pub fn width(mut self, w: f32) -> Self { self.width = Some(w); self }
pub fn height(mut self, h: f32) -> Self { self.height = h; self }
pub fn max_visible(mut self, n: usize) -> Self { self.max_visible = n.max(1); self }
pub fn on_change(mut self, f: impl Fn(String) + Send + Sync + 'static) -> Self {
self.on_change = Some(Arc::new(f)); self
}
pub fn on_select(mut self, f: impl Fn(String) + Send + Sync + 'static) -> Self {
self.on_select = Some(Arc::new(f)); self
}
fn matches(&self) -> Vec<&String> {
let q = self.value.trim().to_lowercase();
if q.is_empty() {
return Vec::new();
}
self.options
.iter()
.filter(|o| o.to_lowercase().contains(&q))
.take(self.max_visible)
.collect()
}
fn input(&self) -> super::TextInput {
let mut input = super::TextInput::new()
.value(self.value.clone())
.placeholder(self.placeholder.clone())
.height(self.height)
.leading(super::Icon::new(super::IconKind::Search).size(18.0));
if let Some(w) = self.width {
input = input.width(w);
}
let open = self.open.clone();
let user_on_change = self.on_change.clone();
input = input.on_change(move |v| {
open.set(!v.trim().is_empty());
if let Some(f) = &user_on_change {
f(v);
}
});
input
}
}
impl Widget for Autocomplete {
fn children(&self) -> Children<'_> { Children::None }
fn layout(&self, ctx: &LayoutCtx) -> Size {
self.input().layout(ctx)
}
fn paint(&self, ctx: &mut PaintCtx) {
let r = ctx.rect;
self.input().paint(ctx);
let filtered = self.matches();
if self.open.get() && !filtered.is_empty() {
let pos = Point { x: r.origin.x, y: r.origin.y + r.size.height + 4.0 };
let mut menu = Menu::new().min_width(self.width.unwrap_or(r.size.width));
for opt in filtered {
let chosen = opt.clone();
let open = self.open.clone();
let cb = self.on_select.clone();
menu = menu.item(opt.clone(), move || {
open.set(false);
if let Some(cb) = &cb {
cb(chosen.clone());
}
});
}
let open2 = self.open.clone();
push_overlay(
OverlayEntry::new(LayerPosition::Absolute(pos), menu)
.input(InputBehavior::PassThrough)
.focus(FocusBehavior::PassThrough)
.scrim(ScrimConfig {
color: Color::TRANSPARENT,
on_tap: Some(Arc::new(move || open2.set(false))),
exclude_rect: Some(r),
}),
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use rosace_layout::Constraints;
fn open_atom() -> Atom<bool> {
Atom::new(rosace_state::next_atom_id(), false)
}
#[test]
fn matches_filters_case_insensitively() {
let ac = Autocomplete::new(vec!["Apple", "Banana", "apricot"], open_atom())
.value("ap");
let m: Vec<&str> = ac.matches().iter().map(|s| s.as_str()).collect();
assert_eq!(m, vec!["Apple", "apricot"]);
}
#[test]
fn empty_query_has_no_matches() {
let ac = Autocomplete::new(vec!["Apple", "Banana"], open_atom());
assert!(ac.matches().is_empty());
}
#[test]
fn respects_max_visible() {
let ac = Autocomplete::new(vec!["a1", "a2", "a3", "a4"], open_atom())
.value("a")
.max_visible(2);
assert_eq!(ac.matches().len(), 2);
}
#[test]
fn layout_matches_the_underlying_field() {
let font = rosace_render::FontCache::embedded();
let theme = rosace_theme::built_in::dark_theme();
let ctx = LayoutCtx::new(Constraints::loose(500.0, 60.0), &font, &theme);
let ac = Autocomplete::new(vec!["A", "B"], open_atom()).width(240.0);
assert_eq!(ac.layout(&ctx).width, 240.0);
}
}