use crate::event::{Event, EventCtx, Key, MouseButton, NamedKey};
use crate::geometry::{Color, Point, Rect};
use crate::include_svg;
use crate::painter::Painter;
use crate::svg::SvgImage;
use crate::theme::Theme;
use crate::widget::{PopupKind, PopupRequest, Widget};
use crate::widgets::scrollbar::{SCROLLBAR_THICKNESS, ScrollBar};
type ChangeHandler = Box<dyn FnMut(&mut EventCtx, usize)>;
const ITEM_HEIGHT: i32 = 18;
const POPUP_PAD_Y: i32 = 2;
const TEXT_PAD_X: i32 = 5;
const SHADOW_SIZE: i32 = 2;
const SHADOW_COLOR: Color = Color::DARK_GRAY;
const MAX_POPUP_ITEMS: usize = 12;
const ARROW: SvgImage = include_svg!("assets/dropdown/down.svg");
pub struct Dropdown {
rect: Rect,
items: Vec<String>,
selected: Option<usize>,
highlighted: Option<usize>,
open: bool,
focused: bool,
enabled: bool,
on_change: Option<ChangeHandler>,
scrollbar: ScrollBar,
}
impl Dropdown {
pub fn new(rect: Rect) -> Self {
Self {
rect,
items: Vec::new(),
selected: None,
highlighted: None,
open: false,
focused: false,
enabled: true,
on_change: None,
scrollbar: ScrollBar::vertical(Rect::new(0, 0, 0, 0)),
}
}
pub fn with_items<I, S>(mut self, items: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.set_items(items.into_iter().map(Into::into).collect());
self
}
pub fn set_items(&mut self, items: Vec<String>) {
self.items = items;
self.selected = match self.selected {
Some(i) if i < self.items.len() => Some(i),
_ if self.items.is_empty() => None,
_ => Some(0),
};
self.highlighted = None;
self.open = false;
self.scrollbar.set_value(0);
}
pub fn with_selected(mut self, idx: usize) -> Self {
self.set_selected(Some(idx));
self
}
pub fn with_enabled(mut self, enabled: bool) -> Self {
self.set_enabled(enabled);
self
}
pub fn on_change<F>(mut self, handler: F) -> Self
where
F: FnMut(&mut EventCtx, usize) + 'static,
{
self.on_change = Some(Box::new(handler));
self
}
pub fn set_on_change<F>(&mut self, handler: F)
where
F: FnMut(&mut EventCtx, usize) + 'static,
{
self.on_change = Some(Box::new(handler));
}
pub fn items(&self) -> &[String] {
&self.items
}
pub fn selected_index(&self) -> Option<usize> {
self.selected
}
pub fn selected_text(&self) -> Option<&str> {
self.selected
.and_then(|i| self.items.get(i))
.map(String::as_str)
}
pub fn set_selected(&mut self, idx: Option<usize>) {
self.selected = idx.filter(|&i| i < self.items.len());
}
pub fn is_enabled(&self) -> bool {
self.enabled
}
pub fn set_enabled(&mut self, enabled: bool) {
self.enabled = enabled;
if !enabled {
self.close();
}
}
pub fn is_open(&self) -> bool {
self.open
}
pub fn open(&mut self) {
self.open_list();
}
pub fn rect(&self) -> Rect {
self.rect
}
pub fn set_rect(&mut self, rect: Rect) {
self.rect = rect;
}
fn arrow_rect(&self) -> Rect {
let size = self.rect.h.max(0);
Rect::new(self.rect.right() - size, self.rect.y, size, size)
}
fn text_area(&self) -> Rect {
let inner = self.rect.inset(2);
let w = (inner.w - self.arrow_rect().w).max(0);
Rect::new(inner.x, inner.y, w, inner.h)
}
fn visible_items(&self) -> usize {
self.items.len().min(MAX_POPUP_ITEMS)
}
fn scrollable(&self) -> bool {
self.items.len() > MAX_POPUP_ITEMS
}
fn scroll_top(&self) -> usize {
self.scrollbar.value().max(0) as usize
}
fn popup_rect(&self) -> Rect {
let h = POPUP_PAD_Y * 2 + self.visible_items() as i32 * ITEM_HEIGHT;
Rect::new(self.rect.x, self.rect.bottom(), self.rect.w, h)
}
fn popup_rows_rect(&self) -> Rect {
let p = self.popup_rect();
let gutter = if self.scrollable() {
SCROLLBAR_THICKNESS
} else {
0
};
Rect::new(p.x, p.y, (p.w - gutter).max(0), p.h)
}
fn popup_scrollbar_rect(&self) -> Rect {
let p = self.popup_rect();
Rect::new(
p.right() - 1 - SCROLLBAR_THICKNESS,
p.y + 1,
SCROLLBAR_THICKNESS,
(p.h - 2).max(0),
)
}
fn sync_scrollbar(&mut self) {
let visible = self.visible_items() as i32;
let max = (self.items.len() as i32 - visible).max(0);
self.scrollbar.set_range(visible, max);
}
fn layout_popup(&mut self) {
self.scrollbar.set_rect(self.popup_scrollbar_rect());
self.sync_scrollbar();
}
fn ensure_visible(&mut self, idx: usize) {
self.sync_scrollbar();
let visible = self.visible_items();
let mut top = self.scroll_top();
if idx < top {
top = idx;
} else if idx >= top + visible {
top = idx + 1 - visible;
}
self.scrollbar.set_value(top as i32);
}
fn hit_item(&self, pos: Point) -> Option<usize> {
if !self.open {
return None;
}
let rows = self.popup_rows_rect();
if !rows.contains(pos) {
return None;
}
let local = pos.y - (rows.y + POPUP_PAD_Y);
if local < 0 {
return None;
}
let row_offset = (local / ITEM_HEIGHT) as usize;
if row_offset >= self.visible_items() {
return None;
}
let idx = self.scroll_top() + row_offset;
(idx < self.items.len()).then_some(idx)
}
fn open_list(&mut self) {
if self.items.is_empty() {
return;
}
self.open = true;
let start = self.selected.unwrap_or(0);
self.highlighted = Some(start);
self.ensure_visible(start);
}
fn close(&mut self) {
self.open = false;
self.highlighted = None;
self.scrollbar.end_drag();
}
fn commit(&mut self, idx: usize, ctx: &mut EventCtx) {
if idx >= self.items.len() {
return;
}
let changed = self.selected != Some(idx);
self.selected = Some(idx);
if changed && let Some(handler) = self.on_change.as_mut() {
handler(ctx, idx);
}
}
fn move_highlight(&mut self, delta: i32) {
if self.items.is_empty() {
return;
}
let n = self.items.len() as i32;
let cur = self.highlighted.or(self.selected).unwrap_or(0) as i32;
let next = (cur + delta).clamp(0, n - 1) as usize;
self.highlighted = Some(next);
self.ensure_visible(next);
}
fn handle_key(&mut self, key: &Key, ctx: &mut EventCtx) {
if self.open {
let page = (self.visible_items() as i32 - 1).max(1);
match key {
Key::Named(NamedKey::Up) => self.move_highlight(-1),
Key::Named(NamedKey::Down) => self.move_highlight(1),
Key::Named(NamedKey::PageUp) => self.move_highlight(-page),
Key::Named(NamedKey::PageDown) => self.move_highlight(page),
Key::Named(NamedKey::Home) => {
self.highlighted = Some(0);
self.ensure_visible(0);
}
Key::Named(NamedKey::End) => {
if let Some(last) = self.items.len().checked_sub(1) {
self.highlighted = Some(last);
self.ensure_visible(last);
}
}
Key::Named(NamedKey::Enter) | Key::Named(NamedKey::Space) => {
if let Some(idx) = self.highlighted {
self.commit(idx, ctx);
}
self.close();
}
Key::Named(NamedKey::Escape) => self.close(),
_ => return,
}
} else {
match key {
Key::Named(NamedKey::Up) => {
let target = self.selected.unwrap_or(0).saturating_sub(1);
self.commit(target, ctx);
}
Key::Named(NamedKey::Down) => {
let target = self.selected.map(|i| i + 1).unwrap_or(0);
self.commit(target.min(self.items.len().saturating_sub(1)), ctx);
}
Key::Named(NamedKey::Home) => self.commit(0, ctx),
Key::Named(NamedKey::End) => {
if let Some(last) = self.items.len().checked_sub(1) {
self.commit(last, ctx);
}
}
Key::Named(NamedKey::Space) => self.open_list(),
_ => return,
}
}
ctx.request_paint();
ctx.consume_event();
}
}
impl Widget for Dropdown {
fn bounds(&self) -> Rect {
self.rect
}
fn paint(&mut self, painter: &mut Painter, theme: &Theme) {
let bg = if self.enabled {
Color::WHITE
} else {
theme.face
};
let btn = self.arrow_rect();
let arrow_color = if self.enabled {
theme.text
} else {
theme.disabled_text
};
painter.fill_rect(self.rect, bg);
painter.sunken_bevel(self.rect, theme.highlight, theme.shadow);
painter.stroke_rect(self.rect, theme.border);
painter.button(btn, theme, self.open, false);
ARROW.draw_tinted(painter, btn, arrow_color);
if let Some(text) = self.selected_text() {
let area = self.text_area();
let saved = painter.push_clip(area);
let th = painter.measure_text(text, theme.font_size).h;
let ty = self.rect.y + ((self.rect.h - th) / 2).max(0);
let fg = if self.enabled {
theme.text
} else {
theme.disabled_text
};
painter.text(area.x + TEXT_PAD_X, ty, text, theme.font_size, fg);
painter.restore_clip(saved);
}
if self.focused && self.enabled {
painter.focus_rect(self.text_area().inset(1), theme.text);
}
}
fn paint_overlay(&mut self, painter: &mut Painter, theme: &Theme) {
if !self.open {
return;
}
let Some(req) = self.popup_request() else {
return;
};
if painter.popup_anchor() != Some(req.rect) {
return;
}
self.layout_popup();
let popup = self.popup_rect();
painter.fill_rect(
Rect::new(popup.x + SHADOW_SIZE, popup.bottom(), popup.w, SHADOW_SIZE),
SHADOW_COLOR,
);
painter.fill_rect(
Rect::new(popup.right(), popup.y + SHADOW_SIZE, SHADOW_SIZE, popup.h),
SHADOW_COLOR,
);
painter.fill_rect(popup, theme.background);
painter.stroke_rect(popup, theme.border);
let row_w = (self.popup_rows_rect().w - 2).max(0);
let top = self.scroll_top();
let mut y = popup.y + POPUP_PAD_Y;
for row_offset in 0..self.visible_items() {
let i = top + row_offset;
let Some(item) = self.items.get(i) else { break };
let row = Rect::new(popup.x + 1, y, row_w, ITEM_HEIGHT);
let (bg, fg) = if self.highlighted == Some(i) {
(theme.highlight_bg, theme.highlight_text)
} else {
(theme.background, theme.text)
};
painter.fill_rect(row, bg);
let th = painter.measure_text(item, theme.font_size).h;
let ty = row.y + ((row.h - th) / 2).max(0);
painter.text(row.x + TEXT_PAD_X, ty, item, theme.font_size, fg);
y += ITEM_HEIGHT;
}
if self.scrollable() {
self.scrollbar.paint(painter, theme);
}
}
fn event(&mut self, event: &Event, ctx: &mut EventCtx) {
if !self.enabled {
return;
}
if self.open {
self.layout_popup();
if self.scrollbar.captures_pointer() {
self.scrollbar.event(event, ctx);
return;
}
match event {
Event::Scroll { pos, .. } if self.popup_rect().contains(*pos) => {
self.scrollbar.event(event, ctx);
return;
}
Event::PointerDown {
pos,
button: MouseButton::Left,
..
} if self.scrollable() && self.popup_scrollbar_rect().contains(*pos) => {
self.scrollbar.event(event, ctx);
ctx.request_paint();
return;
}
_ => {}
}
}
match event {
Event::PointerDown {
pos,
button: MouseButton::Left,
..
} => {
if self.open {
if let Some(idx) = self.hit_item(*pos) {
self.commit(idx, ctx);
}
self.close();
ctx.request_paint();
} else if self.rect.contains(*pos) {
self.open_list();
ctx.request_focus();
ctx.request_paint();
}
}
Event::PointerMove { pos } if self.open => {
if let Some(hit) = self.hit_item(*pos)
&& self.highlighted != Some(hit)
{
self.highlighted = Some(hit);
ctx.request_paint();
}
}
Event::KeyDown { key, modifiers } if self.focused && !modifiers.has_command() => {
self.handle_key(key, ctx);
}
_ => {}
}
}
fn captures_pointer(&self) -> bool {
self.open
}
fn focusable(&self) -> bool {
self.enabled
}
fn set_focused(&mut self, focused: bool) {
self.focused = focused;
if !focused {
self.close();
}
}
fn layout(&mut self, bounds: Rect) {
self.rect = bounds;
}
fn popup_request(&self) -> Option<PopupRequest> {
if !self.open || self.items.is_empty() {
return None;
}
let popup = self.popup_rect();
Some(PopupRequest {
rect: Rect::new(
popup.x,
popup.y,
popup.w + SHADOW_SIZE,
popup.h + SHADOW_SIZE,
),
kind: PopupKind::Popup,
title: None,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::event::Modifiers;
use crate::mock::MockBackend;
fn dropdown_with(n: usize) -> Dropdown {
let items: Vec<String> = (0..n).map(|i| format!("item {i}")).collect();
Dropdown::new(Rect::new(0, 0, 120, 20)).with_items(items)
}
fn key(named: NamedKey) -> Event {
Event::KeyDown {
key: Key::Named(named),
modifiers: Modifiers::default(),
}
}
#[test]
fn a_short_list_shows_every_row_with_no_scrollbar() {
let d = dropdown_with(5);
assert!(!d.scrollable());
assert_eq!(d.visible_items(), 5);
assert_eq!(d.popup_rows_rect().w, d.popup_rect().w);
assert_eq!(d.popup_rect().h, POPUP_PAD_Y * 2 + 5 * ITEM_HEIGHT);
}
#[test]
fn a_long_list_caps_its_height_and_grows_a_scrollbar() {
let d = dropdown_with(40);
assert!(d.scrollable());
assert_eq!(d.visible_items(), MAX_POPUP_ITEMS);
assert_eq!(
d.popup_rect().h,
POPUP_PAD_Y * 2 + MAX_POPUP_ITEMS as i32 * ITEM_HEIGHT
);
assert_eq!(
d.popup_rows_rect().w,
d.popup_rect().w - SCROLLBAR_THICKNESS
);
}
#[test]
fn keyboard_navigation_scrolls_the_window() {
let mut d = dropdown_with(40);
d.set_focused(true);
d.open();
assert_eq!(d.scroll_top(), 0, "opens at the top");
let backend = MockBackend::new(200, 200);
for _ in 0..MAX_POPUP_ITEMS + 3 {
backend.dispatch(&mut d, &key(NamedKey::Down));
}
assert_eq!(d.highlighted, Some(MAX_POPUP_ITEMS + 3));
assert!(d.scroll_top() > 0, "the window scrolled to follow");
assert!(d.scroll_top() <= MAX_POPUP_ITEMS + 3);
backend.dispatch(&mut d, &key(NamedKey::End));
assert_eq!(d.highlighted, Some(39));
assert_eq!(d.scroll_top(), 40 - MAX_POPUP_ITEMS);
backend.dispatch(&mut d, &key(NamedKey::Home));
assert_eq!(d.highlighted, Some(0));
assert_eq!(d.scroll_top(), 0);
}
#[test]
fn hit_item_accounts_for_the_scroll_offset() {
let mut d = dropdown_with(40);
d.open();
d.layout_popup();
d.scrollbar.set_value(5);
let popup = d.popup_rect();
let first_row = Point::new(popup.x + 4, popup.y + POPUP_PAD_Y + 1);
assert_eq!(d.hit_item(first_row), Some(5));
let gutter = d.popup_scrollbar_rect();
assert_eq!(
d.hit_item(Point::new(gutter.x + 2, gutter.y + 4)),
None,
"the scrollbar gutter is not an item"
);
}
#[test]
fn opening_scrolls_the_selection_into_view() {
let mut d = dropdown_with(40);
d.set_selected(Some(35));
d.open();
d.layout_popup();
let top = d.scroll_top();
assert!(
top <= 35 && 35 < top + d.visible_items(),
"selection visible"
);
}
}