use crossterm::event::{
KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
};
use crate::app::{App, Focus, PromptKind, RENDER_CAP};
const PAGE: usize = 20;
pub fn handle_key(app: &mut App, key: KeyEvent) {
if key.kind == KeyEventKind::Release {
return;
}
if !matches!(key.code, KeyCode::Null) {
app.status = None;
}
if app.detail.is_some() {
return handle_detail(app, key);
}
if app.prompt.is_some() {
return handle_prompt(app, key);
}
if key.modifiers.contains(KeyModifiers::CONTROL) {
match key.code {
KeyCode::Char('c') => {
app.should_quit = true;
return;
}
KeyCode::Char('s') => return app.open_prompt(PromptKind::SaveProfile),
KeyCode::Char('l') => return app.open_prompt(PromptKind::LoadProfile),
KeyCode::Char('e') => return app.open_prompt(PromptKind::Export),
KeyCode::Char('r') => return app.reset_view(),
_ => {} }
}
match app.focus {
Focus::Search | Focus::Filter | Focus::GroupBy => handle_text_input(app, key),
Focus::FieldTree => handle_tree(app, key),
Focus::ActiveColumns => handle_columns(app, key),
Focus::Facets => handle_facets(app, key),
Focus::Table => handle_table(app, key),
}
}
fn common_nav(app: &mut App, key: KeyEvent) -> bool {
match key.code {
KeyCode::Char('q') => app.should_quit = true,
KeyCode::Char('/') => app.focus = Focus::Search,
KeyCode::Char('f') => app.focus = Focus::Filter,
KeyCode::Char('g') => {
app.sync_group_input();
app.focus = Focus::GroupBy;
}
KeyCode::Tab => app.cycle_focus(false),
KeyCode::BackTab => app.cycle_focus(true),
_ => return false,
}
true
}
fn handle_text_input(app: &mut App, key: KeyEvent) {
match key.code {
KeyCode::Esc => {
match app.focus {
Focus::Search => app.search_input.clear(),
Focus::Filter => app.filter_input.clear(),
Focus::GroupBy => {
app.group_input.clear();
app.commit_group_input();
}
_ => {}
}
app.recompute();
app.focus = Focus::Table;
}
KeyCode::Enter => {
if app.focus == Focus::GroupBy {
app.commit_group_input();
if app.group_field.is_some() {
app.focus = Focus::Facets;
return;
}
}
app.focus = Focus::Table;
}
KeyCode::Tab => app.cycle_focus(false),
KeyCode::BackTab => app.cycle_focus(true),
KeyCode::Backspace => {
match app.focus {
Focus::Search => {
app.search_input.pop();
app.recompute();
}
Focus::Filter => {
app.filter_input.pop();
app.recompute();
}
Focus::GroupBy => {
app.group_input.pop();
}
_ => {}
};
}
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => match app.focus {
Focus::Search => {
app.search_input.push(c);
app.recompute(); }
Focus::Filter => {
app.filter_input.push(c);
app.recompute(); }
Focus::GroupBy => app.group_input.push(c),
_ => {}
},
_ => {}
}
}
fn list_nav(selected: &mut usize, len: usize, key: KeyCode) -> bool {
if len == 0 {
return matches!(
key,
KeyCode::Up | KeyCode::Down | KeyCode::PageUp | KeyCode::PageDown | KeyCode::Home | KeyCode::End
);
}
match key {
KeyCode::Up => *selected = selected.saturating_sub(1),
KeyCode::Down => *selected = (*selected + 1).min(len - 1),
KeyCode::PageUp => *selected = selected.saturating_sub(PAGE),
KeyCode::PageDown => *selected = (*selected + PAGE).min(len - 1),
KeyCode::Home => *selected = 0,
KeyCode::End => *selected = len - 1,
_ => return false,
}
true
}
fn handle_tree(app: &mut App, key: KeyEvent) {
if common_nav(app, key) {
return;
}
let len = app.field_paths.len();
let mut sel = app.tree_selected;
if list_nav(&mut sel, len, key.code) {
app.tree_selected = sel;
return;
}
if let KeyCode::Char(' ') | KeyCode::Enter = key.code {
app.toggle_field_at(app.tree_selected);
}
}
fn handle_columns(app: &mut App, key: KeyEvent) {
let shifted = key
.modifiers
.intersects(KeyModifiers::SHIFT | KeyModifiers::CONTROL);
match key.code {
KeyCode::Char('[') => return app.move_column(-1),
KeyCode::Char(']') => return app.move_column(1),
KeyCode::Up | KeyCode::Left if shifted => return app.move_column(-1),
KeyCode::Down | KeyCode::Right if shifted => return app.move_column(1),
_ => {}
}
if common_nav(app, key) {
return;
}
let len = app.active_columns.len();
let mut sel = app.columns_selected;
if list_nav(&mut sel, len, key.code) {
app.columns_selected = sel;
return;
}
match key.code {
KeyCode::Char(' ') | KeyCode::Delete | KeyCode::Backspace => app.remove_selected_column(),
KeyCode::Char('s') | KeyCode::Enter => app.sort_by_selected_active_column(),
_ => {}
}
}
fn handle_facets(app: &mut App, key: KeyEvent) {
if common_nav(app, key) {
return;
}
let len = app.facets.len();
let mut sel = app.facet_selected;
if list_nav(&mut sel, len, key.code) {
app.facet_selected = sel;
return;
}
match key.code {
KeyCode::Char(' ') | KeyCode::Enter => app.toggle_facet_at(app.facet_selected),
KeyCode::Esc => {
app.group_facet = None;
app.recompute();
}
_ => {}
}
}
fn handle_table(app: &mut App, key: KeyEvent) {
if common_nav(app, key) {
return;
}
let shown = app.visible_indices.len().min(RENDER_CAP);
let mut sel = app.table_selected;
if list_nav(&mut sel, shown, key.code) {
app.table_selected = sel;
return;
}
match key.code {
KeyCode::Left => {
app.table_col_selected = app.table_col_selected.saturating_sub(1);
}
KeyCode::Right => {
app.table_col_selected = (app.table_col_selected + 1)
.min(app.active_columns.len().saturating_sub(1));
}
KeyCode::Char('s') => app.sort_by_selected_table_column(),
KeyCode::Enter => app.open_detail(),
_ => {}
}
}
fn handle_detail(app: &mut App, key: KeyEvent) {
match key.code {
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Enter => {
app.detail = None;
app.detail_scroll = 0;
}
KeyCode::Up => app.detail_scroll = app.detail_scroll.saturating_sub(1),
KeyCode::Down => app.detail_scroll = app.detail_scroll.saturating_add(1),
KeyCode::PageUp => app.detail_scroll = app.detail_scroll.saturating_sub(PAGE as u16),
KeyCode::PageDown => app.detail_scroll = app.detail_scroll.saturating_add(PAGE as u16),
KeyCode::Home => app.detail_scroll = 0,
_ => {}
}
}
fn handle_prompt(app: &mut App, key: KeyEvent) {
match key.code {
KeyCode::Esc => app.prompt = None,
KeyCode::Enter => app.confirm_prompt(),
KeyCode::Backspace => {
if let Some(p) = &mut app.prompt {
p.input.pop();
}
}
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
if let Some(p) = &mut app.prompt {
p.input.push(c);
}
}
_ => {}
}
}
pub fn handle_mouse(app: &mut App, me: MouseEvent) {
let (x, y) = (me.column, me.row);
if app.prompt.is_some() {
return; }
if app.detail.is_some() {
match me.kind {
MouseEventKind::ScrollUp => app.detail_scroll = app.detail_scroll.saturating_sub(3),
MouseEventKind::ScrollDown => app.detail_scroll = app.detail_scroll.saturating_add(3),
MouseEventKind::Down(MouseButton::Left) if !app.ui.detail.contains(x, y) => {
app.detail = None;
app.detail_scroll = 0;
}
_ => {}
}
return;
}
match me.kind {
MouseEventKind::Down(MouseButton::Left) => handle_click(app, x, y),
MouseEventKind::ScrollUp => handle_scroll(app, x, y, -3),
MouseEventKind::ScrollDown => handle_scroll(app, x, y, 3),
_ => {}
}
}
fn handle_click(app: &mut App, x: u16, y: u16) {
app.status = None;
let ui = app.ui.clone();
if ui.search.contains(x, y) {
app.focus = Focus::Search;
return;
}
if ui.filter.contains(x, y) {
app.focus = Focus::Filter;
return;
}
if ui.group.contains(x, y) {
app.sync_group_input();
app.focus = Focus::GroupBy;
return;
}
if ui.tree.contains(x, y) {
app.focus = Focus::FieldTree;
let idx = ui.tree_offset + (y - ui.tree.y) as usize;
if idx < app.field_paths.len() {
if app.tree_selected == idx {
app.toggle_field_at(idx);
} else {
app.tree_selected = idx;
}
}
return;
}
if ui.columns.contains(x, y) {
app.focus = Focus::ActiveColumns;
let idx = ui.columns_offset + (y - ui.columns.y) as usize;
if idx < app.active_columns.len() {
if app.columns_selected == idx {
app.sort_by_selected_active_column();
} else {
app.columns_selected = idx;
}
}
return;
}
if app.group_field.is_some() && ui.facets.contains(x, y) {
app.focus = Focus::Facets;
let idx = ui.facets_offset + (y - ui.facets.y) as usize;
if idx < app.facets.len() {
app.facet_selected = idx;
app.toggle_facet_at(idx);
}
return;
}
if ui.table.contains(x, y) {
app.focus = Focus::Table;
if y == ui.table.y {
if let Some(col) = ui.col_spans.iter().position(|&(sx, ex)| x >= sx && x < ex) {
if col < app.active_columns.len() {
app.table_col_selected = col;
app.sort_by_selected_table_column();
}
}
} else {
let row = ui.table_offset + (y - ui.table.y - 1) as usize;
if row < app.shown_rows() {
if app.table_selected == row {
app.open_detail();
} else {
app.table_selected = row;
}
}
}
}
}
fn handle_scroll(app: &mut App, x: u16, y: u16, delta: i32) {
let ui = app.ui.clone();
if ui.tree.contains(x, y) {
let len = app.field_paths.len();
scroll_list(
&mut app.tree_view,
&mut app.tree_selected,
len,
ui.tree.h as usize,
delta,
);
} else if ui.columns.contains(x, y) {
let len = app.active_columns.len();
scroll_list(
&mut app.columns_view,
&mut app.columns_selected,
len,
ui.columns.h as usize,
delta,
);
} else if app.group_field.is_some() && ui.facets.contains(x, y) {
let len = app.facets.len();
scroll_list(
&mut app.facets_view,
&mut app.facet_selected,
len,
ui.facets.h as usize,
delta,
);
} else if ui.table.contains(x, y) {
let len = app.shown_rows();
let viewport = (ui.table.h as usize).saturating_sub(1); scroll_list(
&mut app.table_view,
&mut app.table_selected,
len,
viewport,
delta,
);
}
}
fn scroll_list(view: &mut usize, selected: &mut usize, len: usize, viewport: usize, delta: i32) {
if len == 0 || viewport == 0 {
return;
}
let max_view = len.saturating_sub(viewport);
*view = (*view as i32 + delta).clamp(0, max_view as i32) as usize;
let last_visible = (*view + viewport - 1).min(len - 1);
*selected = (*selected).clamp(*view, last_visible);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::{App, Area};
use crate::data::load_from_reader;
use std::io::Cursor;
use std::path::PathBuf;
fn sample_app() -> App {
let input = "{\"type\":\"a\",\"score\":1}\n{\"type\":\"b\",\"score\":2}\n{\"type\":\"a\",\"score\":3}\n";
let ds = load_from_reader(Cursor::new(input), None).unwrap();
let mut app = App::new(ds, PathBuf::from("t.jsonl"));
app.ui.search = Area { x: 0, y: 0, w: 20, h: 3 };
app.ui.tree = Area { x: 0, y: 4, w: 10, h: 10 };
app.ui.table = Area { x: 10, y: 5, w: 40, h: 10 };
app.ui.col_spans = vec![(12, 20), (21, 29)]; app
}
fn click(x: u16, y: u16) -> MouseEvent {
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: x,
row: y,
modifiers: KeyModifiers::NONE,
}
}
#[test]
fn header_click_sorts_and_toggles_direction() {
let mut app = sample_app();
assert_eq!(app.active_columns, vec!["score", "type"]);
handle_mouse(&mut app, click(13, 5)); assert_eq!(app.sort_field.as_deref(), Some("score"));
assert!(!app.sort_desc);
handle_mouse(&mut app, click(13, 5));
assert!(app.sort_desc);
handle_mouse(&mut app, click(25, 5)); assert_eq!(app.sort_field.as_deref(), Some("type"));
assert!(!app.sort_desc);
}
#[test]
fn row_click_selects_then_opens_detail() {
let mut app = sample_app();
handle_mouse(&mut app, click(15, 7)); assert_eq!(app.table_selected, 1);
assert_eq!(app.detail, None);
handle_mouse(&mut app, click(15, 7)); assert_eq!(app.detail, Some(1));
}
#[test]
fn tree_click_selects_then_toggles_column() {
let mut app = sample_app();
assert_eq!(app.tree_selected, 0); handle_mouse(&mut app, click(2, 4));
assert_eq!(app.active_columns, vec!["type"]); handle_mouse(&mut app, click(2, 5)); assert_eq!(app.tree_selected, 1);
}
#[test]
fn input_click_focuses_and_scroll_moves_viewport() {
let mut app = sample_app();
handle_mouse(&mut app, click(5, 1));
assert_eq!(app.focus, Focus::Search);
app.ui.table = Area { x: 10, y: 5, w: 40, h: 3 }; let scroll = MouseEvent {
kind: MouseEventKind::ScrollDown,
column: 15,
row: 6,
modifiers: KeyModifiers::NONE,
};
handle_mouse(&mut app, scroll);
assert_eq!(app.table_view, 1); assert_eq!(app.table_selected, 1); let scroll_up = MouseEvent {
kind: MouseEventKind::ScrollUp,
column: 15,
row: 6,
modifiers: KeyModifiers::NONE,
};
handle_mouse(&mut app, scroll_up);
assert_eq!(app.table_view, 0);
}
#[test]
fn scroll_list_moves_viewport_first_and_clamps() {
let (mut view, mut sel) = (0usize, 0usize);
scroll_list(&mut view, &mut sel, 60, 28, 3);
assert_eq!(view, 3, "content moves on the first notch");
assert_eq!(sel, 3, "selection dragged to stay visible");
for _ in 0..30 {
scroll_list(&mut view, &mut sel, 60, 28, 3);
}
assert_eq!(view, 32, "clamped at len - viewport");
assert_eq!(sel, 32);
scroll_list(&mut view, &mut sel, 60, 28, -100);
assert_eq!(view, 0);
assert_eq!(sel, 27, "selection dragged back into view from below");
let (mut view, mut sel) = (0usize, 2usize);
scroll_list(&mut view, &mut sel, 3, 28, 3);
assert_eq!((view, sel), (0, 2));
}
}