use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Clear, Padding, Paragraph};
use super::theme::{Theme, blend};
#[derive(Clone, PartialEq, Debug)]
pub enum PickAction {
Model(String),
Mode(usize),
Git(String),
Command(String),
Tab(usize),
Insert(String),
Prompt(String),
None,
}
#[derive(Clone, Debug)]
pub struct PickRow {
pub label: String,
pub hint: String,
pub badge: String,
pub action: PickAction,
}
impl PickRow {
pub fn new(label: impl Into<String>, hint: impl Into<String>, action: PickAction) -> Self {
PickRow {
label: label.into(),
hint: hint.into(),
badge: String::new(),
action,
}
}
pub fn badge(mut self, b: impl Into<String>) -> Self {
self.badge = b.into();
self
}
pub fn header(label: impl Into<String>) -> Self {
PickRow {
label: label.into(),
hint: String::new(),
badge: String::new(),
action: PickAction::None,
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum PickerKind {
Model,
Mode,
Skills,
Git,
Tabs,
Context,
Events,
}
#[derive(Clone, Debug)]
pub struct Picker {
pub kind: PickerKind,
pub title: String,
pub rows: Vec<PickRow>,
pub sel: usize,
pub query: String,
pub searchable: bool,
pub footer: String,
pub prompt: Option<(String, String, String)>,
}
#[derive(Clone, PartialEq, Debug)]
pub enum PickerEvent {
None,
Closed,
Chose(PickAction),
Submit(String, String),
}
impl Picker {
pub fn new(kind: PickerKind, title: impl Into<String>, rows: Vec<PickRow>) -> Self {
let sel = rows
.iter()
.position(|r| r.action != PickAction::None)
.unwrap_or(0);
Picker {
kind,
title: title.into(),
rows,
sel,
query: String::new(),
searchable: true,
footer: String::new(),
prompt: None,
}
}
pub fn menu(mut self) -> Self {
self.searchable = false;
self
}
pub fn footer(mut self, f: impl Into<String>) -> Self {
self.footer = f.into();
self
}
pub fn filtered(&self) -> Vec<usize> {
if self.query.is_empty() {
return (0..self.rows.len()).collect();
}
let q = self.query.to_lowercase();
let mut out: Vec<usize> = Vec::new();
for (i, r) in self.rows.iter().enumerate() {
if r.action == PickAction::None {
continue; }
if r.label.to_lowercase().contains(&q) || r.hint.to_lowercase().contains(&q) {
out.push(i);
}
}
out
}
pub fn selected(&self) -> Option<&PickRow> {
self.rows
.get(self.sel)
.filter(|r| r.action != PickAction::None)
}
pub fn move_sel(&mut self, d: i32) {
let vis = self.filtered();
if vis.is_empty() {
return;
}
let cur = vis.iter().position(|i| *i == self.sel).unwrap_or(0) as i32;
let mut next = (cur + d).clamp(0, vis.len() as i32 - 1);
let step = if d >= 0 { 1 } else { -1 };
while self.rows[vis[next as usize]].action == PickAction::None {
let probe = next + step;
if probe < 0 || probe >= vis.len() as i32 {
break;
}
next = probe;
}
self.sel = vis[next as usize];
}
fn resync(&mut self) {
let vis = self.filtered();
if !vis.contains(&self.sel) {
self.sel = vis
.iter()
.copied()
.find(|i| self.rows[*i].action != PickAction::None)
.unwrap_or(0);
}
}
}
pub fn handle_key(p: &mut Picker, code: ratatui::crossterm::event::KeyCode) -> PickerEvent {
use ratatui::crossterm::event::KeyCode::*;
if let Some((key, _label, buf)) = &mut p.prompt {
match code {
Esc => {
p.prompt = None;
return PickerEvent::None;
}
Enter => {
let (key, text) = (key.clone(), buf.clone());
p.prompt = None;
return PickerEvent::Submit(key, text);
}
Backspace => {
buf.pop();
return PickerEvent::None;
}
Char(c) => {
buf.push(c);
return PickerEvent::None;
}
_ => return PickerEvent::None,
}
}
match code {
Esc => PickerEvent::Closed,
Up => {
p.move_sel(-1);
PickerEvent::None
}
Down => {
p.move_sel(1);
PickerEvent::None
}
PageUp => {
p.move_sel(-8);
PickerEvent::None
}
PageDown => {
p.move_sel(8);
PickerEvent::None
}
Home => {
p.sel = 0;
p.move_sel(0);
PickerEvent::None
}
End => {
p.sel = p.rows.len().saturating_sub(1);
p.move_sel(0);
PickerEvent::None
}
Enter => match p.selected().map(|r| r.action.clone()) {
Some(PickAction::Prompt(label)) => {
let key = p
.selected()
.map(|r| r.label.clone())
.unwrap_or_else(|| label.clone());
p.prompt = Some((key, label, String::new()));
PickerEvent::None
}
Some(a) => PickerEvent::Chose(a),
None => PickerEvent::None,
},
Backspace if p.searchable => {
p.query.pop();
p.resync();
PickerEvent::None
}
Char(c) if p.searchable => {
p.query.push(c);
p.resync();
PickerEvent::None
}
Char(c) => {
let c = c.to_ascii_lowercase();
match p
.rows
.iter()
.position(|r| r.action != PickAction::None && r.label.to_lowercase().starts_with(c))
{
Some(i) => {
p.sel = i;
match p.rows[i].action.clone() {
PickAction::Prompt(label) => {
let key = p.rows[i].label.clone();
p.prompt = Some((key, label, String::new()));
PickerEvent::None
}
a => PickerEvent::Chose(a),
}
}
None => PickerEvent::None,
}
}
_ => PickerEvent::None,
}
}
pub fn render(
f: &mut Frame,
area: Rect,
p: &Picker,
theme: &Theme,
) -> Vec<(u16, u16, u16, u16, super::Hit)> {
let mut hits: Vec<(u16, u16, u16, u16, super::Hit)> = Vec::new();
let vis = p.filtered();
let max_h = (area.height * 8 / 10).clamp(1, area.height.max(1));
let h = (vis.len() as u16 + 6).max(9).min(max_h);
let max_w = area.width.saturating_sub(4).max(1);
let w = (area.width * 6 / 10).max(46).min(max_w);
let rect = Rect {
x: area.x + area.width.saturating_sub(w) / 2,
y: area.y + area.height.saturating_sub(h) / 3,
width: w,
height: h,
};
f.render_widget(Clear, rect);
hits.push((rect.x, rect.y, rect.width, rect.height, super::Hit::Panel));
let panel = Block::default()
.style(Style::default().bg(theme.surface))
.padding(Padding::new(1, 1, 0, 0));
let inner = panel.inner(rect);
f.render_widget(panel, rect);
let iw = inner.width as usize;
let surf = theme.surface;
let mut lines: Vec<Line> = Vec::new();
let count = format!(
"{} ",
vis.iter()
.filter(|i| p.rows[**i].action != PickAction::None)
.count()
);
let title_pad = iw.saturating_sub(p.title.chars().count() + count.chars().count() + 3);
lines.push(Line::from(vec![
Span::styled("▍", Style::default().fg(theme.accent).bg(surf)),
Span::styled(
format!(" {} ", p.title),
Style::default()
.fg(theme.user)
.bg(surf)
.add_modifier(Modifier::BOLD),
),
Span::styled(" ".repeat(title_pad), Style::default().bg(surf)),
Span::styled(count, Style::default().fg(theme.border).bg(surf)),
]));
if let Some((_key, label, buf)) = &p.prompt {
lines.push(Line::from(vec![
Span::styled(
format!(" {label} "),
Style::default().fg(theme.on_accent).bg(theme.accent),
),
Span::styled(format!(" {buf}▌"), Style::default().fg(theme.user).bg(surf)),
]));
} else if p.searchable {
lines.push(Line::from(vec![
Span::styled(" ", Style::default().bg(surf)),
Span::styled(
if p.query.is_empty() {
"type to filter".to_string()
} else {
p.query.clone()
},
Style::default()
.fg(if p.query.is_empty() {
theme.border
} else {
theme.user
})
.bg(surf),
),
]));
} else {
lines.push(Line::from(Span::styled(" ", Style::default().bg(surf))));
}
lines.push(Line::from(Span::styled(
"─".repeat(iw),
Style::default().fg(theme.border).bg(surf),
)));
let label_col = vis
.iter()
.filter(|i| p.rows[**i].action != PickAction::None && !p.rows[**i].hint.is_empty())
.map(|i| p.rows[*i].label.chars().count())
.max()
.unwrap_or(0)
.min(iw / 3);
let body_h = (h as usize).saturating_sub(5);
let sel_pos = vis.iter().position(|i| *i == p.sel).unwrap_or(0);
let scroll = sel_pos.saturating_sub(body_h.saturating_sub(1));
for (n, &i) in vis.iter().skip(scroll).take(body_h).enumerate() {
let r = &p.rows[i];
if r.action != PickAction::None {
hits.push((
inner.x,
inner.y + 3 + n as u16,
inner.width,
1,
super::Hit::Row(i),
));
}
if r.action == PickAction::None && r.hint.is_empty() && r.badge.is_empty() {
lines.push(Line::from(Span::styled(
format!(" {}", r.label.to_uppercase()),
Style::default()
.fg(theme.accent2)
.bg(surf)
.add_modifier(Modifier::BOLD),
)));
continue;
}
let selected = i == p.sel;
let bg = if selected {
blend(surf, theme.accent, 0.28)
} else {
surf
};
let fg = if selected {
theme.user
} else {
theme.assistant
};
let mark = if selected { "▍" } else { " " };
let mut spans = vec![
Span::styled(mark.to_string(), Style::default().fg(theme.accent).bg(bg)),
Span::styled(
format!(" {}", r.label),
Style::default().fg(fg).bg(bg).add_modifier(if selected {
Modifier::BOLD
} else {
Modifier::empty()
}),
),
];
let mut used = 1 + 1 + r.label.chars().count();
let badge_w = r.badge.chars().count();
if !r.hint.is_empty() {
let pad = label_col.saturating_sub(r.label.chars().count());
let room = iw.saturating_sub(used + pad + badge_w + 4);
let hint: String = r.hint.chars().take(room).collect();
used += pad + hint.chars().count() + 2;
spans.push(Span::styled(
format!("{} {hint}", " ".repeat(pad)),
Style::default().fg(theme.dim).bg(bg),
));
}
spans.push(Span::styled(
" ".repeat(iw.saturating_sub(used + badge_w)),
Style::default().bg(bg),
));
if !r.badge.is_empty() {
spans.push(Span::styled(
r.badge.clone(),
Style::default().fg(theme.accent2).bg(bg),
));
}
lines.push(Line::from(spans));
}
while lines.len() + 1 < h as usize {
lines.push(Line::from(Span::styled(
" ".repeat(iw),
Style::default().bg(surf),
)));
}
let footer = if p.footer.is_empty() {
"↑↓ move · enter select · esc close".to_string()
} else {
p.footer.clone()
};
lines.push(Line::from(Span::styled(
format!(" {footer}"),
Style::default().fg(theme.border).bg(surf),
)));
f.render_widget(Paragraph::new(lines), inner);
hits
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::crossterm::event::KeyCode;
fn sample() -> Picker {
Picker::new(
PickerKind::Model,
"Models",
vec![
PickRow::header("favorites"),
PickRow::new("gpt-4o", "openai", PickAction::Model("gpt-4o".into())),
PickRow::header("all"),
PickRow::new(
"claude-opus-5",
"anthropic",
PickAction::Model("claude-opus-5".into()),
),
PickRow::new("llama-3", "groq", PickAction::Model("llama-3".into())),
],
)
}
#[test]
fn opens_on_first_selectable_row_and_skips_headers() {
let mut p = sample();
assert_eq!(p.sel, 1);
p.move_sel(1); assert_eq!(p.sel, 3, "header must be skipped");
p.move_sel(-1);
assert_eq!(p.sel, 1);
}
#[test]
fn filtering_drops_headers_and_resyncs_selection() {
let mut p = sample();
for c in "opus".chars() {
handle_key(&mut p, KeyCode::Char(c));
}
let vis = p.filtered();
assert_eq!(vis.len(), 1);
assert_eq!(p.rows[vis[0]].label, "claude-opus-5");
assert_eq!(p.sel, vis[0], "selection follows the filter");
for _ in 0..4 {
handle_key(&mut p, KeyCode::Backspace);
}
assert_eq!(p.filtered().len(), 5);
}
#[test]
fn enter_yields_the_chosen_action() {
let mut p = sample();
p.move_sel(1);
assert_eq!(
handle_key(&mut p, KeyCode::Enter),
PickerEvent::Chose(PickAction::Model("claude-opus-5".into()))
);
assert_eq!(handle_key(&mut p, KeyCode::Esc), PickerEvent::Closed);
}
#[test]
fn menu_mode_letters_run_the_matching_row() {
let mut p = Picker::new(
PickerKind::Git,
"Git",
vec![
PickRow::new("log", "recent commits", PickAction::Git("log".into())),
PickRow::new("push", "push HEAD", PickAction::Git("push".into())),
],
)
.menu();
assert_eq!(
handle_key(&mut p, KeyCode::Char('p')),
PickerEvent::Chose(PickAction::Git("push".into()))
);
assert!(p.query.is_empty());
}
#[test]
fn prompt_rows_capture_text_until_enter() {
let mut p = Picker::new(
PickerKind::Git,
"Git",
vec![PickRow::new(
"commit",
"commit staged changes",
PickAction::Prompt("message".into()),
)],
)
.menu();
assert_eq!(handle_key(&mut p, KeyCode::Enter), PickerEvent::None);
assert!(p.prompt.is_some());
for c in "fix it".chars() {
handle_key(&mut p, KeyCode::Char(c));
}
assert_eq!(
handle_key(&mut p, KeyCode::Enter),
PickerEvent::Submit("commit".into(), "fix it".into())
);
assert!(p.prompt.is_none());
}
}