Skip to main content

ratatui_form/field/
select.rs

1//! Select/dropdown field.
2
3use crossterm::event::{KeyCode, KeyEvent};
4use ratatui::buffer::Buffer;
5use ratatui::layout::Rect;
6use ratatui::style::{Color, Style};
7use ratatui::text::{Line, Span};
8use ratatui::widgets::Widget;
9use serde_json::Value;
10use unicode_width::UnicodeWidthStr;
11
12use crate::field::Field;
13use crate::style::FormStyle;
14use crate::validation::ValidationError;
15
16/// A select/dropdown field.
17pub struct Select {
18    id: String,
19    label: String,
20    options: Vec<(String, String)>, // (value, display)
21    selected_index: Option<usize>,
22    is_open: bool,
23    highlighted_index: usize,
24    required: bool,
25}
26
27impl Select {
28    /// Creates a new select field.
29    pub fn new(id: impl Into<String>, label: impl Into<String>) -> Self {
30        Self {
31            id: id.into(),
32            label: label.into(),
33            options: Vec::new(),
34            selected_index: None,
35            is_open: false,
36            highlighted_index: 0,
37            required: false,
38        }
39    }
40
41    /// Adds an option to the select.
42    pub fn option(mut self, value: impl Into<String>, display: impl Into<String>) -> Self {
43        self.options.push((value.into(), display.into()));
44        self
45    }
46
47    /// Adds multiple options at once.
48    pub fn options(mut self, options: Vec<(impl Into<String>, impl Into<String>)>) -> Self {
49        for (value, display) in options {
50            self.options.push((value.into(), display.into()));
51        }
52        self
53    }
54
55    /// Marks this field as required.
56    pub fn required(mut self) -> Self {
57        self.required = true;
58        self
59    }
60
61    /// Sets the initial selected value.
62    pub fn initial_value(mut self, value: &str) -> Self {
63        for (i, (v, _)) in self.options.iter().enumerate() {
64            if v == value {
65                self.selected_index = Some(i);
66                self.highlighted_index = i;
67                break;
68            }
69        }
70        self
71    }
72
73    fn toggle_open(&mut self) {
74        self.is_open = !self.is_open;
75        if self.is_open {
76            if let Some(idx) = self.selected_index {
77                self.highlighted_index = idx;
78            }
79        }
80    }
81
82    fn select_highlighted(&mut self) {
83        if !self.options.is_empty() {
84            self.selected_index = Some(self.highlighted_index);
85        }
86        self.is_open = false;
87    }
88
89    fn move_highlight_up(&mut self) {
90        if self.highlighted_index > 0 {
91            self.highlighted_index -= 1;
92        }
93    }
94
95    fn move_highlight_down(&mut self) {
96        if self.highlighted_index < self.options.len().saturating_sub(1) {
97            self.highlighted_index += 1;
98        }
99    }
100}
101
102impl Field for Select {
103    fn id(&self) -> &str {
104        &self.id
105    }
106
107    fn label(&self) -> &str {
108        &self.label
109    }
110
111    fn render(&self, area: Rect, buf: &mut Buffer, focused: bool, style: &FormStyle) {
112        if area.height < 1 || area.width < 1 {
113            return;
114        }
115
116        // Render label
117        let label_style = if focused {
118            style.label_focused
119        } else {
120            style.label
121        };
122
123        let required_marker = if self.required { "*" } else { "" };
124        let label_text = format!("{}{}: ", self.label, required_marker);
125        let label_width = label_text.width().min(area.width as usize);
126
127        let label_span = Span::styled(&label_text, label_style);
128        let label_line = Line::from(label_span);
129        let label_area = Rect {
130            x: area.x,
131            y: area.y,
132            width: label_width as u16,
133            height: 1,
134        };
135        label_line.render(label_area, buf);
136
137        // Calculate input area
138        let input_x = area.x + label_width as u16;
139        let input_width = area.width.saturating_sub(label_width as u16);
140
141        if input_width == 0 {
142            return;
143        }
144
145        // Get selected display text
146        let display_text = self
147            .selected_index
148            .and_then(|i| self.options.get(i))
149            .map(|(_, display)| display.as_str())
150            .unwrap_or("-- Select --");
151
152        // Render the selected value with dropdown indicator
153        let input_style = if focused {
154            style.input_focused
155        } else {
156            style.input
157        };
158
159        // Fill input area with background
160        for x in input_x..input_x + input_width {
161            buf[(x, area.y)].set_style(input_style);
162            buf[(x, area.y)].set_char(' ');
163        }
164
165        // Render selected text
166        let arrow = if self.is_open { " ▲" } else { " ▼" };
167        let max_text_width = input_width.saturating_sub(2) as usize;
168        let truncated_text: String = display_text.chars().take(max_text_width).collect();
169
170        for (i, c) in truncated_text.chars().enumerate() {
171            if input_x + i as u16 >= area.x + area.width - 2 {
172                break;
173            }
174            buf[(input_x + i as u16, area.y)].set_char(c);
175        }
176
177        // Render arrow
178        let arrow_x = input_x + input_width - 2;
179        for (i, c) in arrow.chars().enumerate() {
180            if arrow_x + (i as u16) < area.x + area.width {
181                buf[(arrow_x + i as u16, area.y)].set_char(c);
182            }
183        }
184
185        // Render dropdown if open
186        if self.is_open && area.height > 1 {
187            let max_dropdown_height = (area.height - 1).min(self.options.len() as u16);
188
189            for (i, (_, display)) in self.options.iter().enumerate() {
190                if i >= max_dropdown_height as usize {
191                    break;
192                }
193
194                let y = area.y + 1 + i as u16;
195                let is_highlighted = i == self.highlighted_index;
196                let is_selected = Some(i) == self.selected_index;
197
198                let option_style = if is_highlighted {
199                    Style::default().bg(Color::Blue).fg(Color::White)
200                } else if is_selected {
201                    Style::default().bg(Color::DarkGray).fg(Color::White)
202                } else {
203                    style.input
204                };
205
206                // Fill option row with background
207                for x in input_x..input_x + input_width {
208                    buf[(x, y)].set_style(option_style);
209                    buf[(x, y)].set_char(' ');
210                }
211
212                // Render option text
213                let prefix = if is_selected { "● " } else { "  " };
214                for (j, c) in prefix.chars().enumerate() {
215                    buf[(input_x + j as u16, y)].set_char(c);
216                }
217
218                let text_start = input_x + 2;
219                for (j, c) in display.chars().enumerate() {
220                    if text_start + j as u16 >= input_x + input_width {
221                        break;
222                    }
223                    buf[(text_start + j as u16, y)].set_char(c);
224                }
225            }
226        }
227    }
228
229    fn handle_input(&mut self, event: &KeyEvent) -> bool {
230        match event.code {
231            KeyCode::Enter | KeyCode::Char(' ') => {
232                if self.is_open {
233                    self.select_highlighted();
234                } else {
235                    self.toggle_open();
236                }
237                true
238            }
239            KeyCode::Esc => {
240                if self.is_open {
241                    self.is_open = false;
242                    true
243                } else {
244                    false
245                }
246            }
247            KeyCode::Up => {
248                if self.is_open {
249                    self.move_highlight_up();
250                    true
251                } else {
252                    false
253                }
254            }
255            KeyCode::Down => {
256                if self.is_open {
257                    self.move_highlight_down();
258                    true
259                } else {
260                    self.toggle_open();
261                    true
262                }
263            }
264            _ => false,
265        }
266    }
267
268    fn value(&self) -> Value {
269        self.selected_index
270            .and_then(|i| self.options.get(i))
271            .map(|(v, _)| Value::String(v.clone()))
272            .unwrap_or(Value::Null)
273    }
274
275    fn validate(&self) -> Result<(), Vec<ValidationError>> {
276        if self.required && self.selected_index.is_none() {
277            Err(vec![ValidationError {
278                field_id: self.id.clone(),
279                message: format!("{} is required", self.label),
280            }])
281        } else {
282            Ok(())
283        }
284    }
285
286    fn height(&self) -> u16 {
287        if self.is_open {
288            1 + self.options.len().min(10) as u16
289        } else {
290            1
291        }
292    }
293
294    fn is_required(&self) -> bool {
295        self.required
296    }
297}