Skip to main content

lv_tui/widgets/
select.rs

1use crate::component::{Component, EventCx, MeasureCx};
2use crate::event::Event;
3use crate::geom::{Rect, Size};
4use crate::layout::Constraint;
5use crate::render::RenderCx;
6use crate::style::Style;
7use crate::text::Text;
8
9/// A dropdown select widget.
10///
11/// Displays the currently selected option when collapsed ("▼"), and expands
12/// inline to show all options with keyboard navigation when activated.
13pub struct Select {
14    /// List of option strings
15    options: Vec<Text>,
16    /// Currently selected index
17    selected: usize,
18    /// Whether the dropdown is expanded
19    expanded: bool,
20    /// Whether this widget currently has keyboard focus
21    focused: bool,
22    /// Current layout rect
23    rect: Rect,
24    /// Collapsed/default style
25    style: Style,
26    /// Selected/focused option style
27    selected_style: Style,
28}
29
30impl Select {
31    /// Creates an empty select.
32    pub fn new() -> Self {
33        Self {
34            options: Vec::new(),
35            selected: 0,
36            expanded: false,
37            focused: false,
38            rect: Rect::default(),
39            style: Style::default(),
40            selected_style: Style::default().bg(crate::style::Color::White).fg(crate::style::Color::Black),
41        }
42    }
43
44    /// Sets the option list.
45    pub fn options(mut self, options: Vec<impl Into<Text>>) -> Self {
46        self.options = options.into_iter().map(|o| o.into()).collect();
47        self
48    }
49
50    /// Sets the collapsed/default style.
51    pub fn style(mut self, style: Style) -> Self {
52        self.style = style;
53        self
54    }
55
56    /// Sets the selected option style.
57    pub fn selected_style(mut self, style: Style) -> Self {
58        self.selected_style = style;
59        self
60    }
61
62    /// Returns the currently selected index.
63    pub fn selected(&self) -> usize {
64        self.selected
65    }
66
67    /// Returns the text of the currently selected option.
68    pub fn selected_text(&self) -> &str {
69        self.options.get(self.selected).map(|t| t.first_text()).unwrap_or("")
70    }
71
72    /// Sets the selected option.
73    pub fn set_selected(&mut self, index: usize, cx: &mut EventCx) {
74        if index < self.options.len() {
75            self.selected = index;
76            cx.invalidate_paint();
77        }
78    }
79}
80
81impl Component for Select {
82    fn render(&self, cx: &mut RenderCx) {
83        if self.options.is_empty() {
84            return;
85        }
86
87        // First line: selected option + indicator
88        let indicator = if self.expanded { "▲" } else { "▼" };
89        let display = format!("{} {}", self.selected_text(), indicator);
90        if self.focused {
91            cx.set_style(self.selected_style.clone());
92        } else {
93            cx.set_style(self.style.clone());
94        }
95        cx.line(&display);
96
97        // Expanded: show all options
98        if self.expanded {
99            for (i, opt) in self.options.iter().enumerate() {
100                if i == self.selected {
101                    cx.set_style(self.selected_style.clone());
102                    cx.text("❯ ");
103                } else {
104                    cx.set_style(self.style.clone());
105                    cx.text("  ");
106                }
107                cx.line(opt.first_text());
108            }
109        }
110    }
111
112    fn measure(&self, _constraint: Constraint, _cx: &mut MeasureCx) -> Size {
113        if self.options.is_empty() {
114            return Size { width: 0, height: 0 };
115        }
116
117        let max_w = self.options.iter().map(|o| o.max_width()).max().unwrap_or(0) + 2;
118
119        let height = if self.expanded {
120            1u16.saturating_add(self.options.len() as u16)
121        } else {
122            1
123        };
124
125        Size { width: max_w, height }
126    }
127
128    fn event(&mut self, event: &Event, cx: &mut EventCx) {
129        match event {
130            Event::Focus => {
131                self.focused = true;
132                cx.invalidate_paint();
133                return;
134            }
135            Event::Blur => {
136                self.focused = false;
137                self.expanded = false;
138                cx.invalidate_layout();
139                return;
140            }
141            _ => {}
142        }
143
144        if self.options.is_empty() { return; }
145
146        // Only handle key events during Target phase
147        if cx.phase() != crate::event::EventPhase::Target { return; }
148
149        if let Event::Key(key_event) = event {
150            match &key_event.key {
151                crate::event::Key::Enter | crate::event::Key::Char(' ') => {
152                    if self.expanded {
153                        self.expanded = false;
154                    } else {
155                        self.expanded = true;
156                    }
157                    cx.invalidate_layout();
158                    return;
159                }
160                crate::event::Key::Esc => {
161                    if self.expanded {
162                        self.expanded = false;
163                        cx.invalidate_layout();
164                    }
165                    return;
166                }
167                crate::event::Key::Up => {
168                    if self.expanded {
169                        if self.selected > 0 {
170                            self.selected -= 1;
171                        } else {
172                            self.selected = self.options.len() - 1;
173                        }
174                        cx.invalidate_paint();
175                    }
176                    return;
177                }
178                crate::event::Key::Down => {
179                    if self.expanded {
180                        if self.selected + 1 < self.options.len() {
181                            self.selected += 1;
182                        } else {
183                            self.selected = 0;
184                        }
185                        cx.invalidate_paint();
186                    }
187                    return;
188                }
189                _ => {}
190            }
191        }
192    }
193
194    fn layout(&mut self, rect: Rect, _cx: &mut crate::component::LayoutCx) {
195        self.rect = rect;
196    }
197
198    fn focusable(&self) -> bool {
199        true
200    }
201
202    fn style(&self) -> Style {
203        self.style.clone()
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210    use crate::testbuffer::TestBuffer;
211
212    #[test]
213    fn test_collapsed() {
214        let mut tb = TestBuffer::new(20, 1);
215        tb.render(&Select::new().options(vec![Text::from("A")]));
216        assert!(tb.buffer.cells[0].symbol.contains("A"));
217    }
218}