1use 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#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
21pub enum PivotMeltFocus {
22 #[default]
23 TabBar,
24 PivotIndex,
26 PivotColumn,
27 PivotValue,
28 PivotAggregation,
29 MeltIndex,
31 MeltStrategy,
32 MeltPattern,
33 MeltType,
34 MeltColumns,
35 MeltVariable,
36 MeltValue,
37}
38
39#[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#[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#[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#[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 #[serde(default)]
142 #[serde(skip_serializing)]
143 #[allow(dead_code)]
144 pub sort_columns: Option<bool>,
145}
146
147#[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#[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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
172 pub sort_descending: Vec<bool>,
173}
174
175impl ReshapeSource {
176 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 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
195pub const PREVIEW_INPUT_ROWS: usize = 1_000;
198
199pub const PREVIEW_ROWS: usize = 50;
201
202pub const PREVIEW_WIDE_PIVOT: usize = 100;
204
205#[derive(Debug, Clone, PartialEq)]
207pub enum PreviewSpec {
208 Pivot(PivotSpec),
209 Melt(MeltSpec),
210}
211
212#[derive(Debug, Clone)]
214pub struct PreviewInput {
215 pub rows: std::sync::Arc<polars::prelude::DataFrame>,
216 pub whole: bool,
218}
219
220#[derive(Debug, Clone)]
222pub struct PreviewFrame {
223 pub head: polars::prelude::DataFrame,
224 pub rows: usize,
225 pub columns: usize,
226 pub new_columns: Option<usize>,
228 pub melted_columns: Option<usize>,
230}
231
232#[derive(Debug, Default)]
236pub struct ReshapePreview {
237 pub epoch: u64,
239 pub token: u64,
241 pub wanted: Option<PreviewSpec>,
243 pub running: Option<u64>,
245 pub input: Option<PreviewInput>,
246 pub sorted: bool,
248 pub view_rows: Option<usize>,
250 pub shown: Option<(PreviewSpec, Result<PreviewFrame, String>)>,
252}
253
254impl ReshapePreview {
255 pub fn stale(&self) -> bool {
257 self.wanted.is_some() && self.shown.as_ref().map(|(spec, _)| spec) != self.wanted.as_ref()
258 }
259}
260
261fn 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
267pub 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#[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 pub preview: ReshapePreview,
314 pub active_tab: PivotMeltTab,
315 pub focus: PivotMeltFocus,
316
317 pub available_columns: Vec<String>,
319 pub column_dtypes: HashMap<String, DataType>,
321
322 pub picker: Option<PickerState>,
324 picker_before: Option<PickerBefore>,
327
328 pub attention: bool,
332
333 pub index_columns: Vec<String>,
335 pub pivot_column: Option<String>,
336 pub value_column: Option<String>,
337 pub aggregation: PivotAggregation,
338
339 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 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 pub fn is_text_row(&self, focus: PivotMeltFocus) -> bool {
470 matches!(
471 focus,
472 PivotMeltFocus::MeltPattern | PivotMeltFocus::MeltVariable | PivotMeltFocus::MeltValue
473 )
474 }
475
476 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 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 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 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 pub fn is_multi_row(&self, focus: PivotMeltFocus) -> bool {
536 matches!(
537 focus,
538 PivotMeltFocus::PivotIndex | PivotMeltFocus::MeltIndex | PivotMeltFocus::MeltColumns
539 )
540 }
541
542 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 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 #[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(); 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); 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(); 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}