use crate::app::pointer::Hit;
use crate::render::context::RenderContext;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::widgets::{Paragraph, Widget};
#[derive(Debug, Clone, Default)]
pub struct PickerState {
items: Vec<String>,
pub filter: String,
selected: usize,
}
impl PickerState {
pub fn new(items: Vec<String>) -> Self {
Self {
items,
filter: String::new(),
selected: 0,
}
}
pub fn items(&self) -> &[String] {
&self.items
}
pub fn filtered(&self) -> Vec<(usize, &str)> {
let mut ranked: Vec<(u8, usize, &str)> = self
.items
.iter()
.enumerate()
.filter_map(|(i, item)| {
crate::home::fuzzy::substring_rank(&self.filter, item)
.map(|r| (r, i, item.as_str()))
})
.collect();
ranked.sort_by_key(|(rank, i, _)| (*rank, *i));
ranked.into_iter().map(|(_, i, item)| (i, item)).collect()
}
pub fn visible_selection(&self) -> usize {
self.filtered()
.iter()
.position(|(i, _)| *i == self.selected)
.unwrap_or(0)
}
pub fn selected_original(&self) -> Option<usize> {
let filtered = self.filtered();
filtered
.get(self.visible_selection())
.or_else(|| filtered.first())
.map(|(i, _)| *i)
}
pub fn select_original(&mut self, index: usize) {
if index < self.items.len() {
self.selected = index;
}
}
pub fn type_char(&mut self, c: char) {
self.filter.push(c);
self.settle();
}
pub fn backspace(&mut self) {
self.filter.pop();
self.settle();
}
pub fn clear_filter(&mut self) {
self.filter.clear();
self.settle();
}
pub fn filter_key(&mut self, c: char, mods: crossterm::event::KeyModifiers) {
use crossterm::event::KeyModifiers;
let ctrl = mods.contains(KeyModifiers::CONTROL);
if ctrl && c == 'w' {
self.delete_word();
} else if ctrl && c == 'u' {
self.clear_filter();
} else if !ctrl && !mods.contains(KeyModifiers::ALT) {
self.type_char(c);
}
}
pub fn delete_word(&mut self) {
while self.filter.ends_with(' ') {
self.filter.pop();
}
while self.filter.chars().next_back().is_some_and(|c| c != ' ') {
self.filter.pop();
}
self.settle();
}
pub fn move_up(&mut self) {
self.step(-1);
}
pub fn move_down(&mut self) {
self.step(1);
}
fn step(&mut self, delta: isize) {
let filtered = self.filtered();
if filtered.is_empty() {
return;
}
let at = self.visible_selection() as isize;
let n = filtered.len() as isize;
let next = (at + delta).rem_euclid(n) as usize;
self.selected = filtered[next].0;
}
fn settle(&mut self) {
let filtered = self.filtered();
let named = filtered
.first()
.filter(|(_, item)| crate::home::fuzzy::substring_rank(&self.filter, item) == Some(0));
if let Some((exact, _)) = named {
self.selected = *exact;
} else if filtered.iter().all(|(i, _)| *i != self.selected)
&& let Some((first, _)) = filtered.first()
{
self.selected = *first;
}
}
}
pub struct Picker<'a> {
items: Vec<&'a str>,
selected: Option<usize>,
focused: bool,
marks: Option<Vec<bool>>,
details: Option<Vec<String>>,
clicks: Option<Clicks>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Clicks {
Choose,
Tool,
}
impl<'a> Picker<'a> {
pub fn new(items: Vec<&'a str>, selected: Option<usize>, focused: bool) -> Self {
Self {
items,
selected,
focused,
marks: None,
details: None,
clicks: None,
}
}
pub fn on_click(mut self, clicks: Clicks) -> Self {
self.clicks = Some(clicks);
self
}
pub fn from_state(state: &'a PickerState, focused: bool) -> Self {
let items = state.filtered().into_iter().map(|(_, item)| item).collect();
Self {
items,
selected: Some(state.visible_selection()),
focused,
marks: None,
details: None,
clicks: Some(Clicks::Choose),
}
}
pub fn marks(mut self, marks: Vec<bool>) -> Self {
self.marks = Some(marks);
self
}
pub fn details(mut self, details: Vec<String>) -> Self {
self.details = Some(details);
self
}
pub fn render(&self, area: Rect, buf: &mut Buffer, ctx: &RenderContext) {
if area.height == 0 || area.width == 0 {
return;
}
let g = crate::glyphs::get();
let height = area.height as usize;
let selected = self.selected.unwrap_or(0);
let offset = selected.saturating_sub(height.saturating_sub(1));
let below = self.items.len().saturating_sub(offset + height);
if self.clicks == Some(Clicks::Choose) {
crate::app::pointer::record(area, Hit::Picker);
}
for row in 0..height.min(self.items.len().saturating_sub(offset)) {
let i = offset + row;
let is_selected = self.selected == Some(i);
let row_area = Rect {
y: area.y + row as u16,
height: 1,
..area
};
if row + 1 == height && below > 0 && !is_selected {
let more = format!(" {} {} more", g.ellipsis, below + 1);
Paragraph::new(more)
.style(Style::default().fg(ctx.dimmed))
.render(row_area, buf);
break;
}
let (marker, mut style) = if is_selected {
(
if self.focused { g.rail } else { g.middot },
Style::default().fg(ctx.accent),
)
} else {
(" ", Style::default().fg(ctx.text_primary))
};
if is_selected && self.focused {
style = style.patch(ctx.highlight_style());
}
let mark = match &self.marks {
Some(marks) => {
let on = marks.get(i).copied().unwrap_or(false);
format!("{} ", if on { g.checkbox_on } else { g.checkbox_off })
}
None => String::new(),
};
Paragraph::new(format!("{}{}{}", marker, mark, self.items[i]))
.style(style)
.render(row_area, buf);
if let Some(detail) = self.details.as_ref().and_then(|d| d.get(i)) {
let w = crate::glyphs::display_width(detail) as u16;
if w + 2 < row_area.width {
let x = row_area.right() - w;
buf.set_string(x - 1, row_area.y, " ", style);
buf.set_string(x, row_area.y, detail, style.fg(ctx.dimmed));
}
}
if let Some(hit) = self.click_on(i) {
crate::app::pointer::record(row_area, hit);
}
}
}
}
impl Picker<'_> {
fn click_on(&self, i: usize) -> Option<Hit> {
let selected = self.selected.unwrap_or(0);
Some(match self.clicks.as_ref()? {
Clicks::Choose => Hit::PickerItem {
visible: i,
selected,
multi: self.marks.is_some(),
},
Clicks::Tool => Hit::Tool(i),
})
}
}
#[cfg(test)]
mod tests;