use std::io::{self, Stdout, Write};
use std::time::Duration;
use anyhow::Result;
use cera::bundle::LeapBundleEntry;
use crate::display_bundle_id;
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use crossterm::execute;
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Direction, Layout};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
struct ScreenGuard;
impl Drop for ScreenGuard {
fn drop(&mut self) {
let _ = disable_raw_mode();
let _ = execute!(io::stdout(), LeaveAlternateScreen, crossterm::cursor::Show);
}
}
enum Stage {
Bundle,
Quant { bundle: usize },
}
struct PickerState<'a> {
bundles: &'a [LeapBundleEntry],
stage: Stage,
filter: String,
cursor: usize,
offset: usize,
}
impl<'a> PickerState<'a> {
fn new(bundles: &'a [LeapBundleEntry]) -> Self {
Self {
bundles,
stage: Stage::Bundle,
filter: String::new(),
cursor: 0,
offset: 0,
}
}
fn rows(&self) -> Vec<(String, usize)> {
let needle = self.filter.to_ascii_lowercase();
let matches = |s: &str| needle.is_empty() || s.to_ascii_lowercase().contains(&needle);
match self.stage {
Stage::Bundle => self
.bundles
.iter()
.enumerate()
.filter(|(_, e)| matches(display_bundle_id(&e.name)))
.map(|(i, e)| {
(
format!("{} ({})", display_bundle_id(&e.name), e.quants.join(" ")),
i,
)
})
.collect(),
Stage::Quant { bundle } => self.bundles[bundle]
.quants
.iter()
.enumerate()
.filter(|(_, q)| matches(q))
.map(|(i, q)| (q.clone(), i))
.collect(),
}
}
fn settle(&mut self, height: usize) {
let len = self.rows().len();
if len == 0 {
self.cursor = 0;
self.offset = 0;
return;
}
self.cursor = self.cursor.min(len - 1);
if self.cursor < self.offset {
self.offset = self.cursor;
} else if height > 0 && self.cursor >= self.offset + height {
self.offset = self.cursor + 1 - height;
}
self.offset = self.offset.min(len.saturating_sub(height.max(1)));
}
}
pub(crate) fn pick(bundles: &[LeapBundleEntry]) -> Result<Option<(String, String)>> {
if bundles.is_empty() {
anyhow::bail!("the LeapBundles catalog came back empty");
}
enable_raw_mode()?;
let _guard = ScreenGuard;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout.by_ref());
let mut terminal = Terminal::new(backend)?;
run(&mut terminal, bundles)
}
fn run(
terminal: &mut Terminal<CrosstermBackend<&mut Stdout>>,
bundles: &[LeapBundleEntry],
) -> Result<Option<(String, String)>> {
let mut state = PickerState::new(bundles);
let mut settled_height = 0usize;
loop {
let mut list_height = 0usize;
terminal.draw(|frame| {
let area = frame.area();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(1),
Constraint::Length(1),
])
.split(area);
let (title, hint) = match state.stage {
Stage::Bundle => (
"Choose a model".to_string(),
"↑↓ move · ⏎ select · type to filter · Esc cancel",
),
Stage::Quant { bundle } => (
format!(
"Choose a quantization: {}",
display_bundle_id(&bundles[bundle].name)
),
"↑↓ move · ⏎ select · Esc back",
),
};
let header = Paragraph::new(Line::from(vec![
Span::raw("filter: "),
Span::styled(
if state.filter.is_empty() {
"(none)".to_string()
} else {
state.filter.clone()
},
Style::default().add_modifier(Modifier::BOLD),
),
]))
.block(Block::default().borders(Borders::ALL).title(title));
frame.render_widget(header, chunks[0]);
list_height = chunks[1].height as usize;
let rows = state.rows();
let lines: Vec<Line> = if rows.is_empty() {
vec![Line::from(Span::raw(" no matches"))]
} else {
rows.iter()
.skip(state.offset)
.take(list_height)
.enumerate()
.map(|(i, (text, _))| {
let selected = state.offset + i == state.cursor;
let style = if selected {
Style::default().add_modifier(Modifier::REVERSED)
} else {
Style::default()
};
Line::from(Span::styled(
format!("{} {text}", if selected { ">" } else { " " }),
style,
))
})
.collect()
};
frame.render_widget(Paragraph::new(lines), chunks[1]);
frame.render_widget(Paragraph::new(Line::from(Span::raw(hint))), chunks[2]);
})?;
if list_height != settled_height {
state.settle(list_height);
settled_height = list_height;
continue;
}
if !event::poll(Duration::from_millis(200))? {
continue;
}
let Event::Key(KeyEvent {
code,
modifiers,
kind,
..
}) = event::read()?
else {
continue;
};
if kind != KeyEventKind::Press {
continue;
}
if modifiers.contains(KeyModifiers::CONTROL) && matches!(code, KeyCode::Char('c')) {
return Ok(None);
}
match code {
KeyCode::Esc => match state.stage {
Stage::Bundle => return Ok(None),
Stage::Quant { .. } => {
state.stage = Stage::Bundle;
state.filter.clear();
state.cursor = 0;
state.offset = 0;
}
},
KeyCode::Up => state.cursor = state.cursor.saturating_sub(1),
KeyCode::Down => state.cursor += 1,
KeyCode::PageUp => state.cursor = state.cursor.saturating_sub(list_height.max(1)),
KeyCode::PageDown => state.cursor += list_height.max(1),
KeyCode::Home => state.cursor = 0,
KeyCode::End => state.cursor = usize::MAX,
KeyCode::Backspace => {
state.filter.pop();
}
KeyCode::Enter => {
let rows = state.rows();
let Some(&(_, index)) = rows.get(state.cursor) else {
continue;
};
match state.stage {
Stage::Bundle => {
if bundles[index].quants.len() == 1 {
return Ok(Some((
bundles[index].name.clone(),
bundles[index].quants[0].clone(),
)));
}
state.stage = Stage::Quant { bundle: index };
state.filter.clear();
state.cursor = 0;
state.offset = 0;
}
Stage::Quant { bundle } => {
return Ok(Some((
bundles[bundle].name.clone(),
bundles[bundle].quants[index].clone(),
)));
}
}
}
KeyCode::Char(c)
if !modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
{
state.filter.push(c);
state.cursor = 0;
state.offset = 0;
}
_ => {}
}
state.settle(list_height);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn catalog() -> Vec<LeapBundleEntry> {
vec![
LeapBundleEntry {
name: "LFM2-1.2B-GGUF".into(),
quants: vec!["Q4_0".into(), "Q8_0".into()],
},
LeapBundleEntry {
name: "LFM2.5-350M-Instruct-GGUF".into(),
quants: vec!["Q4_K_M".into()],
},
LeapBundleEntry {
name: "Qwen3-1.7B-GGUF".into(),
quants: vec!["Q4_0".into()],
},
]
}
#[test]
fn bundle_rows_strip_the_gguf_suffix() {
let entries = catalog();
let state = PickerState::new(&entries);
let rows = state.rows();
assert_eq!(rows.len(), 3);
assert!(
rows[0].0.starts_with("LFM2-1.2B ("),
"expected a stripped name with its quants; got {:?}",
rows[0].0
);
}
#[test]
fn filter_matches_case_insensitively() {
let entries = catalog();
let mut state = PickerState::new(&entries);
state.filter = "qwen".into();
let rows = state.rows();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].1, 2, "the row must carry the index into `bundles`");
}
#[test]
fn filter_can_match_nothing() {
let entries = catalog();
let mut state = PickerState::new(&entries);
state.filter = "nonexistent".into();
assert!(state.rows().is_empty());
state.settle(10);
assert_eq!(state.cursor, 0);
assert_eq!(state.offset, 0);
}
#[test]
fn quant_stage_lists_that_bundles_quants() {
let entries = catalog();
let mut state = PickerState::new(&entries);
state.stage = Stage::Quant { bundle: 0 };
assert_eq!(
state
.rows()
.iter()
.map(|(t, _)| t.clone())
.collect::<Vec<_>>(),
vec!["Q4_0".to_string(), "Q8_0".to_string()]
);
state.filter = "q8".into();
let rows = state.rows();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].1, 1);
}
#[test]
fn settle_clamps_a_stale_cursor() {
let entries = catalog();
let mut state = PickerState::new(&entries);
state.cursor = 99;
state.settle(2);
assert_eq!(state.cursor, 2, "clamped to the last row");
assert!(
state.offset <= state.cursor,
"the window must contain the cursor; offset={} cursor={}",
state.offset,
state.cursor
);
}
#[test]
fn settle_scrolls_to_follow_the_cursor() {
let entries = catalog();
let mut state = PickerState::new(&entries);
state.cursor = 2;
state.settle(2);
assert_eq!(state.offset, 1, "cursor 2 with height 2 shows rows 1..=2");
}
#[test]
fn settle_follows_a_shrinking_viewport() {
let entries = catalog();
let mut state = PickerState::new(&entries);
state.cursor = 2;
state.settle(3);
assert_eq!(state.offset, 0);
state.settle(1);
assert_eq!(
state.offset, 2,
"the window must move to contain the cursor after a resize"
);
}
}