1mod input;
12mod render;
13
14use ratatui::layout::Rect;
15use ratatui::style::Color;
16
17pub use input::DropdownEvent;
18pub use render::{render_dropdown, render_dropdown_aligned};
19
20use super::FocusState;
21
22#[derive(Debug, Clone)]
24pub struct DropdownState {
25 pub selected: usize,
27 pub options: Vec<String>,
29 pub values: Vec<String>,
32 pub label: String,
34 pub open: bool,
36 pub focus: FocusState,
38 original_selected: Option<usize>,
40 pub scroll_offset: usize,
42 pub max_visible: usize,
44}
45
46impl DropdownState {
47 pub fn new(options: Vec<String>, label: impl Into<String>) -> Self {
49 Self {
50 selected: 0,
51 options,
52 values: Vec::new(),
53 label: label.into(),
54 open: false,
55 focus: FocusState::Normal,
56 original_selected: None,
57 scroll_offset: 0,
58 max_visible: 5, }
60 }
61
62 pub fn with_values(
64 options: Vec<String>,
65 values: Vec<String>,
66 label: impl Into<String>,
67 ) -> Self {
68 debug_assert_eq!(options.len(), values.len());
69 Self {
70 selected: 0,
71 options,
72 values,
73 label: label.into(),
74 open: false,
75 focus: FocusState::Normal,
76 original_selected: None,
77 scroll_offset: 0,
78 max_visible: 5, }
80 }
81
82 pub fn with_selected(mut self, index: usize) -> Self {
84 if index < self.options.len() {
85 self.selected = index;
86 }
87 self
88 }
89
90 pub fn with_focus(mut self, focus: FocusState) -> Self {
92 self.focus = focus;
93 self
94 }
95
96 pub fn is_enabled(&self) -> bool {
98 self.focus != FocusState::Disabled
99 }
100
101 pub fn selected_value(&self) -> Option<&str> {
103 if self.values.is_empty() {
104 self.options.get(self.selected).map(|s| s.as_str())
105 } else {
106 self.values.get(self.selected).map(|s| s.as_str())
107 }
108 }
109
110 pub fn selected_option(&self) -> Option<&str> {
112 self.options.get(self.selected).map(|s| s.as_str())
113 }
114
115 pub fn index_of_value(&self, value: &str) -> Option<usize> {
117 if self.values.is_empty() {
118 self.options.iter().position(|o| o == value)
119 } else {
120 self.values.iter().position(|v| v == value)
121 }
122 }
123
124 pub fn toggle_open(&mut self) {
126 if self.is_enabled() {
127 if !self.open {
128 self.original_selected = Some(self.selected);
129 } else {
130 self.original_selected = None;
131 }
132 self.open = !self.open;
133 }
134 }
135
136 pub fn cancel(&mut self) {
138 if let Some(original) = self.original_selected.take() {
139 self.selected = original;
140 }
141 self.open = false;
142 }
143
144 pub fn confirm(&mut self) {
146 self.original_selected = None;
147 self.open = false;
148 }
149
150 pub fn select_next(&mut self) {
152 if self.is_enabled() && !self.options.is_empty() {
153 self.selected = (self.selected + 1) % self.options.len();
154 self.ensure_visible();
155 }
156 }
157
158 pub fn select_prev(&mut self) {
160 if self.is_enabled() && !self.options.is_empty() {
161 self.selected = if self.selected == 0 {
162 self.options.len() - 1
163 } else {
164 self.selected - 1
165 };
166 self.ensure_visible();
167 }
168 }
169
170 pub fn select(&mut self, index: usize) {
172 if self.is_enabled() && index < self.options.len() {
173 self.selected = index;
174 self.original_selected = None;
175 self.open = false;
176 }
177 }
178
179 pub fn ensure_visible(&mut self) {
181 if self.max_visible == 0 || self.options.len() <= self.max_visible {
182 self.scroll_offset = 0;
183 return;
184 }
185
186 if self.selected < self.scroll_offset {
188 self.scroll_offset = self.selected;
189 }
190 else if self.selected >= self.scroll_offset + self.max_visible {
192 self.scroll_offset = self.selected.saturating_sub(self.max_visible - 1);
193 }
194 }
195
196 pub fn scroll_by(&mut self, delta: i32) {
198 if self.options.len() <= self.max_visible {
199 return;
200 }
201
202 let max_offset = self.options.len().saturating_sub(self.max_visible);
203 if delta > 0 {
204 self.scroll_offset = (self.scroll_offset + delta as usize).min(max_offset);
205 } else {
206 self.scroll_offset = self.scroll_offset.saturating_sub((-delta) as usize);
207 }
208 }
209
210 pub fn needs_scrollbar(&self) -> bool {
212 self.open && self.options.len() > self.max_visible
213 }
214
215 pub fn scroll_fraction(&self) -> f32 {
217 if self.options.len() <= self.max_visible {
218 return 0.0;
219 }
220 let max_offset = self.options.len().saturating_sub(self.max_visible);
221 if max_offset == 0 {
222 return 0.0;
223 }
224 self.scroll_offset as f32 / max_offset as f32
225 }
226}
227
228#[derive(Debug, Clone, Copy)]
230pub struct DropdownColors {
231 pub label: Color,
233 pub selected: Color,
235 pub border: Color,
237 pub arrow: Color,
239 pub option: Color,
241 pub highlight_bg: Color,
243 pub focused: Color,
245 pub disabled: Color,
247}
248
249impl Default for DropdownColors {
250 fn default() -> Self {
251 Self {
252 label: Color::White,
253 selected: Color::Cyan,
254 border: Color::Gray,
255 arrow: Color::DarkGray,
256 option: Color::White,
257 highlight_bg: Color::DarkGray,
258 focused: Color::Cyan,
259 disabled: Color::DarkGray,
260 }
261 }
262}
263
264impl DropdownColors {
265 pub fn from_theme(theme: &crate::view::theme::Theme) -> Self {
267 Self {
268 label: theme.editor_fg,
269 selected: theme.editor_fg,
272 border: theme.line_number_fg,
273 arrow: theme.line_number_fg,
274 option: theme.editor_fg,
275 highlight_bg: theme.selection_bg,
276 focused: theme.menu_highlight_fg,
278 disabled: theme.line_number_fg,
279 }
280 }
281}
282
283#[derive(Debug, Clone, Default)]
285pub struct DropdownLayout {
286 pub button_area: Rect,
288 pub option_areas: Vec<Rect>,
290 pub full_area: Rect,
292 pub scroll_offset: usize,
294}
295
296impl DropdownLayout {
297 pub fn is_button(&self, x: u16, y: u16) -> bool {
299 x >= self.button_area.x
300 && x < self.button_area.x + self.button_area.width
301 && y >= self.button_area.y
302 && y < self.button_area.y + self.button_area.height
303 }
304
305 pub fn option_at(&self, x: u16, y: u16) -> Option<usize> {
308 for (i, area) in self.option_areas.iter().enumerate() {
309 if x >= area.x && x < area.x + area.width && y >= area.y && y < area.y + area.height {
310 return Some(self.scroll_offset + i);
311 }
312 }
313 None
314 }
315
316 pub fn contains(&self, x: u16, y: u16) -> bool {
318 x >= self.full_area.x
319 && x < self.full_area.x + self.full_area.width
320 && y >= self.full_area.y
321 && y < self.full_area.y + self.full_area.height
322 }
323}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328 use ratatui::backend::TestBackend;
329 use ratatui::Terminal;
330
331 fn test_frame<F>(width: u16, height: u16, f: F)
332 where
333 F: FnOnce(&mut ratatui::Frame, Rect),
334 {
335 let backend = TestBackend::new(width, height);
336 let mut terminal = Terminal::new(backend).unwrap();
337 terminal
338 .draw(|frame| {
339 let area = Rect::new(0, 0, width, height);
340 f(frame, area);
341 })
342 .unwrap();
343 }
344
345 #[test]
346 fn test_dropdown_renders() {
347 test_frame(40, 1, |frame, area| {
348 let state = DropdownState::new(
349 vec!["Option A".to_string(), "Option B".to_string()],
350 "Choice",
351 );
352 let colors = DropdownColors::default();
353 let layout = render_dropdown(frame, area, &state, &colors);
354
355 assert!(layout.button_area.width > 0);
356 assert!(layout.option_areas.is_empty());
357 });
358 }
359
360 #[test]
361 fn test_dropdown_open() {
362 test_frame(40, 5, |frame, area| {
363 let mut state = DropdownState::new(
364 vec!["Option A".to_string(), "Option B".to_string()],
365 "Choice",
366 );
367 state.open = true;
368 let colors = DropdownColors::default();
369 let layout = render_dropdown(frame, area, &state, &colors);
370
371 assert_eq!(layout.option_areas.len(), 2);
372 });
373 }
374
375 #[test]
376 fn test_dropdown_selection() {
377 let mut state = DropdownState::new(
378 vec!["A".to_string(), "B".to_string(), "C".to_string()],
379 "Test",
380 );
381
382 assert_eq!(state.selected, 0);
383 state.select_next();
384 assert_eq!(state.selected, 1);
385 state.select_next();
386 assert_eq!(state.selected, 2);
387 state.select_next();
388 assert_eq!(state.selected, 0);
389
390 state.select_prev();
391 assert_eq!(state.selected, 2);
392 }
393
394 #[test]
395 fn test_dropdown_select_by_index() {
396 let mut state = DropdownState::new(
397 vec!["A".to_string(), "B".to_string(), "C".to_string()],
398 "Test",
399 );
400 state.open = true;
401 state.select(2);
402 assert_eq!(state.selected, 2);
403 assert!(!state.open);
404 }
405
406 #[test]
407 fn test_dropdown_disabled() {
408 let mut state = DropdownState::new(vec!["A".to_string(), "B".to_string()], "Test")
409 .with_focus(FocusState::Disabled);
410
411 state.toggle_open();
412 assert!(!state.open);
413
414 state.select_next();
415 assert_eq!(state.selected, 0);
416 }
417
418 #[test]
419 fn test_dropdown_cancel_restores_original() {
420 let mut state = DropdownState::new(
421 vec!["A".to_string(), "B".to_string(), "C".to_string()],
422 "Test",
423 )
424 .with_selected(1);
425
426 state.toggle_open();
427 assert!(state.open);
428 assert_eq!(state.selected, 1);
429
430 state.select_next();
431 assert_eq!(state.selected, 2);
432
433 state.cancel();
434 assert!(!state.open);
435 assert_eq!(state.selected, 1);
436 }
437
438 #[test]
439 fn test_dropdown_confirm_commits_selection() {
440 let mut state = DropdownState::new(
441 vec!["A".to_string(), "B".to_string(), "C".to_string()],
442 "Test",
443 )
444 .with_selected(0);
445
446 state.toggle_open();
447 assert!(state.open);
448
449 state.select_next();
450 assert_eq!(state.selected, 1);
451
452 state.confirm();
453 assert!(!state.open);
454 assert_eq!(state.selected, 1);
455 }
456
457 #[test]
458 fn test_dropdown_toggle_close_confirms() {
459 let mut state = DropdownState::new(
460 vec!["A".to_string(), "B".to_string(), "C".to_string()],
461 "Test",
462 )
463 .with_selected(0);
464
465 state.toggle_open();
466 assert!(state.open);
467
468 state.select_next();
469 assert_eq!(state.selected, 1);
470
471 state.toggle_open();
472 assert!(!state.open);
473 assert_eq!(state.selected, 1);
474 }
475
476 #[test]
477 fn test_dropdown_scrolling() {
478 let options: Vec<String> = (0..20).map(|i| format!("Option {}", i)).collect();
480 let mut state = DropdownState::new(options, "Long List");
481 state.max_visible = 5; assert_eq!(state.scroll_offset, 0);
484
485 state.selected = 10;
487 state.ensure_visible();
488
489 assert!(state.scroll_offset > 0);
491 assert!(state.selected >= state.scroll_offset);
492 assert!(state.selected < state.scroll_offset + state.max_visible);
493 }
494
495 #[test]
496 fn test_dropdown_scroll_by() {
497 let options: Vec<String> = (0..20).map(|i| format!("Option {}", i)).collect();
498 let mut state = DropdownState::new(options, "Long List");
499 state.max_visible = 5;
500
501 state.scroll_by(3);
503 assert_eq!(state.scroll_offset, 3);
504
505 state.scroll_by(-2);
507 assert_eq!(state.scroll_offset, 1);
508
509 state.scroll_by(-10);
511 assert_eq!(state.scroll_offset, 0);
512
513 state.scroll_by(100);
515 assert_eq!(state.scroll_offset, 15); }
517
518 #[test]
519 fn test_dropdown_needs_scrollbar() {
520 let options: Vec<String> = (0..10).map(|i| format!("Option {}", i)).collect();
521 let mut state = DropdownState::new(options, "Test");
522
523 state.max_visible = 5;
525 assert!(!state.needs_scrollbar());
526
527 state.open = true;
529 assert!(state.needs_scrollbar());
530
531 state.max_visible = 20;
533 assert!(!state.needs_scrollbar());
534 }
535
536 #[test]
537 fn test_dropdown_keyboard_nav_scrolls() {
538 let options: Vec<String> = (0..10).map(|i| format!("Option {}", i)).collect();
539 let mut state = DropdownState::new(options, "Test");
540 state.max_visible = 3;
541 state.open = true;
542
543 for _ in 0..5 {
545 state.select_next();
546 }
547
548 assert_eq!(state.selected, 5);
549 assert!(state.selected >= state.scroll_offset);
551 assert!(state.selected < state.scroll_offset + state.max_visible);
552 }
553
554 #[test]
555 fn test_dropdown_selection_always_visible() {
556 let options: Vec<String> = vec![
558 "Auto-detect",
559 "Czech",
560 "German",
561 "English",
562 "Spanish",
563 "French",
564 "Japanese",
565 "Korean",
566 "Portuguese",
567 "Russian",
568 "Thai",
569 "Ukrainian",
570 "Chinese",
571 ]
572 .into_iter()
573 .map(String::from)
574 .collect();
575
576 let mut state = DropdownState::new(options, "Locale");
577 state.max_visible = 5; state.open = true;
579
580 let check_visible = |state: &DropdownState| {
582 assert!(
583 state.selected >= state.scroll_offset,
584 "selected {} below scroll_offset {}",
585 state.selected,
586 state.scroll_offset
587 );
588 assert!(
589 state.selected < state.scroll_offset + state.max_visible,
590 "selected {} above visible area (scroll_offset={}, max_visible={})",
591 state.selected,
592 state.scroll_offset,
593 state.max_visible
594 );
595 };
596
597 for i in 0..12 {
599 state.select_next();
600 check_visible(&state);
601 assert_eq!(state.selected, i + 1);
602 }
603
604 assert_eq!(state.selected, 12);
606 check_visible(&state);
607
608 for i in (0..12).rev() {
610 state.select_prev();
611 check_visible(&state);
612 assert_eq!(state.selected, i);
613 }
614
615 assert_eq!(state.selected, 0);
617 check_visible(&state);
618
619 state.selected = 8;
621 state.ensure_visible();
622 state.selected = 0;
623 state.ensure_visible();
624 check_visible(&state);
625
626 state.selected = 12;
628 state.ensure_visible();
629 check_visible(&state);
630 }
631}