use clap::Args;
use crossterm::{
event::{
read, DisableMouseCapture, EnableMouseCapture, Event,
KeyCode, KeyModifiers, MouseEventKind,
},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::CrosstermBackend,
layout::Rect,
widgets::{Borders, StatefulWidget},
Terminal,
};
use hefesto_widgets::{ChoosePopup, ChoosePopupState, PopupSize};
use crate::popup_config::{PopupConfig, PopupConfigurable};
use crate::{keybinds, style};
const FILTER_GUIDE: &str = include_str!("../guides/FILTER_GUIDE.md");
#[derive(Args)]
pub struct FilterArgs {
#[arg(required = true)]
pub items: Vec<String>,
#[arg(short, long, default_value = "Filtrar")]
pub title: String,
#[arg(short, long)]
pub multi: bool,
#[arg(short = 'W', long, default_value = "0")]
pub width: u16,
#[arg(short = 'H', long, default_value = "0")]
pub height: u16,
#[arg(short = 'M', long)]
pub max: Option<usize>,
#[arg(long)]
pub guide: bool,
}
fn contains(rect: Rect, col: u16, row: u16) -> bool {
col >= rect.x
&& col < rect.x + rect.width
&& row >= rect.y
&& row < rect.y + rect.height
}
fn is_on_border(popup: Rect, col: u16, row: u16) -> bool {
if !contains(popup, col, row) {
return false;
}
let inner = Rect {
x: popup.x + 1,
y: popup.y + 1,
width: popup.width.saturating_sub(2),
height: popup.height.saturating_sub(2),
};
!contains(inner, col, row)
}
fn is_drag_area(popup: Rect, col: u16, row: u16, header_enabled: bool) -> bool {
if !contains(popup, col, row) {
return false;
}
if is_on_border(popup, col, row) {
return true;
}
if !header_enabled {
return false;
}
let header_top = popup.y + 1;
let header_bottom = header_top + 2;
row >= header_top && row < header_bottom
}
pub fn run(args: FilterArgs) {
if args.guide {
println!("{}", FILTER_GUIDE);
return;
}
crate::tty::ensure_terminal_stdin();
let mut tty: Box<dyn std::io::Write> = match std::fs::OpenOptions::new().write(true).open("/dev/tty") {
Ok(f) => Box::new(f),
Err(_) => Box::new(std::io::stdout()),
};
if enable_raw_mode().is_err() || execute!(tty, EnterAlternateScreen, EnableMouseCapture).is_err() {
eprintln!("{} filter: el terminal no es interactivo", crate::BIN_NAME);
std::process::exit(1);
}
let mut terminal = Terminal::new(CrosstermBackend::new(tty)).unwrap();
crate::tty::with_terminal_stdout(|| terminal.clear()).unwrap();
crate::tty::with_terminal_stdout(|| terminal.hide_cursor()).unwrap();
let items: Vec<(String, ratatui::style::Style)> = args.items.iter().map(|s| (s.clone(), ratatui::style::Style::default())).collect();
let mut state = ChoosePopupState {
show_filter: true,
..Default::default()
};
let mut drag = crate::drag::DragState::new();
let mut origin: Option<(u16, u16)> = None;
let mut result: Option<Vec<String>> = None;
let mut cfg = PopupConfig::new()
.border_type(style::BORDER)
.header();
if args.width > 0 { cfg = cfg.width(args.width); }
if args.height > 0 { cfg = cfg.height(args.height); }
while result.is_none() {
let size = terminal.size().unwrap();
let area = Rect::new(0, 0, size.width, size.height);
let mut popup = ChoosePopup::new(items.clone())
.title(&args.title)
.with_config(&cfg);
if let Some(max) = args.max {
popup = popup.max_selected(max);
}
if let Some((ox, oy)) = origin {
popup = popup.origin(ox, oy);
}
popup = popup
.filter_bg_color(style::FILL)
.filter_borders(Borders::LEFT)
.filter_border_type(style::FILTER_BORDER);
let h = match cfg.height {
Some(uh) => PopupSize::Fixed(uh),
None => PopupSize::Fixed(crate::popup_rect::choose_default_height(items.len())),
};
popup = popup.height(h);
let pr = popup.resolve_rect(area);
terminal
.draw(|frame| {
StatefulWidget::render(popup.clone(), frame.area(), frame.buffer_mut(), &mut state);
})
.unwrap();
match read().unwrap() {
Event::Key(key) => {
if key.code == keybinds::EMERGENCY && key.modifiers == KeyModifiers::CONTROL {
result = Some(vec![]);
} else {
match key.code {
keybinds::UP | keybinds::BACK_TAB => state.cursor = state.cursor.saturating_sub(1),
keybinds::DOWN | keybinds::TAB => {
let visible = state.visible_count(&items);
if visible > 0 {
state.cursor = (state.cursor + 1).min(visible.saturating_sub(1));
}
}
keybinds::CONFIRM => {
if state.chosen_indices.is_empty() {
state.toggle_selected(&items);
}
let selected: Vec<String> = state
.chosen_indices
.iter()
.map(|&i| args.items[i].clone())
.collect();
result = Some(selected);
}
keybinds::CANCEL => result = Some(vec![]),
keybinds::TOGGLE_MULTI => {
if args.multi {
state.toggle_selected(&items);
} else if let Some(idx) = state.original_index(&items) {
let selected = vec![args.items[idx].clone()];
result = Some(selected);
}
}
keybinds::FILTER_BACKSPACE => state.delete_before_filter(),
keybinds::FILTER_LEFT => state.filter_cursor_left(),
keybinds::FILTER_RIGHT => state.filter_cursor_right(),
keybinds::FILTER_HOME => state.filter_cursor_home(),
keybinds::FILTER_END => state.filter_cursor_end(),
KeyCode::Char(c) => state.insert_filter_char(c),
_ => {}
}
}
},
Event::Mouse(mouse) => {
let col = mouse.column;
let row = mouse.row;
match mouse.kind {
MouseEventKind::Down(crossterm::event::MouseButton::Left) => {
let on_border = is_on_border(pr, col, row);
let in_drag = is_drag_area(pr, col, row, drag.header_drag_enabled());
drag.begin(pr, col, row, on_border, in_drag);
if !drag.is_dragging() {
if let Some(idx) = popup.item_at(&state, pr, row) {
if args.multi {
state.toggle(idx);
} else {
state.cursor = idx;
}
}
}
}
MouseEventKind::Drag(crossterm::event::MouseButton::Left) => {
if let Some((nx, ny)) = drag.update(area, col, row) {
origin = Some((nx, ny));
}
}
MouseEventKind::Up(crossterm::event::MouseButton::Left) => {
drag.end();
}
_ => {}
}
}
_ => {}
}
let visible = state.visible_count(&items);
if visible > 0 {
state.cursor = state.cursor.min(visible.saturating_sub(1));
} else {
state.cursor = 0;
}
}
let selected = result.unwrap();
disable_raw_mode().unwrap();
execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture).unwrap();
terminal.show_cursor().unwrap();
for item in &selected {
println!("{}", item);
}
if selected.is_empty() {
std::process::exit(1);
}
}
#[cfg(test)]
mod tests {
use super::*;
use hefesto_widgets::TextInputState;
use insta::assert_snapshot;
use ratatui::{backend::TestBackend, style::Style, Terminal};
fn render_popup(name: &str, popup: ChoosePopup<'_>, state: &mut ChoosePopupState) {
let backend = TestBackend::new(60, 20);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| f.render_stateful_widget(popup, f.area(), state))
.unwrap();
insta::with_settings!({
snapshot_path => "filter/snapshots",
prepend_module_to_snapshot => false,
}, {
assert_snapshot!(name, terminal.backend());
});
}
fn items() -> Vec<(String, Style)> {
vec![
("Manzana".to_string(), Style::default()),
("Banana".to_string(), Style::default()),
("Cereza".to_string(), Style::default()),
("Durazno".to_string(), Style::default()),
]
}
#[test]
fn contains_basic() {
let r = Rect::new(10, 5, 20, 10);
assert!(contains(r, 15, 8));
assert!(!contains(r, 5, 8));
}
#[test]
fn is_on_border_basic() {
let r = Rect::new(10, 5, 20, 10);
assert!(is_on_border(r, 10, 5));
assert!(!is_on_border(r, 15, 8));
}
#[test]
fn is_drag_area_header_when_enabled() {
let r = Rect::new(0, 0, 60, 20);
assert!(is_drag_area(r, 5, 1, true));
}
#[test]
fn is_drag_area_header_disabled_when_not_enabled() {
let r = Rect::new(0, 0, 60, 20);
assert!(!is_drag_area(r, 5, 1, false));
assert!(is_drag_area(r, 0, 0, false));
}
fn popup_h(title: &str) -> ChoosePopup<'_> {
ChoosePopup::new(items())
.title(title)
.height(hefesto_widgets::PopupSize::Fixed(10))
}
#[test]
fn snapshot_default() {
let popup = popup_h("Filtrar");
let mut state = ChoosePopupState {
show_filter: true,
..Default::default()
};
render_popup("filter_default", popup, &mut state);
}
#[test]
fn snapshot_with_filter_text() {
let popup = popup_h("Filtrar");
let mut state = ChoosePopupState {
show_filter: true,
text_input: TextInputState {
content: "an".to_string(),
cursor: 2,
},
..Default::default()
};
render_popup("filter_with_text", popup, &mut state);
}
#[test]
fn snapshot_filtered_and_selected() {
let popup = popup_h("Filtrar");
let mut state = ChoosePopupState {
show_filter: true,
chosen_indices: std::collections::HashSet::from([1]),
text_input: TextInputState {
content: "an".to_string(),
cursor: 2,
},
..Default::default()
};
render_popup("filter_with_selection", popup, &mut state);
}
}