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