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