1use crate::widgets::text_input::TextInput;
6use polars::datatypes::DataType;
7use ratatui::widgets::TableState;
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10
11#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
12pub enum PivotMeltTab {
13 #[default]
14 Pivot,
15 Melt,
16}
17
18#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
20pub enum PivotMeltFocus {
21 #[default]
22 TabBar,
23 PivotFilter,
25 PivotIndexList,
26 PivotPivotCol,
27 PivotValueCol,
28 PivotAggregation,
29 MeltFilter,
31 MeltIndexList,
32 MeltStrategy,
33 MeltPattern,
34 MeltType,
35 MeltExplicitList,
36 MeltVarName,
37 MeltValName,
38 Apply,
40 Cancel,
41 Clear,
42}
43
44#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
46pub enum MeltValueStrategy {
47 #[default]
48 AllExceptIndex,
49 ByPattern,
50 ByType,
51 ExplicitList,
52}
53
54impl MeltValueStrategy {
55 pub fn as_str(self) -> &'static str {
56 match self {
57 Self::AllExceptIndex => "All except index",
58 Self::ByPattern => "By pattern",
59 Self::ByType => "By type",
60 Self::ExplicitList => "Explicit list",
61 }
62 }
63}
64
65#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
67pub enum MeltTypeFilter {
68 #[default]
69 Numeric,
70 String,
71 Datetime,
72 Boolean,
73}
74
75impl MeltTypeFilter {
76 pub fn as_str(self) -> &'static str {
77 match self {
78 Self::Numeric => "Numeric",
79 Self::String => "String",
80 Self::Datetime => "Datetime",
81 Self::Boolean => "Boolean",
82 }
83 }
84}
85
86#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
88#[serde(rename_all = "lowercase")]
89pub enum PivotAggregation {
90 #[default]
91 Last,
92 First,
93 Min,
94 Max,
95 Avg,
96 Med,
97 Std,
98 Count,
99}
100
101impl PivotAggregation {
102 pub const ALL: [Self; 8] = [
103 Self::Last,
104 Self::First,
105 Self::Min,
106 Self::Max,
107 Self::Avg,
108 Self::Med,
109 Self::Std,
110 Self::Count,
111 ];
112
113 pub const STRING_ONLY: [Self; 2] = [Self::First, Self::Last];
114
115 pub fn as_str(self) -> &'static str {
116 match self {
117 Self::Last => "last",
118 Self::First => "first",
119 Self::Min => "min",
120 Self::Max => "max",
121 Self::Avg => "avg",
122 Self::Med => "med",
123 Self::Std => "std",
124 Self::Count => "count",
125 }
126 }
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct PivotSpec {
132 pub index: Vec<String>,
133 pub pivot_column: String,
134 pub value_column: String,
135 pub aggregation: PivotAggregation,
136 #[serde(default)]
138 #[serde(skip_serializing)]
139 #[allow(dead_code)]
140 pub sort_columns: Option<bool>,
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct MeltSpec {
146 pub index: Vec<String>,
147 pub value_columns: Vec<String>,
148 pub variable_name: String,
149 pub value_name: String,
150}
151
152pub struct PivotMeltModal {
153 pub active: bool,
154 pub active_tab: PivotMeltTab,
155 pub focus: PivotMeltFocus,
156
157 pub available_columns: Vec<String>,
159 pub column_dtypes: HashMap<String, DataType>,
161
162 pub pivot_filter_input: TextInput,
164 pub pivot_index_table: TableState,
165 pub index_columns: Vec<String>,
166 pub pivot_column: Option<String>,
167 pub pivot_pool_idx: usize,
168 pub pivot_pool_table: TableState,
169 pub value_column: Option<String>,
170 pub value_pool_idx: usize,
171 pub value_pool_table: TableState,
172 pub aggregation_idx: usize,
173
174 pub melt_filter_input: TextInput,
176 pub melt_index_table: TableState,
177 pub melt_index_columns: Vec<String>,
178 pub melt_value_strategy: MeltValueStrategy,
179 pub melt_pattern: String,
180 pub melt_pattern_cursor: usize,
181 pub melt_type_filter: MeltTypeFilter,
182 pub melt_explicit_list: Vec<String>,
183 pub melt_explicit_table: TableState,
184 pub melt_variable_name: String,
185 pub melt_variable_cursor: usize,
186 pub melt_value_name: String,
187 pub melt_value_cursor: usize,
188}
189
190impl Default for PivotMeltModal {
191 fn default() -> Self {
192 Self {
193 active: false,
194 active_tab: PivotMeltTab::default(),
195 focus: PivotMeltFocus::default(),
196 available_columns: Vec::new(),
197 column_dtypes: HashMap::new(),
198 pivot_filter_input: TextInput::new(),
199 pivot_index_table: TableState::default(),
200 index_columns: Vec::new(),
201 pivot_column: None,
202 pivot_pool_idx: 0,
203 pivot_pool_table: TableState::default(),
204 value_column: None,
205 value_pool_idx: 0,
206 value_pool_table: TableState::default(),
207 aggregation_idx: 0,
208 melt_filter_input: TextInput::new(),
209 melt_index_table: TableState::default(),
210 melt_index_columns: Vec::new(),
211 melt_value_strategy: MeltValueStrategy::default(),
212 melt_pattern: String::new(),
213 melt_pattern_cursor: 0,
214 melt_type_filter: MeltTypeFilter::default(),
215 melt_explicit_list: Vec::new(),
216 melt_explicit_table: TableState::default(),
217 melt_variable_name: "variable".to_string(),
218 melt_variable_cursor: 0,
219 melt_value_name: "value".to_string(),
220 melt_value_cursor: 0,
221 }
222 }
223}
224
225impl PivotMeltModal {
226 pub fn new() -> Self {
227 Self::default()
228 }
229
230 pub fn open(&mut self, history_limit: usize, theme: &crate::config::Theme) {
231 self.active = true;
232 self.active_tab = PivotMeltTab::Pivot;
233 self.focus = PivotMeltFocus::TabBar;
234 self.pivot_filter_input = TextInput::new()
235 .with_history_limit(history_limit)
236 .with_theme(theme);
237 self.melt_filter_input = TextInput::new()
238 .with_history_limit(history_limit)
239 .with_theme(theme);
240 self.reset_form();
241 }
242
243 pub fn close(&mut self) {
244 self.active = false;
245 }
246
247 pub fn reset_form(&mut self) {
248 self.pivot_filter_input.clear();
249 self.pivot_index_table
250 .select(if self.available_columns.is_empty() {
251 None
252 } else {
253 Some(0)
254 });
255 self.index_columns.clear();
256 self.pivot_column = None;
257 self.pivot_pool_idx = 0;
258 self.value_column = None;
259 self.value_pool_idx = 0;
260 let pool = self.pivot_pool();
261 if !pool.is_empty() {
262 self.pivot_column = pool.first().cloned();
263 self.pivot_pool_table.select(Some(0));
264 } else {
265 self.pivot_pool_table.select(None);
266 }
267 let vpool = self.pivot_value_pool();
268 if !vpool.is_empty() {
269 self.value_column = vpool.first().cloned();
270 self.value_pool_idx = 0;
271 self.value_pool_table.select(Some(0));
272 } else {
273 self.value_pool_table.select(None);
274 }
275 self.aggregation_idx = 0;
276 self.melt_filter_input.clear();
277 self.melt_index_table
278 .select(if self.available_columns.is_empty() {
279 None
280 } else {
281 Some(0)
282 });
283 self.melt_index_columns.clear();
284 self.melt_value_strategy = MeltValueStrategy::default();
285 self.melt_pattern.clear();
286 self.melt_pattern_cursor = 0;
287 self.melt_type_filter = MeltTypeFilter::default();
288 self.melt_explicit_list.clear();
289 self.melt_explicit_table.select(None);
290 self.melt_variable_name = "variable".to_string();
291 self.melt_variable_cursor = 0;
292 self.melt_value_name = "value".to_string();
293 self.melt_value_cursor = 0;
294 self.focus = PivotMeltFocus::TabBar;
295 }
296
297 fn pivot_focus_order() -> &'static [PivotMeltFocus] {
298 &[
299 PivotMeltFocus::PivotFilter,
300 PivotMeltFocus::PivotIndexList,
301 PivotMeltFocus::PivotPivotCol,
302 PivotMeltFocus::PivotValueCol,
303 PivotMeltFocus::PivotAggregation,
304 PivotMeltFocus::Apply,
305 PivotMeltFocus::Cancel,
306 PivotMeltFocus::Clear,
307 ]
308 }
309
310 fn melt_focus_order() -> &'static [PivotMeltFocus] {
311 &[
312 PivotMeltFocus::MeltFilter,
313 PivotMeltFocus::MeltIndexList,
314 PivotMeltFocus::MeltStrategy,
315 PivotMeltFocus::MeltPattern,
316 PivotMeltFocus::MeltType,
317 PivotMeltFocus::MeltExplicitList,
318 PivotMeltFocus::MeltVarName,
319 PivotMeltFocus::MeltValName,
320 PivotMeltFocus::Apply,
321 PivotMeltFocus::Cancel,
322 PivotMeltFocus::Clear,
323 ]
324 }
325
326 pub fn next_focus(&mut self) {
327 match self.focus {
328 PivotMeltFocus::TabBar => {
329 self.focus = match self.active_tab {
330 PivotMeltTab::Pivot => PivotMeltFocus::PivotFilter,
331 PivotMeltTab::Melt => PivotMeltFocus::MeltFilter,
332 };
333 }
334 f => {
335 let order = match self.active_tab {
336 PivotMeltTab::Pivot => Self::pivot_focus_order(),
337 PivotMeltTab::Melt => Self::melt_focus_order(),
338 };
339 if let Some(pos) = order.iter().position(|&x| x == f) {
340 if pos + 1 < order.len() {
341 self.focus = order[pos + 1];
342 } else {
343 self.focus = PivotMeltFocus::TabBar;
344 }
345 } else {
346 self.focus = PivotMeltFocus::TabBar;
347 }
348 }
349 }
350 }
351
352 pub fn prev_focus(&mut self) {
353 match self.focus {
354 PivotMeltFocus::TabBar => {
355 let order = match self.active_tab {
356 PivotMeltTab::Pivot => Self::pivot_focus_order(),
357 PivotMeltTab::Melt => Self::melt_focus_order(),
358 };
359 self.focus = order[order.len() - 1];
360 }
361 f => {
362 let order = match self.active_tab {
363 PivotMeltTab::Pivot => Self::pivot_focus_order(),
364 PivotMeltTab::Melt => Self::melt_focus_order(),
365 };
366 if let Some(pos) = order.iter().position(|&x| x == f) {
367 if pos > 0 {
368 self.focus = order[pos - 1];
369 } else {
370 self.focus = PivotMeltFocus::TabBar;
371 }
372 } else {
373 self.focus = PivotMeltFocus::TabBar;
374 }
375 }
376 }
377 }
378
379 pub fn switch_tab(&mut self) {
380 self.active_tab = match self.active_tab {
381 PivotMeltTab::Pivot => PivotMeltTab::Melt,
382 PivotMeltTab::Melt => PivotMeltTab::Pivot,
383 };
384 self.focus = PivotMeltFocus::TabBar;
385 }
386
387 pub fn pivot_filtered_columns(&self) -> Vec<String> {
390 let filter_lower = self.pivot_filter_input.value.to_lowercase();
391 self.available_columns
392 .iter()
393 .filter(|c| c.to_lowercase().contains(&filter_lower))
394 .cloned()
395 .collect()
396 }
397
398 pub fn pivot_pool(&self) -> Vec<String> {
399 let idx_set: std::collections::HashSet<_> = self.index_columns.iter().collect();
400 self.pivot_filtered_columns()
401 .into_iter()
402 .filter(|c| !idx_set.contains(c))
403 .collect()
404 }
405
406 pub fn pivot_value_pool(&self) -> Vec<String> {
407 let idx_set: std::collections::HashSet<_> = self.index_columns.iter().collect();
408 let pivot = self.pivot_column.as_deref();
409 self.pivot_filtered_columns()
410 .into_iter()
411 .filter(|c| !idx_set.contains(c) && pivot != Some(c.as_str()))
412 .collect()
413 }
414
415 pub fn pivot_aggregation_options(&self) -> Vec<PivotAggregation> {
418 PivotAggregation::ALL.to_vec()
419 }
420
421 pub fn pivot_aggregation(&self) -> PivotAggregation {
422 let opts = self.pivot_aggregation_options();
423 if opts.is_empty() {
424 return PivotAggregation::Last;
425 }
426 let i = self.aggregation_idx.min(opts.len().saturating_sub(1));
427 opts[i]
428 }
429
430 pub fn pivot_validation_error(&self) -> Option<String> {
431 if self.index_columns.is_empty() {
432 return Some("Select at least one index column.".to_string());
433 }
434 let pivot = match &self.pivot_column {
435 Some(s) => s,
436 None => return Some("Select a pivot column.".to_string()),
437 };
438 if self.index_columns.contains(pivot) {
439 return Some("Pivot column must not be in index.".to_string());
440 }
441 let value = match &self.value_column {
442 Some(s) => s,
443 None => return Some("Select a value column.".to_string()),
444 };
445 if self.index_columns.contains(value) || pivot == value {
446 return Some("Value column must not be in index or equal to pivot.".to_string());
447 }
448 let pool = self.pivot_value_pool();
449 if !pool.contains(value) {
450 return Some("Value column not in available columns.".to_string());
451 }
452 None
453 }
454
455 pub fn build_pivot_spec(&self) -> Option<PivotSpec> {
456 if self.pivot_validation_error().is_some() {
457 return None;
458 }
459 let pivot = self.pivot_column.clone()?;
460 let value = self.value_column.clone()?;
461 Some(PivotSpec {
462 index: self.index_columns.clone(),
463 pivot_column: pivot,
464 value_column: value,
465 aggregation: self.pivot_aggregation(),
466 sort_columns: None,
467 })
468 }
469
470 pub fn pivot_toggle_index_at_selection(&mut self) {
471 let filtered = self.pivot_filtered_columns();
472 let i = match self.pivot_index_table.selected() {
473 Some(i) if i < filtered.len() => i,
474 _ => return,
475 };
476 let col = filtered[i].clone();
477 if let Some(pos) = self.index_columns.iter().position(|c| c == &col) {
478 self.index_columns.remove(pos);
479 } else {
480 self.index_columns.push(col);
481 }
482 self.pivot_fix_pivot_and_value_after_index_change();
483 }
484
485 fn pivot_fix_pivot_and_value_after_index_change(&mut self) {
486 let pool = self.pivot_pool();
487 let in_index = |s: &str| self.index_columns.iter().any(|c| c.as_str() == s);
488 let pivot_valid = self
489 .pivot_column
490 .as_deref()
491 .map(|p| !in_index(p) && pool.iter().any(|c| c.as_str() == p))
492 .unwrap_or(false);
493 if !pivot_valid {
494 if pool.is_empty() {
495 self.pivot_column = None;
496 self.pivot_pool_idx = 0;
497 self.pivot_pool_table.select(None);
498 } else {
499 self.pivot_column = pool.first().cloned();
500 self.pivot_pool_idx = 0;
501 self.pivot_pool_table.select(Some(0));
502 }
503 }
504 self.pivot_fix_value_after_pivot_change();
505 }
506
507 pub fn pivot_move_index_selection(&mut self, down: bool) {
508 let filtered = self.pivot_filtered_columns();
509 let n = filtered.len();
510 if n == 0 {
511 return;
512 }
513 let i = self.pivot_index_table.selected().unwrap_or(0);
514 let next = if down {
515 (i + 1).min(n.saturating_sub(1))
516 } else {
517 i.saturating_sub(1)
518 };
519 self.pivot_index_table.select(Some(next));
520 }
521
522 pub fn pivot_move_pivot_selection(&mut self, down: bool) {
523 let pool = self.pivot_pool();
524 let n = pool.len();
525 if n == 0 {
526 return;
527 }
528 let i = self.pivot_pool_idx;
529 self.pivot_pool_idx = if down {
530 (i + 1).min(n - 1)
531 } else {
532 i.saturating_sub(1)
533 };
534 self.pivot_column = pool.get(self.pivot_pool_idx).cloned();
535 self.pivot_pool_table.select(Some(self.pivot_pool_idx));
536 self.pivot_fix_value_after_pivot_change();
537 }
538
539 fn pivot_fix_value_after_pivot_change(&mut self) {
540 let vpool = self.pivot_value_pool();
541 if vpool.is_empty() {
542 self.value_column = None;
543 self.value_pool_idx = 0;
544 self.value_pool_table.select(None);
545 return;
546 }
547 let pivot = self.pivot_column.as_deref();
548 let valid = self
549 .value_column
550 .as_deref()
551 .map(|v| pivot != Some(v) && vpool.iter().any(|c| c.as_str() == v))
552 .unwrap_or(false);
553 if !valid {
554 self.value_column = vpool.first().cloned();
555 self.value_pool_idx = 0;
556 self.value_pool_table.select(Some(0));
557 if self.value_column.is_some() {
558 let opts = self.pivot_aggregation_options();
559 if !opts.is_empty() && self.aggregation_idx >= opts.len() {
560 self.aggregation_idx = opts.len() - 1;
561 }
562 }
563 }
564 }
565
566 pub fn pivot_move_value_selection(&mut self, down: bool) {
567 let pool = self.pivot_value_pool();
568 let n = pool.len();
569 if n == 0 {
570 return;
571 }
572 let i = self.value_pool_idx;
573 self.value_pool_idx = if down {
574 (i + 1).min(n - 1)
575 } else {
576 i.saturating_sub(1)
577 };
578 self.value_column = pool.get(self.value_pool_idx).cloned();
579 self.value_pool_table.select(Some(self.value_pool_idx));
580 if self.value_column.is_some() {
581 let opts = self.pivot_aggregation_options();
582 if !opts.is_empty() && self.aggregation_idx >= opts.len() {
583 self.aggregation_idx = opts.len() - 1;
584 }
585 }
586 }
587
588 pub fn pivot_move_aggregation_step(&mut self, columns: usize, row_delta: i32, col_delta: i32) {
591 let opts = self.pivot_aggregation_options();
592 let n = opts.len();
593 if n == 0 || columns == 0 {
594 return;
595 }
596 let cols = columns.min(n) as i32;
597 let row = (self.aggregation_idx as i32) / cols;
598 let col = (self.aggregation_idx as i32) % cols;
599 let mut new_row = (row + row_delta).max(0);
600 let mut new_col = (col + col_delta).max(0);
601 let max_row = (n as i32 - 1) / cols;
602 let max_col_in_last = (n as i32 - 1) % cols;
603 if new_row > max_row {
604 new_row = max_row;
605 }
606 if new_row == max_row && new_col > max_col_in_last {
607 new_col = max_col_in_last;
608 } else if new_col >= cols {
609 new_col = cols - 1;
610 }
611 let new_idx = (new_row * cols + new_col).min((n as i32) - 1).max(0) as usize;
612 self.aggregation_idx = new_idx.min(n.saturating_sub(1));
613 }
614
615 pub fn melt_filtered_columns(&self) -> Vec<String> {
618 let filter_lower = self.melt_filter_input.value.to_lowercase();
619 self.available_columns
620 .iter()
621 .filter(|c| c.to_lowercase().contains(&filter_lower))
622 .cloned()
623 .collect()
624 }
625
626 pub fn melt_index_pool(&self) -> Vec<String> {
627 self.melt_filtered_columns()
628 }
629
630 pub fn melt_value_pool(&self) -> Vec<String> {
631 let idx_set: std::collections::HashSet<_> = self.melt_index_columns.iter().collect();
632 self.available_columns
633 .iter()
634 .filter(|c| !idx_set.contains(*c))
635 .cloned()
636 .collect()
637 }
638
639 fn dtype_matches(&self, col: &str) -> bool {
640 let dtype = match self.column_dtypes.get(col) {
641 Some(d) => d,
642 None => return false,
643 };
644 match self.melt_type_filter {
645 MeltTypeFilter::Numeric => matches!(
646 dtype,
647 DataType::Int8
648 | DataType::Int16
649 | DataType::Int32
650 | DataType::Int64
651 | DataType::UInt8
652 | DataType::UInt16
653 | DataType::UInt32
654 | DataType::UInt64
655 | DataType::Float32
656 | DataType::Float64
657 ),
658 MeltTypeFilter::String => matches!(dtype, DataType::String),
659 MeltTypeFilter::Datetime => matches!(
660 dtype,
661 DataType::Datetime(_, _) | DataType::Date | DataType::Time
662 ),
663 MeltTypeFilter::Boolean => matches!(dtype, DataType::Boolean),
664 }
665 }
666
667 pub fn melt_resolve_value_columns(&self) -> Result<Vec<String>, String> {
668 let pool = self.melt_value_pool();
669 match self.melt_value_strategy {
670 MeltValueStrategy::AllExceptIndex => {
671 if pool.is_empty() {
672 return Err("No columns to melt (all columns are index).".to_string());
673 }
674 Ok(pool)
675 }
676 MeltValueStrategy::ByPattern => {
677 let re = regex::Regex::new(&self.melt_pattern)
678 .map_err(|e| format!("Invalid pattern: {}", e))?;
679 let matched: Vec<String> = pool.into_iter().filter(|c| re.is_match(c)).collect();
680 if matched.is_empty() {
681 return Err("Pattern matches no columns.".to_string());
682 }
683 Ok(matched)
684 }
685 MeltValueStrategy::ByType => {
686 let matched: Vec<String> = self
687 .melt_value_pool()
688 .into_iter()
689 .filter(|c| self.dtype_matches(c))
690 .collect();
691 if matched.is_empty() {
692 return Err("No columns of selected type.".to_string());
693 }
694 Ok(matched)
695 }
696 MeltValueStrategy::ExplicitList => {
697 if self.melt_explicit_list.is_empty() {
698 return Err("Select at least one value column.".to_string());
699 }
700 Ok(self.melt_explicit_list.clone())
701 }
702 }
703 }
704
705 pub fn melt_validation_error(&self) -> Option<String> {
706 if self.melt_index_columns.is_empty() {
707 return Some("Select at least one index column.".to_string());
708 }
709 let v = self.melt_variable_name.trim();
710 if v.is_empty() {
711 return Some("Variable name cannot be empty.".to_string());
712 }
713 if self.melt_index_columns.contains(&v.to_string()) {
714 return Some("Variable name must not equal an index column.".to_string());
715 }
716 let w = self.melt_value_name.trim();
717 if w.is_empty() {
718 return Some("Value name cannot be empty.".to_string());
719 }
720 if self.melt_index_columns.contains(&w.to_string()) {
721 return Some("Value name must not equal an index column.".to_string());
722 }
723 if v == w {
724 return Some("Variable and value names must differ.".to_string());
725 }
726 match self.melt_resolve_value_columns() {
727 Ok(cols) if cols.is_empty() => Some("No value columns selected.".to_string()),
728 Err(e) => Some(e),
729 Ok(_) => None,
730 }
731 }
732
733 pub fn build_melt_spec(&self) -> Option<MeltSpec> {
734 if self.melt_validation_error().is_some() {
735 return None;
736 }
737 let value_columns = self.melt_resolve_value_columns().ok()?;
738 Some(MeltSpec {
739 index: self.melt_index_columns.clone(),
740 value_columns,
741 variable_name: self.melt_variable_name.trim().to_string(),
742 value_name: self.melt_value_name.trim().to_string(),
743 })
744 }
745
746 pub fn melt_toggle_index_at_selection(&mut self) {
747 let filtered = self.melt_filtered_columns();
748 let i = match self.melt_index_table.selected() {
749 Some(i) if i < filtered.len() => i,
750 _ => return,
751 };
752 let col = filtered[i].clone();
753 if let Some(pos) = self.melt_index_columns.iter().position(|c| c == &col) {
754 self.melt_index_columns.remove(pos);
755 } else {
756 self.melt_index_columns.push(col);
757 }
758 self.melt_fix_explicit_after_index_change();
759 }
760
761 fn melt_fix_explicit_after_index_change(&mut self) {
762 let idx_set: std::collections::HashSet<_> =
763 self.melt_index_columns.iter().map(|s| s.as_str()).collect();
764 self.melt_explicit_list
765 .retain(|c| !idx_set.contains(c.as_str()));
766 if !self.melt_explicit_pool().is_empty() && self.melt_explicit_table.selected().is_none() {
767 self.melt_explicit_table.select(Some(0));
768 }
769 }
770
771 pub fn melt_move_index_selection(&mut self, down: bool) {
772 let filtered = self.melt_filtered_columns();
773 let n = filtered.len();
774 if n == 0 {
775 return;
776 }
777 let i = self.melt_index_table.selected().unwrap_or(0);
778 let next = if down {
779 (i + 1).min(n.saturating_sub(1))
780 } else {
781 i.saturating_sub(1)
782 };
783 self.melt_index_table.select(Some(next));
784 }
785
786 pub fn melt_move_strategy(&mut self, down: bool) {
787 use MeltValueStrategy::{AllExceptIndex, ByPattern, ByType, ExplicitList};
788 let strategies = [AllExceptIndex, ByPattern, ByType, ExplicitList];
789 let n = strategies.len();
790 let i = strategies
791 .iter()
792 .position(|s| *s == self.melt_value_strategy)
793 .unwrap_or(0);
794 let next = if down {
795 (i + 1) % n
796 } else if i == 0 {
797 n - 1
798 } else {
799 i - 1
800 };
801 self.melt_value_strategy = strategies[next];
802 }
803
804 pub fn melt_move_type_filter(&mut self, down: bool) {
805 use MeltTypeFilter::{Boolean, Datetime, Numeric, String as Str};
806 let types = [Numeric, Str, Datetime, Boolean];
807 let n = types.len();
808 let i = types
809 .iter()
810 .position(|t| *t == self.melt_type_filter)
811 .unwrap_or(0);
812 let next = if down {
813 (i + 1) % n
814 } else if i == 0 {
815 n - 1
816 } else {
817 i - 1
818 };
819 self.melt_type_filter = types[next];
820 }
821
822 pub fn melt_explicit_pool(&self) -> Vec<String> {
823 self.melt_value_pool()
824 }
825
826 pub fn melt_toggle_explicit_at_selection(&mut self) {
827 let pool = self.melt_explicit_pool();
828 let i = match self.melt_explicit_table.selected() {
829 Some(i) if i < pool.len() => i,
830 _ => return,
831 };
832 let col = pool[i].clone();
833 if let Some(pos) = self.melt_explicit_list.iter().position(|c| c == &col) {
834 self.melt_explicit_list.remove(pos);
835 } else {
836 self.melt_explicit_list.push(col);
837 }
838 }
839
840 pub fn melt_move_explicit_selection(&mut self, down: bool) {
841 let pool = self.melt_explicit_pool();
842 let n = pool.len();
843 if n == 0 {
844 return;
845 }
846 let i = self.melt_explicit_table.selected().unwrap_or(0);
847 let next = if down {
848 (i + 1).min(n.saturating_sub(1))
849 } else {
850 i.saturating_sub(1)
851 };
852 self.melt_explicit_table.select(Some(next));
853 }
854}
855
856#[cfg(test)]
857mod tests {
858 use super::*;
859
860 #[test]
861 fn test_pivot_melt_modal_new() {
862 let m = PivotMeltModal::new();
863 assert!(!m.active);
864 assert!(matches!(m.active_tab, PivotMeltTab::Pivot));
865 assert!(matches!(m.focus, PivotMeltFocus::TabBar));
866 }
867
868 #[test]
869 fn test_open_close() {
870 let mut m = PivotMeltModal::new();
871 let config = crate::config::AppConfig::default();
872 let theme = crate::config::Theme::from_config(&config.theme).unwrap();
873 m.open(1000, &theme);
874 assert!(m.active);
875 assert!(matches!(m.active_tab, PivotMeltTab::Pivot));
876 assert!(matches!(m.focus, PivotMeltFocus::TabBar));
877 m.close();
878 assert!(!m.active);
879 }
880
881 #[test]
882 fn test_switch_tab() {
883 let mut m = PivotMeltModal::new();
884 let config = crate::config::AppConfig::default();
885 let theme = crate::config::Theme::from_config(&config.theme).unwrap();
886 m.open(1000, &theme);
887 assert!(matches!(m.active_tab, PivotMeltTab::Pivot));
888 m.switch_tab();
889 assert!(matches!(m.active_tab, PivotMeltTab::Melt));
890 m.switch_tab();
891 assert!(matches!(m.active_tab, PivotMeltTab::Pivot));
892 }
893
894 #[test]
895 fn test_next_focus() {
896 let mut m = PivotMeltModal::new();
897 assert!(matches!(m.focus, PivotMeltFocus::TabBar));
898 m.next_focus();
899 assert!(matches!(m.focus, PivotMeltFocus::PivotFilter));
900 m.next_focus();
901 assert!(matches!(m.focus, PivotMeltFocus::PivotIndexList));
902 m.next_focus();
903 assert!(matches!(m.focus, PivotMeltFocus::PivotPivotCol));
904 m.next_focus();
905 assert!(matches!(m.focus, PivotMeltFocus::PivotValueCol));
906 m.next_focus();
907 assert!(matches!(m.focus, PivotMeltFocus::PivotAggregation));
908 m.next_focus();
909 assert!(matches!(m.focus, PivotMeltFocus::Apply));
910 m.next_focus();
911 assert!(matches!(m.focus, PivotMeltFocus::Cancel));
912 m.next_focus();
913 assert!(matches!(m.focus, PivotMeltFocus::Clear));
914 m.next_focus();
915 assert!(matches!(m.focus, PivotMeltFocus::TabBar));
916 }
917
918 #[test]
919 fn test_prev_focus() {
920 let mut m = PivotMeltModal::new();
921 assert!(matches!(m.focus, PivotMeltFocus::TabBar));
922 m.prev_focus();
923 assert!(matches!(m.focus, PivotMeltFocus::Clear));
924 m.prev_focus();
925 assert!(matches!(m.focus, PivotMeltFocus::Cancel));
926 m.prev_focus();
927 assert!(matches!(m.focus, PivotMeltFocus::Apply));
928 }
929}