1use crate::event::{Event, MouseButton, MouseKind};
4use crate::geometry::{Rect, Size, clamp_u16};
5use crate::keymap::{Key, Modifiers};
6use crate::style::CellStyle;
7use crate::text;
8use crate::theme::State;
9use crate::widget::{EventCx, MeasureCx, PaintCx, Widget};
10
11use super::IndexMessage;
12use super::cells;
13use super::placement::{self, Placement};
14use super::popup_menu::{OptionList, OptionStyles, type_ahead};
15
16pub struct Select<Msg> {
26 options: Vec<String>,
27 selected: Option<usize>,
28 placeholder: String,
29 disabled: bool,
30 max_visible: usize,
31 on_select: Option<IndexMessage<Msg>>,
32}
33
34#[derive(Debug, Default)]
35struct SelectMemory {
36 open: bool,
37 opened_at: std::time::Duration,
38 popup: Rect,
39 list: OptionList,
40}
41
42const OPTION_STYLES: OptionStyles = OptionStyles { menu: "select-menu", item: "select-option", check: "select-check" };
44
45impl<Msg: 'static> Select<Msg> {
46 #[must_use]
48 pub fn new(options: impl IntoIterator<Item = impl Into<String>>) -> Self {
49 Self {
50 options: options.into_iter().map(Into::into).collect(),
51 selected: None,
52 placeholder: String::new(),
53 disabled: false,
54 max_visible: 8,
55 on_select: None,
56 }
57 }
58
59 #[must_use]
61 pub fn selected(mut self, index: Option<usize>) -> Self {
62 self.selected = index;
63 self
64 }
65
66 #[must_use]
68 pub fn placeholder(mut self, text: impl Into<String>) -> Self {
69 self.placeholder = text.into();
70 self
71 }
72
73 #[must_use]
75 pub fn disabled(mut self, disabled: bool) -> Self {
76 self.disabled = disabled;
77 self
78 }
79
80 #[must_use]
82 pub fn max_visible(mut self, rows: usize) -> Self {
83 self.max_visible = rows.max(1);
84 self
85 }
86
87 #[must_use]
89 pub fn on_select(mut self, message: impl Fn(usize) -> Msg + 'static) -> Self {
90 self.on_select = Some(Box::new(message));
91 self
92 }
93
94 fn visible_rows(&self) -> usize {
95 self.options.len().min(self.max_visible)
96 }
97
98 fn open(&self, cx: &mut EventCx<'_, Msg>) {
99 let highlight = self.selected.unwrap_or(0).min(self.options.len().saturating_sub(1));
100 let list = OptionList::open(highlight, self.visible_rows(), cx.interaction.pointer);
101 let now = cx.now();
102 let memory = cx.memory::<SelectMemory>();
103 memory.open = true;
104 memory.opened_at = now;
105 memory.list = list;
106 cx.capture_keys(true);
107 }
108
109 fn close(cx: &mut EventCx<'_, Msg>) {
110 cx.memory::<SelectMemory>().open = false;
111 cx.capture_keys(false);
112 }
113
114 fn choose(&self, cx: &mut EventCx<'_, Msg>, index: usize) {
115 Self::close(cx);
116 cx.flash();
117 if Some(index) != self.selected
118 && let Some(message) = &self.on_select
119 {
120 cx.emit(message(index));
121 }
122 }
123
124 fn move_highlight(&self, cx: &mut EventCx<'_, Msg>, target: usize) {
125 let (len, visible) = (self.options.len(), self.visible_rows());
126 cx.memory::<SelectMemory>().list.move_to(target, len, visible);
127 }
128
129 fn popup_rect(&self, cx: &PaintCx<'_>, anchor: Rect) -> (Rect, Placement) {
130 let screen = cx.clip();
131 let longest = self.options.iter().map(|option| text::width(option)).max().unwrap_or(0);
132 let width = anchor.width.max(longest.saturating_add(6));
133 let height = clamp_u16(i32::try_from(self.visible_rows()).unwrap_or(i32::MAX));
134 placement::place(anchor, Size::new(width, height), screen, Placement::Below)
135 }
136}
137
138pub(crate) fn field_text_width(cx: &mut PaintCx<'_>, area: Rect, states: &[State]) -> u16 {
145 let padding = cx.style("select", None, states).padding();
146 let chevron = text::width(&cx.env().icons().glyph("chevron-down"));
147 area.inset(padding).width.saturating_sub(chevron + 2)
148}
149
150pub(crate) fn paint_field(cx: &mut PaintCx<'_>, area: Rect, states: &[State], label: Option<&str>, placeholder: &str) {
151 let style = cx.style("select", None, states);
152 let surface = style.text();
153 cx.clear(area, surface.bg.unwrap_or_else(|| cx.color("raised")));
154 let padding = style.padding();
155 let inner = area.inset(padding);
156 let pillar = style.color("pillar").filter(|_| padding.left >= 1);
157 if let Some(color) = pillar {
158 cx.pillar(area.x, inner.y, color);
159 }
160 let chevron = cx.env().icons().glyph("chevron-down").into_owned();
161 let chevron_style = cx.style("select-chevron", None, states).text();
162 let chevron_width = text::width(&chevron);
163 cx.text(inner.right() - i32::from(chevron_width), inner.y, &chevron, chevron_style, chevron_width);
164 let budget = inner.width.saturating_sub(chevron_width + 2);
165 let (shown, text_style) = match label {
166 Some(label) => (label, CellStyle { bg: None, ..surface }),
167 None => (placeholder, cx.style("select-placeholder", None, states).text()),
168 };
169 let shown = text::truncate(shown, budget).into_owned();
170 cx.text(inner.x, inner.y, &shown, text_style, budget);
171}
172
173impl<Msg: 'static> Widget<Msg> for Select<Msg> {
174 fn measure(&self, cx: &mut MeasureCx<'_>, available: Size) -> Size {
175 let style = cx.env().theme().style("select", None, &[]);
176 let (vertical, horizontal) = style.pair("padding").unwrap_or((0, 1));
177 let longest =
178 self.options.iter().map(|o| text::width(o)).chain([text::width(&self.placeholder)]).max().unwrap_or(0);
179 Size::new(cells::sum([longest, 3, horizontal.saturating_mul(2)]), vertical.saturating_mul(2).saturating_add(1))
180 .min(available)
181 }
182
183 fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
184 let open = cx.memory::<SelectMemory>().open;
185 let mut states = if self.disabled { vec![State::Disabled] } else { cx.pressable_states() };
186 if open {
187 states.push(State::Active);
188 }
189 let label = self.selected.and_then(|i| self.options.get(i)).map(String::as_str);
190 paint_field(cx, area, &states, label, &self.placeholder);
191 if !self.disabled {
192 cx.register_hit(area);
193 }
194 if open && !self.disabled {
195 cx.request_overlay(area);
196 }
197 }
198
199 fn paint_overlay(&self, cx: &mut PaintCx<'_>, anchor: Rect) {
200 let (full, side) = self.popup_rect(cx, anchor);
201 let opened_at = cx.memory::<SelectMemory>().opened_at;
203 let enter = cx.env().theme().motion().enter;
204 let progress = cx.progress_since(opened_at, enter, crate::motion::Easing::EaseOut);
205 let popup = placement::unfold(full, side, progress);
206 let mut list = cx.memory::<SelectMemory>().list;
207 list.clamp(self.options.len(), usize::from(full.height));
209 list.paint(cx, popup, full.height, &self.options, self.selected, OPTION_STYLES);
210 let memory = cx.memory::<SelectMemory>();
211 memory.popup = popup;
212 memory.list = list;
213 }
214
215 fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
216 if self.disabled || self.options.is_empty() {
217 return false;
218 }
219 let open = cx.memory::<SelectMemory>().open;
220 match event {
221 Event::PointerOutside => {
222 Self::close(cx);
223 true
224 }
225 Event::Key(key) if !open => {
226 let opens = key.is_plain(Key::Enter) || key.is_plain(Key::Space) || key.is_plain(Key::Down);
227 if opens {
228 self.open(cx);
229 }
230 opens
231 }
232 Event::Key(key) => {
233 let page = self.visible_rows();
234 let last = self.options.len() - 1;
235 let highlight = cx.memory::<SelectMemory>().list.highlight.min(last);
237 if key.is_plain(Key::Esc) {
238 Self::close(cx);
239 } else if key.is_plain(Key::Tab) {
240 Self::close(cx);
241 return false;
242 } else if key.is_plain(Key::Up) {
243 self.move_highlight(cx, highlight.saturating_sub(1));
244 } else if key.is_plain(Key::Down) {
245 self.move_highlight(cx, (highlight + 1).min(last));
246 } else if key.is_plain(Key::Home) {
247 self.move_highlight(cx, 0);
248 } else if key.is_plain(Key::End) {
249 self.move_highlight(cx, last);
250 } else if key.is_plain(Key::PageUp) {
251 self.move_highlight(cx, highlight.saturating_sub(page));
252 } else if key.is_plain(Key::PageDown) {
253 self.move_highlight(cx, (highlight + page).min(last));
254 } else if key.is_plain(Key::Enter) || key.is_plain(Key::Space) {
255 self.choose(cx, highlight);
256 } else if let (Some(typed), false) = (key.text, key.chord.mods.ctrl || key.chord.mods.alt) {
257 if let Some(next) = type_ahead(&self.options, highlight, typed) {
258 self.move_highlight(cx, next);
259 }
260 } else if key.chord.mods != Modifiers::default() {
261 return false;
262 }
263 true
264 }
265 Event::Mouse(mouse) => {
266 let (len, visible) = (self.options.len(), self.visible_rows());
267 let (popup, mut list) = {
268 let memory = cx.memory::<SelectMemory>();
269 (memory.popup, memory.list)
270 };
271 if open {
272 let dragged = list.bar_event(cx, mouse, len, visible);
273 cx.memory::<SelectMemory>().list = list;
274 if dragged {
275 return true;
276 }
277 }
278 let in_popup = open && popup.contains(mouse.x, mouse.y);
279 match mouse.kind {
280 MouseKind::Down(MouseButton::Left) if in_popup => {
281 let index = list.offset + usize::try_from(mouse.y - popup.y).unwrap_or(0);
282 if index < self.options.len() {
283 self.choose(cx, index);
284 }
285 true
286 }
287 MouseKind::Down(MouseButton::Left) => {
288 if open {
289 Self::close(cx);
290 } else {
291 self.open(cx);
292 }
293 true
294 }
295 MouseKind::ScrollUp | MouseKind::ScrollDown if in_popup => {
296 cx.memory::<SelectMemory>().list.scroll(mouse.kind == MouseKind::ScrollUp, len, visible);
297 true
298 }
299 _ => false,
300 }
301 }
302 Event::Paste(_) => false,
303 }
304 }
305
306 fn focusable(&self) -> bool {
307 !self.disabled && !self.options.is_empty()
308 }
309}
310
311#[cfg(test)]
312mod tests {
313 use super::*;
314 use crate::runtime::{App, Command, Harness};
315 use crate::widget::{Length, View};
316 use crate::widgets::Text;
317
318 struct Demo {
319 theme: Option<usize>,
320 }
321
322 impl App for Demo {
323 type Msg = usize;
324 fn update(&mut self, index: usize) -> Command<usize> {
325 self.theme = Some(index);
326 Command::none()
327 }
328 fn view(&self, ui: &mut View<'_, usize>) {
329 ui.column(|ui| {
330 ui.add(
331 Select::new(["Monochrome", "Iris", "Nordic", "Amber"])
332 .selected(self.theme)
333 .placeholder("Theme")
334 .max_visible(3)
335 .on_select(|i| i),
336 )
337 .width(Length::Cells(20))
338 .id("theme");
339 ui.add(Text::new("below"));
340 });
341 }
342 }
343
344 #[test]
345 fn opens_as_layer_and_chooses_by_keyboard() {
346 let mut h = Harness::new(Demo { theme: None }, 30, 6);
347 assert!(h.screen().starts_with(" Theme"), "{}", h.screen());
348 h.press("tab").press("enter");
349 let unfolding = h.screen();
350 assert!(!unfolding.contains("Nordic"), "the list unfolds over motion.enter: {unfolding}");
351 h.advance(std::time::Duration::from_millis(200));
352 let screen = h.screen();
353 assert!(screen.contains("Monochrome") && screen.contains("Nordic"), "{screen}");
354 assert!(!screen.contains("below"), "the layer covers content: {screen}");
355 h.press("down").press("down").press("enter");
356 assert_eq!(h.app().theme, Some(2));
357 assert!(h.screen().contains("below"));
358 }
359
360 #[test]
361 fn typing_jumps_and_list_scrolls() {
362 let mut h = Harness::new(Demo { theme: None }, 30, 6);
363 h.press("tab").press("space").press("a").press("enter");
364 assert_eq!(h.app().theme, Some(3));
365 }
366
367 #[test]
368 fn clicks_choose_and_outside_click_closes() {
369 let mut h = Harness::new(Demo { theme: Some(0) }, 30, 6);
370 h.click_text("Monochrome").advance(std::time::Duration::from_millis(200));
371 h.click_text("Iris");
372 assert_eq!(h.app().theme, Some(1));
373 h.click_text("Iris").advance(std::time::Duration::from_millis(200));
374 assert!(h.screen().contains("Nordic"));
375 h.click(28, 5);
376 assert!(!h.screen().contains("Nordic"));
377 assert_eq!(h.app().theme, Some(1));
378 }
379
380 struct Sized(usize);
382
383 impl App for Sized {
384 type Msg = usize;
385 fn update(&mut self, _: usize) -> Command<usize> {
386 Command::none()
387 }
388 fn view(&self, ui: &mut View<'_, usize>) {
389 ui.add(
390 Select::new(["Monochrome", "Iris", "Nordic", "Amber"])
391 .placeholder("Theme")
392 .max_visible(self.0)
393 .on_select(|i| i),
394 )
395 .width(Length::Cells(20));
396 }
397 }
398
399 #[test]
400 fn hovering_the_field_raises_the_pillar_and_slides_the_label_but_not_the_chevron() {
401 let mut h = Harness::new(Sized(8), 30, 12);
402 let resting = h.screen().lines().next().unwrap_or_default().to_owned();
403 h.hover(4, 0);
404 let hovered = h.screen().lines().next().unwrap_or_default().to_owned();
405 assert_eq!(resting, " Theme ▾");
406 assert_eq!(hovered, "▌ Theme ▾", "label slides, chevron stays");
407 }
408
409 #[test]
410 fn the_pointer_moves_the_one_highlight() {
411 let mut h = Harness::new(Sized(8), 30, 12);
412 h.click_text("Theme");
413 h.advance(std::time::Duration::from_millis(300));
414 let (x, y) = h.find("Nordic").expect("open list");
415 h.hover(x + 3, y);
416 let rows: String = h.screen().lines().skip(1).collect::<Vec<_>>().join("\n");
417 assert_eq!(rows.matches('▌').count(), 1, "one raised row under the open field:\n{}", h.screen());
418 assert!(h.screen().contains("▌ Nordic"), "{}", h.screen());
419 }
420
421 #[test]
422 fn keys_move_on_from_a_resting_pointer_and_a_pointer_resting_at_opening_waits() {
423 let mut h = Harness::new(Sized(8), 30, 12);
424 h.click_text("Theme").advance(std::time::Duration::from_millis(300));
425 let (x, y) = h.find("Iris").expect("open list");
426 h.hover(x, y).press("down");
427 let lit =
428 |h: &Harness<Sized>| h.screen().lines().skip(1).filter(|l| l.contains('▌')).collect::<Vec<_>>().join("|");
429 assert!(lit(&h).contains("Nordic"), "the key moves on from the hovered row: {}", h.screen());
430 h.press("esc").press("enter").advance(std::time::Duration::from_millis(300));
431 assert!(lit(&h).contains("Monochrome"), "the pointer resting on Iris does not take it: {}", h.screen());
432 h.hover(x + 1, y);
433 assert!(lit(&h).contains("Iris"), "{}", h.screen());
434 }
435
436 #[test]
437 fn scrollbar_is_decided_by_the_unfolded_height() {
438 let scrollbar = |h: &Harness<Sized>| super::super::scrollbar::column(h, 19).contains('#');
439 let mut fits = Harness::new(Sized(8), 30, 12);
440 fits.click_text("Theme");
441 let mut scrolls = Harness::new(Sized(2), 30, 12);
442 scrolls.click_text("Theme");
443 for _ in 0..8 {
444 assert!(!scrollbar(&fits), "a list that fits never shows one:\n{}", fits.screen());
445 assert!(scrollbar(&scrolls), "a list that scrolls shows one from the start:\n{}", scrolls.screen());
446 fits.advance(std::time::Duration::from_millis(20));
447 scrolls.advance(std::time::Duration::from_millis(20));
448 }
449 }
450
451 #[test]
452 fn reduced_motion_opens_at_once() {
453 let mut h = Harness::new(Demo { theme: None }, 30, 6);
454 h.set_reduced_motion(true).press("tab").press("enter");
455 assert!(h.screen().contains("Nordic"));
456 }
457
458 #[test]
459 fn escape_closes_and_tab_moves_on() {
460 let mut h = Harness::new(Demo { theme: None }, 30, 6);
461 h.press("tab").press("enter").press("esc");
462 assert!(!h.screen().contains("Iris"));
463 h.press("enter").press("tab");
464 assert!(!h.screen().contains("Iris"));
465 }
466
467 #[test]
468 fn the_scrollbar_of_the_open_list_can_be_dragged() {
469 let mut h = Harness::new(Sized(2), 30, 12);
470 h.click_text("Theme").advance(std::time::Duration::from_millis(300));
471 let (_, y) = h.find("Monochrome").expect("open list");
472 h.mouse(MouseKind::Down(MouseButton::Left), 19, y);
473 h.mouse(MouseKind::Drag(MouseButton::Left), 19, y + 5);
474 h.mouse(MouseKind::Up(MouseButton::Left), 19, y + 5);
475 let screen = h.screen();
476 assert!(screen.contains("Amber") && !screen.contains("Monochrome"), "{screen}");
477 assert!(screen.contains("Nordic"), "still open, nothing chosen: {screen}");
478 }
479
480 struct Shrinking {
482 options: Vec<&'static str>,
483 chosen: Vec<usize>,
484 }
485
486 impl App for Shrinking {
487 type Msg = Option<usize>;
488 fn update(&mut self, msg: Option<usize>) -> Command<Option<usize>> {
489 match msg {
490 Some(index) => self.chosen.push(index),
491 None => self.options.truncate(1),
492 }
493 Command::none()
494 }
495 fn view(&self, ui: &mut View<'_, Option<usize>>) {
496 ui.add(Select::new(self.options.clone()).on_select(Some)).width(Length::Cells(20));
497 }
498 }
499
500 #[test]
501 fn options_removed_while_the_list_is_open_are_never_chosen() {
502 let mut h = Harness::new(Shrinking { options: vec!["web", "db", "cache"], chosen: Vec::new() }, 30, 8);
503 h.set_reduced_motion(true).press("tab").press("enter").press("end");
504 h.send(None).press("enter");
505 assert_eq!(h.app().chosen, [0], "the highlight moved back onto the one option left");
506 }
507
508 struct Long;
510
511 impl App for Long {
512 type Msg = usize;
513 fn update(&mut self, _: usize) -> Command<usize> {
514 Command::none()
515 }
516 fn view(&self, ui: &mut View<'_, usize>) {
517 ui.add(Select::new(["eu-central-1 Frankfurt", "us-east-1 North Virginia"]).on_select(|i| i))
518 .width(Length::Cells(20));
519 }
520 }
521
522 #[test]
523 fn a_raised_option_is_cut_at_the_same_place_as_a_resting_one() {
524 let mut h = Harness::new(Long, 22, 6);
525 h.set_reduced_motion(true).press("tab").press("enter");
526 let label = |h: &Harness<Long>, row: usize| {
527 h.screen().lines().nth(row).unwrap_or_default().replace('▌', " ").trim().to_owned()
528 };
529 let resting = label(&h, 2);
530 h.press("down");
531 let raised = label(&h, 2);
532 assert!(resting.ends_with('…'), "{}", h.screen());
533 assert_eq!(raised, resting, "the slide moves the label, it does not cut it shorter");
534 let line = h.screen().lines().nth(2).unwrap_or_default().to_owned();
535 assert!(line.starts_with("▌ us-east"), "the raised label slid one cell: {line}");
536 }
537}