use color_eyre::eyre::Result;
use crossterm::{
event::{
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers,
MouseButton, MouseEvent, MouseEventKind,
},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use log::debug;
use nucleo_matcher::{
Matcher, Utf32Str,
pattern::{AtomKind, CaseMatching, Normalization, Pattern},
};
use ratatui::{
Frame, Terminal,
backend::CrosstermBackend,
layout::{Constraint, Layout},
prelude::{Alignment, Rect},
style::{Color, Style},
text::Span,
widgets::{
Block, Borders, Cell, Padding, Paragraph, Row, Scrollbar, ScrollbarOrientation,
ScrollbarState, Table, TableState,
},
};
use ratatui_textarea::TextArea;
use std::io;
use crate::history::{Entry, EntryId, History, Tracker};
mod items;
pub use items::{ConfigItem, ContainerItem};
use items::{DevContainerItem, HistoryItem};
pub trait Pickable: Clone {
fn title() -> &'static str;
fn headers() -> &'static [&'static str];
fn cells(&self) -> Vec<String>;
fn search_fields(&self) -> Vec<String>;
fn status_lines(&self) -> Vec<String>;
fn column_constraints(max_widths: &[usize]) -> Vec<Constraint>;
}
trait WrappedPickable: Pickable {
type Inner;
fn from_inner(inner: Self::Inner) -> Self;
fn into_inner(self) -> Self::Inner;
}
#[derive(Debug, Clone, Copy, Default)]
pub struct PickerOpts {
pub hide_instructions: bool,
pub hide_info: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum AppAction {
Quit,
SelectNext,
SelectPrevious,
SelectFirst,
SelectLast,
OpenSelected,
DeleteSelectedEntry,
SearchInput(ratatui_textarea::Input),
TableClick(u16),
}
#[derive(Debug, Clone, Copy)]
enum SelectionDirection {
Next,
Previous,
}
#[derive(Debug, Clone)]
struct PickerRow<T: Pickable> {
item: T,
row: Row<'static>,
search_score: Option<u32>,
original_index: usize,
}
#[derive(Debug, Clone)]
struct PickerData<T: Pickable> {
rows: Vec<PickerRow<T>>,
max_column_widths: Vec<usize>,
}
impl<T: Pickable> PickerData<T> {
fn from_items(items: Vec<T>) -> Self {
let mut rows = Vec::with_capacity(items.len());
let mut max_column_widths = vec![0; T::headers().len()];
for (index, item) in items.into_iter().enumerate() {
let cells = item.cells();
if cells.len() > max_column_widths.len() {
max_column_widths.resize(cells.len(), 0);
}
for (column, cell) in cells.iter().enumerate() {
max_column_widths[column] = max_column_widths[column].max(cell.len());
}
rows.push(PickerRow {
item,
row: Row::new(cells).height(1),
search_score: Some(0),
original_index: index,
});
}
Self {
rows,
max_column_widths,
}
}
fn to_rows(&self) -> Vec<Row<'static>> {
self.rows
.iter()
.filter(|row| row.search_score.is_some())
.map(|row| row.row.clone())
.collect()
}
fn as_rows_full(&self) -> impl Iterator<Item = &PickerRow<T>> {
self.rows.iter().filter(|row| row.search_score.is_some())
}
fn apply_filter(&mut self, pattern: &str) -> bool {
let mut changes = false;
let mut matcher = Matcher::default();
let mut buf = Vec::new();
let pattern = Pattern::new(
pattern,
CaseMatching::Ignore,
Normalization::Smart,
AtomKind::Fuzzy,
);
for row in &mut self.rows {
let mut score = None;
for field in row.item.search_fields() {
score = add_num_opt(
score,
pattern.score(Utf32Str::new(&field, &mut buf), &mut matcher),
);
}
changes |= score != row.search_score;
row.search_score = score;
}
self.rows
.sort_by_key(|row| u32::MAX - row.search_score.unwrap_or(0));
changes
}
fn reset_filter(&mut self) {
for row in &mut self.rows {
row.search_score = Some(0);
}
self.rows.sort_by_key(|row| row.original_index);
}
fn delete_by_original_index(&mut self, original_index: usize) -> bool {
if let Some(index) = self
.rows
.iter()
.position(|entry| entry.original_index == original_index)
{
self.rows.remove(index);
return true;
}
false
}
}
struct PickerState<'a, T: Pickable> {
search: TextArea<'a>,
table_state: TableState,
table_data: PickerData<T>,
opts: PickerOpts,
last_clicked_index: Option<usize>,
}
impl<T: Pickable> PickerState<'_, T> {
fn new(items: Vec<T>, opts: PickerOpts) -> Self {
Self {
search: TextArea::default(),
table_state: TableState::default(),
table_data: PickerData::from_items(items),
opts,
last_clicked_index: None,
}
}
fn select_relative(&mut self, direction: SelectionDirection) {
let len = self.table_data.as_rows_full().count();
if len == 0 {
return;
}
let selected = match direction {
SelectionDirection::Next => (self.table_state.selected().unwrap_or(0) + 1) % len,
SelectionDirection::Previous => {
(self.table_state.selected().unwrap_or(len - 1) + len - 1) % len
}
};
self.table_state.select(Some(selected));
}
fn select_first(&mut self) {
self.table_state.select_first();
}
fn select_last(&mut self) {
self.table_state.select_last();
}
fn apply_filter(&mut self, pattern: Option<&str>) {
let pattern = pattern.unwrap_or("");
let prev_selected = self.get_selected_row().map(|row| row.original_index);
let update_selected = if pattern.trim().is_empty() {
self.table_data.reset_filter();
true
} else {
self.table_data.apply_filter(pattern)
};
if !update_selected {
return;
}
if let Some(selected) = prev_selected {
let new_rows = self.table_data.as_rows_full();
match new_rows
.enumerate()
.find_map(|(index, entry)| (entry.original_index == selected).then_some(index))
{
Some(index) => {
self.table_state.select(Some(index));
}
_ => {
self.table_state.select_first();
}
}
} else {
self.table_state.select_first();
}
}
fn get_selected_row(&self) -> Option<PickerRow<T>> {
let index = self.table_state.selected()?;
self.table_data.as_rows_full().nth(index).cloned()
}
fn delete(&mut self, original_index: usize) -> bool {
self.table_data.delete_by_original_index(original_index)
}
fn select_clicked(&mut self, row: u16, table_area: Rect) -> Option<T> {
if row < table_area.y + 2 || row >= table_area.y + table_area.height - 1 {
return None;
}
let clicked_index = usize::from(row - table_area.y - 2);
if clicked_index >= self.table_data.as_rows_full().count() {
return None;
}
if self.last_clicked_index == Some(clicked_index)
&& self.table_state.selected() == Some(clicked_index)
{
return self.get_selected_row().map(|selected| selected.item);
}
self.table_state.select(Some(clicked_index));
self.last_clicked_index = Some(clicked_index);
None
}
}
pub fn pick<T: Pickable>(
items: Vec<T>,
opts: PickerOpts,
on_delete: Option<&mut dyn FnMut(&T)>,
) -> Result<Option<T>> {
debug!("Starting UI...");
debug!("Entering raw mode & alternate screen...");
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let res = run_app(&mut terminal, PickerState::new(items, opts), on_delete);
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
debug!("Terminal restored");
Ok(res?)
}
pub fn start(
tracker: &mut Tracker,
hide_instructions: bool,
hide_info: bool,
) -> Result<Option<(EntryId, Entry)>> {
let items = sorted_history_items(&tracker.history);
let opts = PickerOpts {
hide_instructions,
hide_info,
};
let mut on_delete = |item: &HistoryItem| {
let _ = tracker.history.delete(item.id);
};
let selected = pick(items, opts, Some(&mut on_delete))?;
Ok(selected.map(|item| (item.id, item.entry)))
}
fn sorted_history_items(history: &History) -> Vec<HistoryItem> {
let mut items: Vec<HistoryItem> = history
.iter()
.map(|(id, entry)| HistoryItem {
id: *id,
entry: entry.clone(),
})
.collect();
items.sort_by_key(|item| -item.entry.last_opened.timestamp());
items
}
fn run_app<T: Pickable>(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
mut app: PickerState<'_, T>,
on_delete: Option<&mut dyn FnMut(&T)>,
) -> io::Result<Option<T>> {
app.table_state.select(Some(0));
let mut on_delete = on_delete;
loop {
terminal.draw(|f| render(f, &mut app))?;
let input = event::read()?;
let action = handle_input(&input);
if let Some(action) = action {
match action {
AppAction::Quit => return Ok(None),
AppAction::SelectNext => {
app.select_relative(SelectionDirection::Next);
app.last_clicked_index = None;
}
AppAction::SelectPrevious => {
app.select_relative(SelectionDirection::Previous);
app.last_clicked_index = None;
}
AppAction::SelectFirst => {
app.select_first();
app.last_clicked_index = None;
}
AppAction::SelectLast => {
app.select_last();
app.last_clicked_index = None;
}
AppAction::OpenSelected => {
if let Some(selected) = app.get_selected_row() {
return Ok(Some(selected.item));
}
}
AppAction::DeleteSelectedEntry => {
if let Some(selected) = app.get_selected_row()
&& let Some(callback) = on_delete.as_deref_mut()
{
callback(&selected.item);
let _ = app.delete(selected.original_index);
}
app.last_clicked_index = None;
}
AppAction::TableClick(row) => {
if let Some(selected) = app.select_clicked(row, terminal.get_frame().area()) {
return Ok(Some(selected));
}
}
AppAction::SearchInput(input) => {
if app.search.input(input) {
let line = app.search.lines().first().cloned();
app.apply_filter(line.as_deref());
app.last_clicked_index = None;
}
}
}
}
}
}
fn handle_input(input: &Event) -> Option<AppAction> {
match input {
Event::Key(key) => {
if key.kind != KeyEventKind::Press {
return None;
}
let is_key = |code: KeyCode| key.code == code;
let is_char = |c: char| is_key(KeyCode::Char(c));
let is_ctrl_char =
|c: char| key.modifiers.contains(KeyModifiers::CONTROL) && is_char(c);
if is_key(KeyCode::Esc) || is_ctrl_char('q') || is_ctrl_char('c') {
return Some(AppAction::Quit);
} else if is_key(KeyCode::Down) || is_ctrl_char('j') {
return Some(AppAction::SelectNext);
} else if is_key(KeyCode::Up) || is_ctrl_char('k') {
return Some(AppAction::SelectPrevious);
} else if is_key(KeyCode::KeypadBegin) || is_ctrl_char('1') {
return Some(AppAction::SelectFirst);
} else if is_key(KeyCode::End) || is_ctrl_char('0') {
return Some(AppAction::SelectLast);
} else if is_key(KeyCode::Enter) || is_ctrl_char('o') {
return Some(AppAction::OpenSelected);
} else if is_key(KeyCode::Delete) || is_ctrl_char('r') || is_ctrl_char('x') {
return Some(AppAction::DeleteSelectedEntry);
}
return Some(AppAction::SearchInput((*key).into()));
}
Event::Mouse(MouseEvent { kind, row, .. }) => match kind {
MouseEventKind::Down(MouseButton::Left) => {
return Some(AppAction::TableClick(*row));
}
MouseEventKind::ScrollDown => {
return Some(AppAction::SelectNext);
}
MouseEventKind::ScrollUp => {
return Some(AppAction::SelectPrevious);
}
_ => {}
},
_ => {}
}
None
}
fn render<T: Pickable>(frame: &mut Frame, app: &mut PickerState<'_, T>) {
let selected = app.get_selected_row();
let status_lines = if app.opts.hide_info {
Vec::new()
} else {
selected
.as_ref()
.map_or_else(Vec::new, |row| row.item.status_lines())
};
let mut constraints = vec![
Constraint::Percentage(100),
Constraint::Min(3),
Constraint::Min(1),
];
if !app.opts.hide_info {
constraints.extend((0..status_lines.len()).map(|_| Constraint::Min(1)));
}
let area = Layout::default()
.constraints(&constraints)
.horizontal_margin(1)
.split(frame.area());
render_table(frame, app, area[0]);
render_search_input(frame, app, area[1]);
render_status_area(
frame,
&status_lines,
&area[2..],
app.opts.hide_instructions,
app.opts.hide_info,
);
}
fn render_search_input<T: Pickable>(frame: &mut Frame, app: &mut PickerState<'_, T>, area: Rect) {
let style = Style::default().fg(Color::Blue);
app.search.set_block(
Block::default()
.borders(Borders::all())
.title("Search")
.border_style(style),
);
frame.render_widget(&app.search, area);
}
fn render_table<T: Pickable>(frame: &mut Frame, app: &mut PickerState<'_, T>, area: Rect) {
let (header_style, selected_style) = (
Style::default().bg(Color::Blue),
Style::default().bg(Color::DarkGray),
);
let header_cells = T::headers()
.iter()
.map(|header| Cell::from(*header).style(Style::default().fg(Color::White)));
let header = Row::new(header_cells).style(header_style).height(1);
let widths = T::column_constraints(&app.table_data.max_column_widths);
let table = Table::new(app.table_data.to_rows(), widths)
.header(header)
.block(Block::default().borders(Borders::ALL).title(T::title()))
.row_highlight_style(selected_style)
.highlight_symbol("> ");
frame.render_stateful_widget(table, area, &mut app.table_state);
let total_items = app.table_data.as_rows_full().count();
let viewport_height = usize::from(area.height - 2);
if total_items >= viewport_height {
let mut scrollbar_state = ScrollbarState::default()
.content_length(total_items)
.viewport_content_length(viewport_height)
.position(app.table_state.selected().unwrap_or(0));
let scrollbar_area = Rect {
x: area.x + area.width - 1,
y: area.y + 2,
width: 1,
height: area.height - 3,
};
frame.render_stateful_widget(
Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(Some("↑"))
.end_symbol(Some("↓")),
scrollbar_area,
&mut scrollbar_state,
);
}
}
fn render_status_area(
frame: &mut Frame,
status_lines: &[String],
areas: &[Rect],
hide_instructions: bool,
hide_info: bool,
) {
if !hide_instructions {
let instruction = Span::styled(
"↑/↓ to navigate • Del/Ctrl+X to remove • Enter to open • Type to filter • Esc/Ctrl+C to quit",
Style::default().fg(Color::Gray),
);
let instructions_par = Paragraph::new(instruction)
.block(Block::default().padding(Padding::new(2, 2, 0, 0)))
.alignment(Alignment::Left);
frame.render_widget(instructions_par, areas[0]);
}
if !hide_info && areas.len() > 1 {
for (index, line) in status_lines.iter().enumerate() {
if let Some(area) = areas.get(index + 1) {
let info = Span::styled(line.clone(), Style::default().fg(Color::DarkGray));
let paragraph = Paragraph::new(info)
.block(Block::default().padding(Padding::new(2, 2, 0, 0)))
.alignment(Alignment::Left);
frame.render_widget(paragraph, *area);
}
}
}
}
pub fn pick_container(
containers: Vec<crate::container::Container>,
opts: PickerOpts,
on_delete: Option<&mut dyn FnMut(&ContainerItem)>,
) -> Result<Option<crate::container::Container>> {
pick_wrapped::<ContainerItem>(containers, opts, on_delete)
}
pub fn pick_config(
configs: Vec<crate::config_store::ConfigEntry>,
opts: PickerOpts,
on_delete: Option<&mut dyn FnMut(&ConfigItem)>,
) -> Result<Option<crate::config_store::ConfigEntry>> {
pick_wrapped::<ConfigItem>(configs, opts, on_delete)
}
pub fn pick_devcontainer(
dev_containers: Vec<crate::workspace::DevContainer>,
) -> Result<Option<crate::workspace::DevContainer>> {
let opts = PickerOpts {
hide_instructions: false,
hide_info: false,
};
pick_wrapped::<DevContainerItem>(dev_containers, opts, None)
}
fn pick_wrapped<T: WrappedPickable>(
values: Vec<T::Inner>,
opts: PickerOpts,
on_delete: Option<&mut dyn FnMut(&T)>,
) -> Result<Option<T::Inner>> {
let items = values.into_iter().map(T::from_inner).collect();
let selected = pick(items, opts, on_delete)?;
Ok(selected.map(T::into_inner))
}
fn add_num_opt(o1: Option<u32>, o2: Option<u32>) -> Option<u32> {
match (o1, o2) {
(Some(n1), Some(n2)) => Some(n1 + n2),
(Some(n), None) | (None, Some(n)) => Some(n),
_ => None,
}
}