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