use std::collections::HashSet;
use ratatui::{
buffer::Buffer,
layout::{Constraint, Layout, Rect},
style::{Color, Style},
widgets::{Borders, StatefulWidget},
};
use crate::popup::{Popup, PopupSize};
use crate::scroll_list::{ScrollList, ScrollListState};
use crate::text_input::{TextInput, TextInputState};
use crate::{BorderType, BORDER_GRAY, DEFAULT_HIGHLIGHT};
const DEFAULT_SELECTED: Style = DEFAULT_HIGHLIGHT;
#[derive(Clone)]
pub struct ChoosePopup<'a> {
popup: Popup<'a>,
items: Vec<(String, Style)>,
highlight_style: Style,
filter_input: TextInput<'a>,
filter_margin_bottom: u16,
max_selected: Option<usize>,
}
#[derive(Clone)]
pub struct ChoosePopupState {
pub cursor: usize,
pub chosen_indices: HashSet<usize>,
pub max_selected: Option<usize>,
pub scroll_list_state: ScrollListState,
pub text_input: TextInputState,
pub show_filter: bool,
}
impl Default for ChoosePopupState {
fn default() -> Self {
Self {
cursor: 0,
chosen_indices: HashSet::new(),
max_selected: None,
scroll_list_state: ScrollListState::default(),
text_input: TextInputState::default(),
show_filter: false,
}
}
}
impl ChoosePopupState {
pub fn toggle(&mut self, idx: usize) {
if !self.chosen_indices.remove(&idx) {
if let Some(max) = self.max_selected {
if self.chosen_indices.len() >= max {
return;
}
}
self.chosen_indices.insert(idx);
}
}
pub fn toggle_cursor(&mut self) {
self.toggle(self.cursor);
}
pub fn toggle_selected(&mut self, items: &[(String, Style)]) {
if let Some(orig_idx) = self.original_index(items) {
self.toggle(orig_idx);
}
}
pub fn next(&mut self, item_count: usize) {
if item_count > 0 {
self.cursor = (self.cursor + 1).min(item_count.saturating_sub(1));
}
}
pub fn previous(&mut self) {
self.cursor = self.cursor.saturating_sub(1);
}
pub fn first(&mut self) {
self.cursor = 0;
}
pub fn last(&mut self, item_count: usize) {
self.cursor = item_count.saturating_sub(1);
}
pub fn original_index(&self, items: &[(String, Style)]) -> Option<usize> {
if self.show_filter && !self.text_input.content.is_empty() {
self.filtered_indices(items).get(self.cursor).copied()
} else {
Some(self.cursor)
}
}
pub fn visible_count(&self, items: &[(String, Style)]) -> usize {
if self.show_filter && !self.text_input.content.is_empty() {
self.filtered_indices(items).len()
} else {
items.len()
}
}
pub fn filtered_indices(&self, items: &[(String, Style)]) -> Vec<usize> {
let filter = &self.text_input.content;
items
.iter()
.enumerate()
.filter(|(_, (text, _))| {
text.to_lowercase()
.contains(&filter.to_lowercase())
})
.map(|(i, _)| i)
.collect()
}
pub fn insert_filter_char(&mut self, c: char) {
self.text_input.insert_char(c);
}
pub fn delete_before_filter(&mut self) {
self.text_input.delete_before();
}
pub fn delete_at_filter(&mut self) {
self.text_input.delete_at();
}
pub fn filter_cursor_left(&mut self) {
self.text_input.cursor_left();
}
pub fn filter_cursor_right(&mut self) {
self.text_input.cursor_right();
}
pub fn filter_cursor_home(&mut self) {
self.text_input.cursor_home();
}
pub fn filter_cursor_end(&mut self) {
self.text_input.cursor_end();
}
}
impl<'a> ChoosePopup<'a> {
pub fn item_at(&self, state: &ChoosePopupState, popup_rect: Rect, row: u16) -> Option<usize> {
let content_y = popup_rect.y + self.popup.content_offset();
if row < content_y {
return None;
}
let row_offset = row - content_y;
let filter_height = self.filter_input.required_height();
let filter_total = filter_height + self.filter_margin_bottom;
if state.show_filter && row_offset < filter_total {
return None;
}
let list_row = if state.show_filter { row_offset - filter_total } else { row_offset };
let idx = list_row as usize + state.scroll_list_state.list_state.offset();
if state.show_filter && !state.text_input.content.is_empty() {
state.filtered_indices(&self.items).get(idx).copied()
} else if idx < self.items.len() {
Some(idx)
} else {
None
}
}
pub fn resolve_rect(&self, area: Rect) -> Rect {
self.popup.resolve_rect(area)
}
pub fn new(items: Vec<(String, Style)>) -> Self {
Self {
popup: Popup::new(Color::White).padding(0),
items,
highlight_style: DEFAULT_SELECTED,
filter_input: TextInput::new()
.border_style(Style::new().fg(BORDER_GRAY)),
filter_margin_bottom: 1,
max_selected: None,
}
}
pub fn title(mut self, title: &'a str) -> Self {
self.popup = self.popup.title(title);
self
}
pub fn border_color(mut self, color: Color) -> Self {
self.popup = self.popup.border_color(color);
self
}
pub fn bg_color(mut self, color: Color) -> Self {
self.popup = self.popup.bg_color(color);
self
}
pub fn border_type(mut self, bt: BorderType) -> Self {
self.popup = self.popup.border_type(bt);
self
}
pub fn position(mut self, x: u16, y: u16) -> Self {
self.popup = self.popup.position(x, y);
self
}
pub fn origin(mut self, x: u16, y: u16) -> Self {
self.popup = self.popup.origin(x, y);
self
}
pub fn header(mut self) -> Self {
self.popup = self.popup.header();
self
}
pub fn width(mut self, w: PopupSize) -> Self {
self.popup = self.popup.width(w);
self
}
pub fn height(mut self, h: PopupSize) -> Self {
self.popup = self.popup.height(h);
self
}
pub fn padding(mut self, p: u16) -> Self {
self.popup = self.popup.padding(p);
self
}
pub fn highlight_style(mut self, style: Style) -> Self {
self.highlight_style = style;
self
}
pub fn filter_border_type(mut self, bt: BorderType) -> Self {
self.filter_input = self.filter_input.border_type(bt);
self
}
pub fn filter_borders(mut self, borders: Borders) -> Self {
self.filter_input = self.filter_input.borders(borders);
self
}
pub fn filter_border_style(mut self, style: Style) -> Self {
self.filter_input = self.filter_input.border_style(style);
self
}
pub fn filter_bg_color(mut self, color: Color) -> Self {
self.filter_input = self.filter_input.bg_color(color);
self
}
pub fn filter_cursor_style(mut self, style: Style) -> Self {
self.filter_input = self.filter_input.cursor_style(style);
self
}
pub fn filter_text_style(mut self, style: Style) -> Self {
self.filter_input = self.filter_input.text_style(style);
self
}
pub fn filter_placeholder(mut self, placeholder: &'a str) -> Self {
self.filter_input = self.filter_input.placeholder(placeholder);
self
}
pub fn filter_rows(mut self, rows: u16) -> Self {
self.filter_input = self.filter_input.rows(rows);
self
}
pub fn filter_cols(mut self, cols: u16) -> Self {
self.filter_input = self.filter_input.cols(cols);
self
}
pub fn filter_scroll_padding(mut self, padding: u16) -> Self {
self.filter_input = self.filter_input.scroll_padding(padding);
self
}
pub fn filter_scroll_reserve(mut self, reserve: u16) -> Self {
self.filter_input = self.filter_input.scroll_reserve(reserve);
self
}
pub fn filter_margin_bottom(mut self, margin: u16) -> Self {
self.filter_margin_bottom = margin;
self
}
pub fn max_selected(mut self, max: usize) -> Self {
self.max_selected = Some(max);
self
}
pub fn no_max_selected(mut self) -> Self {
self.max_selected = None;
self
}
}
impl StatefulWidget for ChoosePopup<'_> {
type State = ChoosePopupState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
state.max_selected = self.max_selected;
let mut popup = self.popup;
if popup.get_height() == PopupSize::Auto {
let default_h = PopupSize::Fixed((self.items.len() as u16 + 4).min(60));
popup = popup.height(default_h);
}
let inner = popup.render_inner(area, buf);
let list_area = if state.show_filter {
let filter_height = self.filter_input.required_height();
let vchunks = Layout::vertical([
Constraint::Length(filter_height),
Constraint::Length(self.filter_margin_bottom),
Constraint::Min(0),
]).split(inner);
self.filter_input.render(vchunks[0], buf, &mut state.text_input);
vchunks[2]
} else {
inner
};
let filtered_indices = if state.show_filter && !state.text_input.content.is_empty() {
state.filtered_indices(&self.items)
} else {
Vec::new()
};
let items: Vec<(String, Style)> = if state.show_filter && !state.text_input.content.is_empty() {
filtered_indices
.iter()
.map(|&orig_idx| {
let prefix = if state.chosen_indices.contains(&orig_idx) {
"✓ "
} else {
" "
};
let (text, style) = &self.items[orig_idx];
(format!("{}{}", prefix, text), *style)
})
.collect()
} else {
self.items
.iter()
.enumerate()
.map(|(i, (text, style))| {
let prefix = if state.chosen_indices.contains(&i) {
"✓ "
} else {
" "
};
(format!("{}{}", prefix, text), *style)
})
.collect()
};
state.scroll_list_state.follow = false;
state.scroll_list_state.select(Some(state.cursor));
let scroll_list = ScrollList::new_styled(items)
.highlight_style(self.highlight_style);
scroll_list.render(list_area, buf, &mut state.scroll_list_state);
if let Some(idx) = state.scroll_list_state.list_state.selected() {
state.cursor = idx;
}
}
}
#[cfg(test)]
mod tests;