Skip to main content

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