1use ratatui::widgets::ListState;
4
5use crate::widgets::text_input::TextInput;
6
7#[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#[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#[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 LimitRows,
87}
88
89pub const Y_SERIES_MAX: usize = 7;
91
92pub const HISTOGRAM_DEFAULT_BINS: usize = 20;
94pub const HISTOGRAM_MIN_BINS: usize = 5;
95pub const HISTOGRAM_MAX_BINS: usize = 80;
96
97pub const HEATMAP_DEFAULT_BINS: usize = 20;
99pub const HEATMAP_MIN_BINS: usize = 5;
100pub const HEATMAP_MAX_BINS: usize = 60;
101
102pub const KDE_BANDWIDTH_MIN: f64 = 0.2;
104pub const KDE_BANDWIDTH_MAX: f64 = 5.0;
105pub const KDE_BANDWIDTH_STEP: f64 = 0.1;
106
107pub const CHART_ROW_LIMIT_MIN: usize = 0;
109pub const CHART_ROW_LIMIT_MAX: usize = u32::MAX as usize;
111pub const CHART_ROW_LIMIT_PAGE_STEP: usize = 100_000;
113pub const DEFAULT_CHART_ROW_LIMIT: usize = 10_000;
115pub 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#[derive(Default)]
138pub struct ChartModal {
139 pub active: bool,
140 pub chart_kind: ChartKind,
141 pub chart_type: ChartType,
142 pub x_column: Option<String>,
144 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 pub x_input: TextInput,
152 pub y_input: TextInput,
154 pub x_list_state: ListState,
156 pub y_list_state: ListState,
158 pub x_candidates: Vec<String>,
160 pub y_candidates: Vec<String>,
162 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 pub box_column: Option<String>,
170 pub box_input: TextInput,
171 pub box_list_state: ListState,
172 pub box_candidates: Vec<String>,
173 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 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 pub row_limit: Option<usize>,
191}
192
193impl ChartModal {
194 pub fn new() -> Self {
195 Self::default()
196 }
197
198 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 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 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 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 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 pub fn x_display_list(&self) -> Vec<String> {
323 Self::display_list_with_selected(self.x_filtered(), &self.x_column)
324 }
325
326 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 pub fn effective_x_column(&self) -> Option<&String> {
440 self.x_column.as_ref()
441 }
442
443 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 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 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 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 pub fn toggle_y_starts_at_zero(&mut self) {
659 self.y_starts_at_zero = !self.y_starts_at_zero;
660 }
661
662 pub fn toggle_log_scale(&mut self) {
664 self.log_scale = !self.log_scale;
665 }
666
667 pub fn toggle_show_legend(&mut self) {
669 self.show_legend = !self.show_legend;
670 }
671
672 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 pub fn effective_row_limit(&self) -> usize {
714 self.row_limit.unwrap_or(CHART_ROW_LIMIT_MAX)
715 }
716
717 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 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 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 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 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 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 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 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 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)); modal.y_list_toggle();
1184 assert_eq!(modal.y_columns, vec!["a"]);
1185 modal.y_list_toggle(); assert!(modal.y_columns.is_empty());
1187 modal.y_list_toggle(); 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(); assert_eq!(modal.y_columns.len(), Y_SERIES_MAX);
1207 }
1208}