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    pub fn clamp_list_selections_to_filtered(&mut self) {
535        let x_display = self.x_display_list();
536        let y_display = self.y_display_list();
537        let hist_display = self.hist_display_list();
538        let box_display = self.box_display_list();
539        let kde_display = self.kde_display_list();
540        let heatmap_x_display = self.heatmap_x_display_list();
541        let heatmap_y_display = self.heatmap_y_display_list();
542        if let Some(s) = self.x_list_state.selected() {
543            if s >= x_display.len() {
544                self.x_list_state.select(if x_display.is_empty() {
545                    None
546                } else {
547                    Some(x_display.len().saturating_sub(1))
548                });
549            }
550        }
551        if let Some(s) = self.y_list_state.selected() {
552            if s >= y_display.len() {
553                self.y_list_state.select(if y_display.is_empty() {
554                    None
555                } else {
556                    Some(y_display.len().saturating_sub(1))
557                });
558            }
559        }
560        if let Some(s) = self.hist_list_state.selected() {
561            if s >= hist_display.len() {
562                self.hist_list_state.select(if hist_display.is_empty() {
563                    None
564                } else {
565                    Some(hist_display.len().saturating_sub(1))
566                });
567            }
568        }
569        if let Some(s) = self.box_list_state.selected() {
570            if s >= box_display.len() {
571                self.box_list_state.select(if box_display.is_empty() {
572                    None
573                } else {
574                    Some(box_display.len().saturating_sub(1))
575                });
576            }
577        }
578        if let Some(s) = self.kde_list_state.selected() {
579            if s >= kde_display.len() {
580                self.kde_list_state.select(if kde_display.is_empty() {
581                    None
582                } else {
583                    Some(kde_display.len().saturating_sub(1))
584                });
585            }
586        }
587        if let Some(s) = self.heatmap_x_list_state.selected() {
588            if s >= heatmap_x_display.len() {
589                self.heatmap_x_list_state
590                    .select(if heatmap_x_display.is_empty() {
591                        None
592                    } else {
593                        Some(heatmap_x_display.len().saturating_sub(1))
594                    });
595            }
596        }
597        if let Some(s) = self.heatmap_y_list_state.selected() {
598            if s >= heatmap_y_display.len() {
599                self.heatmap_y_list_state
600                    .select(if heatmap_y_display.is_empty() {
601                        None
602                    } else {
603                        Some(heatmap_y_display.len().saturating_sub(1))
604                    });
605            }
606        }
607    }
608
609    pub fn close(&mut self) {
610        self.active = false;
611        self.chart_kind = ChartKind::XY;
612        self.x_column = None;
613        self.y_columns.clear();
614        self.x_candidates.clear();
615        self.y_candidates.clear();
616        self.hist_column = None;
617        self.box_column = None;
618        self.kde_column = None;
619        self.heatmap_x_column = None;
620        self.heatmap_y_column = None;
621        self.hist_candidates.clear();
622        self.box_candidates.clear();
623        self.kde_candidates.clear();
624        self.heatmap_x_candidates.clear();
625        self.heatmap_y_candidates.clear();
626        self.focus = ChartFocus::TabBar;
627    }
628
629    /// Move focus to next/previous in sidebar. When leaving Y list, apply blur (remember highlight if only one).
630    pub fn next_focus(&mut self) {
631        let prev = self.focus;
632        if prev == ChartFocus::YList {
633            self.y_list_blur();
634        }
635        let order = self.focus_order();
636        if let Some(pos) = order.iter().position(|f| *f == prev) {
637            self.focus = order[(pos + 1) % order.len()];
638        } else {
639            self.focus = order[0];
640        }
641    }
642
643    pub fn prev_focus(&mut self) {
644        let prev = self.focus;
645        if prev == ChartFocus::YList {
646            self.y_list_blur();
647        }
648        let order = self.focus_order();
649        if let Some(pos) = order.iter().position(|f| *f == prev) {
650            let next = if pos == 0 { order.len() - 1 } else { pos - 1 };
651            self.focus = order[next];
652        } else {
653            self.focus = order[0];
654        }
655    }
656
657    /// Toggle Y starts at 0 (when focus is YStartsAtZero).
658    pub fn toggle_y_starts_at_zero(&mut self) {
659        self.y_starts_at_zero = !self.y_starts_at_zero;
660    }
661
662    /// Toggle log scale (when focus is LogScale).
663    pub fn toggle_log_scale(&mut self) {
664        self.log_scale = !self.log_scale;
665    }
666
667    /// Toggle show legend (when focus is ShowLegend).
668    pub fn toggle_show_legend(&mut self) {
669        self.show_legend = !self.show_legend;
670    }
671
672    /// Cycle chart type: Line -> Scatter -> Bar -> Line.
673    pub fn next_chart_type(&mut self) {
674        self.chart_type = match self.chart_type {
675            ChartType::Line => ChartType::Scatter,
676            ChartType::Scatter => ChartType::Bar,
677            ChartType::Bar => ChartType::Line,
678        };
679    }
680
681    pub fn prev_chart_type(&mut self) {
682        self.chart_type = match self.chart_type {
683            ChartType::Line => ChartType::Bar,
684            ChartType::Scatter => ChartType::Line,
685            ChartType::Bar => ChartType::Scatter,
686        };
687    }
688
689    pub fn next_chart_kind(&mut self) {
690        let idx = ChartKind::ALL
691            .iter()
692            .position(|&k| k == self.chart_kind)
693            .unwrap_or(0);
694        self.chart_kind = ChartKind::ALL[(idx + 1) % ChartKind::ALL.len()];
695        self.focus = ChartFocus::TabBar;
696    }
697
698    pub fn prev_chart_kind(&mut self) {
699        let idx = ChartKind::ALL
700            .iter()
701            .position(|&k| k == self.chart_kind)
702            .unwrap_or(0);
703        let prev = if idx == 0 {
704            ChartKind::ALL.len() - 1
705        } else {
706            idx - 1
707        };
708        self.chart_kind = ChartKind::ALL[prev];
709        self.focus = ChartFocus::TabBar;
710    }
711
712    /// Effective row limit to pass to prepare_* (unlimited = CHART_ROW_LIMIT_MAX).
713    pub fn effective_row_limit(&self) -> usize {
714        self.row_limit.unwrap_or(CHART_ROW_LIMIT_MAX)
715    }
716
717    /// Display string for Limit Rows: "Unlimited" or number with commas.
718    pub fn row_limit_display(&self) -> String {
719        match self.row_limit {
720            None => "Unlimited".to_string(),
721            Some(n) => format_usize_with_commas(n),
722        }
723    }
724
725    pub fn adjust_hist_bins(&mut self, delta: i32) {
726        let next = (self.hist_bins as i32 + delta)
727            .clamp(HISTOGRAM_MIN_BINS as i32, HISTOGRAM_MAX_BINS as i32);
728        self.hist_bins = next as usize;
729    }
730
731    pub fn adjust_heatmap_bins(&mut self, delta: i32) {
732        let next = (self.heatmap_bins as i32 + delta)
733            .clamp(HEATMAP_MIN_BINS as i32, HEATMAP_MAX_BINS as i32);
734        self.heatmap_bins = next as usize;
735    }
736
737    pub fn adjust_kde_bandwidth_factor(&mut self, delta: f64) {
738        let next = (self.kde_bandwidth_factor + delta).clamp(KDE_BANDWIDTH_MIN, KDE_BANDWIDTH_MAX);
739        self.kde_bandwidth_factor = (next * 10.0).round() / 10.0;
740    }
741
742    /// Adjust row limit by delta (+/-). Step size depends on current value. None = unlimited.
743    pub fn adjust_row_limit(&mut self, delta: i32) {
744        let current = match self.row_limit {
745            None if delta > 0 => {
746                self.row_limit = Some(DEFAULT_CHART_ROW_LIMIT);
747                return;
748            }
749            None => return,
750            Some(n) => n,
751        };
752        let step = if current < CHART_ROW_LIMIT_STEP_THRESHOLD {
753            CHART_ROW_LIMIT_STEP_SMALL as usize
754        } else {
755            CHART_ROW_LIMIT_STEP_LARGE as usize
756        };
757        let next = match delta.cmp(&0) {
758            std::cmp::Ordering::Greater => current.saturating_add(step).min(CHART_ROW_LIMIT_MAX),
759            std::cmp::Ordering::Less => current.saturating_sub(step).max(CHART_ROW_LIMIT_MIN),
760            std::cmp::Ordering::Equal => current,
761        };
762        self.row_limit = if next == 0 { None } else { Some(next) };
763    }
764
765    /// Adjust row limit by 10,000 (PgUp / PgDown). None = unlimited.
766    pub fn adjust_row_limit_page(&mut self, delta: i32) {
767        let current = match self.row_limit {
768            None if delta > 0 => {
769                self.row_limit = Some(DEFAULT_CHART_ROW_LIMIT);
770                return;
771            }
772            None => return,
773            Some(n) => n,
774        };
775        let step = CHART_ROW_LIMIT_PAGE_STEP;
776        let next = match delta.cmp(&0) {
777            std::cmp::Ordering::Greater => current.saturating_add(step).min(CHART_ROW_LIMIT_MAX),
778            std::cmp::Ordering::Less => current.saturating_sub(step).max(CHART_ROW_LIMIT_MIN),
779            std::cmp::Ordering::Equal => current,
780        };
781        self.row_limit = if next == 0 { None } else { Some(next) };
782    }
783
784    /// Move x-axis list highlight down (does not change remembered x; use spacebar to remember).
785    pub fn x_list_down(&mut self) {
786        let display = self.x_display_list();
787        let len = display.len();
788        if len == 0 {
789            return;
790        }
791        let i = self
792            .x_list_state
793            .selected()
794            .unwrap_or(0)
795            .saturating_add(1)
796            .min(len.saturating_sub(1));
797        self.x_list_state.select(Some(i));
798    }
799
800    /// Move x-axis list highlight up.
801    pub fn x_list_up(&mut self) {
802        let display = self.x_display_list();
803        let len = display.len();
804        if len == 0 {
805            return;
806        }
807        let i = self.x_list_state.selected().unwrap_or(0).saturating_sub(1);
808        self.x_list_state.select(Some(i));
809    }
810
811    /// Toggle x selection with spacebar: set remembered x to the highlighted row (single selection).
812    pub fn x_list_toggle(&mut self) {
813        let display = self.x_display_list();
814        if let Some(i) = self.x_list_state.selected() {
815            if i < display.len() {
816                self.x_column = Some(display[i].clone());
817            }
818        }
819    }
820
821    /// Move y-axis list highlight down (does not change remembered y; use spacebar to toggle).
822    pub fn y_list_down(&mut self) {
823        let display = self.y_display_list();
824        let len = display.len();
825        if len == 0 {
826            return;
827        }
828        let i = self
829            .y_list_state
830            .selected()
831            .unwrap_or(0)
832            .saturating_add(1)
833            .min(len.saturating_sub(1));
834        self.y_list_state.select(Some(i));
835    }
836
837    /// Move y-axis list highlight up.
838    pub fn y_list_up(&mut self) {
839        let display = self.y_display_list();
840        let len = display.len();
841        if len == 0 {
842            return;
843        }
844        let i = self.y_list_state.selected().unwrap_or(0).saturating_sub(1);
845        self.y_list_state.select(Some(i));
846    }
847
848    /// Toggle y selection with spacebar: add highlighted to remembered (up to Y_SERIES_MAX) or remove if already remembered.
849    pub fn y_list_toggle(&mut self) {
850        let display = self.y_display_list();
851        let Some(i) = self.y_list_state.selected() else {
852            return;
853        };
854        if i >= display.len() {
855            return;
856        }
857        let name = display[i].clone();
858        if let Some(pos) = self.y_columns.iter().position(|c| c == &name) {
859            self.y_columns.remove(pos);
860        } else if self.y_columns.len() < Y_SERIES_MAX {
861            self.y_columns.push(name);
862        }
863    }
864
865    pub fn hist_list_down(&mut self) {
866        let display = self.hist_display_list();
867        let len = display.len();
868        if len == 0 {
869            return;
870        }
871        let i = self
872            .hist_list_state
873            .selected()
874            .unwrap_or(0)
875            .saturating_add(1)
876            .min(len.saturating_sub(1));
877        self.hist_list_state.select(Some(i));
878    }
879
880    pub fn hist_list_up(&mut self) {
881        let display = self.hist_display_list();
882        if display.is_empty() {
883            return;
884        }
885        let i = self
886            .hist_list_state
887            .selected()
888            .unwrap_or(0)
889            .saturating_sub(1);
890        self.hist_list_state.select(Some(i));
891    }
892
893    pub fn hist_list_toggle(&mut self) {
894        let display = self.hist_display_list();
895        if let Some(i) = self.hist_list_state.selected() {
896            if i < display.len() {
897                self.hist_column = Some(display[i].clone());
898            }
899        }
900    }
901
902    pub fn box_list_down(&mut self) {
903        let display = self.box_display_list();
904        let len = display.len();
905        if len == 0 {
906            return;
907        }
908        let i = self
909            .box_list_state
910            .selected()
911            .unwrap_or(0)
912            .saturating_add(1)
913            .min(len.saturating_sub(1));
914        self.box_list_state.select(Some(i));
915    }
916
917    pub fn box_list_up(&mut self) {
918        let display = self.box_display_list();
919        if display.is_empty() {
920            return;
921        }
922        let i = self
923            .box_list_state
924            .selected()
925            .unwrap_or(0)
926            .saturating_sub(1);
927        self.box_list_state.select(Some(i));
928    }
929
930    pub fn box_list_toggle(&mut self) {
931        let display = self.box_display_list();
932        if let Some(i) = self.box_list_state.selected() {
933            if i < display.len() {
934                self.box_column = Some(display[i].clone());
935            }
936        }
937    }
938
939    pub fn kde_list_down(&mut self) {
940        let display = self.kde_display_list();
941        let len = display.len();
942        if len == 0 {
943            return;
944        }
945        let i = self
946            .kde_list_state
947            .selected()
948            .unwrap_or(0)
949            .saturating_add(1)
950            .min(len.saturating_sub(1));
951        self.kde_list_state.select(Some(i));
952    }
953
954    pub fn kde_list_up(&mut self) {
955        let display = self.kde_display_list();
956        if display.is_empty() {
957            return;
958        }
959        let i = self
960            .kde_list_state
961            .selected()
962            .unwrap_or(0)
963            .saturating_sub(1);
964        self.kde_list_state.select(Some(i));
965    }
966
967    pub fn kde_list_toggle(&mut self) {
968        let display = self.kde_display_list();
969        if let Some(i) = self.kde_list_state.selected() {
970            if i < display.len() {
971                self.kde_column = Some(display[i].clone());
972            }
973        }
974    }
975
976    pub fn heatmap_x_list_down(&mut self) {
977        let display = self.heatmap_x_display_list();
978        let len = display.len();
979        if len == 0 {
980            return;
981        }
982        let i = self
983            .heatmap_x_list_state
984            .selected()
985            .unwrap_or(0)
986            .saturating_add(1)
987            .min(len.saturating_sub(1));
988        self.heatmap_x_list_state.select(Some(i));
989    }
990
991    pub fn heatmap_x_list_up(&mut self) {
992        let display = self.heatmap_x_display_list();
993        if display.is_empty() {
994            return;
995        }
996        let i = self
997            .heatmap_x_list_state
998            .selected()
999            .unwrap_or(0)
1000            .saturating_sub(1);
1001        self.heatmap_x_list_state.select(Some(i));
1002    }
1003
1004    pub fn heatmap_x_list_toggle(&mut self) {
1005        let display = self.heatmap_x_display_list();
1006        if let Some(i) = self.heatmap_x_list_state.selected() {
1007            if i < display.len() {
1008                self.heatmap_x_column = Some(display[i].clone());
1009            }
1010        }
1011    }
1012
1013    pub fn heatmap_y_list_down(&mut self) {
1014        let display = self.heatmap_y_display_list();
1015        let len = display.len();
1016        if len == 0 {
1017            return;
1018        }
1019        let i = self
1020            .heatmap_y_list_state
1021            .selected()
1022            .unwrap_or(0)
1023            .saturating_add(1)
1024            .min(len.saturating_sub(1));
1025        self.heatmap_y_list_state.select(Some(i));
1026    }
1027
1028    pub fn heatmap_y_list_up(&mut self) {
1029        let display = self.heatmap_y_display_list();
1030        if display.is_empty() {
1031            return;
1032        }
1033        let i = self
1034            .heatmap_y_list_state
1035            .selected()
1036            .unwrap_or(0)
1037            .saturating_sub(1);
1038        self.heatmap_y_list_state.select(Some(i));
1039    }
1040
1041    pub fn heatmap_y_list_toggle(&mut self) {
1042        let display = self.heatmap_y_display_list();
1043        if let Some(i) = self.heatmap_y_list_state.selected() {
1044            if i < display.len() {
1045                self.heatmap_y_column = Some(display[i].clone());
1046            }
1047        }
1048    }
1049
1050    pub fn is_text_input_focused(&self) -> bool {
1051        matches!(
1052            self.focus,
1053            ChartFocus::XInput
1054                | ChartFocus::YInput
1055                | ChartFocus::HistInput
1056                | ChartFocus::BoxInput
1057                | ChartFocus::KdeInput
1058                | ChartFocus::HeatmapXInput
1059                | ChartFocus::HeatmapYInput
1060        )
1061    }
1062
1063    pub fn can_export(&self) -> bool {
1064        match self.chart_kind {
1065            ChartKind::XY => {
1066                self.effective_x_column().is_some() && !self.effective_y_columns().is_empty()
1067            }
1068            ChartKind::Histogram => self.effective_hist_column().is_some(),
1069            ChartKind::BoxPlot => self.effective_box_column().is_some(),
1070            ChartKind::Kde => self.effective_kde_column().is_some(),
1071            ChartKind::Heatmap => {
1072                self.effective_heatmap_x_column().is_some()
1073                    && self.effective_heatmap_y_column().is_some()
1074            }
1075        }
1076    }
1077
1078    fn focus_order(&self) -> &'static [ChartFocus] {
1079        match self.chart_kind {
1080            ChartKind::XY => &[
1081                ChartFocus::TabBar,
1082                ChartFocus::ChartType,
1083                ChartFocus::XInput,
1084                ChartFocus::XList,
1085                ChartFocus::YInput,
1086                ChartFocus::YList,
1087                ChartFocus::YStartsAtZero,
1088                ChartFocus::LogScale,
1089                ChartFocus::ShowLegend,
1090                ChartFocus::LimitRows,
1091            ],
1092            ChartKind::Histogram => &[
1093                ChartFocus::TabBar,
1094                ChartFocus::HistInput,
1095                ChartFocus::HistList,
1096                ChartFocus::HistBins,
1097                ChartFocus::LimitRows,
1098            ],
1099            ChartKind::BoxPlot => &[
1100                ChartFocus::TabBar,
1101                ChartFocus::BoxInput,
1102                ChartFocus::BoxList,
1103                ChartFocus::LimitRows,
1104            ],
1105            ChartKind::Kde => &[
1106                ChartFocus::TabBar,
1107                ChartFocus::KdeInput,
1108                ChartFocus::KdeList,
1109                ChartFocus::KdeBandwidth,
1110                ChartFocus::LimitRows,
1111            ],
1112            ChartKind::Heatmap => &[
1113                ChartFocus::TabBar,
1114                ChartFocus::HeatmapXInput,
1115                ChartFocus::HeatmapXList,
1116                ChartFocus::HeatmapYInput,
1117                ChartFocus::HeatmapYList,
1118                ChartFocus::HeatmapBins,
1119                ChartFocus::LimitRows,
1120            ],
1121        }
1122    }
1123}
1124
1125#[cfg(test)]
1126mod tests {
1127    use super::{ChartFocus, ChartKind, ChartModal, ChartType, Y_SERIES_MAX};
1128
1129    #[test]
1130    fn open_no_default_columns() {
1131        let numeric = vec!["a".to_string(), "b".to_string(), "c".to_string()];
1132        let datetime = vec!["date".to_string()];
1133        let mut modal = ChartModal::new();
1134        modal.open(&numeric, &datetime, Some(10_000));
1135        assert!(modal.active);
1136        assert_eq!(modal.chart_kind, ChartKind::XY);
1137        assert_eq!(modal.chart_type, ChartType::Line);
1138        assert!(modal.x_column.is_none());
1139        assert!(modal.y_columns.is_empty());
1140        assert!(!modal.y_starts_at_zero);
1141        assert!(!modal.log_scale);
1142        assert!(modal.show_legend);
1143        assert_eq!(modal.focus, ChartFocus::TabBar);
1144        assert_eq!(modal.row_limit, Some(10_000));
1145    }
1146
1147    #[test]
1148    fn open_numeric_only_no_defaults() {
1149        let numeric = vec!["x".to_string(), "y".to_string()];
1150        let mut modal = ChartModal::new();
1151        modal.open(&numeric, &[], Some(10_000));
1152        assert!(modal.x_column.is_none());
1153        assert!(modal.y_columns.is_empty());
1154    }
1155
1156    #[test]
1157    fn toggles_persist() {
1158        let mut modal = ChartModal::new();
1159        modal.open(&["a".into(), "b".into()], &[], Some(10_000));
1160        assert!(!modal.y_starts_at_zero);
1161        modal.toggle_y_starts_at_zero();
1162        assert!(modal.y_starts_at_zero);
1163        modal.toggle_log_scale();
1164        assert!(modal.log_scale);
1165        modal.toggle_show_legend();
1166        assert!(!modal.show_legend);
1167    }
1168
1169    #[test]
1170    fn x_display_list_puts_remembered_first() {
1171        let mut modal = ChartModal::new();
1172        modal.open(&["a".into(), "b".into(), "c".into()], &[], Some(10_000));
1173        assert_eq!(modal.x_display_list(), vec!["a", "b", "c"]);
1174        modal.x_column = Some("c".to_string());
1175        assert_eq!(modal.x_display_list(), vec!["c", "a", "b"]);
1176    }
1177
1178    #[test]
1179    fn y_list_toggle_add_remove() {
1180        let mut modal = ChartModal::new();
1181        modal.open(&["a".into(), "b".into(), "c".into()], &[], Some(10_000));
1182        modal.y_list_state.select(Some(0)); // highlight "a"
1183        modal.y_list_toggle();
1184        assert_eq!(modal.y_columns, vec!["a"]);
1185        modal.y_list_toggle(); // toggle "a" off
1186        assert!(modal.y_columns.is_empty());
1187        modal.y_list_toggle(); // toggle "a" on again
1188        assert_eq!(modal.y_columns, vec!["a"]);
1189        modal.y_list_state.select(Some(1));
1190        modal.y_list_toggle();
1191        assert_eq!(modal.y_columns.len(), 2);
1192    }
1193
1194    #[test]
1195    fn y_series_max_cap() {
1196        let mut modal = ChartModal::new();
1197        let cols: Vec<String> = (0..10).map(|i| format!("col_{}", i)).collect();
1198        modal.open(&cols, &[], Some(10_000));
1199        for i in 0..Y_SERIES_MAX {
1200            modal.y_list_state.select(Some(i));
1201            modal.y_list_toggle();
1202        }
1203        assert_eq!(modal.y_columns.len(), Y_SERIES_MAX);
1204        modal.y_list_state.select(Some(Y_SERIES_MAX));
1205        modal.y_list_toggle(); // should not add
1206        assert_eq!(modal.y_columns.len(), Y_SERIES_MAX);
1207    }
1208}