Skip to main content

datui_lib/
chart_modal.rs

1//! Chart view state: chart type, axis columns, and options.
2
3use ratatui::widgets::ListState;
4
5use crate::widgets::text_input::TextInput;
6
7/// Chart kind: full chart category shown as tabs.
8#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
9pub enum ChartKind {
10    #[default]
11    XY,
12    Histogram,
13    BoxPlot,
14    Kde,
15    Heatmap,
16}
17
18impl ChartKind {
19    pub const ALL: [Self; 5] = [
20        Self::XY,
21        Self::Histogram,
22        Self::BoxPlot,
23        Self::Kde,
24        Self::Heatmap,
25    ];
26
27    pub fn as_str(self) -> &'static str {
28        match self {
29            Self::XY => "XY",
30            Self::Histogram => "Histogram",
31            Self::BoxPlot => "Box Plot",
32            Self::Kde => "KDE",
33            Self::Heatmap => "Heatmap",
34        }
35    }
36}
37
38/// XY chart type: Line, Scatter, or Bar (maps to ratatui GraphType).
39#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
40pub enum ChartType {
41    #[default]
42    Line,
43    Scatter,
44    Bar,
45}
46
47impl ChartType {
48    pub const ALL: [Self; 3] = [Self::Line, Self::Scatter, Self::Bar];
49
50    pub fn as_str(self) -> &'static str {
51        match self {
52            Self::Line => "Line",
53            Self::Scatter => "Scatter",
54            Self::Bar => "Bar",
55        }
56    }
57}
58
59/// Focus area in the chart sidebar.
60#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
61pub enum ChartFocus {
62    #[default]
63    TabBar,
64    ChartType,
65    XInput,
66    XList,
67    YInput,
68    YList,
69    YStartsAtZero,
70    LogScale,
71    ShowLegend,
72    HistInput,
73    HistList,
74    HistBins,
75    BoxInput,
76    BoxList,
77    KdeInput,
78    KdeList,
79    KdeBandwidth,
80    HeatmapXInput,
81    HeatmapXList,
82    HeatmapYInput,
83    HeatmapYList,
84    HeatmapBins,
85    /// Limit Rows (shared across all chart types; at bottom of options).
86    LimitRows,
87}
88
89/// Maximum number of y-axis series that can be selected (remembered).
90pub const Y_SERIES_MAX: usize = 7;
91
92/// Default histogram bin count.
93pub const HISTOGRAM_DEFAULT_BINS: usize = 20;
94pub const HISTOGRAM_MIN_BINS: usize = 5;
95pub const HISTOGRAM_MAX_BINS: usize = 80;
96
97/// Default heatmap bin count (applies to both axes).
98pub const HEATMAP_DEFAULT_BINS: usize = 20;
99pub const HEATMAP_MIN_BINS: usize = 5;
100pub const HEATMAP_MAX_BINS: usize = 60;
101
102/// KDE bandwidth multiplier bounds and step.
103pub const KDE_BANDWIDTH_MIN: f64 = 0.2;
104pub const KDE_BANDWIDTH_MAX: f64 = 5.0;
105pub const KDE_BANDWIDTH_STEP: f64 = 0.1;
106
107/// Chart row limit bounds (for Limit Rows option). User can go down to 0; 0 becomes unlimited (None).
108pub const CHART_ROW_LIMIT_MIN: usize = 0;
109/// Maximum applicable limit (Polars slice takes u32).
110pub const CHART_ROW_LIMIT_MAX: usize = u32::MAX as usize;
111/// PgUp/PgDown step for Limit Rows.
112pub const CHART_ROW_LIMIT_PAGE_STEP: usize = 100_000;
113/// Default numeric limit when switching from Unlimited with + or PgUp.
114pub const DEFAULT_CHART_ROW_LIMIT: usize = 10_000;
115/// Below this limit, +/- step is CHART_ROW_LIMIT_STEP_SMALL; at or above, CHART_ROW_LIMIT_STEP_LARGE.
116pub const CHART_ROW_LIMIT_STEP_THRESHOLD: usize = 20_000;
117pub const CHART_ROW_LIMIT_STEP_SMALL: i32 = 1_000;
118pub const CHART_ROW_LIMIT_STEP_LARGE: i32 = 5_000;
119
120fn format_usize_with_commas(n: usize) -> String {
121    let s = n.to_string();
122    let len = s.len();
123    if len <= 3 {
124        return s;
125    }
126    let first_len = len % 3;
127    let first_len = if first_len == 0 { 3 } else { first_len };
128    let mut out = s[..first_len].to_string();
129    for i in (first_len..len).step_by(3) {
130        out.push(',');
131        out.push_str(&s[i..i + 3]);
132    }
133    out
134}
135
136/// Chart modal state: chart kind, axes/columns, and options.
137#[derive(Default)]
138pub struct ChartModal {
139    pub active: bool,
140    pub chart_kind: ChartKind,
141    pub chart_type: ChartType,
142    /// Remembered x-axis column (single; set with spacebar).
143    pub x_column: Option<String>,
144    /// Remembered y-axis column names (order = series order; set with spacebar, max Y_SERIES_MAX).
145    pub y_columns: Vec<String>,
146    pub y_starts_at_zero: bool,
147    pub log_scale: bool,
148    pub show_legend: bool,
149    pub focus: ChartFocus,
150    /// Text input for x-axis column search.
151    pub x_input: TextInput,
152    /// Text input for y-axis column search.
153    pub y_input: TextInput,
154    /// List state for x-axis list (index into x_display_list).
155    pub x_list_state: ListState,
156    /// List state for y-axis list (index into y_display_list).
157    pub y_list_state: ListState,
158    /// Available columns for x-axis (datetime + numeric, order preserved for list).
159    pub x_candidates: Vec<String>,
160    /// Available numeric columns for y-axis.
161    pub y_candidates: Vec<String>,
162    /// Histogram: remembered column (single selection).
163    pub hist_column: Option<String>,
164    pub hist_bins: usize,
165    pub hist_input: TextInput,
166    pub hist_list_state: ListState,
167    pub hist_candidates: Vec<String>,
168    /// Box plot: remembered column (single selection).
169    pub box_column: Option<String>,
170    pub box_input: TextInput,
171    pub box_list_state: ListState,
172    pub box_candidates: Vec<String>,
173    /// KDE: remembered column (single selection).
174    pub kde_column: Option<String>,
175    pub kde_bandwidth_factor: f64,
176    pub kde_input: TextInput,
177    pub kde_list_state: ListState,
178    pub kde_candidates: Vec<String>,
179    /// Heatmap: remembered x/y columns (single selection each).
180    pub heatmap_x_column: Option<String>,
181    pub heatmap_y_column: Option<String>,
182    pub heatmap_bins: usize,
183    pub heatmap_x_input: TextInput,
184    pub heatmap_y_input: TextInput,
185    pub heatmap_x_list_state: ListState,
186    pub heatmap_y_list_state: ListState,
187    pub heatmap_x_candidates: Vec<String>,
188    pub heatmap_y_candidates: Vec<String>,
189    /// Maximum rows for chart data. None = unlimited (display "Unlimited"); Some(n) = cap at n.
190    pub row_limit: Option<usize>,
191}
192
193impl ChartModal {
194    pub fn new() -> Self {
195        Self::default()
196    }
197
198    /// Open the chart modal. No default x or y columns; user selects with spacebar.
199    /// `default_row_limit` is the initial value for Limit Rows (e.g. from config); None = unlimited.
200    pub fn open(
201        &mut self,
202        numeric_columns: &[String],
203        datetime_columns: &[String],
204        default_row_limit: Option<usize>,
205    ) {
206        self.active = true;
207        self.chart_kind = ChartKind::XY;
208        self.chart_type = ChartType::Line;
209        self.y_starts_at_zero = false;
210        self.log_scale = false;
211        self.show_legend = true;
212        self.focus = ChartFocus::TabBar;
213        self.row_limit = default_row_limit.and_then(|n| {
214            if n == 0 {
215                None
216            } else {
217                Some(n.clamp(1, CHART_ROW_LIMIT_MAX))
218            }
219        });
220
221        // x_candidates: datetime first, then numeric (for list order).
222        self.x_candidates = datetime_columns.to_vec();
223        for c in numeric_columns {
224            if !self.x_candidates.contains(c) {
225                self.x_candidates.push(c.clone());
226            }
227        }
228        self.y_candidates = numeric_columns.to_vec();
229        self.hist_candidates = numeric_columns.to_vec();
230        self.box_candidates = numeric_columns.to_vec();
231        self.kde_candidates = numeric_columns.to_vec();
232        self.heatmap_x_candidates = numeric_columns.to_vec();
233        self.heatmap_y_candidates = numeric_columns.to_vec();
234
235        // No default x or y; user selects with spacebar.
236        self.x_column = None;
237        self.y_columns.clear();
238        self.hist_column = None;
239        self.hist_bins = HISTOGRAM_DEFAULT_BINS;
240        self.box_column = None;
241        self.kde_column = None;
242        self.kde_bandwidth_factor = 1.0;
243        self.heatmap_x_column = None;
244        self.heatmap_y_column = None;
245        self.heatmap_bins = HEATMAP_DEFAULT_BINS;
246
247        self.x_input.set_value(String::new());
248        self.y_input.set_value(String::new());
249        self.hist_input.set_value(String::new());
250        self.box_input.set_value(String::new());
251        self.kde_input.set_value(String::new());
252        self.heatmap_x_input.set_value(String::new());
253        self.heatmap_y_input.set_value(String::new());
254
255        let x_display = self.x_display_list();
256        let y_display = self.y_display_list();
257        self.x_list_state
258            .select(if x_display.is_empty() { None } else { Some(0) });
259        self.y_list_state
260            .select(if y_display.is_empty() { None } else { Some(0) });
261        let hist_display = self.hist_display_list();
262        let box_display = self.box_display_list();
263        let kde_display = self.kde_display_list();
264        let heatmap_x_display = self.heatmap_x_display_list();
265        let heatmap_y_display = self.heatmap_y_display_list();
266        self.hist_list_state.select(if hist_display.is_empty() {
267            None
268        } else {
269            Some(0)
270        });
271        self.box_list_state.select(if box_display.is_empty() {
272            None
273        } else {
274            Some(0)
275        });
276        self.kde_list_state.select(if kde_display.is_empty() {
277            None
278        } else {
279            Some(0)
280        });
281        self.heatmap_x_list_state
282            .select(if heatmap_x_display.is_empty() {
283                None
284            } else {
285                Some(0)
286            });
287        self.heatmap_y_list_state
288            .select(if heatmap_y_display.is_empty() {
289                None
290            } else {
291                Some(0)
292            });
293    }
294
295    /// X-axis candidates filtered by current x search string (case-insensitive substring).
296    pub fn x_filtered(&self) -> Vec<String> {
297        let q = self.x_input.value().trim().to_lowercase();
298        if q.is_empty() {
299            return self.x_candidates.clone();
300        }
301        self.x_candidates
302            .iter()
303            .filter(|c| c.to_lowercase().contains(&q))
304            .cloned()
305            .collect()
306    }
307
308    /// Y-axis candidates filtered by current y search string (case-insensitive substring).
309    pub fn y_filtered(&self) -> Vec<String> {
310        let q = self.y_input.value().trim().to_lowercase();
311        if q.is_empty() {
312            return self.y_candidates.clone();
313        }
314        self.y_candidates
315            .iter()
316            .filter(|c| c.to_lowercase().contains(&q))
317            .cloned()
318            .collect()
319    }
320
321    /// X display list: remembered x first (if in filtered), then rest of filtered. Used for list rendering and index.
322    pub fn x_display_list(&self) -> Vec<String> {
323        Self::display_list_with_selected(self.x_filtered(), &self.x_column)
324    }
325
326    /// Y display list: remembered y columns first (in order, that are in filtered), then rest of filtered.
327    pub fn y_display_list(&self) -> Vec<String> {
328        let filtered = self.y_filtered();
329        let mut out: Vec<String> = self
330            .y_columns
331            .iter()
332            .filter(|c| filtered.contains(c))
333            .cloned()
334            .collect();
335        for c in &filtered {
336            if !out.contains(c) {
337                out.push(c.clone());
338            }
339        }
340        out
341    }
342
343    fn display_list_with_selected(filtered: Vec<String>, selected: &Option<String>) -> Vec<String> {
344        if let Some(ref selected) = selected {
345            if let Some(pos) = filtered.iter().position(|c| c == selected) {
346                let mut out = vec![filtered[pos].clone()];
347                for (i, c) in filtered.iter().enumerate() {
348                    if i != pos {
349                        out.push(c.clone());
350                    }
351                }
352                return out;
353            }
354        }
355        filtered
356    }
357
358    pub fn hist_filtered(&self) -> Vec<String> {
359        let q = self.hist_input.value().trim().to_lowercase();
360        if q.is_empty() {
361            return self.hist_candidates.clone();
362        }
363        self.hist_candidates
364            .iter()
365            .filter(|c| c.to_lowercase().contains(&q))
366            .cloned()
367            .collect()
368    }
369
370    pub fn hist_display_list(&self) -> Vec<String> {
371        Self::display_list_with_selected(self.hist_filtered(), &self.hist_column)
372    }
373
374    pub fn box_filtered(&self) -> Vec<String> {
375        let q = self.box_input.value().trim().to_lowercase();
376        if q.is_empty() {
377            return self.box_candidates.clone();
378        }
379        self.box_candidates
380            .iter()
381            .filter(|c| c.to_lowercase().contains(&q))
382            .cloned()
383            .collect()
384    }
385
386    pub fn box_display_list(&self) -> Vec<String> {
387        Self::display_list_with_selected(self.box_filtered(), &self.box_column)
388    }
389
390    pub fn kde_filtered(&self) -> Vec<String> {
391        let q = self.kde_input.value().trim().to_lowercase();
392        if q.is_empty() {
393            return self.kde_candidates.clone();
394        }
395        self.kde_candidates
396            .iter()
397            .filter(|c| c.to_lowercase().contains(&q))
398            .cloned()
399            .collect()
400    }
401
402    pub fn kde_display_list(&self) -> Vec<String> {
403        Self::display_list_with_selected(self.kde_filtered(), &self.kde_column)
404    }
405
406    pub fn heatmap_x_filtered(&self) -> Vec<String> {
407        let q = self.heatmap_x_input.value().trim().to_lowercase();
408        if q.is_empty() {
409            return self.heatmap_x_candidates.clone();
410        }
411        self.heatmap_x_candidates
412            .iter()
413            .filter(|c| c.to_lowercase().contains(&q))
414            .cloned()
415            .collect()
416    }
417
418    pub fn heatmap_y_filtered(&self) -> Vec<String> {
419        let q = self.heatmap_y_input.value().trim().to_lowercase();
420        if q.is_empty() {
421            return self.heatmap_y_candidates.clone();
422        }
423        self.heatmap_y_candidates
424            .iter()
425            .filter(|c| c.to_lowercase().contains(&q))
426            .cloned()
427            .collect()
428    }
429
430    pub fn heatmap_x_display_list(&self) -> Vec<String> {
431        Self::display_list_with_selected(self.heatmap_x_filtered(), &self.heatmap_x_column)
432    }
433
434    pub fn heatmap_y_display_list(&self) -> Vec<String> {
435        Self::display_list_with_selected(self.heatmap_y_filtered(), &self.heatmap_y_column)
436    }
437
438    /// Effective x column for chart/export: the remembered x (no preview on scroll).
439    pub fn effective_x_column(&self) -> Option<&String> {
440        self.x_column.as_ref()
441    }
442
443    /// Effective y columns for chart/export: when Y list focused, remembered + highlighted (if not already remembered); else just remembered.
444    pub fn effective_y_columns(&self) -> Vec<String> {
445        let mut out = self.y_columns.clone();
446        if self.focus == ChartFocus::YList {
447            let display = self.y_display_list();
448            if let Some(i) = self.y_list_state.selected() {
449                if i < display.len() {
450                    let name = &display[i];
451                    if !out.contains(name) {
452                        out.push(name.clone());
453                    }
454                }
455            }
456        }
457        out
458    }
459
460    pub fn effective_hist_column(&self) -> Option<String> {
461        if self.focus == ChartFocus::HistList {
462            let display = self.hist_display_list();
463            if let Some(i) = self.hist_list_state.selected() {
464                if i < display.len() {
465                    return Some(display[i].clone());
466                }
467            }
468        }
469        self.hist_column.clone()
470    }
471
472    pub fn effective_box_column(&self) -> Option<String> {
473        if self.focus == ChartFocus::BoxList {
474            let display = self.box_display_list();
475            if let Some(i) = self.box_list_state.selected() {
476                if i < display.len() {
477                    return Some(display[i].clone());
478                }
479            }
480        }
481        self.box_column.clone()
482    }
483
484    pub fn effective_kde_column(&self) -> Option<String> {
485        if self.focus == ChartFocus::KdeList {
486            let display = self.kde_display_list();
487            if let Some(i) = self.kde_list_state.selected() {
488                if i < display.len() {
489                    return Some(display[i].clone());
490                }
491            }
492        }
493        self.kde_column.clone()
494    }
495
496    pub fn effective_heatmap_x_column(&self) -> Option<String> {
497        if self.focus == ChartFocus::HeatmapXList {
498            let display = self.heatmap_x_display_list();
499            if let Some(i) = self.heatmap_x_list_state.selected() {
500                if i < display.len() {
501                    return Some(display[i].clone());
502                }
503            }
504        }
505        self.heatmap_x_column.clone()
506    }
507
508    pub fn effective_heatmap_y_column(&self) -> Option<String> {
509        if self.focus == ChartFocus::HeatmapYList {
510            let display = self.heatmap_y_display_list();
511            if let Some(i) = self.heatmap_y_list_state.selected() {
512                if i < display.len() {
513                    return Some(display[i].clone());
514                }
515            }
516        }
517        self.heatmap_y_column.clone()
518    }
519
520    /// Called when Y list loses focus: if no series remembered and we had a highlighted row, remember it.
521    pub fn y_list_blur(&mut self) {
522        if !self.y_columns.is_empty() {
523            return;
524        }
525        let display = self.y_display_list();
526        if let Some(i) = self.y_list_state.selected() {
527            if i < display.len() {
528                self.y_columns.push(display[i].clone());
529            }
530        }
531    }
532
533    /// Clamp x/y list selection to display list length (e.g. after search filter changes).
534    /// When a list has items but no selection (e.g. after tabbing from the filter input), select the first item.
535    pub fn clamp_list_selections_to_filtered(&mut self) {
536        let x_display = self.x_display_list();
537        let y_display = self.y_display_list();
538        let hist_display = self.hist_display_list();
539        let box_display = self.box_display_list();
540        let kde_display = self.kde_display_list();
541        let heatmap_x_display = self.heatmap_x_display_list();
542        let heatmap_y_display = self.heatmap_y_display_list();
543
544        fn clamp_one(list_state: &mut ListState, display_len: usize) {
545            if display_len == 0 {
546                list_state.select(None);
547                return;
548            }
549            match list_state.selected() {
550                Some(s) if s >= display_len => {
551                    list_state.select(Some(display_len.saturating_sub(1)));
552                }
553                None => {
554                    list_state.select(Some(0));
555                }
556                _ => {}
557            }
558        }
559
560        clamp_one(&mut self.x_list_state, x_display.len());
561        clamp_one(&mut self.y_list_state, y_display.len());
562        clamp_one(&mut self.hist_list_state, hist_display.len());
563        clamp_one(&mut self.box_list_state, box_display.len());
564        clamp_one(&mut self.kde_list_state, kde_display.len());
565        clamp_one(&mut self.heatmap_x_list_state, heatmap_x_display.len());
566        clamp_one(&mut self.heatmap_y_list_state, heatmap_y_display.len());
567    }
568
569    pub fn close(&mut self) {
570        self.active = false;
571        self.chart_kind = ChartKind::XY;
572        self.x_column = None;
573        self.y_columns.clear();
574        self.x_candidates.clear();
575        self.y_candidates.clear();
576        self.hist_column = None;
577        self.box_column = None;
578        self.kde_column = None;
579        self.heatmap_x_column = None;
580        self.heatmap_y_column = None;
581        self.hist_candidates.clear();
582        self.box_candidates.clear();
583        self.kde_candidates.clear();
584        self.heatmap_x_candidates.clear();
585        self.heatmap_y_candidates.clear();
586        self.focus = ChartFocus::TabBar;
587    }
588
589    /// Move focus to next/previous in sidebar. When leaving Y list, apply blur (remember highlight if only one).
590    pub fn next_focus(&mut self) {
591        let prev = self.focus;
592        if prev == ChartFocus::YList {
593            self.y_list_blur();
594        }
595        let order = self.focus_order();
596        if let Some(pos) = order.iter().position(|f| *f == prev) {
597            self.focus = order[(pos + 1) % order.len()];
598        } else {
599            self.focus = order[0];
600        }
601    }
602
603    pub fn prev_focus(&mut self) {
604        let prev = self.focus;
605        if prev == ChartFocus::YList {
606            self.y_list_blur();
607        }
608        let order = self.focus_order();
609        if let Some(pos) = order.iter().position(|f| *f == prev) {
610            let next = if pos == 0 { order.len() - 1 } else { pos - 1 };
611            self.focus = order[next];
612        } else {
613            self.focus = order[0];
614        }
615    }
616
617    /// Toggle Y starts at 0 (when focus is YStartsAtZero).
618    pub fn toggle_y_starts_at_zero(&mut self) {
619        self.y_starts_at_zero = !self.y_starts_at_zero;
620    }
621
622    /// Toggle log scale (when focus is LogScale).
623    pub fn toggle_log_scale(&mut self) {
624        self.log_scale = !self.log_scale;
625    }
626
627    /// Toggle show legend (when focus is ShowLegend).
628    pub fn toggle_show_legend(&mut self) {
629        self.show_legend = !self.show_legend;
630    }
631
632    /// Cycle chart type: Line -> Scatter -> Bar -> Line.
633    pub fn next_chart_type(&mut self) {
634        self.chart_type = match self.chart_type {
635            ChartType::Line => ChartType::Scatter,
636            ChartType::Scatter => ChartType::Bar,
637            ChartType::Bar => ChartType::Line,
638        };
639    }
640
641    pub fn prev_chart_type(&mut self) {
642        self.chart_type = match self.chart_type {
643            ChartType::Line => ChartType::Bar,
644            ChartType::Scatter => ChartType::Line,
645            ChartType::Bar => ChartType::Scatter,
646        };
647    }
648
649    pub fn next_chart_kind(&mut self) {
650        let idx = ChartKind::ALL
651            .iter()
652            .position(|&k| k == self.chart_kind)
653            .unwrap_or(0);
654        self.chart_kind = ChartKind::ALL[(idx + 1) % ChartKind::ALL.len()];
655        self.focus = ChartFocus::TabBar;
656    }
657
658    pub fn prev_chart_kind(&mut self) {
659        let idx = ChartKind::ALL
660            .iter()
661            .position(|&k| k == self.chart_kind)
662            .unwrap_or(0);
663        let prev = if idx == 0 {
664            ChartKind::ALL.len() - 1
665        } else {
666            idx - 1
667        };
668        self.chart_kind = ChartKind::ALL[prev];
669        self.focus = ChartFocus::TabBar;
670    }
671
672    /// Effective row limit to pass to prepare_* (unlimited = CHART_ROW_LIMIT_MAX).
673    pub fn effective_row_limit(&self) -> usize {
674        self.row_limit.unwrap_or(CHART_ROW_LIMIT_MAX)
675    }
676
677    /// Display string for Limit Rows: "Unlimited" or number with commas.
678    pub fn row_limit_display(&self) -> String {
679        match self.row_limit {
680            None => "Unlimited".to_string(),
681            Some(n) => format_usize_with_commas(n),
682        }
683    }
684
685    pub fn adjust_hist_bins(&mut self, delta: i32) {
686        let next = (self.hist_bins as i32 + delta)
687            .clamp(HISTOGRAM_MIN_BINS as i32, HISTOGRAM_MAX_BINS as i32);
688        self.hist_bins = next as usize;
689    }
690
691    pub fn adjust_heatmap_bins(&mut self, delta: i32) {
692        let next = (self.heatmap_bins as i32 + delta)
693            .clamp(HEATMAP_MIN_BINS as i32, HEATMAP_MAX_BINS as i32);
694        self.heatmap_bins = next as usize;
695    }
696
697    pub fn adjust_kde_bandwidth_factor(&mut self, delta: f64) {
698        let next = (self.kde_bandwidth_factor + delta).clamp(KDE_BANDWIDTH_MIN, KDE_BANDWIDTH_MAX);
699        self.kde_bandwidth_factor = (next * 10.0).round() / 10.0;
700    }
701
702    /// Adjust row limit by delta (+/-). Step size depends on current value. None = unlimited.
703    pub fn adjust_row_limit(&mut self, delta: i32) {
704        let current = match self.row_limit {
705            None if delta > 0 => {
706                self.row_limit = Some(DEFAULT_CHART_ROW_LIMIT);
707                return;
708            }
709            None => return,
710            Some(n) => n,
711        };
712        let step = if current < CHART_ROW_LIMIT_STEP_THRESHOLD {
713            CHART_ROW_LIMIT_STEP_SMALL as usize
714        } else {
715            CHART_ROW_LIMIT_STEP_LARGE as usize
716        };
717        let next = match delta.cmp(&0) {
718            std::cmp::Ordering::Greater => current.saturating_add(step).min(CHART_ROW_LIMIT_MAX),
719            std::cmp::Ordering::Less => current.saturating_sub(step).max(CHART_ROW_LIMIT_MIN),
720            std::cmp::Ordering::Equal => current,
721        };
722        self.row_limit = if next == 0 { None } else { Some(next) };
723    }
724
725    /// Adjust row limit by 10,000 (PgUp / PgDown). None = unlimited.
726    pub fn adjust_row_limit_page(&mut self, delta: i32) {
727        let current = match self.row_limit {
728            None if delta > 0 => {
729                self.row_limit = Some(DEFAULT_CHART_ROW_LIMIT);
730                return;
731            }
732            None => return,
733            Some(n) => n,
734        };
735        let step = CHART_ROW_LIMIT_PAGE_STEP;
736        let next = match delta.cmp(&0) {
737            std::cmp::Ordering::Greater => current.saturating_add(step).min(CHART_ROW_LIMIT_MAX),
738            std::cmp::Ordering::Less => current.saturating_sub(step).max(CHART_ROW_LIMIT_MIN),
739            std::cmp::Ordering::Equal => current,
740        };
741        self.row_limit = if next == 0 { None } else { Some(next) };
742    }
743
744    /// Move x-axis list highlight down (does not change remembered x; use spacebar to remember).
745    pub fn x_list_down(&mut self) {
746        let display = self.x_display_list();
747        let len = display.len();
748        if len == 0 {
749            return;
750        }
751        let i = self
752            .x_list_state
753            .selected()
754            .unwrap_or(0)
755            .saturating_add(1)
756            .min(len.saturating_sub(1));
757        self.x_list_state.select(Some(i));
758    }
759
760    /// Move x-axis list highlight up.
761    pub fn x_list_up(&mut self) {
762        let display = self.x_display_list();
763        let len = display.len();
764        if len == 0 {
765            return;
766        }
767        let i = self.x_list_state.selected().unwrap_or(0).saturating_sub(1);
768        self.x_list_state.select(Some(i));
769    }
770
771    /// Toggle x selection with spacebar: set remembered x to the highlighted row (single selection).
772    pub fn x_list_toggle(&mut self) {
773        let display = self.x_display_list();
774        if let Some(i) = self.x_list_state.selected() {
775            if i < display.len() {
776                self.x_column = Some(display[i].clone());
777            }
778        }
779    }
780
781    /// Move y-axis list highlight down (does not change remembered y; use spacebar to toggle).
782    pub fn y_list_down(&mut self) {
783        let display = self.y_display_list();
784        let len = display.len();
785        if len == 0 {
786            return;
787        }
788        let i = self
789            .y_list_state
790            .selected()
791            .unwrap_or(0)
792            .saturating_add(1)
793            .min(len.saturating_sub(1));
794        self.y_list_state.select(Some(i));
795    }
796
797    /// Move y-axis list highlight up.
798    pub fn y_list_up(&mut self) {
799        let display = self.y_display_list();
800        let len = display.len();
801        if len == 0 {
802            return;
803        }
804        let i = self.y_list_state.selected().unwrap_or(0).saturating_sub(1);
805        self.y_list_state.select(Some(i));
806    }
807
808    /// Toggle y selection with spacebar: add highlighted to remembered (up to Y_SERIES_MAX) or remove if already remembered.
809    pub fn y_list_toggle(&mut self) {
810        let display = self.y_display_list();
811        let Some(i) = self.y_list_state.selected() else {
812            return;
813        };
814        if i >= display.len() {
815            return;
816        }
817        let name = display[i].clone();
818        if let Some(pos) = self.y_columns.iter().position(|c| c == &name) {
819            self.y_columns.remove(pos);
820        } else if self.y_columns.len() < Y_SERIES_MAX {
821            self.y_columns.push(name);
822        }
823    }
824
825    pub fn hist_list_down(&mut self) {
826        let display = self.hist_display_list();
827        let len = display.len();
828        if len == 0 {
829            return;
830        }
831        let i = self
832            .hist_list_state
833            .selected()
834            .unwrap_or(0)
835            .saturating_add(1)
836            .min(len.saturating_sub(1));
837        self.hist_list_state.select(Some(i));
838    }
839
840    pub fn hist_list_up(&mut self) {
841        let display = self.hist_display_list();
842        if display.is_empty() {
843            return;
844        }
845        let i = self
846            .hist_list_state
847            .selected()
848            .unwrap_or(0)
849            .saturating_sub(1);
850        self.hist_list_state.select(Some(i));
851    }
852
853    pub fn hist_list_toggle(&mut self) {
854        let display = self.hist_display_list();
855        if let Some(i) = self.hist_list_state.selected() {
856            if i < display.len() {
857                self.hist_column = Some(display[i].clone());
858            }
859        }
860    }
861
862    pub fn box_list_down(&mut self) {
863        let display = self.box_display_list();
864        let len = display.len();
865        if len == 0 {
866            return;
867        }
868        let i = self
869            .box_list_state
870            .selected()
871            .unwrap_or(0)
872            .saturating_add(1)
873            .min(len.saturating_sub(1));
874        self.box_list_state.select(Some(i));
875    }
876
877    pub fn box_list_up(&mut self) {
878        let display = self.box_display_list();
879        if display.is_empty() {
880            return;
881        }
882        let i = self
883            .box_list_state
884            .selected()
885            .unwrap_or(0)
886            .saturating_sub(1);
887        self.box_list_state.select(Some(i));
888    }
889
890    pub fn box_list_toggle(&mut self) {
891        let display = self.box_display_list();
892        if let Some(i) = self.box_list_state.selected() {
893            if i < display.len() {
894                self.box_column = Some(display[i].clone());
895            }
896        }
897    }
898
899    pub fn kde_list_down(&mut self) {
900        let display = self.kde_display_list();
901        let len = display.len();
902        if len == 0 {
903            return;
904        }
905        let i = self
906            .kde_list_state
907            .selected()
908            .unwrap_or(0)
909            .saturating_add(1)
910            .min(len.saturating_sub(1));
911        self.kde_list_state.select(Some(i));
912    }
913
914    pub fn kde_list_up(&mut self) {
915        let display = self.kde_display_list();
916        if display.is_empty() {
917            return;
918        }
919        let i = self
920            .kde_list_state
921            .selected()
922            .unwrap_or(0)
923            .saturating_sub(1);
924        self.kde_list_state.select(Some(i));
925    }
926
927    pub fn kde_list_toggle(&mut self) {
928        let display = self.kde_display_list();
929        if let Some(i) = self.kde_list_state.selected() {
930            if i < display.len() {
931                self.kde_column = Some(display[i].clone());
932            }
933        }
934    }
935
936    pub fn heatmap_x_list_down(&mut self) {
937        let display = self.heatmap_x_display_list();
938        let len = display.len();
939        if len == 0 {
940            return;
941        }
942        let i = self
943            .heatmap_x_list_state
944            .selected()
945            .unwrap_or(0)
946            .saturating_add(1)
947            .min(len.saturating_sub(1));
948        self.heatmap_x_list_state.select(Some(i));
949    }
950
951    pub fn heatmap_x_list_up(&mut self) {
952        let display = self.heatmap_x_display_list();
953        if display.is_empty() {
954            return;
955        }
956        let i = self
957            .heatmap_x_list_state
958            .selected()
959            .unwrap_or(0)
960            .saturating_sub(1);
961        self.heatmap_x_list_state.select(Some(i));
962    }
963
964    pub fn heatmap_x_list_toggle(&mut self) {
965        let display = self.heatmap_x_display_list();
966        if let Some(i) = self.heatmap_x_list_state.selected() {
967            if i < display.len() {
968                self.heatmap_x_column = Some(display[i].clone());
969            }
970        }
971    }
972
973    pub fn heatmap_y_list_down(&mut self) {
974        let display = self.heatmap_y_display_list();
975        let len = display.len();
976        if len == 0 {
977            return;
978        }
979        let i = self
980            .heatmap_y_list_state
981            .selected()
982            .unwrap_or(0)
983            .saturating_add(1)
984            .min(len.saturating_sub(1));
985        self.heatmap_y_list_state.select(Some(i));
986    }
987
988    pub fn heatmap_y_list_up(&mut self) {
989        let display = self.heatmap_y_display_list();
990        if display.is_empty() {
991            return;
992        }
993        let i = self
994            .heatmap_y_list_state
995            .selected()
996            .unwrap_or(0)
997            .saturating_sub(1);
998        self.heatmap_y_list_state.select(Some(i));
999    }
1000
1001    pub fn heatmap_y_list_toggle(&mut self) {
1002        let display = self.heatmap_y_display_list();
1003        if let Some(i) = self.heatmap_y_list_state.selected() {
1004            if i < display.len() {
1005                self.heatmap_y_column = Some(display[i].clone());
1006            }
1007        }
1008    }
1009
1010    pub fn is_text_input_focused(&self) -> bool {
1011        matches!(
1012            self.focus,
1013            ChartFocus::XInput
1014                | ChartFocus::YInput
1015                | ChartFocus::HistInput
1016                | ChartFocus::BoxInput
1017                | ChartFocus::KdeInput
1018                | ChartFocus::HeatmapXInput
1019                | ChartFocus::HeatmapYInput
1020        )
1021    }
1022
1023    pub fn can_export(&self) -> bool {
1024        match self.chart_kind {
1025            ChartKind::XY => {
1026                self.effective_x_column().is_some() && !self.effective_y_columns().is_empty()
1027            }
1028            ChartKind::Histogram => self.effective_hist_column().is_some(),
1029            ChartKind::BoxPlot => self.effective_box_column().is_some(),
1030            ChartKind::Kde => self.effective_kde_column().is_some(),
1031            ChartKind::Heatmap => {
1032                self.effective_heatmap_x_column().is_some()
1033                    && self.effective_heatmap_y_column().is_some()
1034            }
1035        }
1036    }
1037
1038    fn focus_order(&self) -> &'static [ChartFocus] {
1039        match self.chart_kind {
1040            ChartKind::XY => &[
1041                ChartFocus::TabBar,
1042                ChartFocus::ChartType,
1043                ChartFocus::XInput,
1044                ChartFocus::XList,
1045                ChartFocus::YInput,
1046                ChartFocus::YList,
1047                ChartFocus::YStartsAtZero,
1048                ChartFocus::LogScale,
1049                ChartFocus::ShowLegend,
1050                ChartFocus::LimitRows,
1051            ],
1052            ChartKind::Histogram => &[
1053                ChartFocus::TabBar,
1054                ChartFocus::HistInput,
1055                ChartFocus::HistList,
1056                ChartFocus::HistBins,
1057                ChartFocus::LimitRows,
1058            ],
1059            ChartKind::BoxPlot => &[
1060                ChartFocus::TabBar,
1061                ChartFocus::BoxInput,
1062                ChartFocus::BoxList,
1063                ChartFocus::LimitRows,
1064            ],
1065            ChartKind::Kde => &[
1066                ChartFocus::TabBar,
1067                ChartFocus::KdeInput,
1068                ChartFocus::KdeList,
1069                ChartFocus::KdeBandwidth,
1070                ChartFocus::LimitRows,
1071            ],
1072            ChartKind::Heatmap => &[
1073                ChartFocus::TabBar,
1074                ChartFocus::HeatmapXInput,
1075                ChartFocus::HeatmapXList,
1076                ChartFocus::HeatmapYInput,
1077                ChartFocus::HeatmapYList,
1078                ChartFocus::HeatmapBins,
1079                ChartFocus::LimitRows,
1080            ],
1081        }
1082    }
1083}
1084
1085#[cfg(test)]
1086mod tests {
1087    use super::{ChartFocus, ChartKind, ChartModal, ChartType, Y_SERIES_MAX};
1088
1089    #[test]
1090    fn open_no_default_columns() {
1091        let numeric = vec!["a".to_string(), "b".to_string(), "c".to_string()];
1092        let datetime = vec!["date".to_string()];
1093        let mut modal = ChartModal::new();
1094        modal.open(&numeric, &datetime, Some(10_000));
1095        assert!(modal.active);
1096        assert_eq!(modal.chart_kind, ChartKind::XY);
1097        assert_eq!(modal.chart_type, ChartType::Line);
1098        assert!(modal.x_column.is_none());
1099        assert!(modal.y_columns.is_empty());
1100        assert!(!modal.y_starts_at_zero);
1101        assert!(!modal.log_scale);
1102        assert!(modal.show_legend);
1103        assert_eq!(modal.focus, ChartFocus::TabBar);
1104        assert_eq!(modal.row_limit, Some(10_000));
1105    }
1106
1107    #[test]
1108    fn open_numeric_only_no_defaults() {
1109        let numeric = vec!["x".to_string(), "y".to_string()];
1110        let mut modal = ChartModal::new();
1111        modal.open(&numeric, &[], Some(10_000));
1112        assert!(modal.x_column.is_none());
1113        assert!(modal.y_columns.is_empty());
1114    }
1115
1116    #[test]
1117    fn toggles_persist() {
1118        let mut modal = ChartModal::new();
1119        modal.open(&["a".into(), "b".into()], &[], Some(10_000));
1120        assert!(!modal.y_starts_at_zero);
1121        modal.toggle_y_starts_at_zero();
1122        assert!(modal.y_starts_at_zero);
1123        modal.toggle_log_scale();
1124        assert!(modal.log_scale);
1125        modal.toggle_show_legend();
1126        assert!(!modal.show_legend);
1127    }
1128
1129    #[test]
1130    fn x_display_list_puts_remembered_first() {
1131        let mut modal = ChartModal::new();
1132        modal.open(&["a".into(), "b".into(), "c".into()], &[], Some(10_000));
1133        assert_eq!(modal.x_display_list(), vec!["a", "b", "c"]);
1134        modal.x_column = Some("c".to_string());
1135        assert_eq!(modal.x_display_list(), vec!["c", "a", "b"]);
1136    }
1137
1138    #[test]
1139    fn y_list_toggle_add_remove() {
1140        let mut modal = ChartModal::new();
1141        modal.open(&["a".into(), "b".into(), "c".into()], &[], Some(10_000));
1142        modal.y_list_state.select(Some(0)); // highlight "a"
1143        modal.y_list_toggle();
1144        assert_eq!(modal.y_columns, vec!["a"]);
1145        modal.y_list_toggle(); // toggle "a" off
1146        assert!(modal.y_columns.is_empty());
1147        modal.y_list_toggle(); // toggle "a" on again
1148        assert_eq!(modal.y_columns, vec!["a"]);
1149        modal.y_list_state.select(Some(1));
1150        modal.y_list_toggle();
1151        assert_eq!(modal.y_columns.len(), 2);
1152    }
1153
1154    #[test]
1155    fn y_series_max_cap() {
1156        let mut modal = ChartModal::new();
1157        let cols: Vec<String> = (0..10).map(|i| format!("col_{}", i)).collect();
1158        modal.open(&cols, &[], Some(10_000));
1159        for i in 0..Y_SERIES_MAX {
1160            modal.y_list_state.select(Some(i));
1161            modal.y_list_toggle();
1162        }
1163        assert_eq!(modal.y_columns.len(), Y_SERIES_MAX);
1164        modal.y_list_state.select(Some(Y_SERIES_MAX));
1165        modal.y_list_toggle(); // should not add
1166        assert_eq!(modal.y_columns.len(), Y_SERIES_MAX);
1167    }
1168}