use ratatui::{
style::{Color, Modifier, Style},
text::{Line, Span},
};
const DIM: Style = Style::new().fg(Color::Rgb(124, 111, 100));
const SELECTED: Style = Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD);
const UNSELECTED: Style = Style::new().fg(Color::Rgb(124, 111, 100));
const DESC: Style = Style::new().fg(Color::Rgb(198, 165, 106));
const HINT: Style = Style::new().fg(Color::Rgb(124, 111, 100));
pub const MAX_VISIBLE: usize = 6;
pub trait DropdownItem: Clone {
fn label(&self) -> &str;
fn description(&self) -> String;
fn matches_filter(&self, filter: &str) -> bool;
}
#[derive(Clone, Debug)]
#[allow(dead_code)] pub struct SimpleItem {
pub label: String,
pub description: String,
}
impl SimpleItem {
#[allow(dead_code)] pub fn new(label: impl Into<String>, desc: impl Into<String>) -> Self {
Self {
label: label.into(),
description: desc.into(),
}
}
}
impl DropdownItem for SimpleItem {
fn label(&self) -> &str {
&self.label
}
fn description(&self) -> String {
self.description.clone()
}
fn matches_filter(&self, filter: &str) -> bool {
let lower = self.label.to_lowercase();
let filter_lower = filter.to_lowercase();
lower.contains(&filter_lower)
}
}
#[derive(Clone)]
pub struct DropdownState<T: DropdownItem> {
all_items: Vec<T>,
pub filtered: Vec<T>,
pub selected: usize,
pub scroll_offset: usize,
pub title: String,
}
impl<T: DropdownItem> DropdownState<T> {
pub fn new(items: Vec<T>, title: impl Into<String>) -> Self {
let filtered = items.clone();
Self {
all_items: items,
filtered,
selected: 0,
scroll_offset: 0,
title: title.into(),
}
}
pub fn apply_filter(&mut self, filter: &str) -> bool {
self.filtered = self
.all_items
.iter()
.filter(|item| item.matches_filter(filter))
.cloned()
.collect();
self.selected = 0;
self.scroll_offset = 0;
!self.filtered.is_empty()
}
pub fn up(&mut self) {
self.selected = self.selected.saturating_sub(1);
self.recenter();
}
pub fn down(&mut self) {
if self.selected + 1 < self.filtered.len() {
self.selected += 1;
} else {
self.selected = 0;
self.scroll_offset = 0;
}
self.recenter();
}
fn recenter(&mut self) {
let visible = MAX_VISIBLE.min(self.filtered.len());
if visible == 0 {
return;
}
let half = visible / 2;
let ideal = self.selected.saturating_sub(half);
let max_offset = self.filtered.len().saturating_sub(visible);
self.scroll_offset = ideal.min(max_offset);
}
pub fn selected_item(&self) -> Option<&T> {
self.filtered.get(self.selected)
}
#[allow(dead_code)] pub fn is_empty(&self) -> bool {
self.filtered.is_empty()
}
pub fn visible_count(&self) -> usize {
MAX_VISIBLE.min(self.filtered.len()) + 2 }
}
pub fn build_dropdown_lines<T: DropdownItem>(state: &DropdownState<T>) -> Vec<Line<'static>> {
let visible = MAX_VISIBLE.min(state.filtered.len());
let end = (state.scroll_offset + visible).min(state.filtered.len());
let window = &state.filtered[state.scroll_offset..end];
let has_above = state.scroll_offset > 0;
let has_below = end < state.filtered.len();
let mut lines = Vec::with_capacity(MAX_VISIBLE + 2);
let title = if has_above {
format!(" {} \u{25b2} more", state.title)
} else {
format!(" {}", state.title)
};
lines.push(Line::from(Span::styled(title, DIM)));
for (i, item) in window.iter().enumerate() {
let absolute_idx = state.scroll_offset + i;
let is_selected = absolute_idx == state.selected;
let label = item.label().to_string();
let desc = item.description();
let mut spans = Vec::with_capacity(4);
if is_selected {
spans.push(Span::styled(
" \u{203a} ",
Style::default().fg(Color::Cyan),
));
spans.push(Span::styled(label, SELECTED));
} else {
spans.push(Span::raw(" "));
spans.push(Span::styled(label, UNSELECTED));
}
if !desc.is_empty() {
spans.push(Span::styled(format!(" {desc}"), DESC));
}
lines.push(Line::from(spans));
}
for _ in visible..MAX_VISIBLE {
lines.push(Line::from(""));
}
let hint = if has_below {
" \u{2191}/\u{2193} navigate \u{00b7} enter select \u{00b7} esc cancel \u{25bc} more"
} else {
" \u{2191}/\u{2193} navigate \u{00b7} enter select \u{00b7} esc cancel"
};
lines.push(Line::from(Span::styled(hint, HINT)));
lines
}
#[cfg(test)]
mod tests {
use super::*;
fn test_items() -> Vec<SimpleItem> {
vec![
SimpleItem::new("/agent", "Agents"),
SimpleItem::new("/compact", "Compact"),
SimpleItem::new("/diff", "Diff"),
SimpleItem::new("/exit", "Quit"),
SimpleItem::new("/expand", "Expand"),
SimpleItem::new("/model", "Pick model"),
]
}
#[test]
fn new_contains_all() {
let dd = DropdownState::new(test_items(), "Test");
assert_eq!(dd.filtered.len(), 6);
assert_eq!(dd.selected, 0);
}
#[test]
fn filter_narrows() {
let mut dd = DropdownState::new(test_items(), "Test");
assert!(dd.apply_filter("/m"));
assert_eq!(dd.filtered.len(), 1); assert_eq!(dd.filtered[0].label(), "/model");
}
#[test]
fn filter_no_match() {
let mut dd = DropdownState::new(test_items(), "Test");
assert!(!dd.apply_filter("/z"));
assert!(dd.is_empty());
}
#[test]
fn filter_case_insensitive() {
let mut dd = DropdownState::new(test_items(), "Test");
assert!(dd.apply_filter("/MODEL"));
assert_eq!(dd.filtered.len(), 1);
}
#[test]
fn navigation() {
let mut dd = DropdownState::new(test_items(), "Test");
assert_eq!(dd.selected_item().unwrap().label(), "/agent");
dd.down();
assert_eq!(dd.selected_item().unwrap().label(), "/compact");
for _ in 0..4 {
dd.down();
}
assert_eq!(dd.selected_item().unwrap().label(), "/model");
dd.down(); assert_eq!(dd.selected_item().unwrap().label(), "/agent");
dd.up(); assert_eq!(dd.selected_item().unwrap().label(), "/agent");
}
fn overflow_items() -> Vec<SimpleItem> {
let mut items = test_items();
items.push(SimpleItem::new("/sessions", "Sessions"));
items
}
#[test]
fn scroll_indicators() {
let dd = DropdownState::new(overflow_items(), "Test");
let lines = build_dropdown_lines(&dd);
let hint: String = lines
.last()
.unwrap()
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
assert!(hint.contains('\u{25bc}'), "should show scroll-down: {hint}");
}
#[test]
fn fixed_height() {
let dd = DropdownState::new(test_items(), "Test");
let lines = build_dropdown_lines(&dd);
assert_eq!(lines.len(), 8);
let mut dd2 = DropdownState::new(test_items(), "Test");
dd2.apply_filter("/e");
let lines = build_dropdown_lines(&dd2);
assert_eq!(lines.len(), 8);
}
#[test]
fn selected_marker() {
let dd = DropdownState::new(test_items(), "Test");
let lines = build_dropdown_lines(&dd);
let first: String = lines[1].spans.iter().map(|s| s.content.as_ref()).collect();
assert!(first.contains('\u{203a}'), "got: {first}");
let second: String = lines[2].spans.iter().map(|s| s.content.as_ref()).collect();
assert!(!second.contains('\u{203a}'), "got: {second}");
}
#[test]
fn selected_item_empty() {
let mut dd = DropdownState::new(test_items(), "Test");
dd.apply_filter("/zzz");
assert!(dd.selected_item().is_none());
}
}