From e81a13bb359d2ee28a1c77f5501a5f3d058db0f7 Mon Sep 17 00:00:00 2001 From: SpollaL Date: Thu, 7 May 2026 15:42:28 +0200 Subject: [PATCH 01/10] feat: add y2_cols field to PlotState for dual-panel plotting --- src/app.rs | 1 + src/app_tests.rs | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/src/app.rs b/src/app.rs index fa677ba..ca7b914 100644 --- a/src/app.rs +++ b/src/app.rs @@ -112,6 +112,7 @@ pub struct GroupByState { #[derive(Default)] pub struct PlotState { pub y_cols: Vec, + pub y2_cols: Vec, pub x_col: Option, pub plot_type: PlotType, } diff --git a/src/app_tests.rs b/src/app_tests.rs index 91b323f..9d322ae 100644 --- a/src/app_tests.rs +++ b/src/app_tests.rs @@ -709,6 +709,12 @@ 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()); + } } mod parse_operator_tests { From 948d4850c64dd06306190e36fda0e4b0a4282386 Mon Sep 17 00:00:00 2001 From: SpollaL Date: Thu, 7 May 2026 15:44:29 +0200 Subject: [PATCH 02/10] fix: suppress dead_code lint on y2_cols until Task 2 wires it up --- src/app.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app.rs b/src/app.rs index ca7b914..d017b3b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -112,6 +112,7 @@ pub struct GroupByState { #[derive(Default)] pub struct PlotState { pub y_cols: Vec, + #[allow(dead_code)] pub y2_cols: Vec, pub x_col: Option, pub plot_type: PlotType, From f449d9fad10549266fdd344e2106cb2f71a9642c Mon Sep 17 00:00:00 2001 From: SpollaL Date: Thu, 7 May 2026 15:47:09 +0200 Subject: [PATCH 03/10] feat: handle '2' key in PlotPickY for dual-panel column assignment Wire up y2_cols event handling: Space/2 toggle y1/y2 with mutual exclusion, Esc and 'p' clear both vecs, 't' skips Histogram when dual-panel is active. Remove now-unnecessary #[allow(dead_code)]. Co-Authored-By: Claude Sonnet 4.6 --- src/app.rs | 1 - src/app_tests.rs | 55 ++++++++++++++++++++++++++++++++++++++++++++++++ src/events.rs | 21 +++++++++++++++++- 3 files changed, 75 insertions(+), 2 deletions(-) diff --git a/src/app.rs b/src/app.rs index d017b3b..ca7b914 100644 --- a/src/app.rs +++ b/src/app.rs @@ -112,7 +112,6 @@ pub struct GroupByState { #[derive(Default)] pub struct PlotState { pub y_cols: Vec, - #[allow(dead_code)] pub y2_cols: Vec, pub x_col: Option, pub plot_type: PlotType, diff --git a/src/app_tests.rs b/src/app_tests.rs index 9d322ae..0a26858 100644 --- a/src/app_tests.rs +++ b/src/app_tests.rs @@ -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 { @@ -715,6 +719,57 @@ mod plot_tests { 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 parse_operator_tests { diff --git a/src/events.rs b/src/events.rs index 20d4946..2254eee 100644 --- a/src/events.rs +++ b/src/events.rs @@ -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); } @@ -150,6 +151,10 @@ 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 { @@ -157,6 +162,19 @@ pub(crate) fn dispatch_viewer_key(app: &mut App, key: &event::KeyEvent) { } } } + event::KeyCode::Char('2') => { + if let Some(col) = app.state.selected_column() { + // 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); + } + 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; } @@ -166,6 +184,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; } _ => {} @@ -184,7 +203,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, From 9954e105f4662efaeb2de018b3012e8df83c7acc Mon Sep 17 00:00:00 2001 From: SpollaL Date: Thu, 7 May 2026 15:50:18 +0200 Subject: [PATCH 04/10] style: apply cargo fmt to app_tests.rs --- src/app_tests.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/app_tests.rs b/src/app_tests.rs index 0a26858..3b9e69a 100644 --- a/src/app_tests.rs +++ b/src/app_tests.rs @@ -749,7 +749,10 @@ mod plot_tests { 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"); + assert!( + app.plot.y_cols.is_empty(), + "col should be removed from y_cols" + ); } #[test] @@ -768,7 +771,10 @@ mod plot_tests { 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"); + assert!( + app.plot.y2_cols.is_empty(), + "col should be removed from y2_cols" + ); } } From 96634e484aa0a444b0023c1f493bdaebb7cb6401 Mon Sep 17 00:00:00 2001 From: SpollaL Date: Thu, 7 May 2026 15:51:01 +0200 Subject: [PATCH 05/10] feat: update PlotPickY status bar to show both panel assignments --- src/ui.rs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/ui.rs b/src/ui.rs index 0d299be..2ca571b 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -473,7 +473,7 @@ fn shortcut_bar<'a>(app: &App, theme: &Theme) -> Line<'a> { fn get_bar(app: &App, theme: &Theme) -> (String, Style) { match app.mode { Mode::PlotPickY => { - let y_names = if app.plot.y_cols.is_empty() { + let p1 = if app.plot.y_cols.is_empty() { "none".to_string() } else { app.plot @@ -483,10 +483,20 @@ fn get_bar(app: &App, theme: &Theme) -> (String, Style) { .collect::>() .join(", ") }; + let p2 = if app.plot.y2_cols.is_empty() { + "none".to_string() + } else { + app.plot + .y2_cols + .iter() + .map(|&i| app.headers[i].as_str()) + .collect::>() + .join(", ") + }; ( format!( - " Y: [{}] — Space toggle · ←/→ navigate · i plot with index · Enter pick X · Esc cancel ", - y_names + " P1: [{}] P2: [{}] — Space p1 · 2 p2 · ←/→ navigate · i index · Enter pick X · Esc cancel ", + p1, p2 ), Style::default() .bg(theme.info) From 80dd7c1c1b634415527e32e5eaf11cca645ebc18 Mon Sep 17 00:00:00 2001 From: SpollaL Date: Thu, 7 May 2026 15:57:21 +0200 Subject: [PATCH 06/10] feat: stacked dual-panel plotting with independent Y axes Co-Authored-By: Claude Sonnet 4.6 --- src/ui.rs | 207 +++++++++++++++++++++++++++++++++++------------------- 1 file changed, 134 insertions(+), 73 deletions(-) diff --git a/src/ui.rs b/src/ui.rs index 2ca571b..42ea3f0 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1187,53 +1187,47 @@ fn render_plot(frame: &mut Frame, app: &App, theme: &Theme, full_area: Rect) { return; } - // Histogram: single-column only; use first Y col. - if matches!(app.plot.plot_type, PlotType::Histogram) { + let dual = !app.plot.y2_cols.is_empty(); + + // Histogram only in single-panel, single-column mode. + if matches!(app.plot.plot_type, PlotType::Histogram) && !dual { render_histogram(frame, app, theme, app.plot.y_cols[0], full_area); return; } let max_points = (full_area.width as usize * 2).max(200); - // Extract and downsample data for every Y column. When x_col is None the - // X axis is the row index; when it's Some(idx), use that column as X. - let all_series: Vec<(Vec<(f64, f64)>, bool)> = app - .plot - .y_cols - .iter() - .map(|&y_idx| { - let (raw, cat) = match app.plot.x_col { - Some(x_idx) => extract_plot_data(app, x_idx, y_idx), - None => (extract_plot_data_indexed(app, y_idx), false), - }; - (downsample(raw, max_points), cat) - }) - .collect(); - - let x_is_categorical = all_series.iter().any(|(_, cat)| *cat); - - let nonempty: Vec<(usize, &Vec<(f64, f64)>)> = all_series - .iter() - .enumerate() - .filter(|(_, (d, _))| !d.is_empty()) - .map(|(i, (d, _))| (i, d)) - .collect(); - - // Use first non-empty series length so categorical X labels render even when the - // first selected Y column has no numeric data. - let first_len = nonempty.first().map(|(_, d)| d.len()).unwrap_or(0); - let x_labels = match (x_is_categorical, app.plot.x_col) { - (true, Some(x_idx)) => collect_all_x_labels(app, x_idx, first_len), - _ => vec![], + // Rotated categorical X labels are only shown in single-panel mode. + let (x_labels, label_height) = if !dual { + let all_series: Vec<(Vec<(f64, f64)>, bool)> = app + .plot + .y_cols + .iter() + .map(|&y_idx| { + let (raw, cat) = match app.plot.x_col { + Some(x_idx) => extract_plot_data(app, x_idx, y_idx), + None => (extract_plot_data_indexed(app, y_idx), false), + }; + (downsample(raw, max_points), cat) + }) + .collect(); + let x_is_categorical = all_series.iter().any(|(_, cat)| *cat); + let first_len = all_series + .iter() + .find(|(d, _)| !d.is_empty()) + .map(|(d, _)| d.len()) + .unwrap_or(0); + let labels = match (x_is_categorical, app.plot.x_col) { + (true, Some(x_idx)) => collect_all_x_labels(app, x_idx, first_len), + _ => vec![], + }; + let max_label_len = labels.iter().map(|s| s.chars().count()).max().unwrap_or(0); + let lh = (max_label_len as u16).min(full_area.height / 3); + (labels, lh) + } else { + (vec![], 0) }; - let max_label_len = x_labels - .iter() - .map(|s| s.chars().count()) - .max() - .unwrap_or(0); - let label_height = (max_label_len as u16).min(full_area.height / 3); - // Three-zone layout: chart | rotated-label strip | status bar let zones = Layout::default() .direction(ratatui::layout::Direction::Vertical) .constraints([ @@ -1246,7 +1240,7 @@ fn render_plot(frame: &mut Frame, app: &App, theme: &Theme, full_area: Rect) { let label_area = zones[1]; let bar_area = zones[2]; - let cycle_hint = if app.plot.y_cols.len() > 1 { + let cycle_hint = if app.plot.y_cols.len() > 1 || dual { "t cycle line/bar" } else { "t cycle line/bar/histogram" @@ -1261,6 +1255,87 @@ fn render_plot(frame: &mut Frame, app: &App, theme: &Theme, full_area: Rect) { bar_area, ); + if dual { + let panels = Layout::default() + .direction(ratatui::layout::Direction::Vertical) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(chart_area); + render_plot_panel(frame, app, theme, &app.plot.y_cols, panels[0], max_points); + render_plot_panel(frame, app, theme, &app.plot.y2_cols, panels[1], max_points); + } else { + render_plot_panel(frame, app, theme, &app.plot.y_cols, chart_area, max_points); + if !x_labels.is_empty() && label_area.height > 0 { + // Compute y_label_width for rotated-label alignment (single-panel only). + let all_series: Vec<(Vec<(f64, f64)>, bool)> = app + .plot + .y_cols + .iter() + .map(|&y_idx| { + let (raw, _cat) = match app.plot.x_col { + Some(x_idx) => extract_plot_data(app, x_idx, y_idx), + None => (extract_plot_data_indexed(app, y_idx), false), + }; + (downsample(raw, max_points), false) + }) + .collect(); + let nonempty: Vec<(usize, &Vec<(f64, f64)>)> = all_series + .iter() + .enumerate() + .filter(|(_, (d, _))| !d.is_empty()) + .map(|(i, (d, _))| (i, d)) + .collect(); + let first_len = nonempty.first().map(|(_, d)| d.len()).unwrap_or(0); + let y_min = nonempty + .iter() + .flat_map(|(_, d)| d.iter().map(|p| p.1)) + .fold(f64::INFINITY, f64::min); + let y_max = nonempty + .iter() + .flat_map(|(_, d)| d.iter().map(|p| p.1)) + .fold(f64::NEG_INFINITY, f64::max); + let y_pad = (y_max - y_min).abs() * config::Y_AXIS_PADDING; + let y_bounds = [y_min - y_pad, y_max + y_pad]; + let y_labels = numeric_axis_labels(y_bounds[0], y_bounds[1], config::Y_AXIS_TICKS); + let y_label_width = max_label_width(&y_labels); + render_vertical_x_labels( + frame, + &x_labels, + first_len, + chart_area, + label_area, + y_label_width, + theme.fg_dim, + ); + } + } +} + +fn render_plot_panel( + frame: &mut Frame, + app: &App, + theme: &Theme, + y_cols: &[usize], + area: Rect, + max_points: usize, +) { + let all_series: Vec<(Vec<(f64, f64)>, bool)> = y_cols + .iter() + .map(|&y_idx| { + let (raw, cat) = match app.plot.x_col { + Some(x_idx) => extract_plot_data(app, x_idx, y_idx), + None => (extract_plot_data_indexed(app, y_idx), false), + }; + (downsample(raw, max_points), cat) + }) + .collect(); + + let nonempty: Vec<(usize, &Vec<(f64, f64)>)> = all_series + .iter() + .enumerate() + .filter(|(_, (d, _))| !d.is_empty()) + .map(|(i, (d, _))| (i, d)) + .collect(); + if nonempty.is_empty() { let msg = Paragraph::new(" No data to plot. Y columns must be numeric (int or float). ") .block( @@ -1276,7 +1351,7 @@ fn render_plot(frame: &mut Frame, app: &App, theme: &Theme, full_area: Rect) { .border_style(Style::default().fg(theme.error)), ) .style(Style::default().bg(theme.bg).fg(theme.fg)); - frame.render_widget(msg, chart_area); + frame.render_widget(msg, area); return; } @@ -1316,16 +1391,13 @@ fn render_plot(frame: &mut Frame, app: &App, theme: &Theme, full_area: Rect) { }) .collect(); - let title_y = app - .plot - .y_cols + let title_y = y_cols .iter() .map(|&i| app.headers[i].as_str()) .collect::>() .join(", "); let y_labels = numeric_axis_labels(y_bounds[0], y_bounds[1], config::Y_AXIS_TICKS); - let y_label_width = max_label_width(&y_labels); let x_header: &str = app .plot @@ -1355,8 +1427,8 @@ fn render_plot(frame: &mut Frame, app: &App, theme: &Theme, full_area: Rect) { ) .y_axis( Axis::default() - .title(if app.plot.y_cols.len() == 1 { - app.headers[app.plot.y_cols[0]].as_str() + .title(if y_cols.len() == 1 { + app.headers[y_cols[0]].as_str() } else { "Value" }) @@ -1365,36 +1437,27 @@ fn render_plot(frame: &mut Frame, app: &App, theme: &Theme, full_area: Rect) { .bounds(y_bounds), ); - frame.render_widget(chart, chart_area); + frame.render_widget(chart, area); - // Legend for multi-series plots. - if app.plot.y_cols.len() > 1 { - render_plot_legend(frame, app, theme, chart_area); - } - - if !x_labels.is_empty() && label_area.height > 0 { - render_vertical_x_labels( - frame, - &x_labels, - first_len, - chart_area, - label_area, - y_label_width, - theme.fg_dim, - ); + if y_cols.len() > 1 { + render_plot_legend(frame, app, theme, y_cols, area); } } -fn render_plot_legend(frame: &mut Frame, app: &App, theme: &Theme, chart_area: Rect) { - let legend_inner_w = app - .plot - .y_cols +fn render_plot_legend( + frame: &mut Frame, + app: &App, + theme: &Theme, + y_cols: &[usize], + chart_area: Rect, +) { + let legend_inner_w = y_cols .iter() - .map(|&i| app.headers[i].chars().count() + 3) // "● " prefix + padding + .map(|&i| app.headers[i].chars().count() + 3) .max() .unwrap_or(4) as u16; - let legend_w = legend_inner_w + 2; // borders - let legend_h = app.plot.y_cols.len() as u16 + 2; + let legend_w = legend_inner_w + 2; + let legend_h = y_cols.len() as u16 + 2; let legend_x = chart_area .x @@ -1414,9 +1477,7 @@ fn render_plot_legend(frame: &mut Frame, app: &App, theme: &Theme, chart_area: R height: legend_h, }; - let lines: Vec> = app - .plot - .y_cols + let lines: Vec> = y_cols .iter() .enumerate() .map(|(i, &y_idx)| { From cfbc693e577ab4caedd4a163e033949ffc682a8e Mon Sep 17 00:00:00 2001 From: SpollaL Date: Thu, 7 May 2026 16:04:52 +0200 Subject: [PATCH 07/10] fix: use shared x-bounds in dual-panel plot for aligned horizontal scales Add compute_x_bounds helper that aggregates x values across all panels' series, and pass the resulting [f64; 2] into render_plot_panel so both stacked panels always share the same horizontal axis range. Co-Authored-By: Claude Sonnet 4.6 --- src/ui.rs | 78 ++++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 66 insertions(+), 12 deletions(-) diff --git a/src/ui.rs b/src/ui.rs index 42ea3f0..0b46751 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1180,6 +1180,34 @@ fn render_histogram(frame: &mut Frame, app: &App, theme: &Theme, y_idx: usize, f frame.render_widget(chart, chart_area); } +fn compute_x_bounds(app: &App, y_cols_all: &[usize], max_points: usize) -> [f64; 2] { + let x_min = y_cols_all + .iter() + .flat_map(|&y_idx| { + let (raw, _) = match app.plot.x_col { + Some(x_idx) => extract_plot_data(app, x_idx, y_idx), + None => (extract_plot_data_indexed(app, y_idx), false), + }; + downsample(raw, max_points).into_iter().map(|p| p.0) + }) + .fold(f64::INFINITY, f64::min); + let x_max = y_cols_all + .iter() + .flat_map(|&y_idx| { + let (raw, _) = match app.plot.x_col { + Some(x_idx) => extract_plot_data(app, x_idx, y_idx), + None => (extract_plot_data_indexed(app, y_idx), false), + }; + downsample(raw, max_points).into_iter().map(|p| p.0) + }) + .fold(f64::NEG_INFINITY, f64::max); + if x_min == f64::INFINITY || x_max == f64::NEG_INFINITY { + [0.0, 1.0] + } else { + [x_min, x_max] + } +} + fn render_plot(frame: &mut Frame, app: &App, theme: &Theme, full_area: Rect) { frame.render_widget(Clear, full_area); @@ -1256,14 +1284,47 @@ fn render_plot(frame: &mut Frame, app: &App, theme: &Theme, full_area: Rect) { ); if dual { + let all_y_cols: Vec = app + .plot + .y_cols + .iter() + .chain(app.plot.y2_cols.iter()) + .copied() + .collect(); + let x_bounds = compute_x_bounds(app, &all_y_cols, max_points); let panels = Layout::default() .direction(ratatui::layout::Direction::Vertical) .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) .split(chart_area); - render_plot_panel(frame, app, theme, &app.plot.y_cols, panels[0], max_points); - render_plot_panel(frame, app, theme, &app.plot.y2_cols, panels[1], max_points); + render_plot_panel( + frame, + app, + theme, + &app.plot.y_cols, + panels[0], + max_points, + x_bounds, + ); + render_plot_panel( + frame, + app, + theme, + &app.plot.y2_cols, + panels[1], + max_points, + x_bounds, + ); } else { - render_plot_panel(frame, app, theme, &app.plot.y_cols, chart_area, max_points); + let x_bounds = compute_x_bounds(app, &app.plot.y_cols, max_points); + render_plot_panel( + frame, + app, + theme, + &app.plot.y_cols, + chart_area, + max_points, + x_bounds, + ); if !x_labels.is_empty() && label_area.height > 0 { // Compute y_label_width for rotated-label alignment (single-panel only). let all_series: Vec<(Vec<(f64, f64)>, bool)> = app @@ -1317,6 +1378,7 @@ fn render_plot_panel( y_cols: &[usize], area: Rect, max_points: usize, + x_bounds: [f64; 2], ) { let all_series: Vec<(Vec<(f64, f64)>, bool)> = y_cols .iter() @@ -1355,14 +1417,6 @@ fn render_plot_panel( return; } - let x_min = nonempty - .iter() - .flat_map(|(_, d)| d.iter().map(|p| p.0)) - .fold(f64::INFINITY, f64::min); - let x_max = nonempty - .iter() - .flat_map(|(_, d)| d.iter().map(|p| p.0)) - .fold(f64::NEG_INFINITY, f64::max); let y_min = nonempty .iter() .flat_map(|(_, d)| d.iter().map(|p| p.1)) @@ -1423,7 +1477,7 @@ fn render_plot_panel( Axis::default() .title(x_header) .style(Style::default().fg(theme.fg_dim)) - .bounds([x_min, x_max]), + .bounds(x_bounds), ) .y_axis( Axis::default() From 3d57d02c3988c997070d9cbfab5d021729bc5aac Mon Sep 17 00:00:00 2001 From: SpollaL Date: Thu, 7 May 2026 16:12:15 +0200 Subject: [PATCH 08/10] fix: guard 2-key drain, update qa.sh, add 2-key to help and shortcut bar, fix histogram label in dual mode Co-Authored-By: Claude Sonnet 4.6 --- qa.sh | 45 +++++++++++++++++++++++++++++++++++++++++++++ src/events.rs | 20 ++++++++++++-------- src/ui.rs | 10 ++++++++-- 3 files changed, 65 insertions(+), 10 deletions(-) diff --git a/qa.sh b/qa.sh index 22eb0e0..c1ca85d 100755 --- a/qa.sh +++ b/qa.sh @@ -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 ===" diff --git a/src/events.rs b/src/events.rs index 2254eee..efedae4 100644 --- a/src/events.rs +++ b/src/events.rs @@ -164,14 +164,18 @@ pub(crate) fn dispatch_viewer_key(app: &mut App, key: &event::KeyEvent) { } event::KeyCode::Char('2') => { if let Some(col) = app.state.selected_column() { - // 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); - } - 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); + // 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); + } } } } diff --git a/src/ui.rs b/src/ui.rs index 0b46751..bf30f9a 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -373,6 +373,7 @@ fn shortcut_bar<'a>(app: &App, theme: &Theme) -> Line<'a> { &[ ("← →", "Navigate"), ("Space", "Toggle Y"), + ("2", "Toggle P2"), ("Enter", "Pick X axis"), ("i", "Plot with index"), ("Esc", "Cancel"), @@ -792,6 +793,7 @@ fn help_text(theme: &Theme) -> Text<'static> { key("p", "Mark column as Y, enter pick-Y mode"), key("←/→ h/l", "Navigate columns (pick-Y / pick-X)"), key("Space", "Toggle Y column (pick-Y)"), + key("2", "Toggle column into panel 2 (dual-panel mode)"), key("Enter", "pick-Y: advance to pick-X | pick-X: show chart"), key("i", "Plot against row index (skip pick-X)"), key( @@ -1273,11 +1275,15 @@ fn render_plot(frame: &mut Frame, app: &App, theme: &Theme, full_area: Rect) { } else { "t cycle line/bar/histogram" }; + let type_label = if dual && matches!(app.plot.plot_type, PlotType::Histogram) { + "Bar" + } else { + app.plot_type_label() + }; frame.render_widget( Paragraph::new(format!( " {} chart | {} | Esc / p to close ", - app.plot_type_label(), - cycle_hint + type_label, cycle_hint )) .style(Style::default().bg(theme.bg_alt).fg(theme.fg_dim)), bar_area, From 9da9dc57a6ae12d7b48a58c693cc7fb13d1d9c43 Mon Sep 17 00:00:00 2001 From: SpollaL Date: Thu, 7 May 2026 18:35:13 +0200 Subject: [PATCH 09/10] fix: don't steal T for theme picker when viewer is in a text-input mode --- src/app.rs | 9 +++++++ src/app_tests.rs | 62 +++++++++++++++++++++++++++++++++++++++++++ src/browser/events.rs | 7 +++-- 3 files changed, 76 insertions(+), 2 deletions(-) diff --git a/src/app.rs b/src/app.rs index ca7b914..10dfbd3 100644 --- a/src/app.rs +++ b/src/app.rs @@ -940,6 +940,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", diff --git a/src/app_tests.rs b/src/app_tests.rs index 3b9e69a..4cca675 100644 --- a/src/app_tests.rs +++ b/src/app_tests.rs @@ -778,6 +778,68 @@ mod plot_tests { } } +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 { use super::*; diff --git a/src/browser/events.rs b/src/browser/events.rs index 76604f7..d7f9b29 100644 --- a/src/browser/events.rs +++ b/src/browser/events.rs @@ -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; } From 4d0a1d89ddcd1f5dc5066c817e27088db6bcf949 Mon Sep 17 00:00:00 2001 From: SpollaL Date: Fri, 8 May 2026 09:33:45 +0200 Subject: [PATCH 10/10] feat: add sum to column stats popup and column inspector Co-Authored-By: Claude Sonnet 4.6 --- src/app.rs | 15 +++++++++++---- src/ui.rs | 15 ++++++++------- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/src/app.rs b/src/app.rs index 10dfbd3..7af633f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -23,6 +23,7 @@ pub struct ColumnProfile { pub count: usize, pub null_count: usize, pub unique: usize, + pub sum: Option, pub min: String, pub max: String, pub mean: Option, @@ -69,6 +70,7 @@ pub enum AggFunc { #[derive(Default, Clone)] pub struct ColumnStats { pub count: usize, + pub sum: Option, pub min: String, pub max: String, pub mean: Option, @@ -706,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::().ok()); + let (mean, median) = s .map(|s| (s.mean(), s.median())) .unwrap_or((None, None)); ColumnStats { count, + sum, min, max, mean, @@ -891,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::().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, diff --git a/src/ui.rs b/src/ui.rs index bf30f9a..0c91856 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -225,16 +225,13 @@ fn render_stats_popup(frame: &mut Frame, app: &mut App, theme: &Theme) { let area = centered_rect(40, 40, frame.area()); frame.render_widget(Clear, area); let content = format!( - "\n Count: {}\n Min: {}\n Max: {}\n Mean: {}\n Median: {}", + "\n Count: {}\n Sum: {}\n Min: {}\n Max: {}\n Mean: {}\n Median: {}", stats.count, + stats.sum.map_or("N/A".to_string(), |v| format!("{:.2}", v)), stats.min, stats.max, - stats - .mean - .map_or("N/A".to_string(), |v| format!("{:.2}", v)), - stats - .median - .map_or("N/A".to_string(), |v| format!("{:.2}", v)), + stats.mean.map_or("N/A".to_string(), |v| format!("{:.2}", v)), + stats.median.map_or("N/A".to_string(), |v| format!("{:.2}", v)), ); let popup = Paragraph::new(content) .block( @@ -949,6 +946,7 @@ fn render_columns_view(frame: &mut Frame, app: &mut App, theme: &Theme, full_are Cell::from("Count").style(Style::default().fg(theme.info).add_modifier(Modifier::BOLD)), Cell::from("Nulls").style(Style::default().fg(theme.info).add_modifier(Modifier::BOLD)), Cell::from("Unique").style(Style::default().fg(theme.info).add_modifier(Modifier::BOLD)), + Cell::from("Sum").style(Style::default().fg(theme.info).add_modifier(Modifier::BOLD)), Cell::from("Min").style(Style::default().fg(theme.info).add_modifier(Modifier::BOLD)), Cell::from("Max").style(Style::default().fg(theme.info).add_modifier(Modifier::BOLD)), Cell::from("Mean").style(Style::default().fg(theme.info).add_modifier(Modifier::BOLD)), @@ -978,6 +976,7 @@ fn render_columns_view(frame: &mut Frame, app: &mut App, theme: &Theme, full_are Constraint::Length(9), Constraint::Length(14), Constraint::Length(14), + Constraint::Length(14), Constraint::Length(10), Constraint::Length(10), ]; @@ -1027,6 +1026,8 @@ fn profile_row<'a>(p: &'a ColumnProfile, idx: usize, theme: &Theme) -> Row<'a> { Cell::from(p.count.to_string()).style(Style::default().fg(theme.fg)), Cell::from(p.null_count.to_string()).style(null_style), Cell::from(p.unique.to_string()).style(Style::default().fg(theme.fg)), + Cell::from(p.sum.map_or("—".to_string(), |v| format!("{:.2}", v))) + .style(Style::default().fg(theme.accent)), Cell::from(p.min.clone()).style(Style::default().fg(theme.fg_dim)), Cell::from(p.max.clone()).style(Style::default().fg(theme.fg_dim)), Cell::from(p.mean.map_or("—".to_string(), |v| format!("{:.2}", v)))