use std::cell::Cell;
use crate::anim::{Easing, Transition};
use crate::core::{EventCtx, Widget};
use crate::event::{Event, Key, MenuItem, PointerKind};
use crate::geometry::{Color, Rect, Size};
use crate::render::{Canvas, Paint};
use crate::signal::Signal;
use crate::spec::Align;
use crate::style::Style;
use crate::text::TextEngine;
const PAD_X: i32 = 12;
const CHEVRON_W: i32 = 18;
pub struct Dropdown {
options: Signal<Vec<String>>,
selected: Signal<usize>,
hover: bool,
border_anim: Cell<Transition<Color>>,
primed: Cell<bool>,
}
impl Dropdown {
pub fn new(options: Vec<String>, selected: Signal<usize>) -> Self {
Self::with_signal(crate::signal::signal(options), selected)
}
pub fn new_reactive(options: Signal<Vec<String>>, selected: Signal<usize>) -> Self {
Self::with_signal(options, selected)
}
fn with_signal(options: Signal<Vec<String>>, selected: Signal<usize>) -> Self {
Self {
options,
selected,
hover: false,
border_anim: Cell::new(Transition::new(Color::rgba(0, 0, 0, 0))),
primed: Cell::new(false),
}
}
fn current(&self) -> String {
self.options.with(|opts| {
let i = self.selected.get().min(opts.len().saturating_sub(1));
opts.get(i).cloned().unwrap_or_default()
})
}
fn open(&self, ctx: &mut EventCtx) {
let opts = self.options.get();
if opts.is_empty() {
return;
}
let b = ctx.bounds();
let cur = self.selected.get();
let items: Vec<MenuItem> = opts
.into_iter()
.enumerate()
.map(|(i, o)| {
let sel = self.selected;
MenuItem::run(o, move || sel.set(i), i == cur)
})
.collect();
ctx.show_dropdown_menu(b, items);
}
}
impl Widget for Dropdown {
fn measure(&self, _avail: Size, style: &Style, text: &mut dyn TextEngine) -> Size {
let mut w = 0;
self.options.with(|opts| {
for o in opts {
w = w.max(
text.measure(o, style.font_family.as_deref(), style.font_size, None)
.w,
);
}
});
Size::new(w + 2 * PAD_X + CHEVRON_W, (style.font_size as i32) + 16)
}
fn paint(
&self,
bounds: Rect,
_content: Rect,
focused: bool,
enabled: bool,
canvas: &mut dyn Canvas,
style: &Style,
) {
let th = crate::theme::current();
let (pal, dd) = (&th.palette, &th.dropdown);
let (x, y, w, h) = (
bounds.x as f32,
bounds.y as f32,
bounds.w as f32,
bounds.h as f32,
);
let corner = dd.corner(&th.metrics);
let bg = if enabled { dd.bg(pal) } else { pal.surface_alt };
let text_color = if enabled {
dd.text(pal)
} else {
pal.text_disabled
};
let chevron = if enabled {
dd.chevron(pal)
} else {
pal.text_disabled
};
canvas.fill_round_rect(x, y, w, h, corner, &Paint::fill(bg));
let target_border = if focused || self.hover {
dd.border_focus(pal)
} else {
dd.border(pal)
};
let mut ba = self.border_anim.get();
if !self.primed.get() {
ba = Transition::new(target_border);
self.primed.set(true);
} else if ba.target() != target_border {
ba.retarget(target_border, th.anim.fast(), Easing::EaseOut);
}
let border = ba.animate();
self.border_anim.set(ba);
let bw = if focused {
th.metrics.border_width_focus.to_logical(canvas.dpi_scale())
} else {
th.metrics.border_width.to_logical(canvas.dpi_scale())
};
canvas.stroke_round_rect(x, y, w, h, corner, bw, &Paint::fill(border));
let tr = Rect::new(
bounds.x + PAD_X,
bounds.y,
bounds.w - 2 * PAD_X - CHEVRON_W,
bounds.h,
);
let cur = self.current();
canvas.draw_text(
&cur,
tr,
text_color,
Align::Start,
style.font_family.as_deref(),
style.font_size,
);
let cx = bounds.x as f32 + bounds.w as f32 - PAD_X as f32 - CHEVRON_W as f32 / 2.0;
let cy = bounds.y as f32 + bounds.h as f32 / 2.0;
let p = Paint::fill(chevron);
canvas.draw_line(cx - 4.0, cy - 2.0, cx, cy + 3.0, 1.6, &p);
canvas.draw_line(cx, cy + 3.0, cx + 4.0, cy - 2.0, 1.6, &p);
}
fn on_event(&mut self, ctx: &mut EventCtx, ev: &Event) -> bool {
match ev {
Event::Pointer(p) => match p.kind {
PointerKind::Enter => {
self.hover = true;
ctx.mark_dirty();
true
}
PointerKind::Leave => {
self.hover = false;
ctx.mark_dirty();
true
}
PointerKind::Down => {
ctx.request_focus();
true
}
PointerKind::Up => {
if ctx.bounds().contains(p.pos) {
self.hover = false;
self.open(ctx);
}
true
}
_ => false,
},
Event::Key(k) if k.pressed => match k.key {
Key::Enter | Key::Space | Key::Down => {
self.open(ctx);
true
}
_ => false,
},
_ => false,
}
}
fn focusable(&self) -> bool {
true
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::signal::signal;
#[test]
fn reactive_dropdown_reflects_option_signal() {
let opts = signal(vec!["甲".to_string(), "乙".to_string()]);
let sel = signal(1usize);
let dd = Dropdown::new_reactive(opts, sel);
assert_eq!(dd.current(), "乙");
opts.set(vec!["X".to_string(), "Y".to_string(), "Z".to_string()]);
assert_eq!(dd.current(), "Y");
sel.set(2);
assert_eq!(dd.current(), "Z");
}
#[test]
fn dropdown_current_clamps_when_index_overflows() {
let opts = signal(vec!["a".to_string(), "b".to_string()]);
let sel = signal(5usize); let dd = Dropdown::new_reactive(opts, sel);
assert_eq!(dd.current(), "b"); opts.set(vec![]); assert_eq!(dd.current(), ""); }
#[test]
fn measure_empty_list_is_chrome_only_width() {
use crate::text::NullTextEngine;
let dd = Dropdown::new_reactive(signal(vec![]), signal(0usize));
let style = Style::default();
let mut te = NullTextEngine;
let w = dd.measure(Size::ZERO, &style, &mut te).w;
assert_eq!(w, 2 * PAD_X + CHEVRON_W);
}
}