1use 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#[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 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#[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#[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#[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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
165 pub sort_descending: Vec<bool>,
166}
167
168impl ReshapeSource {
169 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 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
188pub const PREVIEW_INPUT_ROWS: usize = 1_000;
191
192pub const PREVIEW_ROWS: usize = 50;
194
195pub const PREVIEW_WIDE_PIVOT: usize = 100;
197
198#[derive(Debug, Clone, PartialEq)]
200pub enum PreviewSpec {
201 Pivot(PivotSpec),
202 Melt(MeltSpec),
203}
204
205#[derive(Debug, Clone)]
207pub struct PreviewInput {
208 pub rows: std::sync::Arc<polars::prelude::DataFrame>,
209 pub whole: bool,
211}
212
213#[derive(Debug, Clone)]
215pub struct PreviewFrame {
216 pub head: polars::prelude::DataFrame,
217 pub rows: usize,
218 pub columns: usize,
219 pub new_columns: Option<usize>,
221 pub melted_columns: Option<usize>,
223}
224
225#[derive(Debug, Default)]
229pub struct ReshapePreview {
230 pub epoch: u64,
232 pub token: u64,
234 pub wanted: Option<PreviewSpec>,
236 pub running: Option<u64>,
238 pub input: Option<PreviewInput>,
239 pub sorted: bool,
241 pub view_rows: Option<usize>,
243 pub shown: Option<(PreviewSpec, Result<PreviewFrame, String>)>,
245}
246
247impl ReshapePreview {
248 pub fn stale(&self) -> bool {
250 self.wanted.is_some() && self.shown.as_ref().map(|(spec, _)| spec) != self.wanted.as_ref()
251 }
252}
253
254fn 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
260pub 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#[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 pub preview: ReshapePreview,
305 pub active_tab: PivotMeltTab,
306 pub focus: PivotMeltFocus,
307
308 pub available_columns: Vec<String>,
310 pub column_dtypes: HashMap<String, DataType>,
312
313 pub picker: Option<PickerState>,
315 picker_before: Option<PickerBefore>,
318
319 pub attention: bool,
323
324 pub index_columns: Vec<String>,
326 pub pivot_column: Option<String>,
327 pub value_column: Option<String>,
328 pub aggregation: PivotAggregation,
329
330 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 melt_pattern: std::sync::Mutex<Option<(String, Result<regex::Regex, String>)>>,
341 #[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 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 pub fn is_text_row(&self, focus: PivotMeltFocus) -> bool {
467 matches!(
468 focus,
469 PivotMeltFocus::MeltPattern | PivotMeltFocus::MeltVariable | PivotMeltFocus::MeltValue
470 )
471 }
472
473 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 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 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 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 pub fn is_multi_row(&self, focus: PivotMeltFocus) -> bool {
533 matches!(
534 focus,
535 PivotMeltFocus::PivotIndex | PivotMeltFocus::MeltIndex | PivotMeltFocus::MeltColumns
536 )
537 }
538
539 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 #[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(); 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); 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(); 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 #[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}