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    // Melt tab
30    MeltFilter,
31    MeltIndexList,
32    MeltStrategy,
33    MeltPattern,
34    MeltType,
35    MeltExplicitList,
36    MeltVarName,
37    MeltValName,
38    // Footer
39    Apply,
40    Cancel,
41    Clear,
42}
43
44/// Melt value-column strategy.
45#[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/// Type filter for Melt "by type".
66#[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/// Aggregation for pivot value column.
87#[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/// Spec for pivot operation.
130#[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    /// Deprecated: new columns are always sorted alphabetically. Kept for template deserialization.
137    #[serde(default)]
138    #[serde(skip_serializing)]
139    #[allow(dead_code)]
140    pub sort_columns: Option<bool>,
141}
142
143/// Spec for melt operation.
144#[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    /// Column names from current schema. Set when opening modal.
158    pub available_columns: Vec<String>,
159    /// Column name -> DataType. Set when opening.
160    pub column_dtypes: HashMap<String, DataType>,
161
162    // Pivot form
163    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    // Melt form
175    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    // ----- Pivot helpers -----
388
389    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    /// All aggregation options are shown. Polars will error on Apply if the chosen
416    /// aggregation does not apply to the value column type (e.g. mean on string).
417    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    /// Move aggregation selection by row/col in a grid with `columns` per row.
589    /// Used with arrow keys: Up (-1, 0), Down (1, 0), Left (0, -1), Right (0, 1).
590    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    // ----- Melt helpers -----
616
617    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}