Skip to main content

datui_lib/
pivot_melt_modal.rs

1//! Pivot / Melt modal state and focus.
2//!
3//! Phase 4: Pivot tab UI. Phase 5: Melt tab UI.
4
5use 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/// Focus: tab bar, tab-specific body controls, footer buttons.
19#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
20pub enum PivotMeltFocus {
21    #[default]
22    TabBar,
23    // Pivot tab
24    PivotFilter,
25    PivotIndexList,
26    PivotPivotCol,
27    PivotValueCol,
28    PivotAggregation,
29    PivotSortToggle,
30    // Melt tab
31    MeltFilter,
32    MeltIndexList,
33    MeltStrategy,
34    MeltPattern,
35    MeltType,
36    MeltExplicitList,
37    MeltVarName,
38    MeltValName,
39    // Footer
40    Apply,
41    Cancel,
42    Clear,
43}
44
45/// Melt value-column strategy.
46#[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/// Type filter for Melt "by type".
67#[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/// Aggregation for pivot value column.
88#[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/// Spec for pivot operation.
131#[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/// Spec for melt operation.
141#[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    /// Column names from current schema. Set when opening modal.
155    pub available_columns: Vec<String>,
156    /// Column name -> DataType. Set when opening.
157    pub column_dtypes: HashMap<String, DataType>,
158
159    // Pivot form
160    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    // Melt form
173    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    // ----- Pivot helpers -----
389
390    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    // ----- Melt helpers -----
614
615    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}