use std::io::Write;
use std::sync::mpsc::{self, Receiver};
use std::time::{Duration, Instant};
use anyhow::Result;
use crossterm::{
cursor,
event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers},
execute,
style::{Attribute, Color, SetAttribute, SetForegroundColor},
terminal::{self, ClearType},
};
use crate::api_search::{search_api, ArtifactHit};
use crate::search_index::ArtifactRecord;
use crate::search_ui::{format_result_line, truncate_str};
const SEARCH_LIMIT: usize = 50;
const DEBOUNCE_MS: u64 = 400;
const HEADER: &str =
" curie add (API) \u{2191}\u{2193} navigate Enter select Esc cancel";
enum SearchStatus {
Idle,
Searching,
Done(usize),
Error(String),
}
pub(crate) struct UiState {
pub(crate) query: String,
pub(crate) results: Vec<ArtifactHit>,
pub(crate) selected_idx: usize,
scroll_offset: usize,
status: SearchStatus,
last_query_sent: String,
last_keystroke: Instant,
}
impl UiState {
fn new() -> Self {
UiState {
query: String::new(),
results: Vec::new(),
selected_idx: 0,
scroll_offset: 0,
status: SearchStatus::Idle,
last_query_sent: String::new(),
last_keystroke: Instant::now(),
}
}
pub(crate) fn reset_timing(&mut self) {
self.last_keystroke = Instant::now();
}
fn move_up(&mut self) {
if self.selected_idx > 0 {
self.selected_idx -= 1;
}
}
fn move_down(&mut self) {
if self.selected_idx + 1 < self.results.len() {
self.selected_idx += 1;
}
}
fn clear_query(&mut self) {
self.query.clear();
self.results.clear();
self.selected_idx = 0;
self.scroll_offset = 0;
self.status = SearchStatus::Idle;
}
fn adjust_scroll(&mut self, visible_rows: usize) {
if visible_rows == 0 {
return;
}
if self.selected_idx >= self.scroll_offset + visible_rows {
self.scroll_offset = self.selected_idx + 1 - visible_rows;
}
if self.selected_idx < self.scroll_offset {
self.scroll_offset = self.selected_idx;
}
}
}
type SearchResult = Result<Vec<ArtifactHit>, String>;
pub(crate) fn run_ui_inner(
stdout: &mut impl Write,
initial_state: Option<UiState>,
) -> Result<(Option<String>, UiState)> {
let mut state = match initial_state {
Some(mut s) => { s.reset_timing(); s }
None => UiState::new(),
};
let debounce = Duration::from_millis(DEBOUNCE_MS);
let (_, mut rx): (mpsc::Sender<SearchResult>, Receiver<SearchResult>) = mpsc::channel();
redraw(stdout, &state)?;
loop {
if let Ok(res) = rx.try_recv() {
apply_results(&mut state, res);
redraw(stdout, &state)?;
}
let query_pending = !state.query.is_empty() && state.query != state.last_query_sent;
if query_pending && state.last_keystroke.elapsed() >= debounce {
fire_search(&mut state, &mut rx);
redraw(stdout, &state)?;
}
let timeout = if query_pending {
debounce
.saturating_sub(state.last_keystroke.elapsed())
.max(Duration::from_millis(10))
} else {
Duration::from_millis(50) };
if crossterm::event::poll(timeout)? {
match crossterm::event::read()? {
Event::Key(KeyEvent { code, modifiers, kind: KeyEventKind::Press, .. }) => {
if handle_key(&mut state, code, modifiers) {
if !state.results.is_empty() {
let coord = state.results[state.selected_idx].coord.clone();
return Ok((Some(coord), state));
}
} else if matches!(code, KeyCode::Esc)
|| matches!((code, modifiers), (KeyCode::Char('c'), KeyModifiers::CONTROL))
{
return Ok((None, state));
}
}
Event::Resize(..) => {}
_ => {}
}
let (_, height) = terminal::size().unwrap_or((80, 24));
state.adjust_scroll((height as usize).saturating_sub(4));
redraw(stdout, &state)?;
}
}
}
fn handle_key(state: &mut UiState, code: KeyCode, modifiers: KeyModifiers) -> bool {
match (code, modifiers) {
(KeyCode::Enter, _) => return !state.results.is_empty(),
(KeyCode::Up, _) | (KeyCode::Char('k'), KeyModifiers::CONTROL) => state.move_up(),
(KeyCode::Down, _) | (KeyCode::Char('j'), KeyModifiers::CONTROL) => state.move_down(),
(KeyCode::Char('u'), KeyModifiers::CONTROL) => state.clear_query(),
(KeyCode::Backspace, _) => {
state.query.pop();
state.last_keystroke = Instant::now();
}
(KeyCode::Char(c), mods)
if mods == KeyModifiers::NONE || mods == KeyModifiers::SHIFT =>
{
state.query.push(c);
state.last_keystroke = Instant::now();
}
_ => {}
}
false
}
fn fire_search(state: &mut UiState, rx: &mut Receiver<SearchResult>) {
state.last_query_sent = state.query.clone();
state.status = SearchStatus::Searching;
let (new_tx, new_rx) = mpsc::channel::<SearchResult>();
*rx = new_rx;
let q = state.query.clone();
std::thread::spawn(move || {
let result = search_api(&q, SEARCH_LIMIT).map_err(|e| e.to_string());
let _ = new_tx.send(result);
});
}
fn apply_results(state: &mut UiState, res: SearchResult) {
match res {
Ok(hits) => {
let count = hits.len();
state.results = hits;
state.selected_idx = 0;
state.scroll_offset = 0;
state.status = SearchStatus::Done(count);
}
Err(msg) => {
state.status = SearchStatus::Error(msg);
}
}
}
fn redraw(stdout: &mut impl Write, state: &UiState) -> Result<()> {
let (width, height) = terminal::size()?;
let w = width as usize;
let h = height as usize;
let result_rows = h.saturating_sub(4);
let st = state.scroll_offset;
execute!(stdout, cursor::Hide)?;
execute!(stdout, cursor::MoveTo(0, 0), terminal::Clear(ClearType::CurrentLine))?;
write!(stdout, "{}", truncate_str(HEADER, w))?;
for screen_row in 0..result_rows {
let result_idx = st + screen_row;
execute!(
stdout,
cursor::MoveTo(0, (screen_row + 1) as u16),
terminal::Clear(ClearType::CurrentLine)
)?;
if result_idx < state.results.len() {
let is_sel = result_idx == state.selected_idx;
let record = hit_to_record(&state.results[result_idx]);
let line = format_result_line(&record, is_sel, w);
if is_sel {
execute!(stdout, SetAttribute(Attribute::Reverse))?;
write!(stdout, "{}", line)?;
execute!(stdout, SetAttribute(Attribute::Reset))?;
} else {
write!(stdout, "{}", line)?;
}
}
}
execute!(
stdout,
cursor::MoveTo(0, h.saturating_sub(3) as u16),
terminal::Clear(ClearType::CurrentLine)
)?;
execute!(
stdout,
cursor::MoveTo(0, h.saturating_sub(2) as u16),
terminal::Clear(ClearType::CurrentLine)
)?;
execute!(stdout, SetForegroundColor(Color::DarkGrey))?;
write!(stdout, "{}", truncate_str(&format_status(&state.status), w))?;
execute!(stdout, SetForegroundColor(Color::Reset))?;
let query_row = h.saturating_sub(1) as u16;
execute!(stdout, cursor::MoveTo(0, query_row), terminal::Clear(ClearType::CurrentLine))?;
let prompt = format!("> {}", state.query);
write!(stdout, "{}", truncate_str(&prompt, w))?;
let cursor_col = (2 + state.query.chars().count()).min(w.saturating_sub(1)) as u16;
execute!(stdout, cursor::MoveTo(cursor_col, query_row), cursor::Show)?;
stdout.flush()?;
Ok(())
}
fn hit_to_record(hit: &ArtifactHit) -> ArtifactRecord {
ArtifactRecord {
coord: hit.coord.clone(),
name: String::new(),
description: String::new(),
version: hit.latest_version.clone(),
}
}
fn format_status(status: &SearchStatus) -> String {
match status {
SearchStatus::Idle => " type to search Maven Central".to_string(),
SearchStatus::Searching => " Searching\u{2026}".to_string(),
SearchStatus::Done(n) => format!(" {} results", n),
SearchStatus::Error(msg) => format!(" API error: {}", msg),
}
}