use std::collections::VecDeque;
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, TryRecvError};
use std::time::{Duration, Instant};
use color_eyre::Result;
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers, MouseEvent};
use crate::jobs::Hold;
use crate::pointer::Pointer;
use crate::{App, AppEvent};
pub const MAX_HELD_KEYS: usize = 32;
enum Act {
Now,
Hold,
HoldAs(KeyEvent),
Drop,
StopFind,
}
#[derive(Debug, Clone, Copy)]
enum Input {
Key(KeyEvent),
Mouse(MouseEvent),
}
#[derive(Debug)]
pub enum Drained {
Continue {
updated: bool,
progress_only: bool,
},
Exit,
Crash(String),
NotFound(std::path::PathBuf),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Screen {
generation: u64,
modal: bool,
inline_failures: u64,
}
pub struct EventPump {
pub app: App,
tx: Sender<AppEvent>,
rx: Receiver<AppEvent>,
held: VecDeque<KeyEvent>,
held_for: Screen,
next_up: VecDeque<(AppEvent, Hold)>,
backlog: VecDeque<AppEvent>,
typed: VecDeque<Input>,
since_key: usize,
early: usize,
}
const RESULTS_PER_KEY: usize = 64;
impl EventPump {
pub fn new(app: App, tx: Sender<AppEvent>, rx: Receiver<AppEvent>) -> Self {
let held_for = Self::screen_of(&app);
Self {
app,
tx,
rx,
held: VecDeque::new(),
held_for,
next_up: VecDeque::new(),
backlog: VecDeque::new(),
typed: VecDeque::new(),
since_key: 0,
early: 0,
}
}
pub fn handle_first(&mut self, events: impl IntoIterator<Item = AppEvent>) {
for event in events {
match event {
AppEvent::Terminal(Event::Key(key)) => {
self.typed.push_back(Input::Key(key));
self.early += 1;
}
AppEvent::Terminal(Event::Mouse(mouse)) => {
self.typed.push_back(Input::Mouse(mouse));
self.early += 1;
}
event => self.backlog.push_back(event),
}
}
}
pub fn send(&self, event: AppEvent) -> Result<()> {
self.tx.send(event)?;
Ok(())
}
pub fn held_keys(&self) -> impl Iterator<Item = &KeyEvent> {
self.held.iter()
}
pub fn replaying(&self) -> bool {
!self.held.is_empty() && !self.app.is_busy()
}
pub fn terminal_key(&mut self, key: KeyEvent) -> Result<bool> {
self.discard_stale();
match self.classify(&key) {
Act::Now => {
self.dispatch(key)?;
Ok(true)
}
Act::StopFind => {
self.drop_held_finds();
self.dispatch(key)?;
Ok(true)
}
Act::Drop => Ok(false),
Act::Hold => {
self.hold(key);
Ok(false)
}
Act::HoldAs(meant) => {
self.hold(meant);
Ok(false)
}
}
}
pub fn terminal_mouse(&mut self, mouse: MouseEvent) -> Result<bool> {
self.discard_stale();
let acts = |p: &Self, code: KeyCode| {
matches!(
p.classify(&KeyEvent::new(code, KeyModifiers::NONE)),
Act::Now
)
};
match self.app.pointer(&mouse, std::time::Instant::now()) {
Pointer::Nothing => Ok(false),
Pointer::Keys(keys) => self.press_now(keys),
Pointer::Point(target, then) => {
if !acts(self, KeyCode::Down) {
self.app.forget_click();
return Ok(false);
}
self.app.point(&target);
self.press_now(then)?;
Ok(true)
}
Pointer::Form { field, act, keys } => {
if !acts(self, KeyCode::Down) {
return Ok(false);
}
let mut then = keys;
if let Some(id) = field {
let Some(clicked) = self.app.focus_field(&id) else {
return Ok(false);
};
if let Some(back) = act.filter(|_| clicked.acts) {
then.extend(crate::pointer::act_key(clicked.kind, back));
}
}
self.press_now(then)?;
Ok(true)
}
Pointer::Tool(tool) => {
if !acts(self, KeyCode::Enter) {
return Ok(false);
}
self.app.point_at_tool(tool);
self.press_now([KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)])?;
Ok(true)
}
Pointer::Resize { column, x } => {
if !acts(self, KeyCode::Char('>')) {
return Ok(false);
}
self.app.start_resize(column, x);
Ok(false)
}
Pointer::Width { column, width } => {
if !self.app.in_normal_table_view() || !acts(self, KeyCode::Char('>')) {
return Ok(false);
}
self.app.set_dragged_width(column, width);
Ok(true)
}
Pointer::Drop { column, onto } => {
if !self.app.in_normal_table_view() || !acts(self, KeyCode::Char('L')) {
return Ok(false);
}
if let Some(event) = self.app.drop_column(&column, &onto) {
self.queue_continuation(event);
}
Ok(true)
}
Pointer::Menu(hit, at) => {
if !acts(self, KeyCode::Down) {
return Ok(false);
}
if self.app.point_for_menu(&hit) {
self.app.open_context_menu(at);
}
Ok(true)
}
Pointer::MenuChoose(i) => {
if let Some(AppEvent::Press(key)) = self.app.choose_from_menu(i) {
self.press_now([key])?;
}
Ok(true)
}
Pointer::Redraw => Ok(true),
Pointer::CloseMenu => {
self.app.close_context_menu();
Ok(true)
}
}
}
fn press_now(&mut self, keys: impl IntoIterator<Item = KeyEvent>) -> Result<bool> {
let mut acted = false;
for key in keys {
match self.classify(&key) {
Act::Now => {}
Act::StopFind => self.drop_held_finds(),
_ => break,
}
self.dispatch(key)?;
acted = true;
}
Ok(acted)
}
fn classify(&self, key: &KeyEvent) -> Act {
if self.app.hard_escape_while_busy(key) {
if key.code == KeyCode::Esc && self.app.finding() {
return Act::StopFind;
}
return Act::Now;
}
if self.app.menu_takes(key) {
return Act::Now;
}
let queued = !self.held.is_empty();
if !self.app.is_busy() && !queued {
return Act::Now;
}
if self.app.is_busy() && self.app.awaiting_dataset() {
if self.app.key_acts_while_busy(key) {
return Act::Now;
}
return Act::Drop;
}
if self.app.is_busy()
&& key.code == KeyCode::Enter
&& self.app.in_normal_table_view()
&& self.app.enter_inspects()
&& self.held.iter().all(is_navigation)
{
return Act::HoldAs(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
}
if self.app.is_busy() && !queued && self.app.key_acts_while_sampling(key) {
return Act::Now;
}
if self.app.is_busy() && !queued && self.app.in_normal_table_view() {
if self.app.key_acts_while_busy(key) {
return Act::Now;
}
if matches!(key.code, KeyCode::Enter | KeyCode::Esc) {
return Act::Drop;
}
}
Act::Hold
}
pub fn replay_one(&mut self) -> Result<bool> {
self.discard_stale();
if self.app.is_busy() {
return Ok(false);
}
let Some(key) = self.held.pop_front() else {
return Ok(false);
};
if self.held.is_empty() {
self.app.set_input_dropped(false);
}
self.dispatch(key)?;
Ok(true)
}
fn take_next(&mut self) -> Result<(AppEvent, Option<Hold>), TryRecvError> {
if let Some((event, lease)) = self.next_up.pop_front() {
return Ok((event, Some(lease)));
}
if let Some(event) = self.backlog.pop_front() {
return Ok((event, None));
}
if !self.typed.is_empty() && (self.early > 0 || self.since_key >= RESULTS_PER_KEY) {
return Err(TryRecvError::Empty);
}
self.rx.try_recv().map(|event| (event, None))
}
pub fn drain(&mut self) -> Result<Drained> {
let first = self.take_next();
self.drain_from(first)
}
pub fn wait_and_drain(&mut self, timeout: Duration) -> Result<Drained> {
if !self.next_up.is_empty() || !self.backlog.is_empty() || !self.typed.is_empty() {
let first = self.take_next();
return self.drain_from(first);
}
let first = self
.rx
.recv_timeout(timeout)
.map(|event| (event, None))
.map_err(|e| match e {
RecvTimeoutError::Timeout => TryRecvError::Empty,
RecvTimeoutError::Disconnected => TryRecvError::Disconnected,
});
self.drain_from(first)
}
fn drain_from(
&mut self,
mut next: Result<(AppEvent, Option<Hold>), TryRecvError>,
) -> Result<Drained> {
let mut updated = false;
let mut progress_only = true;
loop {
match next {
Ok((AppEvent::Exit, _)) => return Ok(Drained::Exit),
Ok((AppEvent::Crash(msg), _)) => return Ok(Drained::Crash(msg)),
Ok((AppEvent::NamedPathMissing(path), _)) => {
return Ok(Drained::NotFound(path));
}
Ok((AppEvent::Press(key), hold)) => {
if hold.is_some() {
drop(hold);
self.app.let_waiting_errands_in();
}
if self.typed.is_empty() {
self.since_key = 0;
}
self.typed.push_front(Input::Key(key));
if self.early > 0 {
self.early += 1;
}
}
Ok((AppEvent::Terminal(Event::Key(key)), _)) => {
if self.typed.is_empty() {
self.since_key = 0;
}
self.typed.push_back(Input::Key(key));
}
Ok((AppEvent::Terminal(Event::Mouse(mouse)), _)) => {
if self.typed.is_empty() {
self.since_key = 0;
}
self.typed.push_back(Input::Mouse(mouse));
}
Ok((AppEvent::Terminal(Event::Resize(cols, rows)), _)) => {
next = Ok((AppEvent::Resize(cols, rows), None));
continue;
}
Ok((AppEvent::Terminal(_), _)) => {}
Ok((event, mut continuation)) => {
updated = true;
progress_only &= event.is_progress();
self.since_key += 1;
self.app.pointer.changed();
let follow_up = match self.app.handle(&event) {
Ok(follow_up) => follow_up,
Err(deferred) => {
self.hold(deferred);
None
}
};
self.discard_stale();
if let Some(follow_up) = follow_up {
self.queue_continuation(follow_up);
drop(continuation);
break;
}
if let Some(hold) = continuation.take() {
drop(hold);
self.app.let_waiting_errands_in();
}
}
Err(TryRecvError::Empty) => {
if matches!(self.typed.front(), Some(Input::Mouse(_)))
&& !self.app.pointer.on_screen()
{
updated = true;
progress_only = false;
break;
}
let Some(mut input) = self.typed.pop_front() else {
break;
};
while let (Input::Mouse(now), Some(Input::Mouse(next))) =
(input, self.typed.front())
&& is_drag(&now)
&& is_drag(next)
{
input = Input::Mouse(*next);
self.typed.pop_front();
self.early = self.early.saturating_sub(1);
}
self.since_key = 0;
self.early = self.early.saturating_sub(1);
let acted = match input {
Input::Key(key) => self.terminal_key(key)?,
Input::Mouse(mouse) => self.terminal_mouse(mouse)?,
};
if acted {
updated = true;
progress_only = false;
break;
}
}
Err(TryRecvError::Disconnected) => return Ok(Drained::Exit),
}
next = self.take_next();
}
Ok(Drained::Continue {
updated,
progress_only: updated && progress_only,
})
}
pub fn run(&mut self, mut draw: impl FnMut(&mut App) -> Result<()>) -> Result<Ended> {
let mut pacer = Pacer::default();
let mut first_rows = crate::first_rows_trace::FirstRowsTrace::from_env();
draw(&mut self.app)?;
self.app.frame_painted();
first_rows.painted(&self.app);
pacer.drew(Instant::now());
loop {
let mut pass = Pass::default();
pass.updated = self.replay_one()?;
pass.progress_only = !pass.updated;
if let Some(end) = pass.add(self.drain()?) {
return Ok(end);
}
if !pass.updated {
let now = Instant::now();
pacer.spinning(self.app.something_is_spinning(), now);
let timeout = pacer.timeout(self.app.next_deadline(), now);
if let Some(end) = pass.add(self.wait_and_drain(timeout)?) {
return Ok(end);
}
}
let app = &mut self.app;
let now = Instant::now();
let mut redraw = pacer.handled(pass.updated, pass.progress_only, now);
pacer.spinning(app.something_is_spinning(), now);
if pacer.turn_spinner(now) {
app.throbber_frame = app.throbber_frame.wrapping_add(1);
redraw = true;
}
redraw |= app.tick_flash();
redraw |= app.tick_follow_clock();
redraw |= app.flash_background_panic();
redraw |= app.flash_polars_warning();
app.request_what_the_frame_needs();
if redraw {
draw(app)?;
app.frame_painted();
first_rows.painted(app);
pacer.drew(now);
if let Some(state) = &mut app.data_table_state
&& state.needs_recollect
{
state.needs_recollect = false;
app.spawn_async_collect(App::LOADING_BUFFER);
}
app.request_what_the_frame_needs();
}
}
}
fn queue_continuation(&mut self, follow_up: AppEvent) {
let hold = self.app.hold_the_generation();
self.next_up.push_back((follow_up, hold));
}
fn dispatch(&mut self, key: KeyEvent) -> Result<()> {
self.app.pointer.changed();
let gen_before = self.app.screen_generation();
match self.app.handle(&AppEvent::Key(key)) {
Ok(Some(follow_up)) => self.queue_continuation(follow_up),
Ok(None) => {}
Err(deferred) => self.held.push_front(deferred),
}
if self.app.screen_generation() != gen_before {
self.held.clear();
self.app.set_input_dropped(false);
} else {
self.held_for = Self::screen_of(&self.app);
}
Ok(())
}
fn hold(&mut self, key: KeyEvent) {
if self.held.is_empty() {
self.held_for = Self::screen_of(&self.app);
}
if is_navigation(&key)
&& !replays_each_press(&key)
&& self.held.back() == Some(&key)
&& self.app.in_normal_table_view()
&& self.held.iter().all(is_navigation)
{
return;
}
if self.held.len() >= MAX_HELD_KEYS {
self.app.set_input_dropped(true);
return;
}
self.held.push_back(key);
}
fn drop_held_finds(&mut self) {
let run = self.held.iter().take_while(|k| is_navigation(k)).count();
let rest = self.held.split_off(run);
self.held
.retain(|k| !matches!(k.code, KeyCode::Char('n' | 'N')));
self.held.extend(rest);
if self.held.is_empty() {
self.app.set_input_dropped(false);
}
}
fn discard_stale(&mut self) {
if self.held.is_empty() {
return;
}
let now = Self::screen_of(&self.app);
let abandoned = now.generation != self.held_for.generation;
let new_modal = now.modal && !self.held_for.modal;
let failed_inline = now.inline_failures != self.held_for.inline_failures;
if abandoned || new_modal || failed_inline {
self.held.clear();
self.app.set_input_dropped(false);
}
}
fn screen_of(app: &App) -> Screen {
Screen {
generation: app.screen_generation(),
modal: app.modal_showing(),
inline_failures: app.inline_failures(),
}
}
}
#[derive(Debug, PartialEq, Eq)]
pub enum Ended {
Quit,
Crash(String),
NotFound(std::path::PathBuf),
}
#[derive(Default)]
struct Pass {
updated: bool,
progress_only: bool,
}
impl Pass {
fn add(&mut self, drained: Drained) -> Option<Ended> {
match drained {
Drained::Continue {
updated,
progress_only,
} => {
if updated {
self.progress_only = progress_only && (self.progress_only || !self.updated);
self.updated = true;
}
None
}
Drained::Exit => Some(Ended::Quit),
Drained::Crash(msg) => Some(Ended::Crash(msg)),
Drained::NotFound(path) => Some(Ended::NotFound(path)),
}
}
}
pub const SPINNER_FRAME: Duration = Duration::from_millis(33);
pub const PROGRESS_FRAME: Duration = Duration::from_millis(33);
#[derive(Debug, Default)]
pub struct Pacer {
last_draw: Option<Instant>,
owed: bool,
spin_due: Option<Instant>,
}
impl Pacer {
pub fn spinning(&mut self, on: bool, now: Instant) {
if on {
self.spin_due.get_or_insert(now + SPINNER_FRAME);
} else {
self.spin_due = None;
}
}
pub fn turn_spinner(&mut self, now: Instant) -> bool {
match self.spin_due {
Some(due) if now >= due => {
self.spin_due = Some(now + SPINNER_FRAME);
true
}
_ => false,
}
}
pub fn handled(&mut self, updated: bool, progress_only: bool, now: Instant) -> bool {
let progress_due = self
.last_draw
.is_none_or(|last| now >= last + PROGRESS_FRAME);
if updated && !progress_only {
return true;
}
if updated {
self.owed = true;
}
self.owed && progress_due
}
pub fn drew(&mut self, now: Instant) {
self.last_draw = Some(now);
self.owed = false;
}
pub fn timeout(&self, app_deadline: Option<Instant>, now: Instant) -> Duration {
let owed = self
.owed
.then(|| self.last_draw.map(|last| last + PROGRESS_FRAME))
.flatten();
[self.spin_due, owed, app_deadline]
.into_iter()
.flatten()
.min()
.map_or(Duration::MAX, |at| at.saturating_duration_since(now))
}
}
fn replays_each_press(key: &KeyEvent) -> bool {
matches!(
key.code,
KeyCode::Left
| KeyCode::Right
| KeyCode::Char('h')
| KeyCode::Char('l')
| KeyCode::Char('{')
| KeyCode::Char('}')
| KeyCode::Char('n')
| KeyCode::Char('N')
)
}
fn is_drag(mouse: &MouseEvent) -> bool {
matches!(mouse.kind, crossterm::event::MouseEventKind::Drag(_))
}
fn is_navigation(key: &KeyEvent) -> bool {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
KeyCode::Up
| KeyCode::Down
| KeyCode::PageUp
| KeyCode::PageDown
| KeyCode::Home
| KeyCode::End
| KeyCode::Left
| KeyCode::Right
| KeyCode::Char('j')
| KeyCode::Char('k')
| KeyCode::Char('h')
| KeyCode::Char('l')
| KeyCode::Char('{')
| KeyCode::Char('}')
| KeyCode::Char('G')
| KeyCode::Char('n')
| KeyCode::Char('N') => true,
KeyCode::Char('f') | KeyCode::Char('b') | KeyCode::Char('d') | KeyCode::Char('u') => ctrl,
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::export_modal::{ExportFocus, ExportFormat};
use crate::{InputMode, OpenOptions};
use ratatui::{buffer::Buffer, layout::Rect, widgets::Widget};
use std::io::Write;
use std::sync::mpsc;
fn plain(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn ctrl(c: char) -> KeyEvent {
KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)
}
fn held(pump: &EventPump) -> Vec<KeyCode> {
pump.held_keys().map(|k| k.code).collect()
}
fn pump() -> EventPump {
let (tx, rx) = mpsc::channel();
let app = App::new(tx.clone(), crate::tests::test_runtime());
EventPump::new(app, tx, rx)
}
fn type_keys(pump: &mut EventPump, text: &str) {
for c in text.chars() {
pump.terminal_key(plain(KeyCode::Char(c))).unwrap();
}
}
fn settle(pump: &mut EventPump) -> Drained {
fn owed(app: &App) -> bool {
crate::tests::work_pending(app)
|| (app.work_a_bump_would_strand() && !app.awaiting_open_confirmation())
}
let deadline = std::time::Instant::now() + Duration::from_secs(300);
loop {
assert!(
std::time::Instant::now() < deadline,
"the loop did not settle"
);
let replayed = pump.replay_one().unwrap();
pump.app.frame_painted();
let drained = if owed(&pump.app) && !replayed {
pump.wait_and_drain(Duration::from_millis(50))
} else {
pump.drain()
}
.unwrap();
let updated = match &drained {
Drained::Continue { updated, .. } => *updated,
_ => return drained,
};
if replayed || updated || owed(&pump.app) {
continue;
}
if pump.held_keys().next().is_none() {
return drained;
}
}
}
fn loaded_pump() -> (EventPump, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("temp dir");
let path = dir.path().join("people.csv");
let mut file = std::fs::File::create(&path).expect("create csv");
writeln!(file, "name,age\nada,36\ngrace,45\nalan,41").expect("write csv");
drop(file);
let mut pump = pump();
pump.app.app_config.query.default_mode = crate::QueryMode::Q;
pump.send(AppEvent::Open(vec![path], OpenOptions::default()))
.unwrap();
settle(&mut pump);
assert!(
pump.app.data_table_state.is_some(),
"the CSV should have loaded"
);
assert_eq!(pump.app.input_mode, InputMode::Normal);
rendered(&mut pump.app);
(pump, dir)
}
#[test]
fn keys_typed_while_a_find_reads_wait_and_esc_stops_it() {
let (mut p, _dir) = loaded_pump();
p.terminal_key(plain(KeyCode::Char('f'))).unwrap();
type_keys(&mut p, "a");
p.terminal_key(plain(KeyCode::Enter)).unwrap();
assert!(p.app.finding(), "the find reads in the background");
p.terminal_key(plain(KeyCode::Char('n'))).unwrap();
assert_eq!(held(&p), [KeyCode::Char('n')]);
settle(&mut p);
assert!(held(&p).is_empty());
assert_eq!(
p.app.find_hit(),
Some((1, "name".to_string())),
"the held n moved on from the first match"
);
p.terminal_key(plain(KeyCode::Char('n'))).unwrap();
assert!(p.app.finding());
p.terminal_key(plain(KeyCode::Char('n'))).unwrap();
p.terminal_key(plain(KeyCode::Esc)).unwrap();
assert!(!p.app.finding(), "Esc acted at once");
assert!(held(&p).is_empty(), "and dropped the held n");
settle(&mut p);
assert!(!p.app.finding(), "no held find started");
assert_eq!(
p.app.find_hit(),
Some((1, "name".to_string())),
"the cancelled find moved nothing"
);
}
#[test]
fn esc_stopping_a_find_keeps_the_other_held_keys() {
let (mut p, _dir) = loaded_pump();
p.terminal_key(plain(KeyCode::Char('f'))).unwrap();
type_keys(&mut p, "a");
p.terminal_key(plain(KeyCode::Enter)).unwrap();
assert!(p.app.finding());
type_keys(&mut p, "njN/n");
assert_eq!(
held(&p),
[
KeyCode::Char('n'),
KeyCode::Char('j'),
KeyCode::Char('N'),
KeyCode::Char('/'),
KeyCode::Char('n'),
]
);
p.terminal_key(plain(KeyCode::Esc)).unwrap();
assert!(!p.app.finding());
assert_eq!(
held(&p),
[KeyCode::Char('j'), KeyCode::Char('/'), KeyCode::Char('n')]
);
}
#[test]
fn every_n_typed_while_a_find_reads_moves_a_match() {
let dir = tempfile::tempdir().expect("temp dir");
let path = dir.path().join("matches.csv");
let mut file = std::fs::File::create(&path).expect("create csv");
writeln!(file, "id,v").expect("write csv");
for row in 0..10 {
writeln!(file, "{row},x{row}").expect("write csv");
}
drop(file);
let mut p = pump();
p.send(AppEvent::Open(vec![path], OpenOptions::default()))
.unwrap();
settle(&mut p);
rendered(&mut p.app);
p.terminal_key(plain(KeyCode::Char('f'))).unwrap();
type_keys(&mut p, "x");
p.terminal_key(plain(KeyCode::Enter)).unwrap();
assert!(p.app.finding());
for _ in 0..5 {
p.terminal_key(plain(KeyCode::Char('n'))).unwrap();
}
assert_eq!(held(&p), [KeyCode::Char('n'); 5], "none collapsed");
settle(&mut p);
assert!(held(&p).is_empty());
assert_eq!(p.app.find_hit(), Some((5, "v".to_string())));
p.terminal_key(plain(KeyCode::Char('N'))).unwrap();
assert!(p.app.finding());
p.terminal_key(plain(KeyCode::Char('N'))).unwrap();
p.terminal_key(plain(KeyCode::Char('N'))).unwrap();
assert_eq!(held(&p), [KeyCode::Char('N'); 2]);
settle(&mut p);
assert_eq!(p.app.find_hit(), Some((2, "v".to_string())));
}
#[test]
fn enter_held_behind_n_waits_as_space() {
let (mut p, _dir) = loaded_pump();
p.terminal_key(plain(KeyCode::Char('f'))).unwrap();
type_keys(&mut p, "a");
p.terminal_key(plain(KeyCode::Enter)).unwrap();
assert!(p.app.finding());
p.terminal_key(plain(KeyCode::Char('n'))).unwrap();
p.terminal_key(plain(KeyCode::Enter)).unwrap();
assert_eq!(held(&p), [KeyCode::Char('n'), KeyCode::Char(' ')]);
}
#[test]
fn keys_typed_while_the_inspector_reads_wait_their_turn() {
let (mut p, _dir) = loaded_pump();
let state = p.app.data_table_state.as_mut().unwrap();
state.set_column_order(vec!["name".to_string()]);
rendered(&mut p.app);
p.terminal_key(plain(KeyCode::Char(' '))).unwrap();
assert_eq!(p.app.input_mode, InputMode::Inspect);
p.terminal_key(plain(KeyCode::End)).unwrap();
p.terminal_key(plain(KeyCode::Enter)).unwrap();
assert!(
p.app.is_busy(),
"the hidden field is read in the background"
);
p.terminal_key(plain(KeyCode::Char('k'))).unwrap();
p.terminal_key(plain(KeyCode::Esc)).unwrap();
assert_eq!(held(&p), [KeyCode::Char('k'), KeyCode::Esc]);
assert_eq!(p.app.input_mode, InputMode::Inspect, "nothing acted yet");
settle(&mut p);
assert!(held(&p).is_empty());
assert_eq!(
p.app.input_mode,
InputMode::Normal,
"Esc closed it, in turn"
);
assert_eq!(
p.app.inspector_modal.focused().map(|f| f.name.as_str()),
Some("name"),
"k moved first"
);
}
#[test]
fn keys_typed_while_json_parses_replay_inside_the_level() {
let dir = tempfile::tempdir().expect("temp dir");
let path = dir.path().join("docs.csv");
let doc = format!("[{}0]", "0, ".repeat(30_000));
std::fs::write(&path, format!("doc\n\"{doc}\"\n")).expect("write csv");
let mut p = pump();
p.send(AppEvent::Open(vec![path], OpenOptions::default()))
.unwrap();
settle(&mut p);
rendered(&mut p.app);
p.terminal_key(plain(KeyCode::Char(' '))).unwrap();
rendered(&mut p.app);
p.terminal_key(plain(KeyCode::Enter)).unwrap();
assert!(p.app.is_busy(), "the text is parsed in the background");
p.terminal_key(plain(KeyCode::Char('j'))).unwrap();
p.terminal_key(plain(KeyCode::Char('j'))).unwrap();
assert_eq!(held(&p), [KeyCode::Char('j'), KeyCode::Char('j')]);
settle(&mut p);
let drill = p
.app
.inspector_modal
.drill
.as_ref()
.expect("the array opened");
assert_eq!(drill.level().node.len(), 30_001);
assert_eq!(drill.level().selected, 2, "both j moved inside it");
}
fn rendered(app: &mut App) -> String {
let area = Rect::new(0, 0, 100, 20);
let mut buf = Buffer::empty(area);
app.render(area, &mut buf);
buf.content().iter().map(|c| c.symbol()).collect()
}
fn mouse(kind: crossterm::event::MouseEventKind, (x, y): (u16, u16)) -> MouseEvent {
MouseEvent {
kind,
column: x,
row: y,
modifiers: KeyModifiers::NONE,
}
}
fn click(at: (u16, u16)) -> MouseEvent {
use crossterm::event::{MouseButton, MouseEventKind};
mouse(MouseEventKind::Down(MouseButton::Left), at)
}
fn on_screen(app: &mut App, text: &str) -> (u16, u16) {
let area = Rect::new(0, 0, 100, 20);
let mut buf = Buffer::empty(area);
app.render(area, &mut buf);
for y in 0..area.height {
let line: String = (0..area.width).map(|x| buf[(x, y)].symbol()).collect();
if let Some(at) = line.find(text) {
return (line[..at].chars().count() as u16, y);
}
}
panic!("{text:?} is not on screen");
}
fn cell(pump: &EventPump) -> (Option<usize>, Option<String>) {
let state = pump.app.data_table_state.as_ref().expect("a dataset");
(
state.table_state.selected(),
state.current_column().map(str::to_string),
)
}
#[test]
fn a_click_moves_the_cursor_and_a_double_click_inspects() {
let (mut p, _dir) = loaded_pump();
assert_eq!(cell(&p), (Some(0), Some("name".to_string())));
let at = on_screen(&mut p.app, "45");
assert!(p.terminal_mouse(click(at)).unwrap());
assert_eq!(cell(&p), (Some(1), Some("age".to_string())));
assert_eq!(p.app.input_mode, InputMode::Normal, "one click only moves");
let at = on_screen(&mut p.app, "alan");
p.terminal_mouse(click(at)).unwrap();
assert_eq!(cell(&p), (Some(2), Some("name".to_string())));
assert_eq!(p.app.input_mode, InputMode::Normal);
p.terminal_mouse(click(at)).unwrap();
assert_eq!(p.app.input_mode, InputMode::Inspect, "Enter inspects");
let before = cell(&p);
p.terminal_mouse(click((3, 3))).unwrap();
assert_eq!(cell(&p), before);
}
#[test]
fn the_wheel_scrolls_rows_and_across_moves_the_column() {
use crossterm::event::MouseEventKind;
let (mut p, _dir) = numbered_pump(50);
let (mut p2, _dir2) = loaded_pump();
let rows = |p: &EventPump| cell(p).0;
assert!(
p.terminal_mouse(mouse(MouseEventKind::ScrollDown, (5, 5)))
.unwrap()
);
assert_eq!(rows(&p), Some(3));
p.terminal_mouse(mouse(MouseEventKind::ScrollUp, (5, 5)))
.unwrap();
assert_eq!(rows(&p), Some(0));
let mut shift = mouse(MouseEventKind::ScrollDown, (5, 5));
shift.modifiers = KeyModifiers::SHIFT;
p2.terminal_mouse(shift).unwrap();
assert_eq!(cell(&p2), (Some(0), Some("age".to_string())));
p2.terminal_mouse(mouse(MouseEventKind::ScrollLeft, (5, 5)))
.unwrap();
assert_eq!(cell(&p2), (Some(0), Some("name".to_string())));
p2.terminal_mouse(mouse(MouseEventKind::ScrollRight, (5, 5)))
.unwrap();
assert_eq!(cell(&p2).1.as_deref(), Some("age"));
p2.terminal_mouse(mouse(MouseEventKind::ScrollDown, (5, 5)))
.unwrap();
rendered(&mut p2.app);
assert_eq!(rows(&p2), Some(2));
}
#[test]
fn mouse_input_is_never_held() {
use crossterm::event::MouseEventKind;
let (mut p, _dir) = loaded_pump();
let alan = on_screen(&mut p.app, "alan");
p.app.busy = true;
assert!(
!p.terminal_mouse(mouse(MouseEventKind::ScrollDown, (5, 5)))
.unwrap()
);
assert!(!p.terminal_mouse(click(alan)).unwrap());
assert!(held(&p).is_empty(), "nothing held");
assert_eq!(cell(&p), (Some(0), Some("name".to_string())));
assert!(
p.terminal_mouse(mouse(MouseEventKind::ScrollRight, (5, 5)))
.unwrap()
);
assert_eq!(cell(&p), (Some(0), Some("age".to_string())), "at once");
p.terminal_key(plain(KeyCode::Char('j'))).unwrap();
assert_eq!(held(&p), [KeyCode::Char('j')]);
p.app.busy = false;
assert!(!p.terminal_mouse(click(alan)).unwrap());
settle(&mut p);
assert_eq!(cell(&p).0, Some(1), "the held j, and no click");
assert!(p.terminal_mouse(click(alan)).unwrap());
assert_eq!(cell(&p).0, Some(2));
}
#[test]
fn a_dropped_click_does_not_make_the_next_a_double_click() {
let (mut p, _dir) = loaded_pump();
let alan = on_screen(&mut p.app, "alan");
p.app.busy = true;
assert!(!p.terminal_mouse(click(alan)).unwrap());
p.app.busy = false;
assert!(p.terminal_mouse(click(alan)).unwrap());
assert_eq!(cell(&p).0, Some(2));
assert_eq!(p.app.input_mode, InputMode::Normal, "one click, no Enter");
}
#[test]
fn a_click_waits_for_the_frame_after_a_change() {
let (mut p, _dir) = loaded_pump();
let alan = on_screen(&mut p.app, "alan");
p.app.frame_painted();
p.send(AppEvent::Terminal(Event::Resize(100, 20))).unwrap();
p.send(AppEvent::Terminal(Event::Mouse(click(alan))))
.unwrap();
let drained = p.drain().unwrap();
assert!(
matches!(
drained,
Drained::Continue {
updated: true,
progress_only: false
}
),
"a frame is asked for: {drained:?}"
);
assert_eq!(cell(&p).0, Some(0), "not yet");
rendered(&mut p.app);
p.app.frame_painted();
p.drain().unwrap();
assert_eq!(cell(&p).0, Some(2), "on the frame that shows the resize");
}
#[test]
fn a_chip_presses_its_key() {
use crossterm::event::MouseEventKind;
let (mut p, _dir) = loaded_pump();
let help = on_screen(&mut p.app, "? keys");
assert!(p.terminal_mouse(click(help)).unwrap());
assert!(p.app.help_visible(), "the Help chip opened help");
rendered(&mut p.app);
p.terminal_mouse(mouse(MouseEventKind::ScrollDown, (5, 5)))
.unwrap();
assert_eq!(p.app.help.selected, 3);
p.terminal_mouse(click((5, 5))).unwrap();
assert_eq!(cell(&p), (Some(0), Some("name".to_string())));
}
#[test]
fn a_dialog_s_footer_chips_press_their_keys() {
let (mut p, _dir) = loaded_pump();
p.send(AppEvent::Terminal(Event::Key(plain(KeyCode::Char('e')))))
.unwrap();
settle(&mut p);
assert!(p.app.export_modal.active);
let cancel = on_screen(&mut p.app, "Cancel");
assert!(p.terminal_mouse(click(cancel)).unwrap());
settle(&mut p);
assert!(!p.app.export_modal.active, "Esc Cancel closed the form");
p.app.confirmation_modal.show("Delete it?".to_string());
p.terminal_mouse(click((0, 2))).unwrap();
settle(&mut p);
assert!(p.app.confirmation_modal.active, "outside the question");
let cancel = on_screen(&mut p.app, "Cancel");
p.terminal_mouse(click(cancel)).unwrap();
settle(&mut p);
assert!(!p.app.confirmation_modal.active, "its Esc chip answered it");
p.send(AppEvent::Terminal(Event::Key(plain(KeyCode::Char('?')))))
.unwrap();
settle(&mut p);
assert!(p.app.help_visible());
let close = on_screen(&mut p.app, "Close");
p.terminal_mouse(click(close)).unwrap();
settle(&mut p);
assert!(!p.app.help_visible(), "help's Close chip closed it");
}
#[test]
fn mouse_events_keep_their_place_among_the_keys() {
let (mut p, _dir) = loaded_pump();
let alan = on_screen(&mut p.app, "alan");
p.send(AppEvent::Terminal(Event::Key(plain(KeyCode::Char('l')))))
.unwrap();
p.send(AppEvent::Terminal(Event::Mouse(click(alan))))
.unwrap();
settle(&mut p);
assert_eq!(
cell(&p),
(Some(2), Some("name".to_string())),
"the l moved to age, then the click came back to name"
);
}
#[test]
fn a_continuation_never_goes_to_the_back_of_the_channel() {
let source = include_str!("event_pump.rs");
let needle = concat!("self.tx", ".send(");
assert_eq!(
source.matches(needle).count(),
1,
"the one send left should be `EventPump::send`. A follow-up sent to the \
channel holds nothing while it waits, and the generation reads free in the \
middle of an errand — see `jobs::Hold`."
);
}
#[test]
fn a_continuation_holds_the_generation_until_it_is_dispatched() {
let (mut p, dir) = loaded_pump();
let out = dir.path().join("out.csv");
assert!(!p.app.work_a_bump_would_strand(), "nothing is running yet");
let (waits, release) =
crate::tests::worker_waits_once(|job| matches!(job, crate::Job::Export));
p.app.jobs.worker_waits = waits;
p.send(AppEvent::Export(csv_export(&out))).unwrap();
assert!(matches!(p.drain().unwrap(), Drained::Continue { .. }));
assert!(
!p.next_up.is_empty(),
"the continuation is waiting to be dispatched"
);
assert!(
p.app.work_a_bump_would_strand(),
"and the generation is held while it waits"
);
assert!(matches!(p.drain().unwrap(), Drained::Continue { .. }));
assert!(
p.app.work_a_bump_would_strand(),
"the export it started is running now, and holds it in turn"
);
release.send(()).unwrap();
settle(&mut p);
assert!(out.exists(), "and the export finishes, which is the point");
assert!(
!p.app.work_a_bump_would_strand(),
"with the generation free again afterwards"
);
}
#[test]
fn a_key_in_the_handoff_window_does_not_find_the_generation_free() {
let (mut p, dir) = loaded_pump();
let out = dir.path().join("out.csv");
p.app.owe_rows_for_tests("Loading buffer...");
p.send(AppEvent::Export(csv_export(&out))).unwrap();
assert!(matches!(p.drain().unwrap(), Drained::Continue { .. }));
assert!(!p.next_up.is_empty(), "mid-handoff");
let held_at = p.app.task_generation();
p.terminal_key(plain(KeyCode::Char('?'))).unwrap();
assert_eq!(
p.app.task_generation(),
held_at,
"the owed collect did not go in on the back of a key handled in the window"
);
assert!(
p.app.rows_owed(),
"it is still owed, waiting for the export in front of it"
);
}
#[test]
fn an_export_holds_the_generation_at_every_phase_change() {
let (mut p, dir) = loaded_pump();
let out = dir.path().join("out.csv");
p.send(AppEvent::Export(csv_export(&out))).unwrap();
let mut breaks = 0;
for _ in 0..10_000 {
let drained = if p.app.is_busy() {
p.wait_and_drain(Duration::from_secs(10)).unwrap()
} else {
p.drain().unwrap()
};
match drained {
Drained::Continue { updated, .. } => {
if !p.next_up.is_empty() {
breaks += 1;
}
if !p.next_up.is_empty() || p.app.is_busy() {
assert!(
p.app.work_a_bump_would_strand(),
"the export left the generation free after {breaks} hand-offs"
);
}
if !updated && p.next_up.is_empty() && !p.app.is_busy() {
break;
}
}
other => panic!("the export should not end the loop: {other:?}"),
}
}
assert!(
breaks >= 1,
"the export handed off to its job, and that was checked; saw {breaks}"
);
assert!(out.exists(), "and the file was written, which is the point");
assert!(
!p.app.work_a_bump_would_strand(),
"the generation is free the moment the export is done"
);
}
fn csv_export(path: &std::path::Path) -> crate::ExportRequest {
crate::ExportRequest {
path: path.to_path_buf(),
format: ExportFormat::Csv,
options: crate::ExportOptions {
csv_delimiter: b',',
csv_include_header: true,
source_file: false,
csv_compression: None,
json_compression: None,
ndjson_compression: None,
},
overwrite: crate::output_file::Overwrite::Forbid,
}
}
#[test]
fn an_export_whose_worker_dies_ends_and_the_next_one_writes() {
let (mut p, dir) = loaded_pump();
let out = dir.path().join("out.csv");
let mut exports = 0;
p.app.jobs.worker_dies = Some(Box::new(move |job| {
exports += usize::from(matches!(job, crate::Job::Export));
exports == 1
}));
p.send(AppEvent::Export(csv_export(&out))).unwrap();
let deadline = std::time::Instant::now() + Duration::from_secs(300);
while p.app.export_modal.path_error.is_none() {
assert!(
std::time::Instant::now() < deadline,
"the export never ended"
);
p.wait_and_drain(Duration::from_millis(50)).unwrap();
}
let reason = p.app.export_modal.path_error.clone().unwrap_or_default();
assert!(reason.contains("worker died"), "{reason}");
assert!(p.app.export_modal.active);
assert!(!p.app.error_modal.active);
assert!(!p.app.is_busy());
assert!(p.app.status_message.is_none());
assert!(p.app.nothing_loading());
assert!(!out.exists());
assert!(
!p.app.work_a_bump_would_strand(),
"the generation is free as the failure is shown"
);
p.terminal_key(plain(KeyCode::Esc)).unwrap();
assert!(!p.app.export_modal.active);
p.send(AppEvent::Export(csv_export(&out))).unwrap();
settle(&mut p);
assert!(!p.app.error_modal.active, "{}", p.app.error_modal.message);
assert_eq!(p.app.export_modal.path_error, None);
assert!(out.exists(), "the next export writes its file");
assert!(p.app.nothing_loading());
}
#[test]
fn a_drill_whose_read_dies_flashes_one_line_and_the_next_one_drills() {
let (mut p, _dir) = loaded_pump();
p.send(AppEvent::QQuery("select n: count age by name".to_string()))
.unwrap();
settle(&mut p);
p.app
.data_table_state
.as_mut()
.unwrap()
.set_column_order(vec!["n".to_string()]);
settle(&mut p);
rendered(&mut p.app);
p.app.jobs.worker_dies =
crate::tests::worker_dies_once(|job| matches!(job, crate::Job::DrillRow));
p.terminal_key(plain(KeyCode::Enter)).unwrap();
settle(&mut p);
assert_eq!(
p.app.flash_message(),
Some("Could not drill in; see the log")
);
assert!(!p.app.is_busy());
assert!(p.app.status_message.is_none());
assert!(!p.app.error_modal.active);
assert!(!p.app.data_table_state.as_ref().unwrap().is_drilled_down());
rendered(&mut p.app);
p.terminal_key(plain(KeyCode::Enter)).unwrap();
settle(&mut p);
assert!(
p.app.data_table_state.as_ref().unwrap().is_drilled_down(),
"the next one drills"
);
}
#[test]
fn a_pivot_whose_worker_dies_is_shown_and_the_next_one_installs() {
use crate::pivot_melt_modal::{PivotAggregation, PivotSpec};
let dir = tempfile::tempdir().expect("temp dir");
let path = dir.path().join("long.csv");
std::fs::write(&path, "day,key,val\n1,a,10\n1,b,20\n2,a,30\n2,b,40\n").unwrap();
let mut p = pump();
p.send(AppEvent::Open(vec![path], OpenOptions::default()))
.unwrap();
settle(&mut p);
rendered(&mut p.app);
let spec = PivotSpec {
index: vec!["day".to_string()],
pivot_column: "key".to_string(),
value_column: "val".to_string(),
aggregation: PivotAggregation::Last,
sort_columns: None,
};
p.app.input_mode = InputMode::PivotMelt;
p.app.jobs.worker_dies =
crate::tests::worker_dies_once(|job| matches!(job, crate::Job::Pivot));
p.send(AppEvent::Pivot(spec.clone())).unwrap();
settle(&mut p);
assert!(p.app.error_modal.active, "the user is told");
assert!(!p.app.is_busy());
assert!(!p.app.pivot_computing(), "the form is not left computing");
assert_eq!(p.app.input_mode, InputMode::PivotMelt, "and keeps the spec");
let state = p.app.data_table_state.as_ref().unwrap();
assert!(state.last_pivot_spec().is_none(), "the table is as it was");
p.app.error_modal.hide();
p.send(AppEvent::Pivot(spec)).unwrap();
settle(&mut p);
assert!(!p.app.error_modal.active, "{}", p.app.error_modal.message);
let state = p.app.data_table_state.as_ref().unwrap();
assert!(
state.last_pivot_spec().is_some(),
"the next pivot is installed"
);
assert_eq!(p.app.input_mode, InputMode::Normal);
}
#[test]
fn stale_failures_leave_a_newer_open_alone() {
let (mut p, dir) = loaded_pump();
let passed = p.app.task_generation();
let shown = p.app.dataset_generation;
let gone = crate::loading::LoadId::for_tests(u64::MAX);
let jobs = [
crate::Job::Load(gone),
crate::Job::OpenNamed(gone),
crate::Job::Rows(crate::InflightCollect::for_tests(0, 3)),
crate::Job::Analysis(crate::jobs::AnalysisRun::default()),
crate::Job::SampleRows,
crate::Job::Pivot,
crate::Job::DrillRow,
crate::Job::InspectRow { frame: 0, row: 0 },
crate::Job::InspectJson { token: 0 },
crate::Job::Export,
crate::Job::Copy,
crate::Job::QualityReport,
];
let running: Vec<_> = jobs
.into_iter()
.map(|job| p.app.job_for_tests(job, None))
.collect();
let (waits, release) = crate::tests::worker_waits_once(|job| {
matches!(job, crate::Job::OpenNamed(_) | crate::Job::Load(_))
});
p.app.jobs.worker_waits = waits;
p.send(AppEvent::Open(
vec![dir.path().join("people.csv")],
OpenOptions::default(),
))
.unwrap();
assert!(matches!(p.drain().unwrap(), Drained::Continue { .. }));
assert!(p.app.awaiting_dataset(), "the open is under way");
assert_ne!(p.app.task_generation(), passed);
for job in running {
job.end(crate::Outcome::Failed {
message: "from work long gone".to_string(),
panicked: true,
});
}
release.send(()).unwrap();
settle(&mut p);
assert!(!p.app.error_modal.active, "{}", p.app.error_modal.message);
assert_ne!(p.app.dataset_generation, shown, "the open finished");
assert!(!p.app.awaiting_dataset());
assert!(p.app.last_load_error.is_none());
assert!(p.app.nothing_loading());
}
#[test]
fn an_open_whose_schema_read_dies_ends_and_the_next_one_opens() {
let (mut p, dir) = loaded_pump();
let shown = p.app.dataset_generation;
let path = dir.path().join("people.csv");
let mut loads = 0;
p.app.jobs.worker_dies = Some(Box::new(move |job| {
loads += usize::from(matches!(job, crate::Job::Load(_)));
loads == 2
}));
let (waits, release) = crate::tests::worker_waits_once(|job| {
matches!(job, crate::Job::OpenNamed(_) | crate::Job::Load(_))
});
p.app.jobs.worker_waits = waits;
let mut release = Some(release);
p.send(AppEvent::Open(vec![path.clone()], OpenOptions::default()))
.unwrap();
let mut frames = 0;
let deadline = std::time::Instant::now() + Duration::from_secs(300);
while !p.app.error_modal.active {
assert!(std::time::Instant::now() < deadline, "the open never ended");
p.wait_and_drain(Duration::from_millis(50)).unwrap();
if let Some(release) = release.take() {
release.send(()).unwrap();
}
if p.app.awaiting_dataset() {
frames += 1;
assert!(p.app.is_busy(), "busy at frame {frames}");
assert!(p.app.work_a_bump_would_strand(), "held at frame {frames}");
}
}
assert!(frames >= 1, "the open was under way between frames");
assert!(
p.app.error_modal.message.contains("worker died"),
"{}",
p.app.error_modal.message
);
settle(&mut p);
assert!(!p.app.is_busy());
assert!(!p.app.awaiting_dataset(), "nothing is waited on");
assert!(p.app.nothing_loading());
assert_eq!(
p.app.dataset_generation, shown,
"the dataset before it stays"
);
p.terminal_key(plain(KeyCode::Esc)).unwrap();
p.send(AppEvent::Open(vec![path], OpenOptions::default()))
.unwrap();
settle(&mut p);
assert!(!p.app.error_modal.active, "{}", p.app.error_modal.message);
assert_ne!(p.app.dataset_generation, shown, "the next open opens");
}
#[test]
fn a_deferred_collect_with_nothing_to_do_takes_the_loading_screen_down() {
let (mut p, _dir) = loaded_pump();
p.app.owe_rows_for_tests("Loading buffer...");
p.app.loading.first_rows_for_tests();
p.app.busy = true;
p.send(AppEvent::Update).unwrap();
assert!(matches!(p.drain().unwrap(), Drained::Continue { .. }));
assert!(!p.app.rows_owed(), "the errand is done either way");
assert!(
p.app.nothing_loading(),
"and the loading screen is down rather than stuck at 70%"
);
assert!(!p.app.is_busy(), "with the keyboard back");
}
#[test]
fn view_keys_act_while_rows_load() {
let (mut p, _dir) = loaded_pump();
p.app.owe_rows_for_tests("Loading buffer...");
assert!(p.app.is_busy());
let numbered = p.app.data_table_state.as_ref().unwrap().row_numbers();
p.terminal_key(plain(KeyCode::Char('#'))).unwrap();
assert_ne!(
p.app.data_table_state.as_ref().unwrap().row_numbers(),
numbered,
"# toggles at once"
);
let row = p.app.data_table_state.as_ref().unwrap().cursor_row();
p.terminal_key(plain(KeyCode::Char('j'))).unwrap();
assert_eq!(
p.app.data_table_state.as_ref().unwrap().cursor_row(),
row + 1,
"j inside the rows held moves at once"
);
assert!(p.held.is_empty(), "nothing waits");
p.app.busy = true;
p.terminal_key(plain(KeyCode::Char('s'))).unwrap();
p.terminal_key(plain(KeyCode::Char('j'))).unwrap();
assert_eq!(p.held.len(), 2);
assert_eq!(
p.app.data_table_state.as_ref().unwrap().cursor_row(),
row + 1
);
}
#[test]
fn a_key_while_busy_comes_back_deferred() {
let mut p = pump();
p.app.busy = true;
assert!(matches!(
p.app.handle(&AppEvent::Key(plain(KeyCode::Char('j')))),
Err(k) if k.code == KeyCode::Char('j')
));
assert!(
p.app
.event(&AppEvent::Key(plain(KeyCode::Char('j'))))
.is_none()
);
p.app.busy = false;
assert!(matches!(
p.app.handle(&AppEvent::Key(plain(KeyCode::Char('j')))),
Ok(None)
));
}
#[test]
fn a_query_typed_while_busy_lands_in_the_query_bar() {
let (mut p, _dir) = loaded_pump();
p.app.busy = true;
type_keys(&mut p, ":hello");
assert_eq!(
p.app.input_mode,
InputMode::Normal,
"nothing acts while busy"
);
assert_eq!(held(&p).len(), 6);
assert!(matches!(p.drain().unwrap(), Drained::Continue { .. }));
p.app.busy = false;
assert!(matches!(settle(&mut p), Drained::Continue { .. }));
assert_eq!(p.app.input_mode, InputMode::Editing);
assert_eq!(p.app.query_input.value(), "hello");
p.app.busy = true;
type_keys(&mut p, "query");
p.app.busy = false;
assert!(
matches!(settle(&mut p), Drained::Continue { .. }),
"q did not quit"
);
assert_eq!(p.app.query_input.value(), "helloquery");
}
#[test]
fn a_mode_chord_typed_while_busy_switches_before_the_text() {
let (mut p, _dir) = loaded_pump();
p.app.busy = true;
type_keys(&mut p, ":");
p.terminal_key(ctrl('t')).unwrap();
type_keys(&mut p, "ada");
assert_eq!(held(&p).len(), 5, "the chord is held with the text");
p.app.busy = false;
settle(&mut p);
let mode = crate::QueryMode::Q.next();
assert_eq!(p.app.query_prompt_mode(), Some(mode));
let typed = match mode {
crate::QueryMode::Sql => &p.app.sql_input,
crate::QueryMode::Q => &p.app.query_input,
};
assert_eq!(typed.value(), "ada");
if mode != crate::QueryMode::Q {
assert_eq!(p.app.query_input.value(), "");
}
}
#[test]
fn a_fresh_key_waits_behind_the_held_ones() {
let (mut p, _dir) = loaded_pump();
p.app.busy = true;
type_keys(&mut p, ":abc");
p.app.busy = false;
type_keys(&mut p, "d");
assert_eq!(p.app.input_mode, InputMode::Normal, "d waited its turn");
settle(&mut p);
assert_eq!(p.app.query_input.value(), "abcd");
}
#[test]
fn a_held_enter_finishes_its_search_before_the_next_key() {
let (mut p, _dir) = loaded_pump();
p.app.busy = true;
type_keys(&mut p, ":select name where age > 40");
p.terminal_key(plain(KeyCode::Enter)).unwrap();
p.terminal_key(plain(KeyCode::Char('G'))).unwrap();
p.app.busy = false;
while held(&p).len() > 1 {
assert!(p.replay_one().unwrap());
if held(&p).len() > 1 {
assert!(matches!(p.drain().unwrap(), Drained::Continue { .. }));
}
}
assert_eq!(
p.app.input_mode,
InputMode::Editing,
"the search has not run"
);
assert_eq!(held(&p), vec![KeyCode::Char('G')], "G waits for it");
assert!(matches!(p.drain().unwrap(), Drained::Continue { .. }));
assert_eq!(
p.app.data_table_state.as_ref().unwrap().get_active_query(),
"select name where age > 40"
);
assert_eq!(
held(&p),
vec![KeyCode::Char('G')],
"G is offered only after"
);
settle(&mut p);
assert_eq!(p.app.input_mode, InputMode::Normal);
let state = p.app.data_table_state.as_ref().unwrap();
assert_eq!(state.get_active_query(), "select name where age > 40");
assert_eq!(state.num_rows(), 2);
assert_eq!(
state.table_state.selected(),
Some(1),
"G went to the last row of the result"
);
}
#[test]
fn the_cap_drops_the_newest_key_and_says_so() {
let mut p = pump();
p.app.busy = true;
p.app.status_message = Some("Loading buffer...".to_string());
type_keys(&mut p, "/");
type_keys(&mut p, &"a".repeat(39));
assert_eq!(held(&p).len(), MAX_HELD_KEYS);
assert_eq!(held(&p)[0], KeyCode::Char('/'));
assert!(p.app.input_dropped);
assert!(rendered(&mut p.app).contains("input dropped while busy"));
p.app.busy = false;
settle(&mut p);
assert!(held(&p).is_empty());
assert!(
!p.app.input_dropped,
"the notice goes with the last held key"
);
}
#[test]
fn a_held_navigation_key_is_one_press() {
let mut p = pump();
p.app.busy = true;
for _ in 0..50 {
p.terminal_key(plain(KeyCode::PageDown)).unwrap();
}
assert_eq!(held(&p), vec![KeyCode::PageDown]);
for _ in 0..10 {
p.terminal_key(plain(KeyCode::Char('j'))).unwrap();
}
assert_eq!(held(&p), vec![KeyCode::PageDown, KeyCode::Char('j')]);
type_keys(&mut p, "aa");
assert_eq!(held(&p).len(), 4);
}
#[test]
fn keys_held_before_an_error_modal_appears_are_dropped() {
let mut p = pump();
p.app.busy = true;
type_keys(&mut p, "/x");
assert_eq!(held(&p).len(), 2);
let analysis = p.app.job_for_tests(
crate::Job::Analysis(crate::jobs::AnalysisRun::default()),
Some("Running analysis..."),
);
analysis.end(crate::Outcome::Failed {
message: "disk on fire".to_string(),
panicked: false,
});
assert!(matches!(p.drain().unwrap(), Drained::Continue { .. }));
assert!(!p.app.is_busy());
assert!(held(&p).is_empty());
settle(&mut p);
assert!(p.app.error_modal.active, "the error is still on screen");
}
#[cfg(feature = "sql")]
#[test]
fn keys_held_while_a_statement_runs_are_dropped_when_it_fails() {
let (mut p, _dir) = loaded_pump();
p.app.app_config.query.default_mode = crate::QueryMode::Sql;
p.terminal_key(plain(KeyCode::Char(':'))).unwrap();
let sql = "SELECT CAST(name AS INT) AS n FROM df";
type_keys(&mut p, sql);
let (waits, release) =
crate::tests::worker_waits_once(|job| matches!(job, crate::Job::Rows(_)));
p.app.jobs.worker_waits = waits;
p.terminal_key(plain(KeyCode::Enter)).unwrap();
p.drain().unwrap();
assert!(p.app.is_busy());
type_keys(&mut p, "jj");
assert_eq!(held(&p).len(), 2);
release.send(()).unwrap();
settle(&mut p);
assert!(held(&p).is_empty());
assert!(!p.app.error_modal.active);
assert_eq!(p.app.query_prompt_mode(), Some(crate::QueryMode::Sql));
assert_eq!(p.app.query_prompt_text(), Some(sql));
assert!(p.app.query_prompt_error().is_some());
}
#[test]
fn ctrl_c_in_the_query_bar_quits() {
let (mut p, _dir) = loaded_pump();
p.terminal_key(plain(KeyCode::Char(':'))).unwrap();
assert_eq!(p.app.input_mode, InputMode::Editing);
assert!(matches!(
p.app.handle(&AppEvent::Key(ctrl('c'))),
Ok(Some(AppEvent::Exit))
));
let alt_w = KeyEvent::new(KeyCode::Char('w'), KeyModifiers::ALT);
assert!(!matches!(
p.app.handle(&AppEvent::Key(alt_w)),
Ok(Some(AppEvent::Exit))
));
assert_eq!(
p.app.input_mode,
InputMode::Editing,
"Alt+W stays in the field"
);
}
#[test]
fn ctrl_c_quits_from_the_sort_and_chart_search_boxes() {
use crate::chart_modal::ChartFocus;
use crate::sort_filter_modal::{SortFilterField, SortFilterTab};
let (mut p, _dir) = loaded_pump();
p.app.input_mode = InputMode::SortFilter;
p.app.sort_filter_modal.active = true;
p.app.sort_filter_modal.active_tab = SortFilterTab::Columns;
p.app.sort_filter_modal.focus = SortFilterField::Find;
assert!(
p.app.text_field_focused(),
"the sort search is a text field"
);
assert!(matches!(
p.app.handle(&AppEvent::Key(ctrl('c'))),
Ok(Some(AppEvent::Exit))
));
let (mut p2, _d) = loaded_pump();
p2.app.input_mode = InputMode::Chart;
p2.app.chart_modal.active = true;
p2.app.chart_modal.open(
crate::chart_modal::ChartColumns {
numeric: &["a".to_string(), "b".to_string()],
..Default::default()
},
None,
None,
false,
0,
);
p2.app.chart_modal.focus = ChartFocus::X;
p2.app.chart_modal.open_picker();
assert!(
p2.app.text_field_focused(),
"the open Picker narrows by typing"
);
assert!(matches!(
p2.app.handle(&AppEvent::Key(ctrl('c'))),
Ok(Some(AppEvent::Exit))
));
let (mut p3, _d3) = loaded_pump();
assert!(matches!(
p3.app.handle(&AppEvent::Key(ctrl('c'))),
Ok(Some(AppEvent::Exit))
));
}
#[test]
fn q_at_the_startup_spinner_quits_but_slash_query_types() {
let mut p = pump();
p.app.busy = true;
assert!(p.app.in_normal_table_view());
p.terminal_key(plain(KeyCode::Char('q'))).unwrap();
assert!(
matches!(p.drain().unwrap(), Drained::Exit),
"q quits at once"
);
let mut p2 = pump();
p2.app.busy = true;
type_keys(&mut p2, "/query");
assert_eq!(held(&p2).len(), 6, "the q in /query is held, not a quit");
}
#[test]
fn doubled_letters_survive_but_navigation_coalesces() {
let mut p = pump();
p.app.busy = true;
type_keys(&mut p, "/bookkeeper");
let typed: String = held(&p)
.iter()
.filter_map(|c| match c {
KeyCode::Char(ch) => Some(*ch),
_ => None,
})
.collect();
assert_eq!(typed, "/bookkeeper", "both k's, o's and e's survive");
let mut p2 = pump();
p2.app.busy = true;
type_keys(&mut p2, "jj");
assert_eq!(
held(&p2),
vec![KeyCode::Char('j')],
"a doubled navigation key collapses"
);
}
#[test]
fn escapes_act_during_the_replay_window() {
let (mut p, _dir) = loaded_pump();
p.app.busy = true;
type_keys(&mut p, "/foo");
p.app.busy = false;
assert!(p.terminal_key(ctrl('o')).unwrap(), "Ctrl-O acts now");
assert_eq!(p.app.input_mode, InputMode::Home);
assert!(held(&p).is_empty(), "going home cleared the held keys");
let (mut p2, _d) = loaded_pump();
p2.app.busy = true;
type_keys(&mut p2, "/foo");
p2.app.busy = false;
assert!(
!p2.terminal_key(plain(KeyCode::Char('x'))).unwrap(),
"a fresh non-escape key waits behind the held ones"
);
assert_eq!(held(&p2).last().copied(), Some(KeyCode::Char('x')));
}
#[test]
fn a_replayed_search_runs_before_a_fresh_key() {
let (mut p, _dir) = loaded_pump();
p.app.busy = true;
type_keys(&mut p, ":select name where age > 40");
p.terminal_key(plain(KeyCode::Enter)).unwrap();
p.app.busy = false;
let (waits, release) =
crate::tests::worker_waits_once(|job| matches!(job, crate::Job::Rows(_)));
p.app.jobs.worker_waits = waits;
loop {
let replayed = p.replay_one().unwrap();
assert!(matches!(p.drain().unwrap(), Drained::Continue { .. }));
if p.app.is_busy() {
break;
}
assert!(replayed, "should still be replaying the query");
}
assert!(held(&p).is_empty(), "the whole query replayed");
assert!(
!p.terminal_key(plain(KeyCode::Char('G'))).unwrap(),
"G waits behind the running search"
);
assert_eq!(held(&p), [KeyCode::Char('G')]);
assert!(matches!(p.drain().unwrap(), Drained::Continue { .. }));
assert!(!p.replay_one().unwrap(), "G is not replayed while it runs");
release.send(()).unwrap();
settle(&mut p);
assert_eq!(p.app.input_mode, InputMode::Normal);
let state = p.app.data_table_state.as_ref().unwrap();
assert_eq!(state.get_active_query(), "select name where age > 40");
assert_eq!(state.num_rows(), 2);
assert_eq!(
state.table_state.selected(),
Some(1),
"G ran after the search, on its result"
);
}
#[test]
fn a_bare_enter_or_esc_at_a_busy_table_is_dropped() {
let mut p = pump();
p.app.busy = true;
assert!(!p.terminal_key(plain(KeyCode::Enter)).unwrap());
assert!(!p.terminal_key(plain(KeyCode::Esc)).unwrap());
assert!(held(&p).is_empty(), "neither is queued");
let (mut p2, _d) = loaded_pump();
p2.app.busy = true;
type_keys(&mut p2, "/x");
p2.terminal_key(plain(KeyCode::Enter)).unwrap();
assert_eq!(
held(&p2).last().copied(),
Some(KeyCode::Enter),
"the query's Enter is held"
);
}
#[test]
fn column_moves_and_quick_filters_wait_while_busy() {
let (mut p, _dir) = loaded_pump();
p.app.busy = true;
for c in ['L', '+', 'H', '-'] {
assert!(!p.app.key_acts_while_busy(&plain(KeyCode::Char(c))), "{c}");
}
type_keys(&mut p, "L+");
assert_eq!(held(&p), [KeyCode::Char('L'), KeyCode::Char('+')]);
p.app.busy = false;
settle(&mut p);
rendered(&mut p.app);
assert!(held(&p).is_empty());
let state = p.app.data_table_state.as_ref().unwrap();
assert_eq!(state.headers(), ["age", "name"], "L moved name right");
assert_eq!(
state.current_column(),
Some("name"),
"the cursor went with it"
);
let filters = state.view_filters();
assert_eq!(filters.len(), 1);
assert_eq!(
(filters[0].column.as_str(), filters[0].value.as_str()),
("name", "ada"),
"+ filtered on the cell the cursor reached"
);
}
#[test]
fn enter_with_nothing_to_drill_into_waits_as_space() {
let (mut p, _dir) = loaded_pump();
p.app.busy = true;
p.terminal_key(plain(KeyCode::Char('j'))).unwrap();
p.terminal_key(plain(KeyCode::Enter)).unwrap();
assert_eq!(held(&p), [KeyCode::Char('j'), KeyCode::Char(' ')]);
p.app.busy = false;
settle(&mut p);
assert!(held(&p).is_empty());
assert_eq!(p.app.input_mode, InputMode::Inspect);
let state = p.app.data_table_state.as_ref().unwrap();
assert_eq!(state.table_state.selected(), Some(1), "j moved first");
p.terminal_key(plain(KeyCode::Esc)).unwrap();
assert_eq!(p.app.input_mode, InputMode::Normal);
p.send(AppEvent::QQuery("select n: count age by name".to_string()))
.unwrap();
settle(&mut p);
rendered(&mut p.app);
assert!(p.app.data_table_state.as_ref().unwrap().can_drill_down());
p.app.busy = true;
assert!(!p.terminal_key(plain(KeyCode::Enter)).unwrap());
assert!(held(&p).is_empty(), "the drilling Enter is dropped");
}
#[test]
fn enter_over_a_loaded_list_column_waits_as_space() {
use polars::prelude::{IntoLazy, ParquetWriter, col, df};
let dir = tempfile::tempdir().expect("temp dir");
let path = dir.path().join("lists.parquet");
let mut frame = df!("k" => &[1i64, 1, 2], "v" => &[10i64, 11, 12])
.unwrap()
.lazy()
.group_by([col("k")])
.agg([col("v")])
.collect()
.unwrap();
ParquetWriter::new(std::fs::File::create(&path).unwrap())
.finish(&mut frame)
.unwrap();
let mut p = pump();
p.send(AppEvent::Open(vec![path], OpenOptions::default()))
.unwrap();
settle(&mut p);
rendered(&mut p.app);
assert!(!p.app.data_table_state.as_ref().unwrap().can_drill_down());
p.app.busy = true;
p.terminal_key(plain(KeyCode::Char('j'))).unwrap();
p.terminal_key(plain(KeyCode::Enter)).unwrap();
assert_eq!(held(&p), [KeyCode::Char('j'), KeyCode::Char(' ')]);
p.app.busy = false;
settle(&mut p);
assert_eq!(p.app.input_mode, InputMode::Inspect);
assert!(!p.app.data_table_state.as_ref().unwrap().is_drilled_down());
}
#[test]
fn column_cursor_acts_live_and_held_keys_all_replay() {
let cursor = |p: &EventPump| {
p.app
.data_table_state
.as_ref()
.unwrap()
.current_column_index()
.unwrap()
};
let (mut p, _dir) = loaded_pump();
p.app.busy = true;
let before = cursor(&p);
assert!(
p.terminal_key(plain(KeyCode::Right)).unwrap(),
"Right moves the cursor live"
);
assert!(held(&p).is_empty(), "it did not queue");
assert_eq!(cursor(&p), before + 1);
let (mut p2, _d) = wide_pump();
p2.app.busy = true;
p2.terminal_key(plain(KeyCode::Char('k'))).unwrap();
p2.terminal_key(plain(KeyCode::Char('k'))).unwrap();
for _ in 0..2 {
p2.terminal_key(plain(KeyCode::Char('l'))).unwrap();
}
p2.terminal_key(plain(KeyCode::Char('h'))).unwrap();
p2.terminal_key(plain(KeyCode::Char('l'))).unwrap();
assert_eq!(
held(&p2),
vec![
KeyCode::Char('k'),
KeyCode::Char('l'),
KeyCode::Char('l'),
KeyCode::Char('h'),
KeyCode::Char('l')
],
"the held k's collapse; every column key stays"
);
let before = cursor(&p2);
p2.app.busy = false;
while p2.replay_one().unwrap() {}
assert_eq!(cursor(&p2), before + 2, "replayed in order, as typed");
}
fn wide_pump() -> (EventPump, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("temp dir");
let path = dir.path().join("wide.csv");
let mut file = std::fs::File::create(&path).expect("create csv");
let names: Vec<String> = (0..60).map(|i| format!("c{i:02}")).collect();
writeln!(file, "{}", names.join(",")).expect("write csv");
for row in 0..5 {
let values: Vec<String> = (0..60).map(|i| format!("{}", row * 100 + i)).collect();
writeln!(file, "{}", values.join(",")).expect("write csv");
}
drop(file);
let mut pump = pump();
pump.send(AppEvent::Open(vec![path], OpenOptions::default()))
.unwrap();
settle(&mut pump);
rendered(&mut pump.app);
(pump, dir)
}
fn first_scrolled(pump: &EventPump) -> usize {
pump.app.data_table_state.as_ref().unwrap().termcol_index
}
#[test]
fn column_paging_acts_live_and_replays_in_order() {
let (mut p, _dir) = wide_pump();
let shift_right = KeyEvent::new(KeyCode::Right, KeyModifiers::SHIFT);
let shift_left = KeyEvent::new(KeyCode::Left, KeyModifiers::SHIFT);
p.app.busy = true;
assert!(p.terminal_key(shift_right).unwrap());
assert!(held(&p).is_empty(), "Shift+→ did not queue");
rendered(&mut p.app);
let page = first_scrolled(&p);
assert!(page > 1, "a page moves more than a column: {page}");
assert!(p.terminal_key(plain(KeyCode::Char('}'))).unwrap());
rendered(&mut p.app);
let last_page = first_scrolled(&p);
assert!(last_page > page && last_page < 59, "{page} {last_page}");
assert!(p.terminal_key(plain(KeyCode::Char('{'))).unwrap());
assert_eq!(first_scrolled(&p), 0);
assert!(p.terminal_key(shift_right).unwrap());
assert_eq!(first_scrolled(&p), page);
assert!(p.terminal_key(shift_left).unwrap());
assert_eq!(first_scrolled(&p), 0);
p.terminal_key(plain(KeyCode::Char('k'))).unwrap();
for _ in 0..2 {
p.terminal_key(shift_right).unwrap();
}
p.terminal_key(plain(KeyCode::Char('}'))).unwrap();
p.terminal_key(shift_left).unwrap();
assert_eq!(
held(&p),
vec![
KeyCode::Char('k'),
KeyCode::Right,
KeyCode::Right,
KeyCode::Char('}'),
KeyCode::Left,
]
);
assert_eq!(first_scrolled(&p), 0, "nothing acted yet");
p.app.busy = false;
settle(&mut p);
assert!(held(&p).is_empty());
rendered(&mut p.app);
let before_last = first_scrolled(&p);
assert!(
before_last > 0 && before_last < last_page,
"{before_last} before {last_page}"
);
p.app.busy = true;
for key in [
KeyCode::Char('g'),
KeyCode::Char('c'),
KeyCode::Char('0'),
KeyCode::Char('5'),
KeyCode::Enter,
] {
p.terminal_key(plain(key)).unwrap();
}
assert_eq!(held(&p).len(), 5, "g is not a view key: everything waits");
p.app.busy = false;
settle(&mut p);
rendered(&mut p.app);
assert_eq!(first_scrolled(&p), 5, "g c05 Enter shows c05 first");
assert_eq!(p.app.input_mode, InputMode::Normal);
}
#[test]
fn help_opens_during_a_load() {
let (mut p, _dir) = loaded_pump();
p.app.busy = true;
assert!(p.terminal_key(plain(KeyCode::F(1))).unwrap());
assert!(p.app.help_visible(), "F1 opened help immediately");
assert!(held(&p).is_empty());
let (mut p2, _d) = loaded_pump();
p2.app.busy = true;
assert!(p2.terminal_key(plain(KeyCode::Char('?'))).unwrap());
assert!(p2.app.help_visible(), "? opened help immediately");
assert!(held(&p2).is_empty());
}
#[test]
fn a_prompt_a_replayed_key_opens_keeps_its_answer_keys() {
let (mut p, dir) = loaded_pump();
let path = dir.path().join("out.csv");
std::fs::write(&path, "old").expect("seed an existing file");
p.app.export_modal.active = true;
p.app.export_modal.selected_format = ExportFormat::Csv;
p.app.export_modal.focus = ExportFocus::PathInput;
p.app
.export_modal
.path_input
.set_value(path.display().to_string());
p.app.input_mode = InputMode::Export;
p.app.busy = true;
p.terminal_key(plain(KeyCode::Enter)).unwrap();
p.terminal_key(plain(KeyCode::Left)).unwrap();
p.terminal_key(plain(KeyCode::Enter)).unwrap();
assert_eq!(held(&p).len(), 3);
p.app.busy = false;
assert!(p.replay_one().unwrap());
assert!(
p.app.confirmation_modal.active,
"the overwrite prompt is up"
);
assert_eq!(
held(&p),
vec![KeyCode::Left, KeyCode::Enter],
"the answer keys were not discarded by the prompt"
);
settle(&mut p);
assert!(
p.app
.flash
.as_ref()
.is_some_and(|f| f.message.starts_with("Exported to ")),
"the held Left+Enter answered the prompt and the export ran"
);
assert!(
std::fs::read(&path).unwrap().len() > 3,
"the file was overwritten with exported data"
);
p.terminal_key(plain(KeyCode::Down)).unwrap();
assert!(p.app.flash.is_none(), "a keypress clears the flash");
}
#[test]
fn ctrl_o_during_a_load_drops_the_held_keys() {
let mut p = pump();
p.app.busy = true;
p.app.loading.first_rows_for_tests();
p.terminal_key(plain(KeyCode::Char('j'))).unwrap();
p.terminal_key(plain(KeyCode::Enter)).unwrap();
assert_eq!(held(&p).len(), 2);
p.terminal_key(ctrl('o')).unwrap();
assert_eq!(p.app.input_mode, InputMode::Home);
assert!(!p.app.is_busy());
assert!(held(&p).is_empty());
}
#[test]
fn ctrl_o_typed_before_the_app_existed_puts_the_startup_open_down() {
let dir = tempfile::tempdir().expect("temp dir");
let path = dir.path().join("people.csv");
std::fs::write(&path, "name,age\nada,36\n").expect("write csv");
let mut p = pump();
p.app.set_loading_phase("Scanning input", 10);
p.handle_first([
AppEvent::Terminal(Event::Key(ctrl('o'))),
AppEvent::OpenNamed(vec![path], OpenOptions::default()),
]);
settle(&mut p);
let deadline = std::time::Instant::now() + Duration::from_secs(300);
while p.app.background_work_in_flight() {
assert!(std::time::Instant::now() < deadline, "the look never ended");
p.wait_and_drain(Duration::from_millis(50)).unwrap();
}
settle(&mut p);
assert_eq!(p.app.input_mode, InputMode::Home);
assert!(
p.app.data_table_state.is_none(),
"nothing opened behind home"
);
assert!(!p.app.error_modal.active, "{}", p.app.error_modal.message);
}
#[test]
fn ctrl_o_typed_before_the_app_existed_puts_a_startup_frame_down() {
use polars::prelude::IntoLazy;
let lf = polars::df!("a" => [1i64, 2, 3]).expect("frame").lazy();
let mut p = pump();
p.app.set_loading_phase("Scanning input", 10);
p.handle_first([
AppEvent::Terminal(Event::Key(ctrl('o'))),
AppEvent::OpenLazyFrame(Box::new(lf), OpenOptions::default()),
]);
settle(&mut p);
let deadline = std::time::Instant::now() + Duration::from_secs(300);
while p.app.background_work_in_flight() {
assert!(std::time::Instant::now() < deadline, "the read never ended");
p.wait_and_drain(Duration::from_millis(50)).unwrap();
}
settle(&mut p);
assert_eq!(p.app.input_mode, InputMode::Home);
assert!(
p.app.data_table_state.is_none(),
"nothing opened behind home"
);
}
#[test]
fn keys_typed_before_the_app_existed_meet_the_loading_screen() {
let dir = tempfile::tempdir().expect("temp dir");
let path = dir.path().join("people.csv");
std::fs::write(&path, "name,age\nada,36\nbob,41\n").expect("write csv");
let mut p = pump();
p.app.set_loading_phase("Scanning input", 10);
p.handle_first([
AppEvent::Terminal(Event::Key(plain(KeyCode::Char('G')))),
AppEvent::Terminal(Event::Key(plain(KeyCode::Esc))),
AppEvent::OpenNamed(vec![path.clone()], OpenOptions::default()),
]);
assert!(matches!(settle(&mut p), Drained::Continue { .. }));
assert!(held(&p).is_empty());
assert_eq!(p.app.input_mode, InputMode::Normal);
let state = p.app.data_table_state.as_ref().expect("the table opened");
assert_eq!(state.table_state.selected(), Some(0), "G was not replayed");
let mut p = pump();
p.app.set_loading_phase("Scanning input", 10);
p.handle_first([
AppEvent::Terminal(Event::Key(plain(KeyCode::Char('q')))),
AppEvent::OpenNamed(vec![path], OpenOptions::default()),
]);
assert!(matches!(settle(&mut p), Drained::Exit), "q quits");
}
#[test]
fn the_loading_screen_never_holds_keys() {
let mut p = pump();
p.app.opened_from_home = true;
p.app.set_loading_phase("Scanning input", 10);
assert!(p.app.awaiting_dataset() && p.app.is_busy());
p.terminal_key(plain(KeyCode::Char('j'))).unwrap();
p.terminal_key(plain(KeyCode::Enter)).unwrap();
assert!(held(&p).is_empty(), "the loading screen holds nothing");
p.terminal_key(plain(KeyCode::Char('q'))).unwrap();
assert_eq!(
p.app.input_mode,
InputMode::Home,
"q pops home from the loading screen"
);
}
#[test]
fn ctrl_c_quits_from_chart_mode_while_busy() {
for c in ['c', 'q'] {
let mut p = pump();
p.app.input_mode = InputMode::Chart;
p.app.chart_modal.active = true;
p.app.busy = true;
assert!(matches!(
p.app.handle(&AppEvent::Key(ctrl(c))),
Ok(Some(AppEvent::Exit))
));
p.terminal_key(ctrl(c)).unwrap();
assert!(matches!(p.drain().unwrap(), Drained::Exit));
}
}
#[test]
fn home_keys_act_while_background_work_runs() {
let mut p = pump();
p.app.enter_home();
p.app.busy = true;
p.terminal_key(plain(KeyCode::Char('x'))).unwrap();
assert!(held(&p).is_empty());
assert_eq!(p.app.home.filter, "x");
}
fn numbered_pump(rows: usize) -> (EventPump, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("temp dir");
let path = dir.path().join("numbered.csv");
let mut file = std::fs::File::create(&path).expect("create csv");
writeln!(file, "name").unwrap();
for i in 0..rows {
writeln!(file, "r{i:04}").unwrap();
}
drop(file);
let mut pump = pump();
pump.send(AppEvent::Open(vec![path], OpenOptions::default()))
.unwrap();
settle(&mut pump);
rendered(&mut pump.app);
if let Some(state) = pump.app.data_table_state.as_mut()
&& std::mem::take(&mut state.needs_recollect)
{
pump.app.spawn_async_collect(App::LOADING_BUFFER);
}
settle(&mut pump);
rendered(&mut pump.app);
(pump, dir)
}
fn table(pump: &EventPump) -> &crate::widgets::datatable::DataTableState {
pump.app.data_table_state.as_ref().expect("a dataset")
}
fn page_to_the_edge(pump: &mut EventPump) {
for _ in 0..50 {
if table(pump).wants_to_load_ahead() {
return;
}
pump.terminal_key(plain(KeyCode::PageDown)).unwrap();
assert!(
!pump.app.is_busy(),
"paging inside the buffer fetches nothing"
);
rendered(&mut pump.app);
}
panic!("the view never came near the end of the buffer");
}
#[test]
fn paging_near_the_end_of_the_buffer_loads_ahead_quietly() {
let (mut p, _dir) = numbered_pump(2000);
page_to_the_edge(&mut p);
let end = table(&p).buffered_end();
p.app.request_what_the_frame_needs();
assert!(
p.app.rows_in_flight().is_some() && !p.app.rows_waited_on(),
"a load-ahead went out"
);
assert!(!p.app.is_busy(), "and nothing waits on it");
assert_eq!(p.app.status_message, None);
settle(&mut p);
assert!(
table(&p).buffered_end() > end,
"the buffer grew ahead of the view"
);
assert!(!p.app.is_busy());
let generation = p.app.task_generation();
p.app.request_what_the_frame_needs();
p.app.request_what_the_frame_needs();
assert!(p.app.task_generation() - generation <= 1);
}
#[test]
fn a_load_ahead_turned_away_by_a_hold_goes_out_once_it_is_let_go() {
let (mut p, _dir) = numbered_pump(2000);
page_to_the_edge(&mut p);
let end = table(&p).buffered_end();
let hold = p.app.hold_the_generation();
let generation = p.app.task_generation();
assert!(!p.app.is_busy());
p.app.request_what_the_frame_needs();
assert_eq!(
p.app.task_generation(),
generation,
"nothing went out past it"
);
drop(hold);
p.drain().unwrap();
assert!(!p.app.work_a_bump_would_strand());
p.app.request_what_the_frame_needs();
assert!(
p.app.rows_in_flight().is_some() && !p.app.rows_waited_on(),
"the load-ahead went out once the generation was free"
);
settle(&mut p);
assert!(table(&p).buffered_end() > end, "and the buffer grew");
}
#[test]
fn holding_pagedown_during_a_load_ahead_waits_on_it() {
let (mut p, _dir) = numbered_pump(2000);
page_to_the_edge(&mut p);
let state = table(&p);
let (start, end, page) = (
state.buffered_start(),
state.buffered_end(),
state.visible_rows,
);
let dataset = state.len_generation();
let columns = crate::InflightCollect::columns_of(state);
let generation = p.app.task_generation();
let _ahead = p.app.job_for_tests(
crate::Job::Rows(crate::InflightCollect {
began: std::time::Instant::now(),
files: None,
dataset,
columns,
start,
end: end + 10 * page,
}),
None,
);
for _ in 0..30 {
p.terminal_key(plain(KeyCode::PageDown)).unwrap();
p.drain().unwrap();
}
assert_eq!(
p.app.task_generation(),
generation,
"no second fetch went out"
);
assert!(
p.app.rows_waited_on(),
"the page that left the buffer waits on the load-ahead"
);
assert!(p.app.is_busy());
assert!(held(&p).len() <= 1, "held repeats coalesce: {:?}", held(&p));
}
#[test]
fn a_page_still_loading_draws_the_last_whole_one() {
let (mut p, _dir) = numbered_pump(2000);
assert!(rendered(&mut p.app).contains("r0000"));
let state = p.app.data_table_state.as_mut().unwrap();
assert!(
state.slide_table(1500),
"far past the buffer, so a fetch is owed"
);
let screen = rendered(&mut p.app);
assert!(screen.contains("r0000"), "the page before stays up");
}
#[test]
fn the_bar_says_loading_only_once_a_fetch_takes_a_while() {
let (mut p, _dir) = numbered_pump(200);
let dataset = table(&p).len_generation();
let columns = crate::InflightCollect::columns_of(table(&p));
let _read = p.app.job_for_tests(
crate::Job::Rows(crate::InflightCollect {
began: std::time::Instant::now(),
files: None,
dataset,
columns,
start: 0,
end: 200,
}),
Some(App::LOADING_BUFFER),
);
assert!(!rendered(&mut p.app).contains("Loading buffer"));
if let Some(crate::Job::Rows(inflight)) = p
.app
.jobs
.current_mut(|job| matches!(job, crate::Job::Rows(_)))
{
inflight.began -= Duration::from_secs(1);
}
assert!(rendered(&mut p.app).contains("Loading buffer"));
}
fn terminal(key: KeyEvent) -> AppEvent {
AppEvent::Terminal(Event::Key(key))
}
fn long_flash(app: &mut App) {
app.flash = Some(crate::Flash {
message: "guard".to_string(),
path_from: None,
expires: Instant::now() + Duration::from_secs(120),
});
}
#[test]
fn the_pacer_sleeps_until_a_deadline_and_has_none_when_idle() {
let start = Instant::now();
let mut pacer = Pacer::default();
pacer.drew(start);
assert_eq!(pacer.timeout(None, start), Duration::MAX, "idle: no tick");
pacer.spinning(true, start);
assert_eq!(pacer.timeout(None, start), SPINNER_FRAME);
assert!(!pacer.turn_spinner(start + SPINNER_FRAME / 2), "not yet");
assert!(pacer.turn_spinner(start + SPINNER_FRAME), "a frame on time");
assert!(!pacer.turn_spinner(start + SPINNER_FRAME), "once per frame");
pacer.spinning(false, start);
let flash = start + Duration::from_secs(2);
assert_eq!(pacer.timeout(Some(flash), start), Duration::from_secs(2));
}
#[test]
fn progress_reports_share_a_frame_and_nothing_else_waits() {
let start = Instant::now();
let mut pacer = Pacer::default();
pacer.drew(start);
let soon = start + PROGRESS_FRAME / 3;
assert!(
pacer.handled(true, false, soon),
"a result is drawn at once"
);
assert!(!pacer.handled(true, true, soon), "progress waits");
assert_eq!(
pacer.timeout(None, soon),
PROGRESS_FRAME - PROGRESS_FRAME / 3,
"until the owed frame"
);
assert!(
!pacer.handled(false, false, soon),
"nothing new, still owed"
);
assert!(
pacer.handled(false, false, start + PROGRESS_FRAME),
"then drawn"
);
pacer.drew(start + PROGRESS_FRAME);
assert!(!pacer.handled(false, false, start + 2 * PROGRESS_FRAME));
assert!(
pacer.handled(true, true, start + 3 * PROGRESS_FRAME),
"progress long after a frame is drawn at once"
);
}
#[test]
fn terminal_keys_on_the_channel_are_held_in_order_and_ctrl_q_jumps_them() {
let mut p = pump();
p.app.busy = true;
for c in ['/', 'a', 'b'] {
p.send(terminal(plain(KeyCode::Char(c)))).unwrap();
}
assert!(matches!(p.drain().unwrap(), Drained::Continue { .. }));
assert_eq!(
held(&p),
vec![KeyCode::Char('/'), KeyCode::Char('a'), KeyCode::Char('b')]
);
p.send(terminal(ctrl('q'))).unwrap();
assert!(matches!(
p.drain().unwrap(),
Drained::Continue { updated: true, .. }
));
assert!(matches!(p.drain().unwrap(), Drained::Exit));
}
#[test]
fn an_answer_is_not_held_behind_keys_typed_ahead() {
let mut p = pump();
p.app.home.listing_in_flight = true;
for _ in 0..3 {
p.send(terminal(plain(KeyCode::Down))).unwrap();
}
p.send(AppEvent::HomeListingFailed).unwrap();
let keys = p.app.debug.num_key_events;
assert!(matches!(
p.drain().unwrap(),
Drained::Continue { updated: true, .. }
));
assert!(!p.app.home.listing_in_flight, "the answer is in");
assert_eq!(
p.app.debug.num_key_events,
keys + 1,
"and one key, one frame"
);
p.drain().unwrap();
p.drain().unwrap();
assert_eq!(p.app.debug.num_key_events, keys + 3, "the rest, in turn");
}
#[test]
fn a_stream_of_reports_does_not_starve_a_typed_key() {
let mut p = pump();
p.send(terminal(plain(KeyCode::Down))).unwrap();
for _ in 0..RESULTS_PER_KEY * 4 {
p.send(AppEvent::Wake).unwrap();
}
let keys = p.app.debug.num_key_events;
p.drain().unwrap();
assert_eq!(p.app.debug.num_key_events, keys + 1);
}
#[test]
fn a_resize_from_the_terminal_reaches_the_app() {
let mut p = pump();
p.send(AppEvent::Terminal(Event::Resize(90, 30))).unwrap();
let events = p.app.debug.num_events;
assert!(matches!(
p.drain().unwrap(),
Drained::Continue { updated: true, .. }
));
assert_eq!(p.app.debug.num_events, events + 1, "handled once");
}
#[test]
fn a_worker_answer_wakes_the_sleeping_loop() {
let mut p = pump();
long_flash(&mut p.app);
let tx = p.tx.clone();
let mut frames = 0;
let end = p
.run(|_app| {
frames += 1;
if frames == 1 {
let tx = tx.clone();
std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(20));
let _ = tx.send(AppEvent::Exit);
});
}
Ok(())
})
.unwrap();
assert_eq!(end, Ended::Quit);
assert!(p.app.flash.is_some(), "woken by the event, not a deadline");
}
#[test]
fn rows_a_frame_draws_unmeasured_are_asked_for_before_the_loop_sleeps() {
let dir = tempfile::tempdir().expect("temp dir");
let path = dir.path().join("people.csv");
std::fs::write(&path, "name,age\nada,36\n").unwrap();
let mut p = pump();
long_flash(&mut p.app);
p.app.enter_home();
p.app.home_jump_into(dir.path().to_path_buf());
let tx = p.tx.clone();
let mut asked = false;
let end = p
.run(|app| {
rendered(app);
if !asked && app.home.enriched.contains_key(&path) {
asked = true;
let _ = tx.send(AppEvent::Exit);
}
Ok(())
})
.unwrap();
assert_eq!(end, Ended::Quit);
assert!(p.app.home.enriched.contains_key(&path), "measured");
assert!(p.app.flash.is_some(), "without waiting for a deadline");
}
#[test]
fn an_open_draws_each_phase_and_then_the_rows() {
let dir = tempfile::tempdir().expect("temp dir");
let path = dir.path().join("people.csv");
std::fs::write(&path, "name,age\nada,36\ngrace,45\n").unwrap();
let mut p = pump();
p.app.set_loading_phase("Scanning input", 10);
p.app.busy = true;
p.send(AppEvent::Open(vec![path], OpenOptions::default()))
.unwrap();
let tx = p.tx.clone();
let mut screens: Vec<String> = Vec::new();
let end = p
.run(|app| {
let screen = rendered(app);
if screen.contains("grace") && !screens.last().is_some_and(|s| s == &screen) {
let _ = tx.send(AppEvent::Exit);
}
screens.push(screen);
Ok(())
})
.unwrap();
assert_eq!(end, Ended::Quit);
let first_with = |needle: &str| screens.iter().position(|s| s.contains(needle));
let scanning = first_with("Scanning input").expect("the first phase is drawn");
let rows = first_with("grace").expect("the rows are drawn");
assert!(scanning < rows, "the phase is drawn before the rows");
}
fn long_wide_pump() -> (EventPump, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("temp dir");
let path = dir.path().join("wide.csv");
let mut file = std::fs::File::create(&path).expect("create csv");
let names: Vec<String> = (0..12).map(|c| format!("column_{c}")).collect();
writeln!(file, "name,{}", names.join(",")).unwrap();
for row in 0..300 {
let values: Vec<String> = (0..12)
.map(|c| (((row * 7 + c) % 23) * 1234).to_string())
.collect();
writeln!(file, "n{},{}", row % 17, values.join(",")).unwrap();
}
drop(file);
let mut pump = pump();
pump.app.app_config.query.default_mode = crate::QueryMode::Q;
pump.send(AppEvent::Open(vec![path], OpenOptions::default()))
.unwrap();
settle(&mut pump);
paint(&mut pump);
(pump, dir)
}
fn frame(app: &mut App) -> Buffer {
let area = Rect::new(0, 0, 100, 20);
let mut buf = Buffer::empty(area);
app.render(area, &mut buf);
buf
}
fn paint(pump: &mut EventPump) {
for _ in 0..200 {
frame(&mut pump.app);
pump.app.frame_painted();
pump.app.request_what_the_frame_needs();
let collect = pump
.app
.data_table_state
.as_mut()
.is_some_and(|s| std::mem::take(&mut s.needs_recollect));
if collect {
pump.app.spawn_async_collect(App::LOADING_BUFFER);
}
settle(pump);
if !collect {
return;
}
}
}
#[test]
fn every_registry_key_is_taken_where_it_is_listed() {
use datui_cli::keys::{self, Context};
let ch = |c: char| plain(KeyCode::Char(c));
let shift = |c: char| KeyEvent::new(KeyCode::Char(c), KeyModifiers::SHIFT);
let (up, down, right) = (
plain(KeyCode::Up),
plain(KeyCode::Down),
plain(KeyCode::Right),
);
let screens: Vec<(Context, Vec<KeyEvent>, &[&str])> = vec![
(
Context::Table,
vec![],
&["Explore", "Shape", "Analyze", "Output", "Display", "Go"],
),
(Context::Query, vec![ch(':'), ch('a')], &["Run", "Edit"]),
(Context::Find, vec![ch('/'), ch('7')], &["Find"]),
(Context::GoToColumn, vec![ch('g'), ch('c')], &["Go"]),
(Context::Inspector, vec![ch(' ')], &["Fields", "Output"]),
(Context::Info, vec![ch('i')], &["Panel"]),
(
Context::ValueCounts,
vec![shift('F')],
&["Explore", "Output"],
),
(
Context::SortFilter,
vec![ch('s')],
&["Sidebar", "In effect"],
),
(
Context::SortFilter,
vec![ch('s'), up, right, down, down],
&["Columns"],
),
(Context::PivotMelt, vec![ch('p')], &["Form"]),
(Context::Chart, vec![ch('l'), ch('c')], &["Shelves", "Plot"]),
(
Context::Export,
vec![ch('e'), plain(KeyCode::BackTab)],
&["Form"],
),
(Context::Copy, vec![ch('y')], &["Form"]),
(Context::Views, vec![ch('v')], &["List"]),
];
let mut exempt: Vec<(Context, &str)> = vec![
(Context::Info, "c"),
(Context::Table, "b"),
(Context::Table, "Esc"),
(Context::Table, "= / w"),
(Context::Table, "R"),
(Context::SortFilter, "Del"),
(Context::SortFilter, "[ / ]"),
(Context::SortFilter, "w"),
(Context::Query, "↑ / ↓"),
(Context::Find, "↑ / ↓"),
(Context::Inspector, "Enter"),
(Context::Inspector, "r"),
(Context::Inspector, "w"),
(Context::Inspector, "o"),
(Context::Info, "PgUp / PgDn"),
(Context::Info, "Home / End"),
(Context::Info, "Enter"),
(Context::Info, "y"),
(Context::Info, "o"),
(Context::ValueCounts, "a"),
(Context::ValueCounts, "c"),
(Context::ValueCounts, "t"),
(Context::SortFilter, "← / →"),
(Context::SortFilter, "d / Del"),
(Context::SortFilter, "C"),
(Context::Export, "Ctrl+P / Ctrl+N"),
(Context::SortFilter, "1-9"),
(Context::Chart, "1-7"),
(Context::Chart, "+ / -"),
(Context::Chart, "x"),
(Context::Chart, "e"),
(Context::Chart, "t"),
(Context::Views, "↑ / ↓ (j/k)"),
(Context::Views, "Enter"),
(Context::Views, "e"),
(Context::Views, "d"),
(Context::Views, "i"),
];
if crate::QueryMode::available().len() < 2 {
exempt.push((Context::Query, "Ctrl+T"));
}
let mut ignored = Vec::new();
for (context, open, groups) in &screens {
let screen = keys::screen(*context);
for group in screen.groups.iter().filter(|g| groups.contains(&g.name)) {
for key in group.keys {
let Some(chord) = key.action() else {
continue;
};
if exempt.contains(&(*context, key.keys)) {
continue;
}
let (mut p, dir) = long_wide_pump();
for k in open {
p.terminal_key(*k).unwrap();
settle(&mut p);
paint(&mut p);
}
assert_eq!(p.app.keys_context(), *context, "opened {}", screen.title);
if *context == Context::Export {
let out = dir.path().join("out.csv");
p.app
.export_modal
.path_input
.set_value(out.display().to_string());
}
let mut presses = keys::chords(key.keys);
if !presses.contains(&chord) {
presses.insert(0, chord);
}
let mut taken = false;
for press in presses {
let before = frame(&mut p.app);
p.terminal_key(crate::help::key_event(press)).unwrap();
if matches!(settle(&mut p), Drained::Exit) {
taken = true;
break;
}
paint(&mut p);
if before != frame(&mut p.app) || p.app.keys_context() != *context {
taken = true;
break;
}
}
if !taken {
ignored.push(format!("{} · {} · {}", screen.title, group.name, key.keys));
}
}
}
}
assert!(
ignored.is_empty(),
"keys nothing took:\n{}",
ignored.join("\n")
);
}
#[test]
fn enter_in_the_help_runs_the_key() {
let (mut p, _dir) = long_wide_pump();
p.terminal_key(plain(KeyCode::Char('?'))).unwrap();
settle(&mut p);
assert!(p.app.help_visible());
for c in "/value counts".chars() {
p.terminal_key(plain(KeyCode::Char(c))).unwrap();
}
p.terminal_key(plain(KeyCode::Enter)).unwrap();
settle(&mut p);
assert!(!p.app.help_visible(), "Enter closed the help");
assert_eq!(
p.app.input_mode,
InputMode::ValueCounts,
"F ran at the table"
);
}
#[test]
fn the_key_enter_presses_waits_while_busy() {
let (mut p, _dir) = long_wide_pump();
p.terminal_key(plain(KeyCode::Char('?'))).unwrap();
for c in "/sort & filter sidebar".chars() {
p.terminal_key(plain(KeyCode::Char(c))).unwrap();
}
p.terminal_key(plain(KeyCode::Enter)).unwrap();
p.app.busy = true;
p.drain().unwrap();
assert!(!p.app.help_visible());
assert_eq!(p.app.input_mode, InputMode::Normal, "held while busy");
assert_eq!(held(&p), [KeyCode::Char('s')]);
p.app.busy = false;
settle(&mut p);
assert_eq!(
p.app.input_mode,
InputMode::SortFilter,
"replayed once idle"
);
}
fn run_from_help(p: &mut EventPump, filter: &str) {
p.terminal_key(plain(KeyCode::Char('/'))).unwrap();
for c in filter.chars() {
p.terminal_key(plain(KeyCode::Char(c))).unwrap();
}
p.terminal_key(plain(KeyCode::Enter)).unwrap();
}
#[test]
fn enter_in_the_help_never_types_into_a_field() {
let (mut p, _dir) = long_wide_pump();
p.terminal_key(plain(KeyCode::Char('f'))).unwrap();
p.terminal_key(plain(KeyCode::F(1))).unwrap();
run_from_help(&mut p, "next or previous");
settle(&mut p);
assert!(
p.app.help_visible(),
"n / N does not run from the find prompt"
);
p.terminal_key(plain(KeyCode::Esc)).unwrap();
p.terminal_key(plain(KeyCode::Esc)).unwrap();
assert!(!p.app.help_visible());
assert_eq!(p.app.find.input.value(), "", "nothing was typed");
p.terminal_key(plain(KeyCode::Esc)).unwrap();
p.terminal_key(plain(KeyCode::Char(':'))).unwrap();
p.terminal_key(plain(KeyCode::F(1))).unwrap();
run_from_help(&mut p, "screen's keys");
settle(&mut p);
assert_eq!(p.app.query_input.value(), "", "? was not typed");
assert!(p.app.help_visible(), "F1 opened the help again");
}
#[test]
fn a_question_under_the_help_closes_it() {
let (mut p, _dir) = long_wide_pump();
p.terminal_key(plain(KeyCode::Char('?'))).unwrap();
assert!(p.app.help_visible());
p.app.confirmation_modal.active = true;
rendered(&mut p.app);
assert!(!p.app.help_visible());
p.terminal_key(plain(KeyCode::F(1))).unwrap();
assert!(!p.app.help_visible(), "no help over the question");
}
#[test]
fn the_help_closes_while_busy() {
for key in [KeyCode::Esc, KeyCode::F(1), KeyCode::Char('?')] {
let (mut p, _dir) = long_wide_pump();
p.terminal_key(plain(KeyCode::Char('?'))).unwrap();
p.app.busy = true;
assert!(p.terminal_key(plain(key)).unwrap(), "{key:?} acted");
assert!(!p.app.help_visible(), "{key:?} closed the help");
assert!(held(&p).is_empty());
}
}
#[test]
fn the_pressed_key_is_classified_as_typed() {
let (mut p, _dir) = long_wide_pump();
p.terminal_key(plain(KeyCode::Char('?'))).unwrap();
run_from_help(&mut p, "or inspect");
p.app.busy = true;
p.drain().unwrap();
assert!(!p.app.help_visible());
assert_eq!(held(&p), [KeyCode::Char(' ')]);
}
#[test]
fn help_closes_when_its_screen_goes() {
let (mut p, _dir) = long_wide_pump();
p.terminal_key(plain(KeyCode::Char('?'))).unwrap();
assert!(p.app.help_visible());
p.app.input_mode = InputMode::Home;
rendered(&mut p.app);
assert!(!p.app.help_visible());
}
#[test]
fn help_shows_the_keys_of_the_screen_under_it() {
let (mut p, _dir) = long_wide_pump();
p.terminal_key(plain(KeyCode::Char('v'))).unwrap();
settle(&mut p);
p.terminal_key(plain(KeyCode::Char('?'))).unwrap();
assert_eq!(p.app.help_context(), Some(datui_cli::keys::Context::Views));
}
fn right_click(at: (u16, u16)) -> MouseEvent {
use crossterm::event::{MouseButton, MouseEventKind};
mouse(MouseEventKind::Down(MouseButton::Right), at)
}
fn drag(at: (u16, u16)) -> MouseEvent {
use crossterm::event::{MouseButton, MouseEventKind};
mouse(MouseEventKind::Drag(MouseButton::Left), at)
}
fn release(at: (u16, u16)) -> MouseEvent {
use crossterm::event::{MouseButton, MouseEventKind};
mouse(MouseEventKind::Up(MouseButton::Left), at)
}
fn header_of(p: &mut EventPump, column: &str) -> (u16, u16, u16) {
rendered(&mut p.app);
let state = p.app.data_table_state.as_ref().unwrap();
let (area, columns) = state.drawn_header().expect("the table was drawn");
let (from, to, _) = columns
.into_iter()
.find(|(_, _, name)| name == column)
.expect("the column is drawn");
(from, to, area.y)
}
fn order(p: &EventPump) -> Vec<String> {
p.app.data_table_state.as_ref().unwrap().headers()
}
#[test]
fn a_click_focuses_a_form_row_and_acts_on_it() {
use crate::export_modal::{ExportFocus, ExportFormat};
let (mut p, _dir) = loaded_pump();
p.terminal_key(plain(KeyCode::Char('e'))).unwrap();
assert_eq!(p.app.input_mode, InputMode::Export);
assert_eq!(p.app.export_modal.selected_format, ExportFormat::Csv);
let header = p.app.export_modal.csv_include_header;
let at = on_screen(&mut p.app, "Header:");
assert!(p.terminal_mouse(click(at)).unwrap());
assert_eq!(p.app.export_modal.focus, ExportFocus::CsvIncludeHeader);
assert_eq!(p.app.export_modal.csv_include_header, !header, "toggled");
let compression = p.app.export_modal.csv_compression;
let at = on_screen(&mut p.app, "Compression:");
p.terminal_mouse(click(at)).unwrap();
assert_eq!(p.app.export_modal.focus, ExportFocus::Compression);
assert_ne!(p.app.export_modal.csv_compression, compression, "stepped");
p.terminal_mouse(right_click(at)).unwrap();
assert_eq!(p.app.export_modal.csv_compression, compression);
let path = p.app.export_modal.path_input.value().to_string();
let at = on_screen(&mut p.app, "Path:");
p.terminal_mouse(click(at)).unwrap();
assert_eq!(p.app.export_modal.focus, ExportFocus::PathInput);
assert_eq!(p.app.export_modal.path_input.value(), path, "nothing typed");
let at = on_screen(&mut p.app, "Parquet");
p.terminal_mouse(click(at)).unwrap();
assert_eq!(p.app.export_modal.selected_format, ExportFormat::Parquet);
assert_eq!(p.app.input_mode, InputMode::Export, "still open");
}
#[test]
fn a_click_on_a_tab_switches_to_it() {
use crate::sort_filter_modal::{SortFilterField, SortFilterTab};
let (mut p, _dir) = loaded_pump();
p.terminal_key(plain(KeyCode::Char('s'))).unwrap();
assert_eq!(p.app.input_mode, InputMode::SortFilter);
let at = on_screen(&mut p.app, "Columns");
assert!(p.terminal_mouse(click(at)).unwrap());
assert_eq!(p.app.sort_filter_modal.active_tab, SortFilterTab::Columns);
assert_eq!(p.app.sort_filter_modal.focus, SortFilterField::TabBar);
let tab = format!("Sort & Filter {}", crate::glyphs::get().rule);
let at = on_screen(&mut p.app, &tab);
p.terminal_mouse(click(at)).unwrap();
assert_eq!(p.app.sort_filter_modal.active_tab, SortFilterTab::InEffect);
p.terminal_key(plain(KeyCode::Esc)).unwrap();
p.terminal_key(plain(KeyCode::Char('i'))).unwrap();
assert_eq!(p.app.input_mode, InputMode::Info);
let at = on_screen(&mut p.app, "Resources");
p.terminal_mouse(click(at)).unwrap();
assert_eq!(
p.app.info_modal.active_tab,
crate::widgets::info::InfoTab::Resources
);
}
#[test]
fn the_footer_filters_and_query_are_clickable() {
let (mut p, _dir) = loaded_pump();
p.terminal_key(plain(KeyCode::Char('+'))).unwrap();
settle(&mut p);
let at = on_screen(&mut p.app, "name = ");
assert!(p.terminal_mouse(click(at)).unwrap());
assert_eq!(
p.app.input_mode,
InputMode::SortFilter,
"the sidebar opened"
);
p.terminal_key(plain(KeyCode::Esc)).unwrap();
settle(&mut p);
p.terminal_key(plain(KeyCode::Char(':'))).unwrap();
type_keys(&mut p, "select age");
p.terminal_key(plain(KeyCode::Enter)).unwrap();
settle(&mut p);
assert_eq!(p.app.input_mode, InputMode::Normal);
let at = on_screen(&mut p.app, " query ");
assert!(p.terminal_mouse(click((at.0 + 1, at.1))).unwrap());
assert_eq!(p.app.input_mode, InputMode::Editing);
assert_eq!(p.app.query_input.value(), "select age");
}
#[test]
fn a_click_outside_a_dialog_does_nothing() {
let (mut p, _dir) = loaded_pump();
let alan = on_screen(&mut p.app, "alan");
p.terminal_key(plain(KeyCode::Char('s'))).unwrap();
rendered(&mut p.app);
assert!(!p.terminal_mouse(click(alan)).unwrap());
assert!(!p.terminal_mouse(right_click(alan)).unwrap());
assert_eq!(cell(&p), (Some(0), Some("name".to_string())));
assert_eq!(p.app.input_mode, InputMode::SortFilter);
assert!(p.app.context_menu.is_none());
p.terminal_key(plain(KeyCode::Esc)).unwrap();
p.terminal_key(plain(KeyCode::Char('e'))).unwrap();
rendered(&mut p.app);
let focus = p.app.export_modal.focus;
let header = p.app.export_modal.csv_include_header;
assert!(!p.terminal_mouse(click((0, 0))).unwrap());
assert!(!p.terminal_mouse(click(alan)).unwrap());
assert_eq!(p.app.export_modal.focus, focus);
assert_eq!(p.app.export_modal.csv_include_header, header);
assert_eq!(p.app.input_mode, InputMode::Export);
}
#[test]
fn dragging_a_header_moves_its_column() {
let (mut p, _dir) = loaded_pump();
let (from, _, y) = header_of(&mut p, "name");
let (age, age_to, _) = header_of(&mut p, "age");
assert!(p.terminal_mouse(click((from + 1, y))).unwrap());
assert_eq!(cell(&p).1.as_deref(), Some("name"));
assert!(
p.terminal_mouse(drag((age + 1, y))).unwrap(),
"a frame is due for the drop mark"
);
assert!(
!p.terminal_mouse(drag((age + 2, y))).unwrap(),
"still over age: nothing new to draw"
);
let area = Rect::new(0, 0, 100, 20);
let mut buf = Buffer::empty(area);
p.app.render(area, &mut buf);
assert_eq!(
buf[(age_to, y)].symbol(),
crate::glyphs::get().rule,
"the drop mark, after age"
);
p.terminal_mouse(release((age + 1, y))).unwrap();
settle(&mut p);
assert_eq!(order(&p), ["age", "name"]);
assert_eq!(cell(&p).1.as_deref(), Some("name"), "the cursor came along");
let (from, _, y) = header_of(&mut p, "name");
p.terminal_mouse(click((from + 1, y))).unwrap();
p.terminal_mouse(release((from + 1, y))).unwrap();
settle(&mut p);
assert_eq!(order(&p), ["age", "name"]);
}
#[test]
fn dragging_a_header_edge_resizes_its_column() {
use crate::widgets::column_widths::{MAX_WIDTH, MIN_WIDTH, WidthChoice};
let (mut p, _dir) = loaded_pump();
let (from, to, y) = header_of(&mut p, "name");
let width = to - from;
assert!(!p.terminal_mouse(click((to, y))).unwrap(), "a press only");
assert!(p.terminal_mouse(drag((to + 5, y))).unwrap());
let choice = |p: &EventPump| {
p.app
.data_table_state
.as_ref()
.unwrap()
.width_choice("name")
};
assert_eq!(choice(&p), WidthChoice::Manual(width + 5));
p.terminal_mouse(drag((0, y))).unwrap();
assert_eq!(choice(&p), WidthChoice::Manual(MIN_WIDTH));
p.terminal_mouse(drag((99, y))).unwrap();
assert!(matches!(choice(&p), WidthChoice::Manual(w) if w <= MAX_WIDTH));
p.terminal_mouse(release((99, y))).unwrap();
assert_eq!(order(&p), ["name", "age"], "a resize moves no column");
let before = choice(&p);
p.terminal_mouse(drag((to + 9, y))).unwrap();
assert_eq!(choice(&p), before);
}
fn click_and_release(p: &mut EventPump, at: (u16, u16)) {
rendered(&mut p.app);
p.app.frame_painted();
p.terminal_mouse(click(at)).unwrap();
p.terminal_mouse(release(at)).unwrap();
settle(p);
}
fn sorted(p: &EventPump) -> (Vec<String>, Vec<bool>) {
let state = p.app.data_table_state.as_ref().unwrap();
let columns = state.view_sort_columns().to_vec();
let descending = state.view_sort_descending().to_vec();
(
columns.clone(),
descending[..columns.len().min(descending.len())].to_vec(),
)
}
#[test]
fn a_double_click_on_a_header_cycles_its_sort() {
let (mut p, _dir) = loaded_pump();
let (age, _, y) = header_of(&mut p, "age");
let at = (age + 1, y);
click_and_release(&mut p, at);
assert_eq!(cell(&p).1.as_deref(), Some("age"));
assert_eq!(sorted(&p), (vec![], vec![]), "one click only moves");
click_and_release(&mut p, at);
assert_eq!(sorted(&p), (vec!["age".to_string()], vec![false]));
assert_eq!(order(&p), ["name", "age"], "a sort moves no column");
click_and_release(&mut p, at);
click_and_release(&mut p, at);
assert_eq!(sorted(&p), (vec!["age".to_string()], vec![true]));
click_and_release(&mut p, at);
click_and_release(&mut p, at);
assert_eq!(sorted(&p), (vec![], vec![]), "the third takes it away");
}
#[test]
fn a_double_click_on_a_header_edge_fits_the_column() {
use crate::widgets::column_widths::WidthChoice;
let (mut p, _dir) = loaded_pump();
let (_, to, y) = header_of(&mut p, "name");
let (age, _, _) = header_of(&mut p, "age");
click_and_release(&mut p, (age + 1, y));
click_and_release(&mut p, (to, y));
let state = p.app.data_table_state.as_ref().unwrap();
assert_ne!(
state.width_choice("name"),
WidthChoice::Fit,
"one press only"
);
click_and_release(&mut p, (to, y));
let state = p.app.data_table_state.as_ref().unwrap();
assert_eq!(state.width_choice("name"), WidthChoice::Fit);
assert_eq!(sorted(&p), (vec![], vec![]), "never a sort");
assert_eq!(cell(&p).1.as_deref(), Some("name"));
assert_eq!(order(&p), ["name", "age"]);
}
#[test]
fn a_key_handled_puts_the_next_click_behind_a_frame() {
let (mut p, _dir) = loaded_pump();
rendered(&mut p.app);
p.app.frame_painted();
assert!(p.app.pointer.on_screen());
p.terminal_key(plain(KeyCode::Char('j'))).unwrap();
assert!(!p.app.pointer.on_screen());
}
#[test]
fn a_click_on_the_chart_panel_focuses_and_acts() {
use crate::chart_modal::ChartFocus;
let (mut p, _dir) = loaded_pump();
p.terminal_key(plain(KeyCode::Char('c'))).unwrap();
settle(&mut p);
assert_eq!(p.app.input_mode, InputMode::Chart);
let mark = p.app.chart_modal.spec.mark;
let at = on_screen(&mut p.app, "Type");
assert!(p.terminal_mouse(click(at)).unwrap());
settle(&mut p);
assert_eq!(p.app.chart_modal.focus, ChartFocus::Type);
assert_ne!(p.app.chart_modal.spec.mark, mark, "the type stepped");
let grid = p.app.chart_modal.grid;
let at = on_screen(&mut p.app, "Grid");
p.terminal_mouse(click(at)).unwrap();
settle(&mut p);
assert_eq!(p.app.chart_modal.focus, ChartFocus::Grid);
assert_ne!(p.app.chart_modal.grid, grid, "the grid flipped");
}
#[test]
fn a_header_drag_is_cancelled_off_the_columns_or_by_a_key() {
let (mut p, _dir) = loaded_pump();
let (from, _, y) = header_of(&mut p, "name");
let (age, _, _) = header_of(&mut p, "age");
p.terminal_mouse(click((from + 1, y))).unwrap();
p.terminal_mouse(drag((age + 1, y))).unwrap();
assert!(
p.terminal_mouse(drag((age + 1, 19))).unwrap(),
"the mark goes"
);
p.terminal_mouse(release((age + 1, 19))).unwrap();
settle(&mut p);
assert_eq!(order(&p), ["name", "age"]);
p.terminal_mouse(click((from + 1, y))).unwrap();
p.terminal_mouse(drag((age + 1, y))).unwrap();
p.terminal_key(plain(KeyCode::Char('#'))).unwrap();
assert!(p.app.pointer.drag().is_none());
p.terminal_mouse(release((age + 1, y))).unwrap();
settle(&mut p);
assert_eq!(order(&p), ["name", "age"]);
}
#[test]
fn the_last_column_resizes_from_the_right_side() {
use crate::widgets::column_widths::WidthChoice;
let (mut p, _dir) = loaded_pump();
p.terminal_key(plain(KeyCode::Char('l'))).unwrap();
for _ in 0..30 {
p.terminal_key(plain(KeyCode::Char('>'))).unwrap();
}
let (from, to, y) = header_of(&mut p, "age");
assert_eq!(to, 100, "age reaches the right side");
let choice = |p: &EventPump| p.app.data_table_state.as_ref().unwrap().width_choice("age");
let WidthChoice::Manual(set) = choice(&p) else {
panic!("set by hand: {:?}", choice(&p));
};
assert!(set > to - from, "cut at the side");
assert!(
!p.terminal_mouse(click((to - 1, y))).unwrap(),
"a press on the edge"
);
p.terminal_mouse(drag((to - 31, y))).unwrap();
assert_eq!(choice(&p), WidthChoice::Manual(set - 30));
}
#[test]
fn a_right_click_on_a_checkbox_only_focuses_it() {
use crate::export_modal::ExportFocus;
let (mut p, _dir) = loaded_pump();
p.terminal_key(plain(KeyCode::Char('e'))).unwrap();
let header = p.app.export_modal.csv_include_header;
let at = on_screen(&mut p.app, "Header:");
p.terminal_mouse(right_click(at)).unwrap();
assert_eq!(p.app.export_modal.focus, ExportFocus::CsvIncludeHeader);
assert_eq!(p.app.export_modal.csv_include_header, header, "not toggled");
}
#[test]
fn a_list_row_focuses_first_and_acts_on_a_second_click() {
use crate::sort_filter_modal::{SortFilterField, SortFilterTab};
let (mut p, _dir) = loaded_pump();
p.terminal_key(plain(KeyCode::Char('['))).unwrap();
settle(&mut p);
p.terminal_key(plain(KeyCode::Char('s'))).unwrap();
assert_eq!(p.app.sort_filter_modal.active_tab, SortFilterTab::InEffect);
p.app.sort_filter_modal.focus = SortFilterField::TabBar;
let descending = |p: &EventPump| {
let m = &p.app.sort_filter_modal.sort;
m.columns[m.sort_entries()[0]].sort_descending
};
let before = descending(&p);
let row = format!(" 1 {} name", crate::glyphs::get().sort_asc);
let at = on_screen(&mut p.app, &row);
p.terminal_mouse(click(at)).unwrap();
assert_eq!(p.app.sort_filter_modal.focus, SortFilterField::Sort(0));
assert_eq!(descending(&p), before, "the first click only focuses");
p.terminal_mouse(click(at)).unwrap();
assert_ne!(descending(&p), before, "the second flips it");
}
#[test]
fn the_menu_opens_only_where_the_cursor_lands() {
let (mut p, _dir) = numbered_pump(50);
let at = on_screen(&mut p.app, "r0002");
p.terminal_key(plain(KeyCode::End)).unwrap();
settle(&mut p);
let row = cell(&p).0;
p.terminal_mouse(right_click(at)).unwrap();
assert_eq!(cell(&p).0, row, "the cursor stays");
assert!(p.app.context_menu.is_none());
}
#[test]
fn the_menus_keys_act_while_busy() {
let (mut p, _dir) = loaded_pump();
let alan = on_screen(&mut p.app, "alan");
p.terminal_mouse(right_click(alan)).unwrap();
p.app.busy = true;
assert!(p.terminal_key(plain(KeyCode::Down)).unwrap());
assert_eq!(p.app.context_menu.as_ref().map(|m| m.selected), Some(1));
assert!(p.terminal_key(plain(KeyCode::Esc)).unwrap());
assert!(p.app.context_menu.is_none());
assert!(held(&p).is_empty());
}
#[test]
fn drags_waiting_together_are_one() {
let (mut p, _dir) = loaded_pump();
let (from, _, y) = header_of(&mut p, "name");
let (age, _, _) = header_of(&mut p, "age");
p.app.frame_painted();
for event in [
click((from + 1, y)),
drag((age + 1, y)),
drag((from + 1, y)),
drag((age + 1, y)),
release((age + 1, y)),
] {
p.send(AppEvent::Terminal(Event::Mouse(event))).unwrap();
}
for _ in 0..6 {
p.drain().unwrap();
rendered(&mut p.app);
p.app.frame_painted();
}
settle(&mut p);
assert_eq!(order(&p), ["age", "name"]);
}
#[test]
fn a_right_click_opens_the_menu_and_enter_runs_a_line() {
let (mut p, _dir) = loaded_pump();
let alan = on_screen(&mut p.app, "alan");
assert!(p.terminal_mouse(right_click(alan)).unwrap());
assert_eq!(cell(&p), (Some(2), Some("name".to_string())));
assert!(p.app.context_menu.is_some());
let text = rendered(&mut p.app);
assert!(text.contains("Filter to this value"), "the menu is drawn");
p.terminal_key(plain(KeyCode::Down)).unwrap();
p.terminal_key(plain(KeyCode::Up)).unwrap();
assert_eq!(cell(&p).0, Some(2));
p.terminal_key(plain(KeyCode::Enter)).unwrap();
settle(&mut p);
assert!(p.app.context_menu.is_none());
let state = p.app.data_table_state.as_ref().unwrap();
assert_eq!(state.view_filters().len(), 1, "+ filtered to the value");
assert_eq!(state.num_rows(), 1);
}
#[test]
fn a_click_chooses_from_the_menu_and_outside_closes_it() {
let (mut p, _dir) = loaded_pump();
let grace = on_screen(&mut p.app, "grace");
p.terminal_mouse(right_click(grace)).unwrap();
let counts = on_screen(&mut p.app, "Value counts");
assert!(p.terminal_mouse(click(counts)).unwrap());
assert!(p.app.context_menu.is_none());
assert_eq!(p.app.input_mode, InputMode::ValueCounts, "F ran");
p.terminal_key(plain(KeyCode::Esc)).unwrap();
settle(&mut p);
let alan = on_screen(&mut p.app, "alan");
p.terminal_mouse(right_click(alan)).unwrap();
rendered(&mut p.app);
assert!(p.terminal_mouse(click((0, 0))).unwrap());
assert!(p.app.context_menu.is_none(), "closed");
assert_eq!(cell(&p).0, Some(2), "and nothing else moved");
p.terminal_mouse(right_click(alan)).unwrap();
p.terminal_key(plain(KeyCode::Esc)).unwrap();
assert!(p.app.context_menu.is_none());
assert_eq!(p.app.input_mode, InputMode::Normal);
}
#[test]
fn mouse_actions_while_busy_obey_the_key_rules() {
use crate::widgets::column_widths::WidthChoice;
let (mut p, _dir) = loaded_pump();
let alan = on_screen(&mut p.app, "alan");
let (from, to, y) = header_of(&mut p, "name");
let (age, _, _) = header_of(&mut p, "age");
p.app.busy = true;
assert!(!p.terminal_mouse(right_click(alan)).unwrap());
assert!(p.app.context_menu.is_none());
p.app.busy = false;
p.terminal_mouse(click((from + 1, y))).unwrap();
p.terminal_mouse(drag((age + 1, y))).unwrap();
p.app.busy = true;
p.terminal_mouse(release((age + 1, y))).unwrap();
assert!(held(&p).is_empty(), "the mouse is never held");
p.app.busy = false;
settle(&mut p);
assert_eq!(order(&p), ["name", "age"], "dropped");
p.app.busy = true;
p.terminal_mouse(click((to, y))).unwrap();
p.terminal_mouse(drag((to + 3, y))).unwrap();
let state = p.app.data_table_state.as_ref().unwrap();
assert_eq!(
state.width_choice("name"),
WidthChoice::Manual(to - from + 3)
);
p.app.busy = false;
p.terminal_mouse(right_click(alan)).unwrap();
rendered(&mut p.app);
let line = on_screen(&mut p.app, "Filter to this value");
p.app.busy = true;
p.terminal_mouse(click(line)).unwrap();
assert!(p.app.context_menu.is_none(), "the menu closes");
p.app.busy = false;
settle(&mut p);
let state = p.app.data_table_state.as_ref().unwrap();
assert!(state.view_filters().is_empty(), "and its key was dropped");
}
}