mod input;
mod render;
use ratatui::layout::Rect;
use ratatui::style::Color;
pub use input::DropdownEvent;
pub use render::{render_dropdown, render_dropdown_aligned};
use super::FocusState;
#[derive(Debug, Clone)]
pub struct DropdownState {
pub selected: usize,
pub options: Vec<String>,
pub values: Vec<String>,
pub label: String,
pub open: bool,
pub focus: FocusState,
original_selected: Option<usize>,
pub scroll_offset: usize,
pub max_visible: usize,
}
impl DropdownState {
pub fn new(options: Vec<String>, label: impl Into<String>) -> Self {
Self {
selected: 0,
options,
values: Vec::new(),
label: label.into(),
open: false,
focus: FocusState::Normal,
original_selected: None,
scroll_offset: 0,
max_visible: 5, }
}
pub fn with_values(
options: Vec<String>,
values: Vec<String>,
label: impl Into<String>,
) -> Self {
debug_assert_eq!(options.len(), values.len());
Self {
selected: 0,
options,
values,
label: label.into(),
open: false,
focus: FocusState::Normal,
original_selected: None,
scroll_offset: 0,
max_visible: 5, }
}
pub fn with_selected(mut self, index: usize) -> Self {
if index < self.options.len() {
self.selected = index;
}
self
}
pub fn with_focus(mut self, focus: FocusState) -> Self {
self.focus = focus;
self
}
pub fn is_enabled(&self) -> bool {
self.focus != FocusState::Disabled
}
pub fn selected_value(&self) -> Option<&str> {
if self.values.is_empty() {
self.options.get(self.selected).map(|s| s.as_str())
} else {
self.values.get(self.selected).map(|s| s.as_str())
}
}
pub fn selected_option(&self) -> Option<&str> {
self.options.get(self.selected).map(|s| s.as_str())
}
pub fn index_of_value(&self, value: &str) -> Option<usize> {
if self.values.is_empty() {
self.options.iter().position(|o| o == value)
} else {
self.values.iter().position(|v| v == value)
}
}
pub fn toggle_open(&mut self) {
if self.is_enabled() {
if !self.open {
self.original_selected = Some(self.selected);
} else {
self.original_selected = None;
}
self.open = !self.open;
}
}
pub fn cancel(&mut self) {
if let Some(original) = self.original_selected.take() {
self.selected = original;
}
self.open = false;
}
pub fn confirm(&mut self) {
self.original_selected = None;
self.open = false;
}
pub fn select_next(&mut self) {
if self.is_enabled() && !self.options.is_empty() {
self.selected = (self.selected + 1) % self.options.len();
self.ensure_visible();
}
}
pub fn select_prev(&mut self) {
if self.is_enabled() && !self.options.is_empty() {
self.selected = if self.selected == 0 {
self.options.len() - 1
} else {
self.selected - 1
};
self.ensure_visible();
}
}
pub fn select(&mut self, index: usize) {
if self.is_enabled() && index < self.options.len() {
self.selected = index;
self.original_selected = None;
self.open = false;
}
}
pub fn ensure_visible(&mut self) {
if self.max_visible == 0 || self.options.len() <= self.max_visible {
self.scroll_offset = 0;
return;
}
if self.selected < self.scroll_offset {
self.scroll_offset = self.selected;
}
else if self.selected >= self.scroll_offset + self.max_visible {
self.scroll_offset = self.selected.saturating_sub(self.max_visible - 1);
}
}
pub fn scroll_by(&mut self, delta: i32) {
if self.options.len() <= self.max_visible {
return;
}
let max_offset = self.options.len().saturating_sub(self.max_visible);
if delta > 0 {
self.scroll_offset = (self.scroll_offset + delta as usize).min(max_offset);
} else {
self.scroll_offset = self.scroll_offset.saturating_sub((-delta) as usize);
}
}
pub fn needs_scrollbar(&self) -> bool {
self.open && self.options.len() > self.max_visible
}
pub fn scroll_fraction(&self) -> f32 {
if self.options.len() <= self.max_visible {
return 0.0;
}
let max_offset = self.options.len().saturating_sub(self.max_visible);
if max_offset == 0 {
return 0.0;
}
self.scroll_offset as f32 / max_offset as f32
}
}
#[derive(Debug, Clone, Copy)]
pub struct DropdownColors {
pub label: Color,
pub selected: Color,
pub border: Color,
pub arrow: Color,
pub option: Color,
pub highlight_bg: Color,
pub focused: Color,
pub disabled: Color,
}
impl Default for DropdownColors {
fn default() -> Self {
Self {
label: Color::White,
selected: Color::Cyan,
border: Color::Gray,
arrow: Color::DarkGray,
option: Color::White,
highlight_bg: Color::DarkGray,
focused: Color::Cyan,
disabled: Color::DarkGray,
}
}
}
impl DropdownColors {
pub fn from_theme(theme: &crate::view::theme::Theme) -> Self {
Self {
label: theme.editor_fg,
selected: theme.editor_fg,
border: theme.line_number_fg,
arrow: theme.line_number_fg,
option: theme.editor_fg,
highlight_bg: theme.selection_bg,
focused: theme.menu_highlight_fg,
disabled: theme.line_number_fg,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct DropdownLayout {
pub button_area: Rect,
pub option_areas: Vec<Rect>,
pub full_area: Rect,
pub scroll_offset: usize,
}
impl DropdownLayout {
pub fn is_button(&self, x: u16, y: u16) -> bool {
x >= self.button_area.x
&& x < self.button_area.x + self.button_area.width
&& y >= self.button_area.y
&& y < self.button_area.y + self.button_area.height
}
pub fn option_at(&self, x: u16, y: u16) -> Option<usize> {
for (i, area) in self.option_areas.iter().enumerate() {
if x >= area.x && x < area.x + area.width && y >= area.y && y < area.y + area.height {
return Some(self.scroll_offset + i);
}
}
None
}
pub fn contains(&self, x: u16, y: u16) -> bool {
x >= self.full_area.x
&& x < self.full_area.x + self.full_area.width
&& y >= self.full_area.y
&& y < self.full_area.y + self.full_area.height
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::backend::TestBackend;
use ratatui::Terminal;
fn test_frame<F>(width: u16, height: u16, f: F)
where
F: FnOnce(&mut ratatui::Frame, Rect),
{
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
let area = Rect::new(0, 0, width, height);
f(frame, area);
})
.unwrap();
}
#[test]
fn test_dropdown_renders() {
test_frame(40, 1, |frame, area| {
let state = DropdownState::new(
vec!["Option A".to_string(), "Option B".to_string()],
"Choice",
);
let colors = DropdownColors::default();
let layout = render_dropdown(frame, area, &state, &colors);
assert!(layout.button_area.width > 0);
assert!(layout.option_areas.is_empty());
});
}
#[test]
fn test_dropdown_open() {
test_frame(40, 5, |frame, area| {
let mut state = DropdownState::new(
vec!["Option A".to_string(), "Option B".to_string()],
"Choice",
);
state.open = true;
let colors = DropdownColors::default();
let layout = render_dropdown(frame, area, &state, &colors);
assert_eq!(layout.option_areas.len(), 2);
});
}
#[test]
fn test_dropdown_selection() {
let mut state = DropdownState::new(
vec!["A".to_string(), "B".to_string(), "C".to_string()],
"Test",
);
assert_eq!(state.selected, 0);
state.select_next();
assert_eq!(state.selected, 1);
state.select_next();
assert_eq!(state.selected, 2);
state.select_next();
assert_eq!(state.selected, 0);
state.select_prev();
assert_eq!(state.selected, 2);
}
#[test]
fn test_dropdown_select_by_index() {
let mut state = DropdownState::new(
vec!["A".to_string(), "B".to_string(), "C".to_string()],
"Test",
);
state.open = true;
state.select(2);
assert_eq!(state.selected, 2);
assert!(!state.open);
}
#[test]
fn test_dropdown_disabled() {
let mut state = DropdownState::new(vec!["A".to_string(), "B".to_string()], "Test")
.with_focus(FocusState::Disabled);
state.toggle_open();
assert!(!state.open);
state.select_next();
assert_eq!(state.selected, 0);
}
#[test]
fn test_dropdown_cancel_restores_original() {
let mut state = DropdownState::new(
vec!["A".to_string(), "B".to_string(), "C".to_string()],
"Test",
)
.with_selected(1);
state.toggle_open();
assert!(state.open);
assert_eq!(state.selected, 1);
state.select_next();
assert_eq!(state.selected, 2);
state.cancel();
assert!(!state.open);
assert_eq!(state.selected, 1);
}
#[test]
fn test_dropdown_confirm_commits_selection() {
let mut state = DropdownState::new(
vec!["A".to_string(), "B".to_string(), "C".to_string()],
"Test",
)
.with_selected(0);
state.toggle_open();
assert!(state.open);
state.select_next();
assert_eq!(state.selected, 1);
state.confirm();
assert!(!state.open);
assert_eq!(state.selected, 1);
}
#[test]
fn test_dropdown_toggle_close_confirms() {
let mut state = DropdownState::new(
vec!["A".to_string(), "B".to_string(), "C".to_string()],
"Test",
)
.with_selected(0);
state.toggle_open();
assert!(state.open);
state.select_next();
assert_eq!(state.selected, 1);
state.toggle_open();
assert!(!state.open);
assert_eq!(state.selected, 1);
}
#[test]
fn test_dropdown_scrolling() {
let options: Vec<String> = (0..20).map(|i| format!("Option {}", i)).collect();
let mut state = DropdownState::new(options, "Long List");
state.max_visible = 5;
assert_eq!(state.scroll_offset, 0);
state.selected = 10;
state.ensure_visible();
assert!(state.scroll_offset > 0);
assert!(state.selected >= state.scroll_offset);
assert!(state.selected < state.scroll_offset + state.max_visible);
}
#[test]
fn test_dropdown_scroll_by() {
let options: Vec<String> = (0..20).map(|i| format!("Option {}", i)).collect();
let mut state = DropdownState::new(options, "Long List");
state.max_visible = 5;
state.scroll_by(3);
assert_eq!(state.scroll_offset, 3);
state.scroll_by(-2);
assert_eq!(state.scroll_offset, 1);
state.scroll_by(-10);
assert_eq!(state.scroll_offset, 0);
state.scroll_by(100);
assert_eq!(state.scroll_offset, 15); }
#[test]
fn test_dropdown_needs_scrollbar() {
let options: Vec<String> = (0..10).map(|i| format!("Option {}", i)).collect();
let mut state = DropdownState::new(options, "Test");
state.max_visible = 5;
assert!(!state.needs_scrollbar());
state.open = true;
assert!(state.needs_scrollbar());
state.max_visible = 20;
assert!(!state.needs_scrollbar());
}
#[test]
fn test_dropdown_keyboard_nav_scrolls() {
let options: Vec<String> = (0..10).map(|i| format!("Option {}", i)).collect();
let mut state = DropdownState::new(options, "Test");
state.max_visible = 3;
state.open = true;
for _ in 0..5 {
state.select_next();
}
assert_eq!(state.selected, 5);
assert!(state.selected >= state.scroll_offset);
assert!(state.selected < state.scroll_offset + state.max_visible);
}
#[test]
fn test_dropdown_selection_always_visible() {
let options: Vec<String> = vec![
"Auto-detect",
"Czech",
"German",
"English",
"Spanish",
"French",
"Japanese",
"Korean",
"Portuguese",
"Russian",
"Thai",
"Ukrainian",
"Chinese",
]
.into_iter()
.map(String::from)
.collect();
let mut state = DropdownState::new(options, "Locale");
state.max_visible = 5; state.open = true;
let check_visible = |state: &DropdownState| {
assert!(
state.selected >= state.scroll_offset,
"selected {} below scroll_offset {}",
state.selected,
state.scroll_offset
);
assert!(
state.selected < state.scroll_offset + state.max_visible,
"selected {} above visible area (scroll_offset={}, max_visible={})",
state.selected,
state.scroll_offset,
state.max_visible
);
};
for i in 0..12 {
state.select_next();
check_visible(&state);
assert_eq!(state.selected, i + 1);
}
assert_eq!(state.selected, 12);
check_visible(&state);
for i in (0..12).rev() {
state.select_prev();
check_visible(&state);
assert_eq!(state.selected, i);
}
assert_eq!(state.selected, 0);
check_visible(&state);
state.selected = 8;
state.ensure_visible();
state.selected = 0;
state.ensure_visible();
check_visible(&state);
state.selected = 12;
state.ensure_visible();
check_visible(&state);
}
}