Skip to main content

turbo_vision/views/
combo_box.rs

1// (C) 2025 - Enzo Lombardi
2
3//! ComboBox view - a text field showing one choice, with a drop-down list.
4//!
5//! Not part of the Borland Turbo Vision widget set. It follows the same
6//! two-step pattern the history button already uses: the control itself cannot
7//! reach the terminal from `handle_event`, so opening the list is a command
8//! ([`CM_SHOW_DROPDOWN`]) that the modal `Dialog` loop, or `Application`,
9//! turns into a [`DropdownWindow`] running modally.
10//!
11//! This is the read-only flavour: the user picks from the list and cannot type
12//! a value that is not in it. An editable flavour, where the field is a real
13//! `InputLine`, is still open on the roadmap.
14//!
15//! Every combo box registers its shared [`ComboState`] under a caller-chosen
16//! id so the popup can find its items. Ids are per-thread and freed when the
17//! control is dropped.
18//!
19//! # Keys
20//!
21//! | Key | Action |
22//! |-----|--------|
23//! | F4, Alt+Down | Open the drop-down list |
24//! | Up, Down | Step to the previous or next item without opening |
25//! | Home, End | Jump to the first or last item |
26//!
27//! Clicking anywhere on the field also opens the list.
28//!
29//! # Example
30//!
31//! ```rust
32//! use turbo_vision::views::combo_box::ComboBox;
33//! use turbo_vision::core::geometry::Rect;
34//!
35//! let mut combo = ComboBox::new(Rect::new(10, 3, 30, 4), 1);
36//! combo.set_items(vec!["Red".into(), "Green".into(), "Blue".into()]);
37//! combo.set_selected(Some(2));
38//! assert_eq!(combo.selected_text().as_deref(), Some("Blue"));
39//! ```
40
41use super::view::{View, ViewCore, write_line_to_terminal};
42use crate::core::command::{CM_SHOW_DROPDOWN, CommandId};
43use crate::core::draw::DrawBuffer;
44use crate::core::event::{
45    Event, EventType, KB_DOWN, KB_END, KB_ENTER, KB_ESC, KB_F4, KB_HOME, KB_UP,
46};
47use crate::core::geometry::{Point, Rect};
48use crate::core::palette::{INPUT_ARROWS, INPUT_NORMAL, INPUT_SELECTED};
49use crate::core::state::{State, StateFlags};
50use crate::terminal::Terminal;
51use std::cell::RefCell;
52use std::collections::HashMap;
53use std::rc::Rc;
54
55/// Glyph drawn at the right edge of the field.
56const DROP_ARROW: char = '\u{25BC}'; // ▼
57
58/// Largest number of rows a drop-down list shows before it scrolls.
59const MAX_DROPDOWN_ROWS: i16 = 8;
60
61/// The items and current choice of one combo box.
62///
63/// Shared between the control and the popup that the dialog opens on its
64/// behalf, so the popup can list the items and write the choice straight back.
65#[derive(Debug, Default)]
66pub struct ComboState {
67    /// The choices, in display order.
68    pub items: Vec<String>,
69    /// Index into `items`, or `None` when nothing is chosen.
70    pub selected: Option<usize>,
71    /// Screen rect of the field, so the popup can be placed under it.
72    pub field: Rect,
73}
74
75impl ComboState {
76    /// Text of the current choice, if any.
77    pub fn selected_text(&self) -> Option<&str> {
78        self.selected.and_then(|i| self.items.get(i)).map(|s| &**s)
79    }
80
81    /// Clamp `selected` to the current item list, dropping it when the list is
82    /// empty. Called after any change to `items`.
83    fn clamp(&mut self) {
84        if self.items.is_empty() {
85            self.selected = None;
86        } else if let Some(i) = self.selected {
87            self.selected = Some(i.min(self.items.len() - 1));
88        }
89    }
90}
91
92thread_local! {
93    /// Live combo states by id. Thread-local rather than a global mutex because
94    /// the shared state is `Rc`, and the UI runs on one thread.
95    static REGISTRY: RefCell<HashMap<u16, Rc<RefCell<ComboState>>>> =
96        RefCell::new(HashMap::new());
97}
98
99/// Look up a registered combo state by id.
100///
101/// Used by `Dialog` and `Application` when they see [`CM_SHOW_DROPDOWN`].
102/// Returns `None` when the id was never registered or its control was dropped.
103pub fn lookup(id: u16) -> Option<Rc<RefCell<ComboState>>> {
104    REGISTRY.with(|r| r.borrow().get(&id).cloned())
105}
106
107/// A field showing one choice, with a drop-down list of the alternatives.
108pub struct ComboBox {
109    core: ViewCore,
110    id: u16,
111    state: Rc<RefCell<ComboState>>,
112    /// Command emitted when the choice changes. Zero means none.
113    on_change: CommandId,
114    view_state: StateFlags,
115}
116
117impl ComboBox {
118    /// Create an empty combo box registered under `id`.
119    ///
120    /// The id identifies this control to the drop-down popup and must be unique
121    /// among the combo boxes alive at the same time. Reusing a live id replaces
122    /// the earlier registration, which leaves the earlier control unable to open
123    /// its list.
124    pub fn new(bounds: Rect, id: u16) -> Self {
125        let state = Rc::new(RefCell::new(ComboState {
126            items: Vec::new(),
127            selected: None,
128            field: bounds,
129        }));
130        REGISTRY.with(|r| r.borrow_mut().insert(id, Rc::clone(&state)));
131        Self {
132            core: ViewCore {
133                bounds,
134                palette_chain: None,
135                ..ViewCore::default()
136            },
137            id,
138            state,
139            on_change: 0,
140            view_state: State::empty(),
141        }
142    }
143
144    /// Create a combo box already holding `items`, with the first selected.
145    pub fn with_items(bounds: Rect, id: u16, items: Vec<String>) -> Self {
146        let mut combo = Self::new(bounds, id);
147        combo.set_items(items);
148        combo
149    }
150
151    /// Registration id, as passed to [`ComboBox::new`].
152    pub fn id(&self) -> u16 {
153        self.id
154    }
155
156    /// Shared state, for callers that want to read the choice later without
157    /// holding on to the control.
158    pub fn state(&self) -> Rc<RefCell<ComboState>> {
159        Rc::clone(&self.state)
160    }
161
162    /// Replace the item list. Selects the first item when there was no previous
163    /// choice, and clamps an existing choice to the new list.
164    pub fn set_items(&mut self, items: Vec<String>) {
165        let mut state = self.state.borrow_mut();
166        let had_selection = state.selected.is_some();
167        state.items = items;
168        if !had_selection && !state.items.is_empty() {
169            state.selected = Some(0);
170        }
171        state.clamp();
172    }
173
174    /// Append one item.
175    pub fn add_item(&mut self, item: impl Into<String>) {
176        let mut state = self.state.borrow_mut();
177        state.items.push(item.into());
178        if state.selected.is_none() {
179            state.selected = Some(0);
180        }
181    }
182
183    /// Number of items in the list.
184    pub fn item_count(&self) -> usize {
185        self.state.borrow().items.len()
186    }
187
188    /// Index of the current choice.
189    pub fn selected(&self) -> Option<usize> {
190        self.state.borrow().selected
191    }
192
193    /// Set the current choice. Out-of-range indices are ignored, so a stale
194    /// index never silently selects the wrong item.
195    pub fn set_selected(&mut self, index: Option<usize>) {
196        let mut state = self.state.borrow_mut();
197        match index {
198            None => state.selected = None,
199            Some(i) if i < state.items.len() => state.selected = Some(i),
200            Some(_) => {}
201        }
202    }
203
204    /// Text of the current choice.
205    pub fn selected_text(&self) -> Option<String> {
206        self.state.borrow().selected_text().map(str::to_string)
207    }
208
209    /// Command broadcast when the choice changes. Zero, the default, sends none.
210    pub fn set_on_change(&mut self, command: CommandId) {
211        self.on_change = command;
212    }
213
214    /// Move the choice by `delta` items, clamping at both ends.
215    ///
216    /// Returns true when the choice actually changed.
217    fn step(&mut self, delta: i32) -> bool {
218        let mut state = self.state.borrow_mut();
219        if state.items.is_empty() {
220            return false;
221        }
222        let last = state.items.len() as i32 - 1;
223        let current = state.selected.map_or(0, |i| i as i32);
224        let next = (current + delta).clamp(0, last);
225        if Some(next as usize) == state.selected {
226            return false;
227        }
228        state.selected = Some(next as usize);
229        true
230    }
231
232    /// Build the command event that asks the dialog to open the list.
233    ///
234    /// The field rect travels in the shared state, so the event only needs to
235    /// name the control.
236    fn open_request(&self) -> Event {
237        let mut event = Event::command(CM_SHOW_DROPDOWN);
238        event.info = self.id;
239        event
240    }
241}
242
243impl Drop for ComboBox {
244    fn drop(&mut self) {
245        // Only clear the slot if it still points at this control's state; a
246        // later combo box may have taken the id over.
247        REGISTRY.with(|r| {
248            let mut map = r.borrow_mut();
249            if let Some(existing) = map.get(&self.id) {
250                if Rc::ptr_eq(existing, &self.state) {
251                    map.remove(&self.id);
252                }
253            }
254        });
255    }
256}
257
258impl View for ComboBox {
259    fn core(&self) -> &ViewCore {
260        &self.core
261    }
262
263    fn core_mut(&mut self) -> &mut ViewCore {
264        &mut self.core
265    }
266
267    fn set_bounds(&mut self, bounds: Rect) {
268        self.core.bounds = bounds;
269        self.state.borrow_mut().field = bounds;
270    }
271
272    fn can_focus(&self) -> bool {
273        true
274    }
275
276    fn state(&self) -> StateFlags {
277        self.view_state
278    }
279
280    fn set_state(&mut self, state: StateFlags) {
281        self.view_state = state;
282    }
283
284    fn draw(&mut self, terminal: &mut Terminal) {
285        let width = self.core.bounds.width_clamped().max(0) as usize;
286        if width == 0 {
287            return;
288        }
289        // Borland's input palette gives "normal" and "focused" the same colour,
290        // because an InputLine shows focus with its cursor. These controls draw
291        // no cursor, so they borrow the selected-text colour to make focus
292        // visible.
293        let text_attr = if self.is_focused() {
294            self.map_color(INPUT_SELECTED)
295        } else {
296            self.map_color(INPUT_NORMAL)
297        };
298        let arrow_attr = self.map_color(INPUT_ARROWS);
299
300        let mut buf = DrawBuffer::new(width);
301        buf.move_char(0, ' ', text_attr, width);
302
303        // The arrow owns the last cell; the caption gets what is left.
304        let caption_width = width.saturating_sub(1);
305        if caption_width > 0 {
306            if let Some(text) = self.state.borrow().selected_text() {
307                let shown: String = text.chars().take(caption_width).collect();
308                buf.move_str(1.min(caption_width), &shown, text_attr);
309            }
310        }
311        buf.put_char(width - 1, DROP_ARROW, arrow_attr);
312
313        write_line_to_terminal(terminal, 0, 0, &buf);
314    }
315
316    fn handle_event(&mut self, event: &mut Event) {
317        // A click anywhere on the field opens the list, focused or not, so the
318        // control behaves the way a mouse user expects on first click.
319        if event.what == EventType::MouseDown && self.extent().contains(event.mouse.pos) {
320            *event = self.open_request();
321            return;
322        }
323
324        if !self.is_focused() || event.what != EventType::Keyboard {
325            return;
326        }
327
328        let alt = event
329            .key_modifiers
330            .contains(crossterm::event::KeyModifiers::ALT);
331
332        match event.key_code {
333            KB_F4 => {
334                *event = self.open_request();
335            }
336            KB_DOWN if alt => {
337                *event = self.open_request();
338            }
339            KB_DOWN => {
340                if self.step(1) {
341                    let cmd = self.on_change;
342                    event.clear();
343                    if cmd != 0 {
344                        *event = Event::broadcast_with_info(cmd, self.id);
345                    }
346                } else {
347                    event.clear();
348                }
349            }
350            KB_UP => {
351                if self.step(-1) {
352                    let cmd = self.on_change;
353                    event.clear();
354                    if cmd != 0 {
355                        *event = Event::broadcast_with_info(cmd, self.id);
356                    }
357                } else {
358                    event.clear();
359                }
360            }
361            KB_HOME => {
362                self.step(i32::MIN / 2);
363                event.clear();
364            }
365            KB_END => {
366                self.step(i32::MAX / 2);
367                event.clear();
368            }
369            _ => {}
370        }
371    }
372
373    fn get_palette(&self) -> Option<crate::core::palette::Palette> {
374        use crate::core::palette::{Palette, palettes};
375        // The field is an input line in everything but editability.
376        Some(Palette::from_slice(palettes::CP_INPUT_LINE))
377    }
378
379    fn as_any(&self) -> &dyn std::any::Any {
380        self
381    }
382
383    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
384        self
385    }
386}
387
388/// Box-drawing characters for the popup frame, clockwise from the top left.
389const FRAME_CHARS: [char; 6] = [
390    '\u{250C}', '\u{2500}', '\u{2510}', '\u{2502}', '\u{2514}', '\u{2518}',
391];
392
393/// The modal list a combo box drops down.
394///
395/// Mirrors `HistoryWindow`: created and run by whoever owns the terminal, and
396/// it writes the choice straight into the shared [`ComboState`]. It draws its
397/// own thin frame rather than using `Window`, because a drop-down has no title,
398/// no close box and nothing to resize.
399pub struct DropdownWindow {
400    core: ViewCore,
401    state: Rc<RefCell<ComboState>>,
402    /// Index highlighted in the list.
403    cursor: usize,
404    /// First visible item, for lists taller than the popup.
405    top: usize,
406    /// Rect of the item area, inside the frame, in screen coordinates.
407    list_rect: Rect,
408}
409
410impl DropdownWindow {
411    /// Build the popup for `state`, placed just under its field.
412    ///
413    /// The popup is pushed back on screen if the field sits near an edge, using
414    /// `screen` as the available area.
415    pub fn new(state: Rc<RefCell<ComboState>>, screen: Rect) -> Self {
416        let (field, count, selected) = {
417            let s = state.borrow();
418            (s.field, s.items.len(), s.selected)
419        };
420
421        let rows = (count as i16).clamp(1, MAX_DROPDOWN_ROWS);
422        let height = rows + 2; // frame top and bottom
423        let width = field.width().max(8);
424
425        let mut x = field.a.x;
426        let mut y = field.b.y; // the row under the field
427        // Flip above the field when there is no room below.
428        if y + height > screen.b.y {
429            y = (field.a.y - height).max(screen.a.y);
430        }
431        if x + width > screen.b.x {
432            x = (screen.b.x - width).max(screen.a.x);
433        }
434
435        let window_bounds = Rect::new(x, y, x + width, y + height);
436        // The list sits inside the frame, in the popup's own space
437        let list_rect = Rect::new(1, 1, width - 1, 1 + rows);
438
439        Self {
440            core: ViewCore {
441                bounds: window_bounds,
442                ..ViewCore::default()
443            },
444            state,
445            cursor: selected.unwrap_or(0),
446            top: 0,
447            list_rect,
448        }
449    }
450
451    fn visible_rows(&self) -> usize {
452        self.list_rect.height_clamped().max(0) as usize
453    }
454
455    /// Scroll so the cursor is on screen.
456    fn scroll_into_view(&mut self) {
457        let rows = self.visible_rows();
458        if rows == 0 {
459            return;
460        }
461        if self.cursor < self.top {
462            self.top = self.cursor;
463        } else if self.cursor >= self.top + rows {
464            self.top = self.cursor + 1 - rows;
465        }
466    }
467
468    fn move_cursor(&mut self, delta: i32) {
469        let count = self.state.borrow().items.len();
470        if count == 0 {
471            return;
472        }
473        let last = count as i32 - 1;
474        self.cursor = (self.cursor as i32 + delta).clamp(0, last) as usize;
475        self.scroll_into_view();
476    }
477
478    /// Item index under a screen point, if the point is on the list.
479    fn item_at(&self, pos: Point) -> Option<usize> {
480        if !self.list_rect.contains(pos) {
481            return None;
482        }
483        let row = (pos.y - self.list_rect.a.y) as usize;
484        let idx = self.top + row;
485        (idx < self.state.borrow().items.len()).then_some(idx)
486    }
487
488    /// Draw the frame and the visible items.
489    fn draw_popup(&mut self, terminal: &mut Terminal) {
490        let width = self.list_rect.width_clamped().max(0) as usize;
491        let outer = self.core.bounds.width_clamped().max(0) as usize;
492        if width == 0 || outer == 0 {
493            return;
494        }
495        let normal = self.map_color(crate::core::palette::LISTBOX_NORMAL);
496        let selected = self.map_color(crate::core::palette::LISTBOX_SELECTED);
497
498        let [tl, horiz, tr, vert, bl, br] = FRAME_CHARS;
499
500        // Top and bottom frame rows.
501        let mut top = DrawBuffer::new(outer);
502        top.move_char(0, horiz, normal, outer);
503        top.put_char(0, tl, normal);
504        top.put_char(outer - 1, tr, normal);
505        write_line_to_terminal(terminal, 0, 0, &top);
506
507        let mut bottom = DrawBuffer::new(outer);
508        bottom.move_char(0, horiz, normal, outer);
509        bottom.put_char(0, bl, normal);
510        bottom.put_char(outer - 1, br, normal);
511        write_line_to_terminal(
512            terminal,
513            0,
514            self.extent().b.y - 1,
515            &bottom,
516        );
517
518        let state = self.state.borrow();
519        for row in 0..self.visible_rows() {
520            let mut buf = DrawBuffer::new(width);
521            let idx = self.top + row;
522            let attr = if idx == self.cursor { selected } else { normal };
523            buf.move_char(0, ' ', attr, width);
524            if let Some(text) = state.items.get(idx) {
525                let shown: String = text.chars().take(width).collect();
526                buf.move_str(0, &shown, attr);
527            }
528            let y = self.list_rect.a.y + row as i16;
529            // Side frame, then the item text between the edges.
530            let mut edge = DrawBuffer::new(1);
531            edge.put_char(0, vert, normal);
532            write_line_to_terminal(terminal, 0, y, &edge);
533            write_line_to_terminal(terminal, self.extent().b.x - 1, y, &edge);
534            write_line_to_terminal(terminal, self.list_rect.a.x, y, &buf);
535        }
536    }
537
538    /// Run the popup modally.
539    ///
540    /// Returns the chosen index and writes it into the shared state, or `None`
541    /// when the user cancelled, leaving the state untouched.
542    pub fn execute(&mut self, terminal: &mut Terminal) -> Option<usize> {
543        self.scroll_into_view();
544        loop {
545            // Nothing owns this popup, so it pushes its own origin and
546            // translates the raw screen events itself.
547            terminal.push_origin(self.core.bounds.a);
548            self.draw_popup(terminal);
549            terminal.pop_origin();
550            let _ = terminal.flush();
551
552            let Ok(Some(mut event)) = terminal.poll_event(std::time::Duration::from_millis(50))
553            else {
554                continue;
555            };
556            let origin = self.core.bounds.a;
557            event.mouse.pos.x -= origin.x;
558            event.mouse.pos.y -= origin.y;
559
560            match event.what {
561                EventType::Keyboard => match event.key_code {
562                    KB_ENTER => return self.commit(),
563                    KB_ESC | KB_F4 => return None,
564                    KB_UP => self.move_cursor(-1),
565                    KB_DOWN => self.move_cursor(1),
566                    KB_HOME => self.move_cursor(i32::MIN / 2),
567                    KB_END => self.move_cursor(i32::MAX / 2),
568                    crate::core::event::KB_PGUP => {
569                        let rows = self.visible_rows() as i32;
570                        self.move_cursor(-rows);
571                    }
572                    crate::core::event::KB_PGDN => {
573                        let rows = self.visible_rows() as i32;
574                        self.move_cursor(rows);
575                    }
576                    _ => {}
577                },
578                EventType::MouseDown => {
579                    match self.item_at(event.mouse.pos) {
580                        Some(idx) => {
581                            self.cursor = idx;
582                            return self.commit();
583                        }
584                        // A click outside the popup dismisses it, the way every
585                        // other drop-down behaves.
586                        None if !self.extent().contains(event.mouse.pos) => return None,
587                        None => {}
588                    }
589                    event.clear();
590                }
591                EventType::MouseWheelUp => self.move_cursor(-1),
592                EventType::MouseWheelDown => self.move_cursor(1),
593                _ => {}
594            }
595        }
596    }
597
598    /// Write the cursor position into the shared state and return it.
599    fn commit(&mut self) -> Option<usize> {
600        let mut state = self.state.borrow_mut();
601        if state.items.is_empty() {
602            return None;
603        }
604        let idx = self.cursor.min(state.items.len() - 1);
605        state.selected = Some(idx);
606        Some(idx)
607    }
608}
609
610impl View for DropdownWindow {
611    fn core(&self) -> &ViewCore {
612        &self.core
613    }
614
615    fn core_mut(&mut self) -> &mut ViewCore {
616        &mut self.core
617    }
618
619    /// The popup drives its own loop through [`DropdownWindow::execute`]; this
620    /// exists so it can use `map_color`, not so it can join a view hierarchy.
621    fn draw(&mut self, terminal: &mut Terminal) {
622        self.draw_popup(terminal);
623    }
624
625    fn handle_event(&mut self, _event: &mut Event) {}
626
627    fn get_palette(&self) -> Option<crate::core::palette::Palette> {
628        use crate::core::palette::{Palette, palettes};
629        Some(Palette::from_slice(palettes::CP_LISTBOX))
630    }
631
632    fn as_any(&self) -> &dyn std::any::Any {
633        self
634    }
635
636    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
637        self
638    }
639}
640
641/// Builder for creating combo boxes with a fluent API.
642pub struct ComboBoxBuilder {
643    bounds: Option<Rect>,
644    id: u16,
645    items: Vec<String>,
646    selected: Option<usize>,
647    on_change: CommandId,
648}
649
650impl ComboBoxBuilder {
651    pub fn new() -> Self {
652        Self {
653            bounds: None,
654            id: 0,
655            items: Vec::new(),
656            selected: None,
657            on_change: 0,
658        }
659    }
660
661    #[must_use]
662    pub fn bounds(mut self, bounds: Rect) -> Self {
663        self.bounds = Some(bounds);
664        self
665    }
666
667    #[must_use]
668    pub fn id(mut self, id: u16) -> Self {
669        self.id = id;
670        self
671    }
672
673    #[must_use]
674    pub fn items<I: Into<String>>(mut self, items: impl IntoIterator<Item = I>) -> Self {
675        self.items = items.into_iter().map(Into::into).collect();
676        self
677    }
678
679    #[must_use]
680    pub fn selected(mut self, index: usize) -> Self {
681        self.selected = Some(index);
682        self
683    }
684
685    #[must_use]
686    pub fn on_change(mut self, command: CommandId) -> Self {
687        self.on_change = command;
688        self
689    }
690
691    pub fn build(self) -> ComboBox {
692        let bounds = self.bounds.expect("ComboBox bounds must be set");
693        let mut combo = ComboBox::with_items(bounds, self.id, self.items);
694        if let Some(i) = self.selected {
695            combo.set_selected(Some(i));
696        }
697        combo.set_on_change(self.on_change);
698        combo
699    }
700
701    pub fn build_boxed(self) -> Box<ComboBox> {
702        Box::new(self.build())
703    }
704}
705
706impl Default for ComboBoxBuilder {
707    fn default() -> Self {
708        Self::new()
709    }
710}
711
712#[cfg(test)]
713mod tests {
714    use super::*;
715
716    fn combo(id: u16) -> ComboBox {
717        ComboBox::with_items(
718            Rect::new(0, 0, 20, 1),
719            id,
720            vec!["one".into(), "two".into(), "three".into()],
721        )
722    }
723
724    fn key(code: u16) -> Event {
725        Event::keyboard(code)
726    }
727
728    #[test]
729    fn first_item_is_selected_by_default() {
730        let c = combo(900);
731        assert_eq!(c.selected(), Some(0));
732        assert_eq!(c.selected_text().as_deref(), Some("one"));
733    }
734
735    #[test]
736    fn empty_combo_has_no_selection() {
737        let c = ComboBox::new(Rect::new(0, 0, 10, 1), 901);
738        assert_eq!(c.selected(), None);
739        assert_eq!(c.selected_text(), None);
740    }
741
742    #[test]
743    fn out_of_range_selection_is_ignored() {
744        let mut c = combo(902);
745        c.set_selected(Some(99));
746        assert_eq!(
747            c.selected(),
748            Some(0),
749            "stale index must not move the choice"
750        );
751    }
752
753    #[test]
754    fn shrinking_the_item_list_clamps_the_choice() {
755        let mut c = combo(903);
756        c.set_selected(Some(2));
757        c.set_items(vec!["only".into()]);
758        assert_eq!(c.selected(), Some(0));
759    }
760
761    #[test]
762    fn arrows_step_the_choice_and_clamp() {
763        let mut c = combo(904);
764        c.set_state(State::FOCUSED);
765        let mut e = key(KB_DOWN);
766        c.handle_event(&mut e);
767        assert_eq!(c.selected(), Some(1));
768        for _ in 0..5 {
769            let mut e = key(KB_DOWN);
770            c.handle_event(&mut e);
771        }
772        assert_eq!(c.selected(), Some(2), "clamped at the last item");
773        let mut e = key(KB_UP);
774        c.handle_event(&mut e);
775        assert_eq!(c.selected(), Some(1));
776    }
777
778    #[test]
779    fn home_and_end_jump_to_the_ends() {
780        let mut c = combo(905);
781        c.set_state(State::FOCUSED);
782        let mut e = key(KB_END);
783        c.handle_event(&mut e);
784        assert_eq!(c.selected(), Some(2));
785        let mut e = key(KB_HOME);
786        c.handle_event(&mut e);
787        assert_eq!(c.selected(), Some(0));
788    }
789
790    #[test]
791    fn f4_asks_the_dialog_to_open_the_list() {
792        let mut c = combo(906);
793        c.set_state(State::FOCUSED);
794        let mut e = key(KB_F4);
795        c.handle_event(&mut e);
796        assert_eq!(e.what, EventType::Command);
797        assert_eq!(e.command, CM_SHOW_DROPDOWN);
798        assert_eq!(e.info, 906, "the popup is told which combo asked");
799    }
800
801    #[test]
802    fn a_click_opens_the_list_even_when_unfocused() {
803        let mut c = combo(907);
804        let mut e = Event::nothing();
805        e.what = EventType::MouseDown;
806        e.mouse.pos = Point::new(3, 0);
807        c.handle_event(&mut e);
808        assert_eq!(e.command, CM_SHOW_DROPDOWN);
809    }
810
811    #[test]
812    fn keys_are_ignored_when_not_focused() {
813        let mut c = combo(908);
814        let mut e = key(KB_DOWN);
815        c.handle_event(&mut e);
816        assert_eq!(c.selected(), Some(0));
817        assert_eq!(e.what, EventType::Keyboard, "event left for other views");
818    }
819
820    #[test]
821    fn on_change_command_is_broadcast_when_the_choice_moves() {
822        let mut c = combo(909);
823        c.set_state(State::FOCUSED);
824        c.set_on_change(777);
825        let mut e = key(KB_DOWN);
826        c.handle_event(&mut e);
827        assert_eq!(e.what, EventType::Broadcast);
828        assert_eq!(e.command, 777);
829
830        // Already at the last item: no change, so no broadcast.
831        c.set_selected(Some(2));
832        let mut e = key(KB_DOWN);
833        c.handle_event(&mut e);
834        assert_eq!(e.what, EventType::Nothing);
835    }
836
837    #[test]
838    fn state_is_registered_for_the_popup_and_freed_on_drop() {
839        {
840            let c = combo(910);
841            let found = lookup(910).expect("registered while alive");
842            assert_eq!(found.borrow().items.len(), 3);
843            drop(c);
844        }
845        assert!(lookup(910).is_none(), "registration freed on drop");
846    }
847
848    #[test]
849    fn moving_the_control_moves_the_popup_anchor() {
850        let mut c = combo(911);
851        c.set_bounds(Rect::new(5, 9, 25, 10));
852        assert_eq!(lookup(911).unwrap().borrow().field, Rect::new(5, 9, 25, 10));
853    }
854
855    #[test]
856    fn popup_sits_under_the_field() {
857        let c = combo(912);
858        let screen = Rect::new(0, 0, 80, 25);
859        let popup = DropdownWindow::new(c.state(), screen);
860        assert_eq!(popup.bounds().a, Point::new(0, 1), "just below");
861        assert_eq!(popup.visible_rows(), 3, "one row per item");
862    }
863
864    #[test]
865    fn popup_flips_above_a_field_near_the_bottom() {
866        let mut c = combo(913);
867        c.set_bounds(Rect::new(0, 23, 20, 24));
868        let screen = Rect::new(0, 0, 80, 25);
869        let popup = DropdownWindow::new(c.state(), screen);
870        assert!(
871            popup.bounds().b.y <= 23,
872            "popup must not run off the bottom: {:?}",
873            popup.bounds()
874        );
875    }
876
877    #[test]
878    fn popup_height_is_capped_for_long_lists() {
879        let mut c = combo(914);
880        c.set_items((0..50).map(|i| format!("item {i}")).collect());
881        let popup = DropdownWindow::new(c.state(), Rect::new(0, 0, 80, 25));
882        assert_eq!(popup.visible_rows(), MAX_DROPDOWN_ROWS as usize);
883    }
884
885    #[test]
886    fn popup_scrolls_to_keep_the_cursor_visible() {
887        let mut c = combo(915);
888        c.set_items((0..20).map(|i| format!("item {i}")).collect());
889        let mut popup = DropdownWindow::new(c.state(), Rect::new(0, 0, 80, 25));
890        popup.move_cursor(15);
891        assert!(popup.top > 0, "list scrolled");
892        assert!(popup.cursor >= popup.top && popup.cursor < popup.top + popup.visible_rows());
893    }
894
895    #[test]
896    fn popup_commit_writes_the_choice_back() {
897        let c = combo(916);
898        let mut popup = DropdownWindow::new(c.state(), Rect::new(0, 0, 80, 25));
899        popup.move_cursor(2);
900        assert_eq!(popup.commit(), Some(2));
901        assert_eq!(c.selected(), Some(2));
902    }
903
904    #[test]
905    fn popup_click_maps_to_the_right_item() {
906        let c = combo(917);
907        let popup = DropdownWindow::new(c.state(), Rect::new(0, 0, 80, 25));
908        let inside = Point::new(popup.list_rect.a.x, popup.list_rect.a.y + 1);
909        assert_eq!(popup.item_at(inside), Some(1));
910        assert_eq!(popup.item_at(Point::new(0, 0)), None, "outside the list");
911    }
912}