Skip to main content

datui_lib/
pivot_melt_modal.rs

1//! Pivot & Melt builder state: a form of rows, each edited through one Picker
2//! scoped to that row alone, and the live preview of the spec it stages. The spec
3//! is echoed live; Enter applies it.
4
5use crate::filter_modal::FilterStatement;
6use crate::widgets::text_input::TextInput;
7use crate::widgets::ui::PickerState;
8use polars::datatypes::DataType;
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11
12#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
13pub enum PivotMeltTab {
14    #[default]
15    Pivot,
16    Melt,
17}
18
19/// Focus: the tab bar, or one row of the active tab's form.
20#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
21pub enum PivotMeltFocus {
22    #[default]
23    TabBar,
24    // Pivot tab
25    PivotIndex,
26    PivotColumn,
27    PivotValue,
28    PivotAggregation,
29    // Melt tab
30    MeltIndex,
31    MeltStrategy,
32    MeltPattern,
33    MeltType,
34    MeltColumns,
35    MeltVariable,
36    MeltValue,
37}
38
39/// Melt value-column strategy.
40#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
41pub enum MeltValueStrategy {
42    #[default]
43    AllExceptIndex,
44    ByPattern,
45    ByType,
46    ExplicitList,
47}
48
49impl MeltValueStrategy {
50    pub const ALL: [Self; 4] = [
51        Self::AllExceptIndex,
52        Self::ByPattern,
53        Self::ByType,
54        Self::ExplicitList,
55    ];
56
57    pub fn as_str(self) -> &'static str {
58        match self {
59            Self::AllExceptIndex => "All except index",
60            Self::ByPattern => "By pattern",
61            Self::ByType => "By type",
62            Self::ExplicitList => "Explicit list",
63        }
64    }
65}
66
67/// Type filter for Melt "by type".
68#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
69pub enum MeltTypeFilter {
70    #[default]
71    Numeric,
72    String,
73    Datetime,
74    Boolean,
75}
76
77impl MeltTypeFilter {
78    pub const ALL: [Self; 4] = [Self::Numeric, Self::String, Self::Datetime, Self::Boolean];
79
80    pub fn as_str(self) -> &'static str {
81        match self {
82            Self::Numeric => "Numeric",
83            Self::String => "String",
84            Self::Datetime => "Datetime",
85            Self::Boolean => "Boolean",
86        }
87    }
88}
89
90/// Aggregation for pivot value column.
91#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
92#[serde(rename_all = "lowercase")]
93pub enum PivotAggregation {
94    #[default]
95    Last,
96    First,
97    Min,
98    Max,
99    Avg,
100    Med,
101    Std,
102    Count,
103}
104
105impl PivotAggregation {
106    pub const ALL: [Self; 8] = [
107        Self::Last,
108        Self::First,
109        Self::Min,
110        Self::Max,
111        Self::Avg,
112        Self::Med,
113        Self::Std,
114        Self::Count,
115    ];
116
117    pub const STRING_ONLY: [Self; 2] = [Self::First, Self::Last];
118
119    pub fn as_str(self) -> &'static str {
120        match self {
121            Self::Last => "last",
122            Self::First => "first",
123            Self::Min => "min",
124            Self::Max => "max",
125            Self::Avg => "avg",
126            Self::Med => "med",
127            Self::Std => "std",
128            Self::Count => "count",
129        }
130    }
131}
132
133/// Spec for pivot operation.
134#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
135pub struct PivotSpec {
136    pub index: Vec<String>,
137    pub pivot_column: String,
138    pub value_column: String,
139    pub aggregation: PivotAggregation,
140    /// Deprecated: new columns are always sorted alphabetically. Kept for view deserialization.
141    #[serde(default)]
142    #[serde(skip_serializing)]
143    #[allow(dead_code)]
144    pub sort_columns: Option<bool>,
145}
146
147/// Spec for melt operation.
148#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
149pub struct MeltSpec {
150    pub index: Vec<String>,
151    pub value_columns: Vec<String>,
152    pub variable_name: String,
153    pub value_name: String,
154}
155
156/// What a pivot or melt ran over: the query, filters and sort in effect when it was
157/// applied. A view replays these before the reshape.
158#[derive(Debug, Clone, Default, Serialize, Deserialize)]
159pub struct ReshapeSource {
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    pub query: Option<String>,
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub sql_query: Option<String>,
164    #[serde(default, skip_serializing_if = "Option::is_none")]
165    pub fuzzy_query: Option<String>,
166    #[serde(default, skip_serializing_if = "Vec::is_empty")]
167    pub filters: Vec<FilterStatement>,
168    #[serde(default, skip_serializing_if = "Vec::is_empty")]
169    pub sort_columns: Vec<String>,
170    /// Per entry of `sort_columns`, whether it runs descending.
171    #[serde(default, skip_serializing_if = "Vec::is_empty")]
172    pub sort_descending: Vec<bool>,
173}
174
175impl ReshapeSource {
176    /// Nothing to replay: the reshape ran over the data as loaded.
177    pub fn is_empty(&self) -> bool {
178        self.query.is_none()
179            && self.sql_query.is_none()
180            && self.fuzzy_query.is_none()
181            && self.filters.is_empty()
182            && self.sort_columns.is_empty()
183    }
184
185    /// The per-column directions its sort runs, ascending where none was recorded.
186    pub fn sort_directions(&self) -> Vec<bool> {
187        if self.sort_descending.len() == self.sort_columns.len() {
188            self.sort_descending.clone()
189        } else {
190            vec![false; self.sort_columns.len()]
191        }
192    }
193}
194
195/// Rows of the view the preview reshapes. Small enough that every edit reruns it at
196/// once; the head is read once per opening of the builder.
197pub const PREVIEW_INPUT_ROWS: usize = 1_000;
198
199/// Rows of the result the preview keeps to draw.
200pub const PREVIEW_ROWS: usize = 50;
201
202/// New columns past which the preview warns that a pivot is wide.
203pub const PREVIEW_WIDE_PIVOT: usize = 100;
204
205/// A staged reshape the preview runs: what Enter would apply.
206#[derive(Debug, Clone, PartialEq)]
207pub enum PreviewSpec {
208    Pivot(PivotSpec),
209    Melt(MeltSpec),
210}
211
212/// The head of the view the preview runs over.
213#[derive(Debug, Clone)]
214pub struct PreviewInput {
215    pub rows: std::sync::Arc<polars::prelude::DataFrame>,
216    /// The head is all of the view: the preview's shape is the result's.
217    pub whole: bool,
218}
219
220/// What a preview found: the first rows of the result, and its shape.
221#[derive(Debug, Clone)]
222pub struct PreviewFrame {
223    pub head: polars::prelude::DataFrame,
224    pub rows: usize,
225    pub columns: usize,
226    /// A pivot's new columns, past its index.
227    pub new_columns: Option<usize>,
228    /// A melt's value columns: each input row becomes this many.
229    pub melted_columns: Option<usize>,
230}
231
232/// The builder's live preview: the head it runs over, the request in flight, and the
233/// last answer. Requests are numbered; one runs at a time, and an edit made while it
234/// runs is previewed when it ends, so typing never piles up workers.
235#[derive(Debug, Default)]
236pub struct ReshapePreview {
237    /// Which opening of the builder this is: an answer for an earlier one is dropped.
238    pub epoch: u64,
239    /// The newest request; an answer for an older one is not shown.
240    pub token: u64,
241    /// What the newest request is for; `None` while the spec is incomplete.
242    pub wanted: Option<PreviewSpec>,
243    /// The request whose worker is running.
244    pub running: Option<u64>,
245    pub input: Option<PreviewInput>,
246    /// The view is sorted: the head is read without its order.
247    pub sorted: bool,
248    /// The view's row count when it was known at opening.
249    pub view_rows: Option<usize>,
250    /// The answer for `shown.0`: the result, or why it failed.
251    pub shown: Option<(PreviewSpec, Result<PreviewFrame, String>)>,
252}
253
254impl ReshapePreview {
255    /// Whether the preview on screen is not for the spec staged now.
256    pub fn stale(&self) -> bool {
257        self.wanted.is_some() && self.shown.as_ref().map(|(spec, _)| spec) != self.wanted.as_ref()
258    }
259}
260
261/// A fresh epoch for each opening of the builder.
262fn next_epoch() -> u64 {
263    static EPOCH: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
264    EPOCH.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
265}
266
267/// `spec` run over `input`, in memory: the preview's worker.
268pub fn run_preview(
269    input: &polars::prelude::DataFrame,
270    spec: &PreviewSpec,
271) -> Result<PreviewFrame, String> {
272    use polars::prelude::IntoLazy;
273    let message = |e: color_eyre::Report| crate::error_display::user_message_from_report(&e, None);
274    let (result, new_columns, melted_columns) = match spec {
275        PreviewSpec::Pivot(spec) => {
276            let job =
277                crate::widgets::datatable::PivotJob::new(input.clone().lazy(), spec.clone(), false);
278            let df = job.run().map_err(message)?;
279            let new = df.width().saturating_sub(spec.index.len());
280            (df, Some(new), None)
281        }
282        PreviewSpec::Melt(spec) => {
283            let lf = crate::widgets::datatable::DataTableState::melt_lf(input.clone().lazy(), spec)
284                .map_err(message)?;
285            let df = lf
286                .collect()
287                .map_err(|e| crate::error_display::user_message_from_polars(&e))?;
288            (df, None, Some(spec.value_columns.len()))
289        }
290    };
291    Ok(PreviewFrame {
292        head: result.head(Some(PREVIEW_ROWS)),
293        rows: result.height(),
294        columns: result.width(),
295        new_columns,
296        melted_columns,
297    })
298}
299
300/// What a toggle Picker may change, kept from when it opened.
301#[derive(Debug, Clone, Default)]
302struct PickerBefore {
303    index_columns: Vec<String>,
304    melt_index_columns: Vec<String>,
305    melt_explicit_list: Vec<String>,
306    pivot_column: Option<String>,
307    value_column: Option<String>,
308}
309
310pub struct PivotMeltModal {
311    pub active: bool,
312    /// The live preview of the staged spec.
313    pub preview: ReshapePreview,
314    pub active_tab: PivotMeltTab,
315    pub focus: PivotMeltFocus,
316
317    /// Column names from current schema. Set when opening modal.
318    pub available_columns: Vec<String>,
319    /// Column name -> DataType. Set when opening.
320    pub column_dtypes: HashMap<String, DataType>,
321
322    /// The one Picker, open for the focused row; None while the form has the keys.
323    pub picker: Option<PickerState>,
324    /// The rows a toggle Picker changes, as they were when it opened: Esc puts them
325    /// back, Enter keeps the toggles.
326    picker_before: Option<PickerBefore>,
327
328    /// Set when Enter was pressed on an incomplete form: the spec line that
329    /// names the gap re-accents instead of a modal repeating it. Any other key
330    /// clears it.
331    pub attention: bool,
332
333    // Pivot form
334    pub index_columns: Vec<String>,
335    pub pivot_column: Option<String>,
336    pub value_column: Option<String>,
337    pub aggregation: PivotAggregation,
338
339    // Melt form
340    pub melt_index_columns: Vec<String>,
341    pub melt_value_strategy: MeltValueStrategy,
342    pub melt_pattern_input: TextInput,
343    pub melt_type_filter: MeltTypeFilter,
344    pub melt_explicit_list: Vec<String>,
345    pub melt_variable_input: TextInput,
346    pub melt_value_input: TextInput,
347}
348
349impl Default for PivotMeltModal {
350    fn default() -> Self {
351        let mut modal = Self {
352            active: false,
353            preview: ReshapePreview::default(),
354            active_tab: PivotMeltTab::default(),
355            focus: PivotMeltFocus::default(),
356            available_columns: Vec::new(),
357            column_dtypes: HashMap::new(),
358            picker: None,
359            picker_before: None,
360            attention: false,
361            index_columns: Vec::new(),
362            pivot_column: None,
363            value_column: None,
364            aggregation: PivotAggregation::default(),
365            melt_index_columns: Vec::new(),
366            melt_value_strategy: MeltValueStrategy::default(),
367            melt_pattern_input: TextInput::new(),
368            melt_type_filter: MeltTypeFilter::default(),
369            melt_explicit_list: Vec::new(),
370            melt_variable_input: TextInput::new(),
371            melt_value_input: TextInput::new(),
372        };
373        modal.melt_variable_input.suggest("variable");
374        modal.melt_value_input.suggest("value");
375        modal
376    }
377}
378
379impl PivotMeltModal {
380    pub fn new() -> Self {
381        Self::default()
382    }
383
384    pub fn open(&mut self, history_limit: usize, theme: &crate::config::Theme) {
385        self.active = true;
386        self.preview = ReshapePreview {
387            epoch: next_epoch(),
388            ..ReshapePreview::default()
389        };
390        self.active_tab = PivotMeltTab::Pivot;
391        self.melt_pattern_input = TextInput::new()
392            .with_history_limit(history_limit)
393            .with_theme(theme);
394        self.melt_variable_input = TextInput::new()
395            .with_history_limit(history_limit)
396            .with_theme(theme);
397        self.melt_value_input = TextInput::new()
398            .with_history_limit(history_limit)
399            .with_theme(theme);
400        self.reset_form();
401    }
402
403    pub fn close(&mut self) {
404        self.active = false;
405        self.picker = None;
406        self.preview = ReshapePreview::default();
407    }
408
409    pub fn reset_form(&mut self) {
410        self.focus = PivotMeltFocus::TabBar;
411        self.picker = None;
412        self.index_columns.clear();
413        self.pivot_column = None;
414        self.value_column = None;
415        self.aggregation = PivotAggregation::default();
416        self.melt_index_columns.clear();
417        self.melt_value_strategy = MeltValueStrategy::default();
418        self.melt_pattern_input.clear();
419        self.melt_type_filter = MeltTypeFilter::default();
420        self.melt_explicit_list.clear();
421        self.melt_variable_input.suggest("variable");
422        self.melt_value_input.suggest("value");
423    }
424
425    // ----- Focus -----
426
427    /// The active tab's rows, in Tab order. Melt's value rows follow its strategy.
428    pub fn row_order(&self) -> &'static [PivotMeltFocus] {
429        use PivotMeltFocus::*;
430        match self.active_tab {
431            PivotMeltTab::Pivot => &[PivotIndex, PivotColumn, PivotValue, PivotAggregation],
432            PivotMeltTab::Melt => match self.melt_value_strategy {
433                MeltValueStrategy::AllExceptIndex => {
434                    &[MeltIndex, MeltStrategy, MeltVariable, MeltValue]
435                }
436                MeltValueStrategy::ByPattern => &[
437                    MeltIndex,
438                    MeltStrategy,
439                    MeltPattern,
440                    MeltVariable,
441                    MeltValue,
442                ],
443                MeltValueStrategy::ByType => {
444                    &[MeltIndex, MeltStrategy, MeltType, MeltVariable, MeltValue]
445                }
446                MeltValueStrategy::ExplicitList => &[
447                    MeltIndex,
448                    MeltStrategy,
449                    MeltColumns,
450                    MeltVariable,
451                    MeltValue,
452                ],
453            },
454        }
455    }
456
457    pub fn switch_tab(&mut self) {
458        self.active_tab = match self.active_tab {
459            PivotMeltTab::Pivot => PivotMeltTab::Melt,
460            PivotMeltTab::Melt => PivotMeltTab::Pivot,
461        };
462        self.focus = PivotMeltFocus::TabBar;
463        self.picker = None;
464    }
465
466    // ----- Rows -----
467
468    /// Rows whose value is a text field rather than a picked choice.
469    pub fn is_text_row(&self, focus: PivotMeltFocus) -> bool {
470        matches!(
471            focus,
472            PivotMeltFocus::MeltPattern | PivotMeltFocus::MeltVariable | PivotMeltFocus::MeltValue
473        )
474    }
475
476    /// Rows whose value steps through a short list: the aggregation, the melt
477    /// strategy and its type.
478    pub fn is_choice_row(&self, focus: PivotMeltFocus) -> bool {
479        matches!(
480            focus,
481            PivotMeltFocus::PivotAggregation
482                | PivotMeltFocus::MeltStrategy
483                | PivotMeltFocus::MeltType
484        )
485    }
486
487    /// Rows edited through the Picker: the column rows.
488    pub fn is_picker_row(&self, focus: PivotMeltFocus) -> bool {
489        !self.is_text_row(focus) && !self.is_choice_row(focus) && focus != PivotMeltFocus::TabBar
490    }
491
492    /// ←/→ (and Space) on a choice row: the next or previous value, wrapping. A
493    /// new strategy changes which melt rows follow it.
494    pub fn step_choice(&mut self, delta: i8) {
495        use crate::form::step_value;
496        match self.focus {
497            PivotMeltFocus::PivotAggregation => {
498                self.aggregation = step_value(&PivotAggregation::ALL, self.aggregation, delta);
499            }
500            PivotMeltFocus::MeltStrategy => {
501                self.melt_value_strategy =
502                    step_value(&MeltValueStrategy::ALL, self.melt_value_strategy, delta);
503            }
504            PivotMeltFocus::MeltType => {
505                self.melt_type_filter =
506                    step_value(&MeltTypeFilter::ALL, self.melt_type_filter, delta);
507            }
508            _ => {}
509        }
510    }
511
512    /// ←/→ on a pick-one column row: the next or previous column, chosen at once.
513    /// A row with nothing chosen yet starts at the first (→) or the last (←).
514    pub fn step_picker_row(&mut self, delta: i8) {
515        if !self.is_picker_row(self.focus) || self.is_multi_row(self.focus) {
516            return;
517        }
518        let chosen = match self.focus {
519            PivotMeltFocus::PivotColumn => self.pivot_column.is_some(),
520            PivotMeltFocus::PivotValue => self.value_column.is_some(),
521            _ => false,
522        };
523        self.open_picker();
524        if let Some(picker) = self.picker.as_mut() {
525            if delta < 0 {
526                picker.move_up();
527            } else if chosen {
528                picker.move_down();
529            }
530        }
531        self.picker_choose();
532    }
533
534    /// Rows where the Picker toggles several choices rather than picking one.
535    pub fn is_multi_row(&self, focus: PivotMeltFocus) -> bool {
536        matches!(
537            focus,
538            PivotMeltFocus::PivotIndex | PivotMeltFocus::MeltIndex | PivotMeltFocus::MeltColumns
539        )
540    }
541
542    /// The focused row's text field, when it has one.
543    pub fn focused_text_input_mut(&mut self) -> Option<&mut TextInput> {
544        match self.focus {
545            PivotMeltFocus::MeltPattern => Some(&mut self.melt_pattern_input),
546            PivotMeltFocus::MeltVariable => Some(&mut self.melt_variable_input),
547            PivotMeltFocus::MeltValue => Some(&mut self.melt_value_input),
548            _ => None,
549        }
550    }
551
552    // ----- Picker -----
553
554    /// What the focused row's Picker offers: each row sees only its own pool,
555    /// so narrowing one list can never silently empty another.
556    pub fn picker_items(&self) -> Vec<String> {
557        let minus = |exclude: &[&str]| -> Vec<String> {
558            self.available_columns
559                .iter()
560                .filter(|c| !exclude.contains(&c.as_str()))
561                .cloned()
562                .collect()
563        };
564        match self.focus {
565            PivotMeltFocus::PivotIndex | PivotMeltFocus::MeltIndex => {
566                self.available_columns.clone()
567            }
568            PivotMeltFocus::PivotColumn => {
569                let index: Vec<&str> = self.index_columns.iter().map(|s| s.as_str()).collect();
570                minus(&index)
571            }
572            PivotMeltFocus::PivotValue => {
573                let mut exclude: Vec<&str> =
574                    self.index_columns.iter().map(|s| s.as_str()).collect();
575                if let Some(pivot) = self.pivot_column.as_deref() {
576                    exclude.push(pivot);
577                }
578                minus(&exclude)
579            }
580            PivotMeltFocus::PivotAggregation => PivotAggregation::ALL
581                .iter()
582                .map(|a| a.as_str().to_string())
583                .collect(),
584            PivotMeltFocus::MeltStrategy => MeltValueStrategy::ALL
585                .iter()
586                .map(|s| s.as_str().to_string())
587                .collect(),
588            PivotMeltFocus::MeltType => MeltTypeFilter::ALL
589                .iter()
590                .map(|t| t.as_str().to_string())
591                .collect(),
592            PivotMeltFocus::MeltColumns => {
593                let index: Vec<&str> = self.melt_index_columns.iter().map(|s| s.as_str()).collect();
594                minus(&index)
595            }
596            _ => Vec::new(),
597        }
598    }
599
600    /// Open the Picker for the focused row, cursor on the current choice.
601    pub fn open_picker(&mut self) {
602        if !self.is_picker_row(self.focus) {
603            return;
604        }
605        let items = self.picker_items();
606        let current = match self.focus {
607            PivotMeltFocus::PivotColumn => self.pivot_column.as_deref(),
608            PivotMeltFocus::PivotValue => self.value_column.as_deref(),
609            PivotMeltFocus::PivotAggregation => Some(self.aggregation.as_str()),
610            PivotMeltFocus::MeltStrategy => Some(self.melt_value_strategy.as_str()),
611            PivotMeltFocus::MeltType => Some(self.melt_type_filter.as_str()),
612            _ => None,
613        };
614        let mut state = PickerState::new(items.clone());
615        if let Some(current) = current
616            && let Some(i) = items.iter().position(|item| item == current)
617        {
618            state.select_original(i);
619        }
620        self.picker = Some(state);
621        self.picker_before = Some(PickerBefore {
622            index_columns: self.index_columns.clone(),
623            melt_index_columns: self.melt_index_columns.clone(),
624            melt_explicit_list: self.melt_explicit_list.clone(),
625            pivot_column: self.pivot_column.clone(),
626            value_column: self.value_column.clone(),
627        });
628    }
629
630    /// Esc in the Picker: it closes, and the toggles made since it opened go with
631    /// it (a pick-one row changed nothing yet).
632    pub fn picker_cancel(&mut self) {
633        self.picker = None;
634        if let Some(before) = self.picker_before.take() {
635            self.index_columns = before.index_columns;
636            self.melt_index_columns = before.melt_index_columns;
637            self.melt_explicit_list = before.melt_explicit_list;
638            self.pivot_column = before.pivot_column;
639            self.value_column = before.value_column;
640        }
641    }
642
643    /// Enter in the Picker: a pick-one row takes the cursor's item; a toggle
644    /// row's choices are already staged. Either way the Picker closes.
645    pub fn picker_choose(&mut self) {
646        self.picker_before = None;
647        let Some(state) = self.picker.take() else {
648            return;
649        };
650        if self.is_multi_row(self.focus) {
651            return;
652        }
653        let Some(i) = state.selected_original() else {
654            return;
655        };
656        let items = self.picker_items();
657        let Some(item) = items.get(i) else {
658            return;
659        };
660        match self.focus {
661            PivotMeltFocus::PivotColumn => {
662                if self.value_column.as_deref() == Some(item.as_str()) {
663                    self.value_column = None;
664                }
665                self.pivot_column = Some(item.clone());
666            }
667            PivotMeltFocus::PivotValue => self.value_column = Some(item.clone()),
668            PivotMeltFocus::PivotAggregation => self.aggregation = PivotAggregation::ALL[i],
669            PivotMeltFocus::MeltStrategy => self.melt_value_strategy = MeltValueStrategy::ALL[i],
670            PivotMeltFocus::MeltType => self.melt_type_filter = MeltTypeFilter::ALL[i],
671            _ => {}
672        }
673    }
674
675    /// Space in a toggle row's Picker: flip the cursor's column in or out.
676    pub fn picker_toggle(&mut self) {
677        let Some(i) = self.picker.as_ref().and_then(|s| s.selected_original()) else {
678            return;
679        };
680        let items = self.picker_items();
681        let Some(col) = items.get(i).cloned() else {
682            return;
683        };
684        let toggle = |list: &mut Vec<String>| {
685            if let Some(pos) = list.iter().position(|c| *c == col) {
686                list.remove(pos);
687            } else {
688                list.push(col.clone());
689            }
690        };
691        match self.focus {
692            PivotMeltFocus::PivotIndex => {
693                toggle(&mut self.index_columns);
694                // A column moved into the index can no longer pivot or fill values.
695                if let Some(pivot) = self.pivot_column.as_deref()
696                    && self.index_columns.iter().any(|c| c == pivot)
697                {
698                    self.pivot_column = None;
699                }
700                if let Some(value) = self.value_column.as_deref()
701                    && self.index_columns.iter().any(|c| c == value)
702                {
703                    self.value_column = None;
704                }
705            }
706            PivotMeltFocus::MeltIndex => {
707                toggle(&mut self.melt_index_columns);
708                let index = &self.melt_index_columns;
709                self.melt_explicit_list.retain(|c| !index.contains(c));
710            }
711            PivotMeltFocus::MeltColumns => toggle(&mut self.melt_explicit_list),
712            _ => {}
713        }
714    }
715
716    /// Whether an item in the focused row's Picker is currently chosen.
717    pub fn is_marked(&self, item: &str) -> bool {
718        match self.focus {
719            PivotMeltFocus::PivotIndex => self.index_columns.iter().any(|c| c == item),
720            PivotMeltFocus::MeltIndex => self.melt_index_columns.iter().any(|c| c == item),
721            PivotMeltFocus::MeltColumns => self.melt_explicit_list.iter().any(|c| c == item),
722            _ => false,
723        }
724    }
725
726    // ----- Spec echo -----
727
728    /// The staged pivot as one line — `index × columns → agg(values)` — or
729    /// what is still missing from it.
730    pub fn pivot_spec_line(&self, g: &crate::glyphs::Glyphs) -> Result<String, String> {
731        if let Some(err) = self.pivot_validation_error() {
732            return Err(err);
733        }
734        let pivot = self.pivot_column.as_deref().unwrap_or_default();
735        let value = self.value_column.as_deref().unwrap_or_default();
736        Ok(format!(
737            "{} {} {} {} {}({})",
738            self.index_columns.join(", "),
739            g.times,
740            pivot,
741            g.arrow_right,
742            self.aggregation.as_str(),
743            value
744        ))
745    }
746
747    /// The staged melt as one line, or what is still missing from it.
748    pub fn melt_spec_line(&self) -> Result<String, String> {
749        if let Some(err) = self.melt_validation_error() {
750            return Err(err);
751        }
752        let n = self.melt_resolve_value_columns()?.len();
753        let columns = if n == 1 { "column" } else { "columns" };
754        Ok(match self.melt_value_strategy {
755            MeltValueStrategy::AllExceptIndex => {
756                format!("melt {n} value {columns} (all except index)")
757            }
758            MeltValueStrategy::ByPattern => format!(
759                "melt {n} value {columns} by pattern \"{}\"",
760                self.melt_pattern_input.value()
761            ),
762            MeltValueStrategy::ByType => format!(
763                "melt {n} {} {columns}",
764                self.melt_type_filter.as_str().to_lowercase()
765            ),
766            MeltValueStrategy::ExplicitList => format!("melt {n} chosen {columns}"),
767        })
768    }
769
770    // ----- Pivot backend -----
771
772    pub fn pivot_validation_error(&self) -> Option<String> {
773        if self.index_columns.is_empty() {
774            return Some("Select at least one index column.".to_string());
775        }
776        let pivot = match &self.pivot_column {
777            Some(s) => s,
778            None => return Some("Select the column whose values become columns.".to_string()),
779        };
780        if self.index_columns.contains(pivot) {
781            return Some("Pivot column must not be in index.".to_string());
782        }
783        let value = match &self.value_column {
784            Some(s) => s,
785            None => return Some("Select the column that fills the cells.".to_string()),
786        };
787        if self.index_columns.contains(value) || pivot == value {
788            return Some("Value column must not be in index or equal to pivot.".to_string());
789        }
790        None
791    }
792
793    pub fn build_pivot_spec(&self) -> Option<PivotSpec> {
794        if self.pivot_validation_error().is_some() {
795            return None;
796        }
797        let pivot = self.pivot_column.clone()?;
798        let value = self.value_column.clone()?;
799        Some(PivotSpec {
800            index: self.index_columns.clone(),
801            pivot_column: pivot,
802            value_column: value,
803            aggregation: self.aggregation,
804            sort_columns: None,
805        })
806    }
807
808    // ----- Melt backend -----
809
810    pub fn melt_value_pool(&self) -> Vec<String> {
811        let idx_set: std::collections::HashSet<_> = self.melt_index_columns.iter().collect();
812        self.available_columns
813            .iter()
814            .filter(|c| !idx_set.contains(*c))
815            .cloned()
816            .collect()
817    }
818
819    fn dtype_matches(&self, col: &str) -> bool {
820        let dtype = match self.column_dtypes.get(col) {
821            Some(d) => d,
822            None => return false,
823        };
824        match self.melt_type_filter {
825            MeltTypeFilter::Numeric => matches!(
826                dtype,
827                DataType::Int8
828                    | DataType::Int16
829                    | DataType::Int32
830                    | DataType::Int64
831                    | DataType::UInt8
832                    | DataType::UInt16
833                    | DataType::UInt32
834                    | DataType::UInt64
835                    | DataType::Float32
836                    | DataType::Float64
837            ),
838            MeltTypeFilter::String => matches!(dtype, DataType::String),
839            MeltTypeFilter::Datetime => matches!(
840                dtype,
841                DataType::Datetime(_, _) | DataType::Date | DataType::Time
842            ),
843            MeltTypeFilter::Boolean => matches!(dtype, DataType::Boolean),
844        }
845    }
846
847    pub fn melt_resolve_value_columns(&self) -> Result<Vec<String>, String> {
848        let pool = self.melt_value_pool();
849        match self.melt_value_strategy {
850            MeltValueStrategy::AllExceptIndex => {
851                if pool.is_empty() {
852                    return Err("No columns to melt (all columns are index).".to_string());
853                }
854                Ok(pool)
855            }
856            MeltValueStrategy::ByPattern => {
857                let re = regex::Regex::new(self.melt_pattern_input.value())
858                    .map_err(|e| format!("Invalid pattern: {}", e))?;
859                let matched: Vec<String> = pool.into_iter().filter(|c| re.is_match(c)).collect();
860                if matched.is_empty() {
861                    return Err("Pattern matches no columns.".to_string());
862                }
863                Ok(matched)
864            }
865            MeltValueStrategy::ByType => {
866                let matched: Vec<String> = self
867                    .melt_value_pool()
868                    .into_iter()
869                    .filter(|c| self.dtype_matches(c))
870                    .collect();
871                if matched.is_empty() {
872                    return Err("No columns of selected type.".to_string());
873                }
874                Ok(matched)
875            }
876            MeltValueStrategy::ExplicitList => {
877                if self.melt_explicit_list.is_empty() {
878                    return Err("Select at least one value column.".to_string());
879                }
880                Ok(self.melt_explicit_list.clone())
881            }
882        }
883    }
884
885    pub fn melt_validation_error(&self) -> Option<String> {
886        if self.melt_index_columns.is_empty() {
887            return Some("Select at least one index column.".to_string());
888        }
889        let v = self.melt_variable_input.value().trim().to_string();
890        if v.is_empty() {
891            return Some("Variable name cannot be empty.".to_string());
892        }
893        if self.melt_index_columns.contains(&v) {
894            return Some("Variable name must not equal an index column.".to_string());
895        }
896        let w = self.melt_value_input.value().trim().to_string();
897        if w.is_empty() {
898            return Some("Value name cannot be empty.".to_string());
899        }
900        if self.melt_index_columns.contains(&w) {
901            return Some("Value name must not equal an index column.".to_string());
902        }
903        if v == w {
904            return Some("Variable and value names must differ.".to_string());
905        }
906        match self.melt_resolve_value_columns() {
907            Ok(cols) if cols.is_empty() => Some("No value columns selected.".to_string()),
908            Err(e) => Some(e),
909            Ok(_) => None,
910        }
911    }
912
913    pub fn build_melt_spec(&self) -> Option<MeltSpec> {
914        if self.melt_validation_error().is_some() {
915            return None;
916        }
917        let value_columns = self.melt_resolve_value_columns().ok()?;
918        Some(MeltSpec {
919            index: self.melt_index_columns.clone(),
920            value_columns,
921            variable_name: self.melt_variable_input.value().trim().to_string(),
922            value_name: self.melt_value_input.value().trim().to_string(),
923        })
924    }
925
926    /// The spec Enter would apply on the active tab, if it is complete.
927    pub fn staged_spec(&self) -> Option<PreviewSpec> {
928        match self.active_tab {
929            PivotMeltTab::Pivot => self.build_pivot_spec().map(PreviewSpec::Pivot),
930            PivotMeltTab::Melt => self.build_melt_spec().map(PreviewSpec::Melt),
931        }
932    }
933}
934
935impl crate::form::Form for PivotMeltModal {
936    type Field = PivotMeltFocus;
937
938    /// The tab bar, then the active tab's rows. Melt's value rows follow its strategy.
939    fn fields(&self) -> Vec<(PivotMeltFocus, crate::form::FieldKind)> {
940        use crate::form::FieldKind;
941        std::iter::once(PivotMeltFocus::TabBar)
942            .chain(self.row_order().iter().copied())
943            .map(|row| {
944                let kind = if row == PivotMeltFocus::TabBar || self.is_choice_row(row) {
945                    FieldKind::Choice
946                } else if self.is_text_row(row) {
947                    FieldKind::Text
948                } else {
949                    FieldKind::Picker {
950                        multi: self.is_multi_row(row),
951                    }
952                };
953                (row, kind)
954            })
955            .collect()
956    }
957
958    fn focused(&self) -> PivotMeltFocus {
959        self.focus
960    }
961
962    fn set_focused(&mut self, field: PivotMeltFocus) {
963        self.focus = field;
964    }
965}
966
967#[cfg(test)]
968mod tests {
969    use super::*;
970    use crate::form::Form;
971
972    fn modal_with_columns(columns: &[&str]) -> PivotMeltModal {
973        let mut m = PivotMeltModal::new();
974        m.available_columns = columns.iter().map(|s| s.to_string()).collect();
975        m.column_dtypes = columns
976            .iter()
977            .map(|s| (s.to_string(), DataType::Int64))
978            .collect();
979        let config = crate::config::AppConfig::default();
980        let theme = crate::config::Theme::from_config(&config.theme).unwrap();
981        m.open(1000, &theme);
982        m
983    }
984
985    #[test]
986    fn test_pivot_melt_modal_new() {
987        let m = PivotMeltModal::new();
988        assert!(!m.active);
989        assert!(matches!(m.active_tab, PivotMeltTab::Pivot));
990        assert!(matches!(m.focus, PivotMeltFocus::TabBar));
991        assert!(m.picker.is_none());
992    }
993
994    #[test]
995    fn test_open_close() {
996        let mut m = modal_with_columns(&["a", "b"]);
997        assert!(m.active);
998        assert!(matches!(m.active_tab, PivotMeltTab::Pivot));
999        assert!(matches!(m.focus, PivotMeltFocus::TabBar));
1000        m.close();
1001        assert!(!m.active);
1002    }
1003
1004    #[test]
1005    fn test_switch_tab_returns_to_the_tab_bar() {
1006        let mut m = modal_with_columns(&["a", "b"]);
1007        m.focus_next();
1008        m.switch_tab();
1009        assert!(matches!(m.active_tab, PivotMeltTab::Melt));
1010        assert!(matches!(m.focus, PivotMeltFocus::TabBar));
1011        m.switch_tab();
1012        assert!(matches!(m.active_tab, PivotMeltTab::Pivot));
1013    }
1014
1015    #[test]
1016    fn tab_walks_the_pivot_rows_and_wraps() {
1017        let mut m = modal_with_columns(&["a", "b"]);
1018        let walked: Vec<PivotMeltFocus> = (0..5)
1019            .map(|_| {
1020                m.focus_next();
1021                m.focus
1022            })
1023            .collect();
1024        assert_eq!(
1025            walked,
1026            vec![
1027                PivotMeltFocus::PivotIndex,
1028                PivotMeltFocus::PivotColumn,
1029                PivotMeltFocus::PivotValue,
1030                PivotMeltFocus::PivotAggregation,
1031                PivotMeltFocus::TabBar,
1032            ]
1033        );
1034        m.focus_prev();
1035        assert_eq!(m.focus, PivotMeltFocus::PivotAggregation);
1036    }
1037
1038    /// The melt rows follow the strategy: only the active strategy's own row
1039    /// is walkable, so Tab never lands on a control that does nothing.
1040    #[test]
1041    fn the_melt_rows_follow_the_strategy() {
1042        let mut m = modal_with_columns(&["a", "b"]);
1043        m.switch_tab();
1044        assert!(!m.row_order().contains(&PivotMeltFocus::MeltPattern));
1045        m.melt_value_strategy = MeltValueStrategy::ByPattern;
1046        assert!(m.row_order().contains(&PivotMeltFocus::MeltPattern));
1047        assert!(!m.row_order().contains(&PivotMeltFocus::MeltType));
1048        m.melt_value_strategy = MeltValueStrategy::ExplicitList;
1049        assert!(m.row_order().contains(&PivotMeltFocus::MeltColumns));
1050    }
1051
1052    /// Each row's Picker sees only its own pool: the index never offers what
1053    /// cannot be an index, and the value list excludes the index and the
1054    /// pivot column — the cross-list filter trap is structurally gone.
1055    #[test]
1056    fn each_picker_is_scoped_to_its_row() {
1057        let mut m = modal_with_columns(&["a", "b", "c", "d"]);
1058        m.index_columns = vec!["a".to_string()];
1059        m.pivot_column = Some("b".to_string());
1060
1061        m.focus = PivotMeltFocus::PivotIndex;
1062        assert_eq!(m.picker_items(), ["a", "b", "c", "d"]);
1063        m.focus = PivotMeltFocus::PivotColumn;
1064        assert_eq!(m.picker_items(), ["b", "c", "d"]);
1065        m.focus = PivotMeltFocus::PivotValue;
1066        assert_eq!(m.picker_items(), ["c", "d"]);
1067    }
1068
1069    #[test]
1070    fn toggling_a_column_into_the_index_clears_a_now_invalid_choice() {
1071        let mut m = modal_with_columns(&["a", "b", "c"]);
1072        m.pivot_column = Some("a".to_string());
1073        m.value_column = Some("b".to_string());
1074        m.focus = PivotMeltFocus::PivotIndex;
1075        m.open_picker();
1076        m.picker_toggle(); // toggles "a", the cursor's initial item
1077        assert_eq!(m.index_columns, ["a"]);
1078        assert_eq!(m.pivot_column, None, "a is index now, not a pivot column");
1079        assert_eq!(m.value_column, Some("b".to_string()), "b is untouched");
1080    }
1081
1082    #[test]
1083    fn choosing_the_value_column_as_pivot_clears_the_value() {
1084        let mut m = modal_with_columns(&["a", "b", "c"]);
1085        m.value_column = Some("b".to_string());
1086        m.focus = PivotMeltFocus::PivotColumn;
1087        m.open_picker();
1088        m.picker.as_mut().unwrap().select_original(1); // "b"
1089        m.picker_choose();
1090        assert_eq!(m.pivot_column, Some("b".to_string()));
1091        assert_eq!(m.value_column, None);
1092        assert!(m.picker.is_none(), "choosing closes the picker");
1093    }
1094
1095    #[test]
1096    fn the_picker_opens_on_the_current_choice() {
1097        let mut m = modal_with_columns(&["a", "b", "c"]);
1098        m.pivot_column = Some("c".to_string());
1099        m.focus = PivotMeltFocus::PivotColumn;
1100        m.open_picker();
1101        let state = m.picker.as_ref().unwrap();
1102        assert_eq!(state.selected_original(), Some(2), "c is item 2");
1103    }
1104
1105    #[test]
1106    fn choice_rows_step_and_wrap() {
1107        let mut m = modal_with_columns(&["a", "b", "c"]);
1108        m.focus = PivotMeltFocus::PivotAggregation;
1109        m.step_choice(-1);
1110        assert_eq!(m.aggregation, PivotAggregation::Count, "last wraps back");
1111        m.step_choice(1);
1112        assert_eq!(m.aggregation, PivotAggregation::Last);
1113        m.switch_tab();
1114        m.focus = PivotMeltFocus::MeltStrategy;
1115        m.step_choice(1);
1116        assert_eq!(m.melt_value_strategy, MeltValueStrategy::ByPattern);
1117        assert!(m.row_order().contains(&PivotMeltFocus::MeltPattern));
1118    }
1119
1120    #[test]
1121    fn a_column_row_steps_through_its_own_pool() {
1122        let mut m = modal_with_columns(&["a", "b", "c"]);
1123        m.index_columns = vec!["a".to_string()];
1124        m.focus = PivotMeltFocus::PivotColumn;
1125        m.step_picker_row(1);
1126        assert_eq!(
1127            m.pivot_column.as_deref(),
1128            Some("b"),
1129            "the index is not offered"
1130        );
1131        m.step_picker_row(1);
1132        assert_eq!(m.pivot_column.as_deref(), Some("c"));
1133        assert!(m.picker.is_none(), "stepping never leaves the picker open");
1134    }
1135
1136    #[test]
1137    fn a_melt_index_toggle_drops_the_column_from_the_explicit_list() {
1138        let mut m = modal_with_columns(&["a", "b", "c"]);
1139        m.switch_tab();
1140        m.melt_explicit_list = vec!["a".to_string(), "b".to_string()];
1141        m.focus = PivotMeltFocus::MeltIndex;
1142        m.open_picker();
1143        m.picker_toggle(); // "a" into the index
1144        assert_eq!(m.melt_index_columns, ["a"]);
1145        assert_eq!(m.melt_explicit_list, ["b"]);
1146    }
1147
1148    #[test]
1149    fn the_pivot_spec_line_echoes_the_full_spec_or_names_the_gap() {
1150        let g = crate::glyphs::unicode();
1151        let mut m = modal_with_columns(&["dept", "job", "salary"]);
1152        assert!(m.pivot_spec_line(g).is_err(), "nothing chosen yet");
1153        m.index_columns = vec!["dept".to_string()];
1154        m.pivot_column = Some("job".to_string());
1155        m.value_column = Some("salary".to_string());
1156        m.aggregation = PivotAggregation::Avg;
1157        assert_eq!(
1158            m.pivot_spec_line(g).unwrap(),
1159            "dept × job → avg(salary)".to_string()
1160        );
1161    }
1162
1163    #[test]
1164    fn the_melt_spec_line_counts_what_the_strategy_resolves() {
1165        let mut m = modal_with_columns(&["id", "q1", "q2", "q3"]);
1166        m.switch_tab();
1167        m.melt_index_columns = vec!["id".to_string()];
1168        assert_eq!(
1169            m.melt_spec_line().unwrap(),
1170            "melt 3 value columns (all except index)"
1171        );
1172        m.melt_value_strategy = MeltValueStrategy::ByPattern;
1173        m.melt_pattern_input.set_value("q[12]");
1174        assert_eq!(
1175            m.melt_spec_line().unwrap(),
1176            "melt 2 value columns by pattern \"q[12]\""
1177        );
1178    }
1179
1180    #[test]
1181    fn esc_worthy_state_dies_with_reset() {
1182        let mut m = modal_with_columns(&["a", "b"]);
1183        m.index_columns = vec!["a".to_string()];
1184        m.melt_pattern_input.set_value("x");
1185        m.reset_form();
1186        assert!(m.index_columns.is_empty());
1187        assert_eq!(m.melt_pattern_input.value(), "");
1188        assert_eq!(m.melt_variable_input.value(), "variable");
1189        assert_eq!(m.melt_value_input.value(), "value");
1190    }
1191}