use crossterm::{
event::{self, Event, KeyCode, KeyEventKind, KeyModifiers},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use fuzzy_matcher::{FuzzyMatcher, skim::SkimMatcherV2};
use ratatui::{
Frame, Terminal,
backend::{Backend, CrosstermBackend},
layout::{Constraint, Direction, Layout},
style::{Color, Style},
widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
};
use std::io;
use tui_input::Input;
use tui_input::backend::crossterm::EventHandler;
use crate::models::ProblemSummary;
pub enum InputMode {
Normal,
Editing,
}
pub struct App {
pub should_quit: bool,
pub all_problems: Vec<ProblemSummary>,
pub filtered_problems: Vec<ProblemSummary>,
pub list_state: ListState,
pub selected_problem: Option<String>,
pub input: Input,
pub input_mode: InputMode,
pub difficulty_filter: Option<u8>,
pub debug: String,
}
impl App {
pub fn new(problems: Vec<ProblemSummary>) -> Self {
let mut list_state = ListState::default();
if !problems.is_empty() {
list_state.select(Some(0)); }
Self {
should_quit: false,
selected_problem: None,
filtered_problems: problems.clone(),
all_problems: problems,
list_state,
input: Input::default(),
input_mode: InputMode::Normal,
difficulty_filter: None,
debug: "Nothing".to_string(),
}
}
pub fn next(&mut self) {
let i = match self.list_state.selected() {
Some(i) => {
if i >= self.all_problems.len() - 1 {
0
} else {
i + 1
}
}
None => 0,
};
self.list_state.select(Some(i));
}
pub fn previous(&mut self) {
let i = match self.list_state.selected() {
Some(i) => {
if i == 0 {
self.all_problems.len() - 1
} else {
i - 1
}
}
None => 0,
};
self.list_state.select(Some(i));
}
pub fn switch_difficulty(&mut self, difficulty: u8) {
if difficulty > 0 && difficulty < 4 {
self.difficulty_filter = Some(difficulty)
} else {
self.difficulty_filter = None;
}
self.filter_problems();
}
pub fn filter_problems(&mut self) {
let mut filtered = vec![];
if let Some(difficulty) = self.difficulty_filter {
for problem in &self.all_problems {
if problem.difficulty == difficulty {
filtered.push(problem.clone());
}
}
self.filtered_problems = filtered;
} else {
self.filtered_problems = self.all_problems.clone()
}
}
pub fn update_search(&mut self) {
let query = self.input.value();
if query.is_empty() {
self.filtered_problems = self.all_problems.clone();
if let Some(diff) = self.difficulty_filter {
self.switch_difficulty(diff);
}
} else {
let matcher = SkimMatcherV2::default();
let mut matched = Vec::new();
for problem in &self.filtered_problems {
let search_target = format!("{} {}", problem.title, problem.id);
if let Some(score) = matcher.fuzzy_match(&search_target, query) {
matched.push((score, problem.clone()));
}
}
matched.sort_by(|a, b| b.0.cmp(&a.0));
self.filtered_problems = matched.into_iter().map(|(_, p)| p).collect();
}
if !self.filtered_problems.is_empty() {
self.list_state.select(Some(0));
} else {
self.list_state.select(None);
}
}
}
pub async fn run_tui(
problems: Vec<ProblemSummary>,
) -> Result<Option<String>, Box<dyn std::error::Error>> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let mut app = App::new(problems);
let res = run_app(&mut terminal, &mut app).await;
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
if let Err(err) = res {
eprintln!("Error running TUI: {:?}", err);
}
Ok(app.selected_problem)
}
async fn run_app<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> io::Result<()> {
loop {
let _ = terminal.draw(|f| ui(f, app));
if event::poll(std::time::Duration::from_millis(50))? {
let event = event::read()?;
match app.input_mode {
InputMode::Normal => {
if let Event::Key(key) = event {
if key.kind == KeyEventKind::Press {
match key.code {
KeyCode::Char('q') | KeyCode::Esc => app.should_quit = true,
KeyCode::Down | KeyCode::Char('j') => app.next(),
KeyCode::Up | KeyCode::Char('k') => app.previous(),
KeyCode::Char('/') => app.input_mode = InputMode::Editing,
KeyCode::Enter => {
if let Some(i) = app.list_state.selected() {
if !app.filtered_problems.is_empty() {
app.selected_problem =
Some(app.filtered_problems[i].slug.clone());
app.should_quit = true;
}
}
}
KeyCode::Char(c) => {
if let Some(number) = c.to_digit(10) {
app.switch_difficulty(number as u8);
}
}
_ => {}
}
}
}
}
InputMode::Editing => {
if let Event::Key(key) = event {
if key.kind == KeyEventKind::Press {
match key.code {
KeyCode::Esc => {
app.input_mode = InputMode::Normal;
}
KeyCode::Enter => {
if let Some(i) = app.list_state.selected() {
if !app.filtered_problems.is_empty() {
app.selected_problem =
Some(app.filtered_problems[i].slug.clone());
app.should_quit = true;
}
}
}
KeyCode::Char('j')
if key.modifiers.contains(KeyModifiers::CONTROL) =>
{
app.next();
}
KeyCode::Char('k')
if key.modifiers.contains(KeyModifiers::CONTROL) =>
{
app.previous();
}
_ => {
app.input.handle_event(&Event::Key(key));
if let Some(diff) = app.difficulty_filter {
app.switch_difficulty(diff);
}
app.update_search();
}
}
}
}
}
}
}
if app.should_quit {
return Ok(());
}
}
}
fn ui(f: &mut Frame, app: &mut App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.margin(1)
.constraints([Constraint::Length(3), Constraint::Min(1)])
.split(f.area());
match app.input_mode {
InputMode::Normal => (
"Press '/' to search, 'j'/'k' to scroll, 'Enter' to select, 'q' to quit.",
Style::default().fg(Color::DarkGray),
),
InputMode::Editing => (
"Type to filter, press 'Esc' or 'Enter' to return to list.",
Style::default().fg(Color::Yellow),
),
};
let title = format!(" Search ({} matches) ", app.filtered_problems.len());
let input_widget = Paragraph::new(app.input.value())
.style(match app.input_mode {
InputMode::Editing => Style::default().fg(Color::Yellow),
InputMode::Normal => Style::default(),
})
.block(Block::default().borders(Borders::ALL).title(title));
f.render_widget(input_widget, chunks[0]);
if let InputMode::Editing = app.input_mode {
f.set_cursor(
chunks[0].x + app.input.visual_cursor() as u16 + 1,
chunks[0].y + 1,
);
}
let items: Vec<ListItem> = app
.filtered_problems
.iter()
.map(|p| {
let diff_color = match p.difficulty {
1 => Color::Green,
2 => Color::Yellow,
_ => Color::Red,
};
let line = format!("[{}] {}", p.id, p.title);
ListItem::new(line).style(Style::default().fg(diff_color))
})
.collect();
let list = List::new(items)
.block(Block::default().title(" Problems ").borders(Borders::ALL))
.highlight_style(Style::default().bg(Color::DarkGray).fg(Color::White))
.highlight_symbol(">> ");
f.render_stateful_widget(list, chunks[1], &mut app.list_state);
}