Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions qa.sh
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,51 @@ assert_not_contains "J/picky-esc2" "Space toggle"

quit

# ── Suite J2: Dual-panel plot mode ─────────────────────────────────────────────
echo ""
echo "=== Suite J2: Dual-panel plot mode ==="

start_app "tests/fixtures/orders.csv"

# J2-1: assign total_amount to panel 1 (pre-selected by p), quantity to panel 2
send "lllllllll" # total_amount (col 9)
send "p" 0.25 # PlotPickY: total_amount pre-selected in y_cols
assert_contains "J2/picky-mode" "P1" # status bar shows P1/P2 labels

key Left 0.15
key Left 0.15 # navigate to quantity (col 7)
send "2" 0.25 # toggle quantity into y2_cols (panel 2)
assert_contains "J2/p2-assigned" "quantity" # quantity appears in P2 status

# J2-2: guard: pressing 2 again on quantity removes it from y2 (toggle off)
# navigate back to total_amount — pressing 2 on it (the only y1 col) must be a no-op
send "ll" # back to total_amount (col 9)
send "2" 0.15 # this would drain y_cols — must be refused
assert_contains "J2/drain-guard" "total_amount" # total_amount still in P1

# J2-3: re-navigate to quantity, ensure it is still in P2 (guard didn't corrupt state)
key Left 0.15
key Left 0.15 # back to quantity
assert_contains "J2/p2-intact" "quantity"

# J2-4: press i to plot against row index
send "i" 0.40
assert_contains "J2/panel1-title" "total_amount" # panel 1 chart title
assert_contains "J2/panel2-title" "quantity" # panel 2 chart title

# J2-5: t cycles Line → Bar only (no Histogram) in dual mode
send "t" 0.25
assert_contains "J2/dual-bar" "Bar"
assert_not_contains "J2/dual-no-hist" "Histogram"
send "t" 0.25
assert_contains "J2/dual-line" "Line"

esc
sleep 0.15
assert_contains "J2/dual-exit" "order_id"

quit

# ── Suite K: Help popup ────────────────────────────────────────────────────────
echo ""
echo "=== Suite K: Help popup ==="
Expand Down
25 changes: 21 additions & 4 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ pub struct ColumnProfile {
pub count: usize,
pub null_count: usize,
pub unique: usize,
pub sum: Option<f64>,
pub min: String,
pub max: String,
pub mean: Option<f64>,
Expand Down Expand Up @@ -69,6 +70,7 @@ pub enum AggFunc {
#[derive(Default, Clone)]
pub struct ColumnStats {
pub count: usize,
pub sum: Option<f64>,
pub min: String,
pub max: String,
pub mean: Option<f64>,
Expand Down Expand Up @@ -112,6 +114,7 @@ pub struct GroupByState {
#[derive(Default)]
pub struct PlotState {
pub y_cols: Vec<usize>,
pub y2_cols: Vec<usize>,
pub x_col: Option<usize>,
pub plot_type: PlotType,
}
Expand Down Expand Up @@ -705,12 +708,14 @@ impl App {
.ok()
.map(|s| s.value().to_string())
.unwrap_or_default();
let (mean, median) = series
.as_series()
let s = series.as_series();
let sum = s.as_ref().and_then(|s| s.sum::<f64>().ok());
let (mean, median) = s
.map(|s| (s.mean(), s.median()))
.unwrap_or((None, None));
ColumnStats {
count,
sum,
min,
max,
mean,
Expand Down Expand Up @@ -890,14 +895,17 @@ impl App {
.ok()
.map(|s| s.value().to_string())
.unwrap_or_default();
let mean = col.as_series().and_then(|s| s.mean());
let median = col.as_series().and_then(|s| s.median());
let s = col.as_series();
let sum = s.as_ref().and_then(|s| s.sum::<f64>().ok());
let mean = s.as_ref().and_then(|s| s.mean());
let median = s.and_then(|s| s.median());
ColumnProfile {
name,
dtype,
count,
null_count,
unique,
sum,
min,
max,
mean,
Expand Down Expand Up @@ -939,6 +947,15 @@ impl App {
});
}

pub fn is_typing(&self) -> bool {
match self.mode {
Mode::Search | Mode::Filter => true,
Mode::ColumnsView => self.columns_view.searching,
Mode::UniqueValues => self.unique_values.searching,
_ => false,
}
}

pub fn plot_type_label(&self) -> &str {
match self.plot.plot_type {
PlotType::Line => "Line",
Expand Down
129 changes: 129 additions & 0 deletions src/app_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -659,6 +659,10 @@ mod plot_tests {
}

fn toggle_y_col(app: &mut App, col: usize) {
// mutual exclusion: remove from y2_cols if present
if let Some(pos) = app.plot.y2_cols.iter().position(|&c| c == col) {
app.plot.y2_cols.remove(pos);
}
if let Some(pos) = app.plot.y_cols.iter().position(|&c| c == col) {
app.plot.y_cols.remove(pos);
} else {
Expand Down Expand Up @@ -709,6 +713,131 @@ mod plot_tests {
toggle_y_col(&mut app, 0);
assert_eq!(app.plot.y_cols, vec![2]);
}

#[test]
fn test_plot_state_default_y2_cols_empty() {
let state = PlotState::default();
assert!(state.y2_cols.is_empty());
}

fn toggle_y2_col(app: &mut App, col: usize) {
// mutual exclusion: remove from y_cols if present
if let Some(pos) = app.plot.y_cols.iter().position(|&c| c == col) {
app.plot.y_cols.remove(pos);
}
// toggle in y2_cols
if let Some(pos) = app.plot.y2_cols.iter().position(|&c| c == col) {
app.plot.y2_cols.remove(pos);
} else {
app.plot.y2_cols.push(col);
}
}

#[test]
fn test_toggle_y2_col_adds_to_y2() {
let df = df! { "a" => [1i32], "b" => [2i32] }.unwrap();
let mut app = App::new(df, "test.csv".to_string(), crate::theme::default_theme());
toggle_y2_col(&mut app, 1);
assert_eq!(app.plot.y2_cols, vec![1]);
assert!(app.plot.y_cols.is_empty());
}

#[test]
fn test_toggle_y2_col_removes_from_y1_if_present() {
let df = df! { "a" => [1i32], "b" => [2i32] }.unwrap();
let mut app = App::new(df, "test.csv".to_string(), crate::theme::default_theme());
app.plot.y_cols = vec![1];
toggle_y2_col(&mut app, 1);
assert_eq!(app.plot.y2_cols, vec![1]);
assert!(
app.plot.y_cols.is_empty(),
"col should be removed from y_cols"
);
}

#[test]
fn test_toggle_y2_col_removes_if_already_in_y2() {
let df = df! { "a" => [1i32], "b" => [2i32] }.unwrap();
let mut app = App::new(df, "test.csv".to_string(), crate::theme::default_theme());
app.plot.y2_cols = vec![1];
toggle_y2_col(&mut app, 1);
assert!(app.plot.y2_cols.is_empty());
}

#[test]
fn test_toggle_y1_col_removes_from_y2_if_present() {
let df = df! { "a" => [1i32], "b" => [2i32] }.unwrap();
let mut app = App::new(df, "test.csv".to_string(), crate::theme::default_theme());
app.plot.y2_cols = vec![0];
toggle_y_col(&mut app, 0);
assert_eq!(app.plot.y_cols, vec![0]);
assert!(
app.plot.y2_cols.is_empty(),
"col should be removed from y2_cols"
);
}
}

mod is_typing_tests {
use super::*;

fn make_app() -> App {
let df = df! { "a" => [1i32], "b" => [2i32] }.unwrap();
App::new(df, "test.csv".to_string(), crate::theme::default_theme())
}

#[test]
fn test_normal_mode_not_typing() {
let mut app = make_app();
app.mode = Mode::Normal;
assert!(!app.is_typing());
}

#[test]
fn test_search_mode_is_typing() {
let mut app = make_app();
app.mode = Mode::Search;
assert!(app.is_typing());
}

#[test]
fn test_filter_mode_is_typing() {
let mut app = make_app();
app.mode = Mode::Filter;
assert!(app.is_typing());
}

#[test]
fn test_columns_view_not_searching_not_typing() {
let mut app = make_app();
app.mode = Mode::ColumnsView;
app.columns_view.searching = false;
assert!(!app.is_typing());
}

#[test]
fn test_columns_view_searching_is_typing() {
let mut app = make_app();
app.mode = Mode::ColumnsView;
app.columns_view.searching = true;
assert!(app.is_typing());
}

#[test]
fn test_unique_values_not_searching_not_typing() {
let mut app = make_app();
app.mode = Mode::UniqueValues;
app.unique_values.searching = false;
assert!(!app.is_typing());
}

#[test]
fn test_unique_values_searching_is_typing() {
let mut app = make_app();
app.mode = Mode::UniqueValues;
app.unique_values.searching = true;
assert!(app.is_typing());
}
}

mod parse_operator_tests {
Expand Down
7 changes: 5 additions & 2 deletions src/browser/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,11 @@ pub fn run_browser_app(
continue;
}

// T (uppercase) opens the picker.
if key.code == event::KeyCode::Char('T') {
// T (uppercase) opens the picker — but not when the viewer is accepting text input
// (Search, Filter, ColumnsView/UniqueValues in search sub-mode).
let viewer_typing = app.focus == Focus::Viewer
&& app.viewer.as_ref().map(|v| v.is_typing()).unwrap_or(false);
if key.code == event::KeyCode::Char('T') && !viewer_typing {
app.picker = Some(crate::theme_picker::ThemePicker::open(app.theme));
continue;
}
Expand Down
25 changes: 24 additions & 1 deletion src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ pub(crate) fn dispatch_viewer_key(app: &mut App, key: &event::KeyEvent) {
event::KeyCode::Char('=') => app.autofit_all_columns(),
event::KeyCode::Char('p') if !app.df.is_empty() => {
app.plot.y_cols.clear();
app.plot.y2_cols.clear();
if let Some(col) = app.state.selected_column() {
app.plot.y_cols.push(col);
}
Expand Down Expand Up @@ -150,13 +151,34 @@ pub(crate) fn dispatch_viewer_key(app: &mut App, key: &event::KeyEvent) {
event::KeyCode::Right | event::KeyCode::Char('l') => app.select_next_column(),
event::KeyCode::Char(' ') => {
if let Some(col) = app.state.selected_column() {
// mutual exclusion: remove from y2 if present
if let Some(pos) = app.plot.y2_cols.iter().position(|&c| c == col) {
app.plot.y2_cols.remove(pos);
}
if let Some(pos) = app.plot.y_cols.iter().position(|&c| c == col) {
app.plot.y_cols.remove(pos);
} else {
app.plot.y_cols.push(col);
}
}
}
event::KeyCode::Char('2') => {
if let Some(col) = app.state.selected_column() {
// refuse if this would leave y_cols empty
let already_in_y1 = app.plot.y_cols.contains(&col);
let would_drain_y1 = already_in_y1 && app.plot.y_cols.len() == 1;
if !would_drain_y1 {
if let Some(pos) = app.plot.y_cols.iter().position(|&c| c == col) {
app.plot.y_cols.remove(pos);
}
if let Some(pos) = app.plot.y2_cols.iter().position(|&c| c == col) {
app.plot.y2_cols.remove(pos);
} else {
app.plot.y2_cols.push(col);
}
}
}
}
event::KeyCode::Enter if !app.plot.y_cols.is_empty() => {
app.mode = Mode::PlotPickX;
}
Expand All @@ -166,6 +188,7 @@ pub(crate) fn dispatch_viewer_key(app: &mut App, key: &event::KeyEvent) {
}
event::KeyCode::Esc => {
app.plot.y_cols.clear();
app.plot.y2_cols.clear();
app.mode = Mode::Normal;
}
_ => {}
Expand All @@ -184,7 +207,7 @@ pub(crate) fn dispatch_viewer_key(app: &mut App, key: &event::KeyEvent) {
},
Mode::Plot => match key.code {
event::KeyCode::Char('t') => {
app.plot.plot_type = if app.plot.y_cols.len() > 1 {
app.plot.plot_type = if app.plot.y_cols.len() > 1 || !app.plot.y2_cols.is_empty() {
match app.plot.plot_type {
PlotType::Line => PlotType::Bar,
_ => PlotType::Line,
Expand Down
Loading
Loading