use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph};
use super::{Component, EventContext, RenderContext};
use crate::input::{Event, Key};
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum DropdownMessage {
Open,
Close,
Toggle,
Insert(char),
Backspace,
ClearFilter,
Down,
Up,
Confirm,
SetFilter(String),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum DropdownOutput {
Selected(String),
SelectionChanged(usize),
Submitted(usize),
FilterChanged(String),
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub struct DropdownState {
options: Vec<String>,
selected_index: Option<usize>,
filter_text: String,
filtered_indices: Vec<usize>,
highlighted_index: usize,
is_open: bool,
placeholder: String,
}
impl Default for DropdownState {
fn default() -> Self {
Self {
options: Vec::new(),
selected_index: None,
filter_text: String::new(),
filtered_indices: Vec::new(),
highlighted_index: 0,
is_open: false,
placeholder: String::from("Search..."),
}
}
}
impl DropdownState {
pub fn new<S: Into<String>>(options: Vec<S>) -> Self {
let options: Vec<String> = options.into_iter().map(|s| s.into()).collect();
let filtered_indices: Vec<usize> = (0..options.len()).collect();
Self {
options,
filtered_indices,
..Default::default()
}
}
pub fn with_selection<S: Into<String>>(options: Vec<S>, selected: usize) -> Self {
let options: Vec<String> = options.into_iter().map(|s| s.into()).collect();
let selected_index = if selected < options.len() {
Some(selected)
} else {
None
};
let filtered_indices: Vec<usize> = (0..options.len()).collect();
Self {
options,
selected_index,
filtered_indices,
..Default::default()
}
}
pub fn options(&self) -> &[String] {
&self.options
}
pub fn set_options<S: Into<String>>(&mut self, options: Vec<S>) {
self.options = options.into_iter().map(|s| s.into()).collect();
if let Some(idx) = self.selected_index {
if idx >= self.options.len() {
self.selected_index = None;
}
}
self.update_filter();
}
pub fn selected_index(&self) -> Option<usize> {
self.selected_index
}
pub fn selected(&self) -> Option<usize> {
self.selected_index()
}
pub fn selected_value(&self) -> Option<&str> {
self.selected_index
.and_then(|idx| self.options.get(idx).map(|s| s.as_str()))
}
pub fn selected_item(&self) -> Option<&str> {
self.selected_value()
}
pub fn set_selected(&mut self, index: Option<usize>) {
if let Some(idx) = index {
if idx < self.options.len() {
self.selected_index = Some(idx);
}
} else {
self.selected_index = None;
}
}
pub fn filter_text(&self) -> &str {
&self.filter_text
}
pub fn filtered_options(&self) -> Vec<&str> {
self.filtered_indices
.iter()
.filter_map(|&idx| self.options.get(idx).map(|s| s.as_str()))
.collect()
}
pub fn filtered_count(&self) -> usize {
self.filtered_indices.len()
}
pub fn is_open(&self) -> bool {
self.is_open
}
pub fn placeholder(&self) -> &str {
&self.placeholder
}
pub fn set_placeholder(&mut self, placeholder: impl Into<String>) {
self.placeholder = placeholder.into();
}
pub fn with_placeholder(mut self, placeholder: impl Into<String>) -> Self {
self.placeholder = placeholder.into();
self
}
pub fn update(&mut self, msg: DropdownMessage) -> Option<DropdownOutput> {
Dropdown::update(self, msg)
}
fn update_filter(&mut self) {
let filter_lower = self.filter_text.to_lowercase();
self.filtered_indices = self
.options
.iter()
.enumerate()
.filter(|(_, opt)| {
if filter_lower.is_empty() {
true
} else {
opt.to_lowercase().contains(&filter_lower)
}
})
.map(|(i, _)| i)
.collect();
self.highlighted_index = 0;
}
}
pub struct Dropdown;
impl Component for Dropdown {
type State = DropdownState;
type Message = DropdownMessage;
type Output = DropdownOutput;
fn init() -> Self::State {
DropdownState::default()
}
fn update(state: &mut Self::State, msg: Self::Message) -> Option<Self::Output> {
match msg {
DropdownMessage::Open => {
if !state.options.is_empty() {
state.is_open = true;
state.filter_text.clear();
state.update_filter();
if let Some(selected) = state.selected_index {
state.highlighted_index = state
.filtered_indices
.iter()
.position(|&idx| idx == selected)
.unwrap_or(0);
}
}
None
}
DropdownMessage::Close => {
state.is_open = false;
state.filter_text.clear();
state.update_filter();
None
}
DropdownMessage::Toggle => {
if state.is_open {
state.is_open = false;
state.filter_text.clear();
state.update_filter();
} else if !state.options.is_empty() {
state.is_open = true;
state.filter_text.clear();
state.update_filter();
if let Some(selected) = state.selected_index {
state.highlighted_index = state
.filtered_indices
.iter()
.position(|&idx| idx == selected)
.unwrap_or(0);
}
}
None
}
DropdownMessage::Insert(c) => {
state.filter_text.push(c);
state.update_filter();
if !state.is_open && !state.options.is_empty() {
state.is_open = true;
}
Some(DropdownOutput::FilterChanged(state.filter_text.clone()))
}
DropdownMessage::Backspace => {
if state.filter_text.pop().is_some() {
state.update_filter();
Some(DropdownOutput::FilterChanged(state.filter_text.clone()))
} else {
None
}
}
DropdownMessage::ClearFilter => {
if !state.filter_text.is_empty() {
state.filter_text.clear();
state.update_filter();
Some(DropdownOutput::FilterChanged(state.filter_text.clone()))
} else {
None
}
}
DropdownMessage::SetFilter(text) => {
if state.filter_text != text {
state.filter_text = text;
state.update_filter();
if !state.is_open && !state.options.is_empty() {
state.is_open = true;
}
Some(DropdownOutput::FilterChanged(state.filter_text.clone()))
} else {
None
}
}
DropdownMessage::Down => {
if state.is_open && !state.filtered_indices.is_empty() {
state.highlighted_index =
(state.highlighted_index + 1) % state.filtered_indices.len();
let original_index = state.filtered_indices[state.highlighted_index];
Some(DropdownOutput::SelectionChanged(original_index))
} else {
None
}
}
DropdownMessage::Up => {
if state.is_open && !state.filtered_indices.is_empty() {
if state.highlighted_index == 0 {
state.highlighted_index = state.filtered_indices.len() - 1;
} else {
state.highlighted_index -= 1;
}
let original_index = state.filtered_indices[state.highlighted_index];
Some(DropdownOutput::SelectionChanged(original_index))
} else {
None
}
}
DropdownMessage::Confirm => {
if state.is_open && !state.filtered_indices.is_empty() {
let original_index = state.filtered_indices[state.highlighted_index];
let old_selection = state.selected_index;
state.selected_index = Some(original_index);
state.is_open = false;
state.filter_text.clear();
state.update_filter();
if old_selection != state.selected_index {
Some(DropdownOutput::Selected(
state.options[original_index].clone(),
))
} else {
Some(DropdownOutput::Submitted(original_index))
}
} else {
None
}
}
}
}
fn handle_event(
state: &Self::State,
event: &Event,
ctx: &EventContext,
) -> Option<Self::Message> {
if !ctx.focused || ctx.disabled {
return None;
}
if let Some(key) = event.as_key() {
if state.is_open {
match key.code {
Key::Enter => Some(DropdownMessage::Confirm),
Key::Esc => Some(DropdownMessage::Close),
Key::Up => Some(DropdownMessage::Up),
Key::Down => Some(DropdownMessage::Down),
Key::Char(c) if key.modifiers.is_none() => Some(DropdownMessage::Insert(c)),
Key::Backspace => Some(DropdownMessage::Backspace),
_ => None,
}
} else {
match key.code {
Key::Enter => Some(DropdownMessage::Toggle),
_ => None,
}
}
} else {
None
}
}
fn view(state: &Self::State, ctx: &mut RenderContext<'_, '_>) {
crate::annotation::with_registry(|reg| {
let mut ann = crate::annotation::Annotation::dropdown("dropdown")
.with_focus(ctx.focused)
.with_disabled(ctx.disabled)
.with_expanded(state.is_open);
if let Some(val) = state.selected_value() {
ann = ann.with_value(val.to_string());
}
reg.register(ctx.area, ann);
});
let style = if ctx.disabled {
ctx.theme.disabled_style()
} else if ctx.focused {
ctx.theme.focused_style()
} else {
ctx.theme.normal_style()
};
let border_style = if ctx.focused && !ctx.disabled {
ctx.theme.focused_border_style()
} else {
ctx.theme.border_style()
};
let display_text = if state.is_open {
let arrow = "▲";
if state.filter_text.is_empty() {
format!("█ {}", arrow)
} else {
format!("{}█ {}", state.filter_text, arrow)
}
} else if let Some(value) = state.selected_value() {
format!("{} ▼", value)
} else {
format!("{} ▼", state.placeholder)
};
let text_style = if !state.is_open
&& state.selected_value().is_none()
&& !ctx.disabled
&& !ctx.focused
{
ctx.theme.placeholder_style()
} else {
style
};
let paragraph = Paragraph::new(display_text).style(text_style).block(
Block::default()
.borders(Borders::ALL)
.border_style(border_style),
);
if !state.is_open {
ctx.frame.render_widget(paragraph, ctx.area);
} else {
let closed_height = 3; let closed_area = Rect {
x: ctx.area.x,
y: ctx.area.y,
width: ctx.area.width,
height: closed_height.min(ctx.area.height),
};
ctx.frame.render_widget(paragraph, closed_area);
if ctx.area.height > closed_height {
let list_area = Rect {
x: ctx.area.x,
y: ctx.area.y + closed_height,
width: ctx.area.width,
height: ctx.area.height.saturating_sub(closed_height),
};
if state.filtered_indices.is_empty() {
let no_match = Paragraph::new(" No matches")
.style(ctx.theme.placeholder_style())
.block(
Block::default()
.borders(Borders::ALL)
.border_style(border_style),
);
ctx.frame.render_widget(no_match, list_area);
} else {
let items: Vec<ListItem> = state
.filtered_indices
.iter()
.enumerate()
.map(|(i, &orig_idx)| {
let opt = &state.options[orig_idx];
let prefix = if i == state.highlighted_index {
"> "
} else {
" "
};
let text = format!("{}{}", prefix, opt);
let item_style = if i == state.highlighted_index {
ctx.theme.selected_style(ctx.focused)
} else {
ctx.theme.normal_style()
};
ListItem::new(text).style(item_style)
})
.collect();
let list = List::new(items).block(
Block::default()
.borders(Borders::ALL)
.border_style(border_style),
);
ctx.frame.render_widget(list, list_area);
}
}
}
}
}
#[cfg(test)]
mod tests;