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 SelectMessage {
Open,
Close,
Toggle,
Down,
Up,
Confirm,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SelectOutput {
Selected(String),
SelectionChanged(usize),
Submitted(usize),
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub struct SelectState {
options: Vec<String>,
selected_index: Option<usize>,
highlighted_index: usize,
is_open: bool,
placeholder: String,
}
impl Default for SelectState {
fn default() -> Self {
Self {
options: Vec::new(),
selected_index: None,
highlighted_index: 0,
is_open: false,
placeholder: String::from("Select..."),
}
}
}
impl SelectState {
pub fn new<S: Into<String>>(options: Vec<S>) -> Self {
Self {
options: options.into_iter().map(|s| s.into()).collect(),
..Self::default()
}
}
pub fn with_selection<S: Into<String>>(options: Vec<S>, selected: usize) -> Self {
let options_vec: Vec<String> = options.into_iter().map(|s| s.into()).collect();
let selected_index = if selected < options_vec.len() {
Some(selected)
} else {
None
};
Self {
options: options_vec,
selected_index,
highlighted_index: selected_index.unwrap_or(0),
..Self::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;
}
}
if self.highlighted_index >= self.options.len() && !self.options.is_empty() {
self.highlighted_index = 0;
}
}
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);
self.highlighted_index = idx;
}
} else {
self.selected_index = None;
}
}
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: SelectMessage) -> Option<SelectOutput> {
Select::update(self, msg)
}
}
pub struct Select;
impl Component for Select {
type State = SelectState;
type Message = SelectMessage;
type Output = SelectOutput;
fn init() -> Self::State {
SelectState::default()
}
fn update(state: &mut Self::State, msg: Self::Message) -> Option<Self::Output> {
match msg {
SelectMessage::Open => {
if !state.options.is_empty() {
state.is_open = true;
state.highlighted_index = state.selected_index.unwrap_or(0);
}
None
}
SelectMessage::Close => {
state.is_open = false;
None
}
SelectMessage::Toggle => {
if state.is_open {
state.is_open = false;
} else if !state.options.is_empty() {
state.is_open = true;
state.highlighted_index = state.selected_index.unwrap_or(0);
}
None
}
SelectMessage::Down => {
if state.is_open && !state.options.is_empty() {
state.highlighted_index = (state.highlighted_index + 1) % state.options.len();
Some(SelectOutput::SelectionChanged(state.highlighted_index))
} else {
None
}
}
SelectMessage::Up => {
if state.is_open && !state.options.is_empty() {
if state.highlighted_index == 0 {
state.highlighted_index = state.options.len() - 1;
} else {
state.highlighted_index -= 1;
}
Some(SelectOutput::SelectionChanged(state.highlighted_index))
} else {
None
}
}
SelectMessage::Confirm => {
if state.is_open && !state.options.is_empty() {
let old_selection = state.selected_index;
let highlighted = state.highlighted_index;
state.selected_index = Some(highlighted);
state.is_open = false;
if old_selection != state.selected_index {
Some(SelectOutput::Selected(state.options[highlighted].clone()))
} else {
Some(SelectOutput::Submitted(highlighted))
}
} 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(SelectMessage::Confirm),
Key::Esc => Some(SelectMessage::Close),
Key::Up | Key::Char('k') => Some(SelectMessage::Up),
Key::Down | Key::Char('j') => Some(SelectMessage::Down),
_ => None,
}
} else {
match key.code {
Key::Enter | Key::Char(' ') => Some(SelectMessage::Toggle),
_ => None,
}
}
} else {
None
}
}
fn view(state: &Self::State, ctx: &mut RenderContext<'_, '_>) {
crate::annotation::with_registry(|reg| {
let mut ann = crate::annotation::Annotation::new(crate::annotation::WidgetType::Select)
.with_id("select")
.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 let Some(value) = state.selected_value() {
let arrow = if state.is_open { "▲" } else { "▼" };
format!("{} {}", value, arrow)
} else {
let arrow = if state.is_open { "▲" } else { "▼" };
format!("{} {}", state.placeholder, arrow)
};
let paragraph = Paragraph::new(display_text).style(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),
};
let items: Vec<ListItem> = state
.options
.iter()
.enumerate()
.map(|(idx, opt)| {
let prefix = if idx == state.highlighted_index {
"> "
} else {
" "
};
let text = format!("{}{}", prefix, opt);
let item_style = if idx == 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;