mod display;
mod eval;
mod filter_editor;
mod help;
mod input;
mod output;
mod partial_eval;
mod viewport;
use std::sync::Arc;
use std::time::{Duration, Instant};
use arboard::Clipboard;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use simd_json::OwnedValue as Value;
use crate::error::FilterError;
use crate::filter;
use crate::loader::Loader;
use crate::ui::RenderOptions;
use crate::worker::{EvalRequest, Worker};
use display::DisplayOptions;
use eval::EvalTracker;
use filter_editor::FilterEditor;
use help::HelpState;
use input::InputState;
use output::OutputState;
use viewport::Viewport;
pub use input::LoadingState;
const DEBOUNCE_DURATION: Duration = Duration::from_millis(250);
pub enum AppAction {
Continue,
Quit,
}
const STATUS_MESSAGE_DURATION: Duration = Duration::from_secs(2);
pub struct App {
input: InputState,
filter: FilterEditor,
filter_error: Option<FilterError>,
pending_filter_update: Option<Instant>,
output: OutputState,
editing_filter: bool,
eval_paused: bool,
worker: Worker,
help: HelpState,
eval: EvalTracker,
viewport: Viewport,
display: DisplayOptions,
status_message: Option<(String, Instant)>,
}
impl App {
pub fn new_loading(loader: Loader) -> Self {
Self {
input: InputState::new(loader),
filter: FilterEditor::new(),
filter_error: None,
pending_filter_update: None,
output: OutputState::new(),
editing_filter: false,
eval_paused: false,
worker: Worker::spawn(),
help: HelpState::new(),
eval: EvalTracker::new(),
viewport: Viewport::new(),
display: DisplayOptions::new(),
status_message: None,
}
}
pub fn input_count(&self) -> usize {
self.input.count()
}
pub fn loading_state(&self) -> &LoadingState {
self.input.state()
}
pub fn render_options(&self) -> RenderOptions {
self.display.render_options()
}
pub fn output_values(&self) -> &[Value] {
self.output.values()
}
fn recompute_total_lines(&mut self) {
let options = self.render_options();
self.output.recompute_lines(&options);
}
fn mark_filter_dirty(&mut self) {
self.pending_filter_update = Some(Instant::now());
}
fn request_eval(&mut self) {
let Some(input_values) = self.input.values() else {
return;
};
let id = self.eval.next_id();
let filter_text = self.filter.eval_text();
self.worker.send(EvalRequest {
id,
filter_text,
inputs: Arc::clone(input_values),
slurp_mode: self.display.slurp,
});
self.eval.start(id);
self.pending_filter_update = None;
}
pub fn tick(&mut self) {
if self.input.poll() && matches!(self.input.state(), LoadingState::Ready) {
self.request_eval(); }
while let Some(result) = self.worker.try_recv() {
if self.eval.complete(result.id) {
match result.result {
Ok(values) => {
let options = self.render_options();
self.output.set_values(values, &options);
self.filter_error = None;
self.viewport.reset_scroll();
}
Err(e) => {
self.filter_error = Some(e);
}
}
}
}
if let Some(changed_at) = self.pending_filter_update
&& changed_at.elapsed() >= DEBOUNCE_DURATION
{
self.pending_filter_update = None;
let filter_text = self.filter.eval_text();
match filter::parse_check(&filter_text) {
Ok(()) => {
self.filter_error = None;
if !self.eval_paused {
self.request_eval();
}
}
Err(e) => {
self.filter_error = Some(FilterError::Parse(e));
}
}
}
if let Some((_, timestamp)) = &self.status_message
&& timestamp.elapsed() >= STATUS_MESSAGE_DURATION
{
self.status_message = None;
}
}
pub fn scroll_offset(&self) -> usize {
self.viewport.scroll_offset
}
pub fn total_lines(&self) -> usize {
self.output.total_lines()
}
pub fn horizontal_offset(&self) -> usize {
self.viewport.horizontal_offset
}
pub fn show_help(&self) -> bool {
self.help.visible
}
pub fn help_scroll(&self) -> usize {
self.help.scroll
}
pub fn clamp_help_scroll(&mut self, max_scroll: usize) {
self.help.clamp_scroll(max_scroll);
}
pub fn wrap_lines(&self) -> bool {
self.display.wrap_lines
}
pub fn editing_filter(&self) -> bool {
self.editing_filter
}
pub fn partial_eval_mode(&self) -> bool {
self.filter.partial_eval_mode()
}
pub fn computed_partial_eval_ranges(&self) -> Option<Vec<(usize, usize)>> {
self.filter.partial_eval_ranges()
}
pub fn raw_output(&self) -> bool {
self.display.raw_output
}
pub fn slurp_mode(&self) -> bool {
self.display.slurp
}
pub fn compact_output(&self) -> bool {
self.display.compact
}
pub fn is_evaluating(&self) -> bool {
self.eval.is_evaluating()
}
pub fn eval_paused(&self) -> bool {
self.eval_paused
}
fn cancel_eval(&mut self) {
self.eval.cancel();
self.pending_filter_update = None;
}
pub fn filter_text(&self) -> &str {
self.filter.text()
}
fn copy_filter_to_clipboard(&mut self) {
let mut flags = String::new();
if self.display.slurp {
flags.push('s');
}
if self.display.raw_output {
flags.push('r');
}
if self.display.compact {
flags.push('c');
}
let command = if flags.is_empty() {
format!("jq '{}'", self.filter.text())
} else {
format!("jq -{} '{}'", flags, self.filter.text())
};
match Clipboard::new().and_then(|mut cb| cb.set_text(&command)) {
Ok(()) => {
self.set_status_message(format!("Copied: {}", command));
}
Err(e) => {
self.set_status_message(format!("Clipboard error: {}", e));
}
}
}
fn set_status_message(&mut self, message: String) {
self.status_message = Some((message, Instant::now()));
}
pub fn status_message(&self) -> Option<&str> {
self.status_message.as_ref().map(|(msg, _)| msg.as_str())
}
pub fn filter_cursor_display_col(&self) -> usize {
self.filter.cursor_display_col()
}
pub fn filter_error(&self) -> Option<&FilterError> {
self.filter_error.as_ref()
}
pub fn set_viewport(&mut self, width: usize, height: usize) {
self.viewport.set_size(width, height);
self.viewport.clamp_scroll(self.output.total_lines());
}
pub fn handle_key(&mut self, key: KeyEvent) -> AppAction {
if key.code == KeyCode::Char('h') && key.modifiers.contains(KeyModifiers::CONTROL) {
self.help.toggle(self.editing_filter);
return AppAction::Continue;
}
if self.help.visible {
match key.code {
KeyCode::Char('j') | KeyCode::Down => {
self.help.scroll_down(1);
}
KeyCode::Char('k') | KeyCode::Up => {
self.help.scroll_up(1);
}
KeyCode::PageDown => {
self.help.scroll_down(10);
}
KeyCode::PageUp => {
self.help.scroll_up(10);
}
KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.help.scroll_down(10);
}
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.help.scroll_up(10);
}
KeyCode::Char('g') => {
self.help.scroll = 0;
}
KeyCode::Char('G') => {
self.help.scroll = usize::MAX;
}
_ => {
self.help.visible = false;
}
}
return AppAction::Continue;
}
if self.editing_filter {
return self.handle_filter_key(key);
}
self.handle_navigation_key(key)
}
pub fn help_for_filter_mode(&self) -> bool {
self.help.for_filter_mode
}
fn handle_navigation_key(&mut self, key: KeyEvent) -> AppAction {
match key.code {
KeyCode::Char('q') | KeyCode::Esc => AppAction::Quit,
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => AppAction::Quit,
KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => AppAction::Quit,
KeyCode::Char('i') | KeyCode::Char('/') => {
self.editing_filter = true;
AppAction::Continue
}
KeyCode::Char('j') | KeyCode::Down => {
self.viewport.scroll_down(1, self.output.total_lines());
AppAction::Continue
}
KeyCode::Char('k') | KeyCode::Up => {
self.viewport.scroll_up(1);
AppAction::Continue
}
KeyCode::Char('h') | KeyCode::Left => {
if !self.display.wrap_lines {
self.viewport.scroll_left(4);
}
AppAction::Continue
}
KeyCode::Char('l') | KeyCode::Right => {
if !self.display.wrap_lines {
self.viewport.scroll_right(4);
}
AppAction::Continue
}
KeyCode::Char('g') => {
self.viewport.scroll_to_top();
AppAction::Continue
}
KeyCode::Char('G') => {
self.viewport.scroll_to_bottom(self.output.total_lines());
AppAction::Continue
}
KeyCode::Char('0') => {
self.viewport.horizontal_offset = 0;
AppAction::Continue
}
KeyCode::Char('w') => {
self.display.wrap_lines = !self.display.wrap_lines;
if self.display.wrap_lines {
self.viewport.horizontal_offset = 0;
}
AppAction::Continue
}
KeyCode::Char('r') => {
self.display.raw_output = !self.display.raw_output;
self.recompute_total_lines();
AppAction::Continue
}
KeyCode::Char('s') => {
self.display.slurp = !self.display.slurp;
self.request_eval();
AppAction::Continue
}
KeyCode::Char('o') => {
self.display.compact = !self.display.compact;
self.recompute_total_lines();
AppAction::Continue
}
KeyCode::Char('y') => {
self.copy_filter_to_clipboard();
AppAction::Continue
}
KeyCode::PageDown => {
self.viewport.scroll_down(
self.viewport.height.saturating_sub(1),
self.output.total_lines(),
);
AppAction::Continue
}
KeyCode::PageUp => {
self.viewport
.scroll_up(self.viewport.height.saturating_sub(1));
AppAction::Continue
}
KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.viewport
.scroll_down(self.viewport.height / 2, self.output.total_lines());
AppAction::Continue
}
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.viewport.scroll_up(self.viewport.height / 2);
AppAction::Continue
}
_ => AppAction::Continue,
}
}
fn handle_filter_key(&mut self, key: KeyEvent) -> AppAction {
match key.code {
KeyCode::Esc => {
self.exit_filter_edit();
}
KeyCode::Enter => {
if self.eval_paused {
self.request_eval();
}
}
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
if self.eval.started_at.is_some() {
self.cancel_eval();
self.eval_paused = true;
}
}
KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.exit_filter_edit();
}
KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.eval_paused = !self.eval_paused;
if !self.eval_paused && self.pending_filter_update.is_some() {
self.request_eval();
}
}
KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.display.slurp = !self.display.slurp;
self.request_eval();
}
KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.display.raw_output = !self.display.raw_output;
self.recompute_total_lines();
}
KeyCode::Char('o') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.display.compact = !self.display.compact;
self.recompute_total_lines();
}
KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.copy_filter_to_clipboard();
}
KeyCode::Backspace => {
if self.filter.backspace() {
self.mark_filter_dirty();
}
}
KeyCode::Delete => {
if self.filter.delete() {
self.mark_filter_dirty();
}
}
KeyCode::Left => {
self.filter.move_left();
if self.filter.partial_eval_mode() {
self.mark_filter_dirty();
}
}
KeyCode::Right => {
self.filter.move_right();
if self.filter.partial_eval_mode() {
self.mark_filter_dirty();
}
}
KeyCode::Tab => {
self.filter.toggle_partial_eval();
self.mark_filter_dirty();
}
KeyCode::Home => {
self.filter.move_to_start();
}
KeyCode::End => {
self.filter.move_to_end();
}
KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.filter.move_to_start();
}
KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.filter.move_to_end();
}
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.filter.reset();
self.mark_filter_dirty();
}
KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::CONTROL) => {
if self.filter.delete_word_back() {
self.mark_filter_dirty();
}
}
KeyCode::Char('b') if key.modifiers.contains(KeyModifiers::ALT) => {
self.filter.jump_word_back();
if self.filter.partial_eval_mode() {
self.mark_filter_dirty();
}
}
KeyCode::Char('f') if key.modifiers.contains(KeyModifiers::ALT) => {
self.filter.jump_word_forward();
if self.filter.partial_eval_mode() {
self.mark_filter_dirty();
}
}
KeyCode::Char(c) => {
self.filter.insert(c);
self.mark_filter_dirty();
}
_ => {}
}
AppAction::Continue
}
fn exit_filter_edit(&mut self) {
if self.pending_filter_update.is_some() {
self.request_eval();
}
self.editing_filter = false;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use simd_json::json;
use std::thread;
use std::time::Duration;
fn test_app(input_values: Vec<Value>) -> App {
let mut app = App {
input: InputState::ready(input_values),
filter: FilterEditor::new(),
filter_error: None,
pending_filter_update: None,
output: OutputState::new(),
editing_filter: false,
eval_paused: false,
worker: Worker::spawn(),
help: HelpState::new(),
eval: EvalTracker::new(),
viewport: Viewport::new(),
display: DisplayOptions::new(),
status_message: None,
};
app.request_eval();
wait_for_eval(&mut app);
app
}
fn wait_for_eval(app: &mut App) {
for _ in 0..100 {
app.tick();
if !app.is_evaluating() && !app.eval.has_pending() {
return;
}
thread::sleep(Duration::from_millis(10));
}
panic!("Evaluation did not complete in time");
}
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn key_ctrl(c: char) -> KeyEvent {
KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)
}
#[test]
fn test_enter_filter_edit_with_i() {
let mut app = test_app(vec![json!({"a": 1})]);
assert!(!app.editing_filter());
app.handle_key(key(KeyCode::Char('i')));
assert!(app.editing_filter());
}
#[test]
fn test_enter_filter_edit_with_slash() {
let mut app = test_app(vec![json!({"a": 1})]);
assert!(!app.editing_filter());
app.handle_key(key(KeyCode::Char('/')));
assert!(app.editing_filter());
}
#[test]
fn test_exit_filter_edit_with_esc() {
let mut app = test_app(vec![json!({"a": 1})]);
app.handle_key(key(KeyCode::Char('i'))); assert!(app.editing_filter());
app.handle_key(key(KeyCode::Esc));
assert!(!app.editing_filter());
}
#[test]
fn test_exit_filter_edit_with_ctrl_d() {
let mut app = test_app(vec![json!({"a": 1})]);
app.handle_key(key(KeyCode::Char('i'))); assert!(app.editing_filter());
app.handle_key(key_ctrl('d'));
assert!(!app.editing_filter());
}
#[test]
fn test_quit_from_navigation_mode() {
let mut app = test_app(vec![json!(1)]);
let action = app.handle_key(key(KeyCode::Char('q')));
assert!(matches!(action, AppAction::Quit));
}
#[test]
fn test_esc_quits_in_navigation_mode() {
let mut app = test_app(vec![json!(1)]);
let action = app.handle_key(key(KeyCode::Esc));
assert!(matches!(action, AppAction::Quit));
}
#[test]
fn test_mark_filter_dirty_sets_pending() {
let mut app = test_app(vec![json!(1)]);
assert!(app.pending_filter_update.is_none());
app.mark_filter_dirty();
assert!(app.pending_filter_update.is_some());
}
#[test]
fn test_debounce_delays_evaluation() {
let mut app = test_app(vec![json!({"name": "test"})]);
app.handle_key(key(KeyCode::Char('i')));
app.handle_key(key(KeyCode::Char('a')));
assert!(app.pending_filter_update.is_some());
app.tick();
assert!(
app.pending_filter_update.is_some() || app.eval.has_pending(),
"Should have pending update or started eval"
);
}
#[test]
fn test_debounce_fires_after_duration() {
let mut app = test_app(vec![json!({"name": "test"})]);
app.handle_key(key(KeyCode::Char('i')));
app.handle_key(key(KeyCode::Char('a')));
assert!(app.pending_filter_update.is_some());
thread::sleep(DEBOUNCE_DURATION + Duration::from_millis(50));
app.tick();
assert!(app.pending_filter_update.is_none());
}
#[test]
fn test_cancel_eval_clears_pending() {
let mut app = test_app(vec![json!(1)]);
app.mark_filter_dirty();
app.pending_filter_update = Some(Instant::now());
let id = app.eval.next_id();
app.eval.start(id);
assert!(app.eval.has_pending());
app.cancel_eval();
assert!(!app.eval.has_pending());
assert!(app.pending_filter_update.is_none());
}
#[test]
fn test_ctrl_c_cancels_eval_in_filter_mode() {
let mut app = test_app(vec![json!(1)]);
app.handle_key(key(KeyCode::Char('i')));
let id = app.eval.next_id();
app.eval.start(id);
assert!(app.eval.started_at.is_some());
app.handle_key(key_ctrl('c'));
assert!(!app.eval.has_pending());
assert!(app.eval_paused());
}
#[test]
fn test_ctrl_c_only_cancels_when_evaluating() {
let mut app = test_app(vec![json!(1)]);
app.handle_key(key(KeyCode::Char('i')));
assert!(app.eval.started_at.is_none());
assert!(!app.eval_paused());
app.handle_key(key_ctrl('c'));
assert!(!app.eval_paused());
}
#[test]
fn test_tab_toggles_partial_eval_mode() {
let mut app = test_app(vec![json!(1)]);
app.handle_key(key(KeyCode::Char('i')));
assert!(!app.partial_eval_mode());
app.handle_key(key(KeyCode::Tab));
assert!(app.partial_eval_mode());
app.handle_key(key(KeyCode::Tab));
assert!(!app.partial_eval_mode());
}
#[test]
fn test_partial_eval_mode_marks_dirty_on_toggle() {
let mut app = test_app(vec![json!(1)]);
app.handle_key(key(KeyCode::Char('i')));
assert!(app.pending_filter_update.is_none());
app.handle_key(key(KeyCode::Tab));
assert!(app.pending_filter_update.is_some());
}
#[test]
fn test_partial_eval_mode_marks_dirty_on_cursor_move() {
let mut app = test_app(vec![json!(1)]);
app.handle_key(key(KeyCode::Char('i'))); app.handle_key(key(KeyCode::Tab));
app.pending_filter_update = None;
app.handle_key(key(KeyCode::Left));
assert!(app.pending_filter_update.is_some());
}
#[test]
fn test_cursor_move_without_partial_eval_no_dirty() {
let mut app = test_app(vec![json!(1)]);
app.handle_key(key(KeyCode::Char('i')));
assert!(!app.partial_eval_mode());
app.pending_filter_update = None;
app.handle_key(key(KeyCode::Left));
assert!(app.pending_filter_update.is_none());
}
#[test]
fn test_ctrl_p_toggles_eval_paused() {
let mut app = test_app(vec![json!(1)]);
app.handle_key(key(KeyCode::Char('i')));
assert!(!app.eval_paused());
app.handle_key(key_ctrl('p'));
assert!(app.eval_paused());
app.handle_key(key_ctrl('p'));
assert!(!app.eval_paused());
}
#[test]
fn test_enter_triggers_eval_when_paused() {
let mut app = test_app(vec![json!({"a": 1})]);
app.handle_key(key(KeyCode::Char('i'))); app.handle_key(key_ctrl('p'));
app.filter.insert('a');
app.mark_filter_dirty();
thread::sleep(DEBOUNCE_DURATION + Duration::from_millis(50));
app.tick();
let had_pending_before = app.eval.has_pending();
app.handle_key(key(KeyCode::Enter));
assert!(
app.eval.has_pending() || !had_pending_before || !app.output_values().is_empty(),
"Enter should trigger evaluation in paused mode"
);
}
#[test]
fn test_slurp_toggle_in_navigation_mode() {
let mut app = test_app(vec![json!(1), json!(2)]);
wait_for_eval(&mut app);
assert!(!app.slurp_mode());
app.handle_key(key(KeyCode::Char('s')));
assert!(app.slurp_mode());
wait_for_eval(&mut app);
assert!(app.output_values().len() > 0);
}
#[test]
fn test_slurp_toggle_in_filter_mode() {
let mut app = test_app(vec![json!(1), json!(2)]);
app.handle_key(key(KeyCode::Char('i')));
assert!(!app.slurp_mode());
app.handle_key(key_ctrl('s'));
assert!(app.slurp_mode());
}
#[test]
fn test_ctrl_h_toggles_help() {
let mut app = test_app(vec![json!(1)]);
assert!(!app.show_help());
app.handle_key(key_ctrl('h'));
assert!(app.show_help());
app.handle_key(key_ctrl('h'));
assert!(!app.show_help());
}
#[test]
fn test_help_from_filter_mode_remembers_context() {
let mut app = test_app(vec![json!(1)]);
app.handle_key(key(KeyCode::Char('i'))); assert!(app.editing_filter());
app.handle_key(key_ctrl('h'));
assert!(app.show_help());
assert!(app.help_for_filter_mode());
}
#[test]
fn test_help_scroll_keys() {
let mut app = test_app(vec![json!(1)]);
app.handle_key(key_ctrl('h')); assert!(app.show_help());
app.handle_key(key(KeyCode::Char('j')));
assert_eq!(app.help_scroll(), 1);
app.handle_key(key(KeyCode::Char('k')));
assert_eq!(app.help_scroll(), 0);
}
#[test]
fn test_any_non_scroll_key_closes_help() {
let mut app = test_app(vec![json!(1)]);
app.handle_key(key_ctrl('h')); assert!(app.show_help());
app.handle_key(key(KeyCode::Char('q')));
assert!(!app.show_help());
}
#[test]
fn test_wrap_lines_toggle() {
let mut app = test_app(vec![json!(1)]);
assert!(!app.wrap_lines());
app.handle_key(key(KeyCode::Char('w')));
assert!(app.wrap_lines());
app.viewport.horizontal_offset = 10;
app.handle_key(key(KeyCode::Char('w'))); app.handle_key(key(KeyCode::Char('w'))); assert_eq!(app.horizontal_offset(), 0);
}
#[test]
fn test_raw_output_toggle() {
let mut app = test_app(vec![json!("hello")]);
assert!(!app.raw_output());
app.handle_key(key(KeyCode::Char('r')));
assert!(app.raw_output());
}
#[test]
fn test_compact_output_toggle() {
let mut app = test_app(vec![json!({"a": 1})]);
assert!(!app.compact_output());
app.handle_key(key(KeyCode::Char('o')));
assert!(app.compact_output());
}
#[test]
fn test_scroll_keys() {
let mut app = test_app(vec![json!(1)]);
app.output.set_total_lines(100);
app.viewport.set_size(80, 20);
app.handle_key(key(KeyCode::Char('j')));
assert_eq!(app.scroll_offset(), 1);
app.handle_key(key(KeyCode::Char('k')));
assert_eq!(app.scroll_offset(), 0);
app.handle_key(key(KeyCode::Char('G')));
assert_eq!(app.scroll_offset(), 80);
app.handle_key(key(KeyCode::Char('g')));
assert_eq!(app.scroll_offset(), 0);
}
#[test]
fn test_horizontal_scroll_when_wrap_disabled() {
let mut app = test_app(vec![json!(1)]);
assert!(!app.wrap_lines());
app.handle_key(key(KeyCode::Char('l')));
assert_eq!(app.horizontal_offset(), 4);
app.handle_key(key(KeyCode::Char('h')));
assert_eq!(app.horizontal_offset(), 0);
}
#[test]
fn test_horizontal_scroll_blocked_when_wrap_enabled() {
let mut app = test_app(vec![json!(1)]);
app.handle_key(key(KeyCode::Char('w'))); assert!(app.wrap_lines());
app.handle_key(key(KeyCode::Char('l')));
assert_eq!(app.horizontal_offset(), 0);
app.handle_key(key(KeyCode::Char('h')));
assert_eq!(app.horizontal_offset(), 0);
}
#[test]
fn test_zero_resets_horizontal_offset() {
let mut app = test_app(vec![json!(1)]);
app.viewport.horizontal_offset = 20;
app.handle_key(key(KeyCode::Char('0')));
assert_eq!(app.horizontal_offset(), 0);
}
#[test]
fn test_typing_in_filter_mode() {
let mut app = test_app(vec![json!({"foo": 1})]);
app.handle_key(key(KeyCode::Char('i')));
app.handle_key(key(KeyCode::Char('f')));
app.handle_key(key(KeyCode::Char('o')));
app.handle_key(key(KeyCode::Char('o')));
assert_eq!(app.filter_text(), ".foo");
}
#[test]
fn test_backspace_in_filter() {
let mut app = test_app(vec![json!(1)]);
app.handle_key(key(KeyCode::Char('i')));
app.handle_key(key(KeyCode::Char('a')));
assert_eq!(app.filter_text(), ".a");
app.handle_key(key(KeyCode::Backspace));
assert_eq!(app.filter_text(), ".");
}
#[test]
fn test_ctrl_u_clears_filter() {
let mut app = test_app(vec![json!(1)]);
app.handle_key(key(KeyCode::Char('i')));
app.handle_key(key(KeyCode::Char('f')));
app.handle_key(key(KeyCode::Char('o')));
app.handle_key(key(KeyCode::Char('o')));
assert_eq!(app.filter_text(), ".foo");
app.handle_key(key_ctrl('u'));
assert_eq!(app.filter_text(), ".");
}
#[test]
fn test_status_message_auto_clears() {
let mut app = test_app(vec![json!(1)]);
app.set_status_message("Test message".to_string());
assert!(app.status_message().is_some());
thread::sleep(STATUS_MESSAGE_DURATION + Duration::from_millis(100));
app.tick();
assert!(app.status_message().is_none());
}
#[test]
fn test_exit_filter_triggers_pending_eval() {
let mut app = test_app(vec![json!({"a": 1})]);
app.handle_key(key(KeyCode::Char('i')));
app.handle_key(key(KeyCode::Char('a')));
assert!(app.pending_filter_update.is_some());
app.handle_key(key(KeyCode::Esc));
assert!(app.pending_filter_update.is_none());
assert!(!app.editing_filter());
}
}