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) {
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 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 pub fn toggle_y_starts_at_zero(&mut self) {
619 self.y_starts_at_zero = !self.y_starts_at_zero;
620 }
621
622 pub fn toggle_log_scale(&mut self) {
624 self.log_scale = !self.log_scale;
625 }
626
627 pub fn toggle_show_legend(&mut self) {
629 self.show_legend = !self.show_legend;
630 }
631
632 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 pub fn effective_row_limit(&self) -> usize {
674 self.row_limit.unwrap_or(CHART_ROW_LIMIT_MAX)
675 }
676
677 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 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 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 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 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 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 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 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 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)); modal.y_list_toggle();
1144 assert_eq!(modal.y_columns, vec!["a"]);
1145 modal.y_list_toggle(); assert!(modal.y_columns.is_empty());
1147 modal.y_list_toggle(); 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(); assert_eq!(modal.y_columns.len(), Y_SERIES_MAX);
1167 }
1168}