Skip to main content

datui_lib/widgets/
pivot_melt.rs

1//! The Pivot & Melt builder: one Surface over the whole screen, the form on the
2//! left and a live preview of the result on the right, stacked on a narrow
3//! terminal. The form is a FormRow per field with the one Picker below the rows;
4//! the preview is the first rows of the reshaped head, typed and colored as the
5//! table draws them, under a line that says what it ran over and the shape.
6
7use crate::numfmt;
8use crate::pivot_melt_modal::{
9    PREVIEW_INPUT_ROWS, PREVIEW_WIDE_PIVOT, PivotMeltFocus, PivotMeltModal, PivotMeltTab,
10    PreviewFrame, ReshapePreview,
11};
12use crate::pointer::{FieldId, Hit};
13use crate::render::context::RenderContext;
14use crate::render::footer::{Hint, registry_hint_in};
15use crate::widgets::ui::{FormRow, FormValue, Picker, SectionRule, Surface};
16use datui_cli::keys::Context;
17use polars::prelude::{AnyValue, DataFrame};
18use ratatui::buffer::Buffer;
19use ratatui::layout::Rect;
20use ratatui::style::{Modifier, Style};
21use ratatui::text::{Line, Span};
22use ratatui::widgets::{Paragraph, Widget};
23
24/// Where the value column starts, past the rail gutter: the longest label,
25/// "Variable name:", plus two cells of air.
26const LABEL_WIDTH: u16 = 16;
27
28/// Content width from which the form and the preview sit side by side.
29pub const SIDE_BY_SIDE: u16 = 96;
30
31/// The form's width beside the preview.
32const FORM_WIDTH: u16 = 46;
33
34/// Cells between the form and the preview.
35const GAP: u16 = 3;
36
37/// The widest a preview column draws; a longer value is cut with the ellipsis.
38const MAX_CELL: usize = 24;
39
40/// Where the label column of the preview's lines ends.
41const PANE_LABEL: usize = 9;
42
43fn row_label(focus: PivotMeltFocus) -> &'static str {
44    match focus {
45        PivotMeltFocus::PivotIndex | PivotMeltFocus::MeltIndex => "Index:",
46        PivotMeltFocus::PivotColumn | PivotMeltFocus::MeltColumns => "Columns:",
47        PivotMeltFocus::PivotValue => "Values:",
48        PivotMeltFocus::PivotAggregation => "Aggregate:",
49        PivotMeltFocus::MeltStrategy => "Strategy:",
50        PivotMeltFocus::MeltPattern => "Pattern:",
51        PivotMeltFocus::MeltType => "Type:",
52        PivotMeltFocus::MeltVariable => "Variable name:",
53        PivotMeltFocus::MeltValue => "Value name:",
54        PivotMeltFocus::TabBar => "",
55    }
56}
57
58/// The builder's keys for the status footer, the two or three that act right now:
59/// editing a row through the Picker, or walking and applying the form. Labels come
60/// from the key registry.
61pub fn hints(modal: &PivotMeltModal) -> Vec<Hint> {
62    let form = |keys| registry_hint_in(Context::PivotMelt, Some("Form"), keys);
63    let picker = |keys| registry_hint_in(Context::PivotMelt, Some("Picker"), keys);
64    match &modal.picker {
65        Some(_) if modal.is_multi_row(modal.focus) => {
66            vec![picker("Space"), picker("Enter"), picker("Esc")]
67        }
68        Some(_) => vec![picker("Enter"), picker("Esc")],
69        None if modal.is_picker_row(modal.focus) => {
70            vec![form("Enter"), form("Space"), form("Esc")]
71        }
72        None if modal.focus == PivotMeltFocus::TabBar || modal.is_choice_row(modal.focus) => {
73            let change = form("← / →");
74            vec![
75                form("Enter"),
76                Hint::new(crate::glyphs::get().updown_lr, change.label),
77                form("Esc"),
78            ]
79        }
80        None => vec![form("Enter"), form("Esc")],
81    }
82}
83
84/// Whether `?` types in the builder right now (a picker narrows, a text field
85/// types), so help is F1.
86pub fn question_types(modal: &PivotMeltModal) -> bool {
87    modal.picker.is_some() || modal.is_text_row(modal.focus)
88}
89
90/// Draw the builder over `area`, the whole screen above the status footer, which
91/// carries its keys ([`hints`]).
92pub fn render(area: Rect, buf: &mut Buffer, modal: &mut PivotMeltModal, ctx: &RenderContext) {
93    let content = Surface::new("Pivot & Melt").render(area, buf, ctx);
94    if content.height < 4 || content.width < 10 {
95        return;
96    }
97    let (form, preview) = split(content, modal);
98    render_form(form, buf, modal, ctx);
99    if let Some(preview) = preview {
100        render_preview(preview, buf, modal, ctx);
101    }
102}
103
104/// The form's area and the preview's: side by side when there is room, else the
105/// form on top at the height it needs and the preview under it.
106pub fn split(content: Rect, modal: &PivotMeltModal) -> (Rect, Option<Rect>) {
107    if content.width >= SIDE_BY_SIDE {
108        let form = Rect {
109            width: FORM_WIDTH,
110            ..content
111        };
112        let preview = Rect {
113            x: content.x + FORM_WIDTH + GAP,
114            width: content.width - FORM_WIDTH - GAP,
115            ..content
116        };
117        return (form, Some(preview));
118    }
119    let form_height = form_height(modal).min(content.height);
120    let form = Rect {
121        height: form_height,
122        ..content
123    };
124    // A blank row between them; the preview only where it has room for its lines.
125    let rest = content.height.saturating_sub(form_height + 1);
126    let preview = (rest >= 3).then(|| Rect {
127        y: content.y + form_height + 1,
128        height: rest,
129        ..content
130    });
131    (form, preview)
132}
133
134/// Rows the form takes when stacked: the tab line and a blank, a row per field,
135/// the open Picker under them, then a blank and the spec line.
136fn form_height(modal: &PivotMeltModal) -> u16 {
137    let rows = modal.row_order().len() as u16;
138    let picker = modal
139        .picker
140        .as_ref()
141        .map_or(0, |state| 1 + (state.filtered().len() as u16).clamp(1, 8));
142    2 + rows + picker + 2
143}
144
145fn render_form(area: Rect, buf: &mut Buffer, modal: &mut PivotMeltModal, ctx: &RenderContext) {
146    if area.height < 3 || area.width < 10 {
147        return;
148    }
149    // Tab line: the active tab carries the accent, and the rail sits beside
150    // its name while the tab bar holds focus — never beside a tab the
151    // surface is not on. The slot is reserved either way, so nothing moves.
152    let g = crate::glyphs::get();
153    let pivot_tab = modal.active_tab == PivotMeltTab::Pivot;
154    let on_tab_bar = modal.focus == PivotMeltFocus::TabBar;
155    let mark = |active: bool| {
156        if on_tab_bar && active { g.rail } else { " " }
157    };
158    let tab_style = |active: bool| {
159        if active {
160            Style::default().fg(ctx.accent).add_modifier(Modifier::BOLD)
161        } else {
162            Style::default().fg(ctx.text_secondary)
163        }
164    };
165    let tab_line = Line::from(vec![
166        Span::styled(mark(pivot_tab), Style::default().fg(ctx.accent)),
167        Span::styled("Pivot", tab_style(pivot_tab)),
168        Span::styled(format!(" {}", g.rule), Style::default().fg(ctx.dimmed)),
169        Span::styled(mark(!pivot_tab), Style::default().fg(ctx.accent)),
170        Span::styled("Melt", tab_style(!pivot_tab)),
171    ]);
172    let tab_area = Rect { height: 1, ..area };
173    let field = Some(FieldId::of::<PivotMeltModal>(PivotMeltFocus::TabBar));
174    let current = usize::from(!pivot_tab);
175    crate::pointer::record_spans(
176        tab_area,
177        &tab_line,
178        [1, 4]
179            .into_iter()
180            .enumerate()
181            .map(|(index, span)| {
182                let hit = Hit::Option {
183                    field: field.clone(),
184                    index,
185                    current,
186                };
187                (span, hit)
188            })
189            .collect(),
190    );
191    Paragraph::new(tab_line).render(tab_area, buf);
192
193    // The spec line sits on the form's last row.
194    let spec_y = area.y + area.height - 1;
195
196    modal
197        .melt_pattern_input
198        .set_focused(modal.focus == PivotMeltFocus::MeltPattern);
199    modal
200        .melt_variable_input
201        .set_focused(modal.focus == PivotMeltFocus::MeltVariable);
202    modal
203        .melt_value_input
204        .set_focused(modal.focus == PivotMeltFocus::MeltValue);
205
206    // One FormRow per field; the chosen value is always echoed on the row, so
207    // nothing is ambiguous when focus is elsewhere.
208    let rows = modal.row_order();
209    let mut y = area.y + 2;
210    for &row in rows {
211        if y >= spec_y {
212            break;
213        }
214        let echo;
215        let value = match row {
216            PivotMeltFocus::PivotIndex => {
217                echo = modal.index_columns.join(", ");
218                echo_or_placeholder(&echo, "none")
219            }
220            PivotMeltFocus::PivotColumn => {
221                echo_or_placeholder(modal.pivot_column.as_deref().unwrap_or(""), "none")
222            }
223            PivotMeltFocus::PivotValue => {
224                echo_or_placeholder(modal.value_column.as_deref().unwrap_or(""), "none")
225            }
226            PivotMeltFocus::PivotAggregation => FormValue::Choice(modal.aggregation.as_str()),
227            PivotMeltFocus::MeltIndex => {
228                echo = modal.melt_index_columns.join(", ");
229                echo_or_placeholder(&echo, "none")
230            }
231            PivotMeltFocus::MeltStrategy => FormValue::Choice(modal.melt_value_strategy.as_str()),
232            PivotMeltFocus::MeltPattern => FormValue::Input(&modal.melt_pattern_input),
233            PivotMeltFocus::MeltType => FormValue::Choice(modal.melt_type_filter.as_str()),
234            PivotMeltFocus::MeltColumns => {
235                echo = modal.melt_explicit_list.join(", ");
236                echo_or_placeholder(&echo, "none")
237            }
238            PivotMeltFocus::MeltVariable => FormValue::Input(&modal.melt_variable_input),
239            PivotMeltFocus::MeltValue => FormValue::Input(&modal.melt_value_input),
240            PivotMeltFocus::TabBar => continue,
241        };
242        let row_area = Rect {
243            y,
244            height: 1,
245            ..area
246        };
247        FormRow {
248            label: row_label(row),
249            value,
250            focused: modal.focus == row,
251            label_width: LABEL_WIDTH,
252        }
253        .render_picking(row_area, buf, ctx, modal.picker.is_some());
254        crate::pointer::record_field::<PivotMeltModal>(row_area, row);
255        y += 1;
256    }
257
258    // The focused row's Picker drops in below the rows and reaches down to the
259    // spec line; the selection carries the rail while the list is up.
260    if let Some(state) = &modal.picker {
261        // It owns the keys even with no room to draw: the rows take no clicks.
262        crate::pointer::record(area, Hit::Picker);
263        let picker_y = y + 1;
264        if picker_y < spec_y {
265            let picker_area = Rect {
266                x: area.x + 2,
267                y: picker_y,
268                width: area.width.saturating_sub(2),
269                height: spec_y - picker_y,
270            };
271            let mut picker = Picker::from_state(state, true);
272            if modal.is_multi_row(modal.focus) {
273                let marks = state
274                    .filtered()
275                    .into_iter()
276                    .map(|(_, item)| modal.is_marked(item))
277                    .collect();
278                picker = picker.marks(marks);
279            }
280            picker.render(picker_area, buf, ctx);
281        }
282    }
283
284    // The full spec, echoed: a mis-aimed aggregation is catchable here, before
285    // it runs. While the spec is incomplete, the line says what is missing.
286    let spec = match modal.active_tab {
287        PivotMeltTab::Pivot => modal.pivot_spec_line(g),
288        PivotMeltTab::Melt => modal.melt_spec_line(),
289    };
290    let (text, style) = match spec {
291        Ok(line) => (line, Style::default().fg(ctx.text_primary)),
292        // Enter on the incomplete form lit the line up; edits dim it again.
293        Err(gap) if modal.attention => (gap, Style::default().fg(ctx.warning)),
294        Err(gap) => (gap, Style::default().fg(ctx.dimmed)),
295    };
296    let text = crate::glyphs::fit_cells(&text, area.width as usize, g.ellipsis).into_owned();
297    Paragraph::new(text).style(style).render(
298        Rect {
299            y: spec_y,
300            height: 1,
301            ..area
302        },
303        buf,
304    );
305}
306
307fn echo_or_placeholder<'a>(value: &'a str, placeholder: &'a str) -> FormValue<'a> {
308    if value.is_empty() {
309        FormValue::Placeholder(placeholder)
310    } else {
311        FormValue::Choice(value)
312    }
313}
314
315/// What the preview runs over: all of a small view, or its first rows.
316pub fn input_line(preview: &ReshapePreview) -> String {
317    let n = numfmt::group_chrome;
318    let whole = match &preview.input {
319        Some(input) => input.whole.then(|| input.rows.height()),
320        None => preview.view_rows.filter(|rows| *rows <= PREVIEW_INPUT_ROWS),
321    };
322    if let Some(rows) = whole {
323        return format!("all {} rows", n(rows));
324    }
325    let of = preview
326        .view_rows
327        .map_or(String::new(), |rows| format!(" of {}", n(rows)));
328    let unsorted = if preview.sorted { ", unsorted" } else { "" };
329    format!("first {} rows{of}{unsorted}", n(PREVIEW_INPUT_ROWS))
330}
331
332/// The result's shape, `rows × columns`: exact when the head is the whole view;
333/// otherwise `?` for what the head cannot say, and a pivot's columns as a floor.
334pub fn shape_line(preview: &ReshapePreview, frame: &PreviewFrame) -> String {
335    let g = crate::glyphs::get();
336    let n = numfmt::group_chrome;
337    let whole = preview.input.as_ref().is_some_and(|input| input.whole);
338    let (rows, columns) = if whole {
339        (n(frame.rows), n(frame.columns))
340    } else if frame.new_columns.is_some() {
341        ("?".to_string(), format!("{}+", n(frame.columns)))
342    } else {
343        // Each row of the view becomes one per value column.
344        let rows = match (preview.view_rows, frame.melted_columns) {
345            (Some(rows), Some(each)) => n(rows.saturating_mul(each)),
346            _ => "?".to_string(),
347        };
348        (rows, n(frame.columns))
349    };
350    format!("{rows} rows {} {columns} columns", g.times)
351}
352
353/// A callout for a surprise: a pivot that makes many columns.
354pub fn wide_pivot_callout(preview: &ReshapePreview, frame: &PreviewFrame) -> Option<String> {
355    let new = frame.new_columns?;
356    if new < PREVIEW_WIDE_PIVOT {
357        return None;
358    }
359    let n = numfmt::group_chrome;
360    let whole = preview.input.as_ref().is_some_and(|input| input.whole);
361    let input_rows = preview
362        .input
363        .as_ref()
364        .map_or(0, |input| input.rows.height());
365    Some(if whole {
366        format!("{} new columns", n(new))
367    } else {
368        format!(
369            "{} new columns from {} rows; the whole view may make more",
370            n(new),
371            n(input_rows)
372        )
373    })
374}
375
376fn render_preview(area: Rect, buf: &mut Buffer, modal: &PivotMeltModal, ctx: &RenderContext) {
377    if area.height == 0 || area.width < 10 {
378        return;
379    }
380    let g = crate::glyphs::get();
381    let preview = &modal.preview;
382    SectionRule {
383        title: "Preview",
384        chip: None,
385    }
386    .render(Rect { height: 1, ..area }, buf, ctx);
387
388    let fresh = preview
389        .shown
390        .as_ref()
391        .filter(|(spec, _)| preview.wanted.as_ref() == Some(spec));
392    let computing = preview.wanted.is_some() && fresh.is_none();
393    let (result, callout): (String, Option<(String, Style)>) = match fresh {
394        _ if preview.wanted.is_none() => ("-".to_string(), None),
395        None => ("computing...".to_string(), None),
396        Some((_, Ok(frame))) => (
397            shape_line(preview, frame),
398            wide_pivot_callout(preview, frame).map(|text| (text, Style::default().fg(ctx.warning))),
399        ),
400        // One line: a message's own line breaks become spaces.
401        Some((_, Err(message))) => (
402            "-".to_string(),
403            Some((
404                message.split_whitespace().collect::<Vec<_>>().join(" "),
405                Style::default().fg(ctx.warning),
406            )),
407        ),
408    };
409
410    let width = area.width as usize;
411    let pane_line = |label: &str, value: &str| {
412        let value = crate::glyphs::fit_cells(value, width.saturating_sub(PANE_LABEL), g.ellipsis)
413            .into_owned();
414        Line::from(vec![
415            Span::styled(
416                format!("{label:<PANE_LABEL$}"),
417                Style::default().fg(ctx.label),
418            ),
419            Span::styled(value, Style::default().fg(ctx.text_primary)),
420        ])
421    };
422    let mut y = area.y + 1;
423    let bottom = area.y + area.height;
424    let mut line = |line: Line, y: &mut u16| {
425        if *y < bottom {
426            Paragraph::new(line).render(
427                Rect {
428                    y: *y,
429                    height: 1,
430                    ..area
431                },
432                buf,
433            );
434        }
435        *y += 1;
436    };
437    line(pane_line("Input", &input_line(preview)), &mut y);
438    line(pane_line("Result", &result), &mut y);
439    // The callout's row is kept whether or not there is one, so the grid holds
440    // still as answers arrive.
441    let callout = callout.map(|(text, style)| {
442        let text = format!("{} {text}", g.warning);
443        Line::styled(
444            crate::glyphs::fit_cells(&text, width, g.ellipsis).into_owned(),
445            style,
446        )
447    });
448    line(callout.unwrap_or_default(), &mut y);
449    y += 1;
450
451    // The last rows shown stay up, dimmed, while the next are computed.
452    let grid = match &preview.shown {
453        Some((_, Ok(frame))) if preview.wanted.is_some() => Some(&frame.head),
454        _ => None,
455    };
456    if let Some(head) = grid
457        && y < bottom
458    {
459        let grid_area = Rect {
460            y,
461            height: bottom - y,
462            ..area
463        };
464        render_grid(grid_area, buf, head, computing, ctx);
465    }
466}
467
468/// One column of the grid as drawn: name, type and cells, and its width.
469struct GridColumn {
470    name: String,
471    dtype: String,
472    color: ratatui::style::Color,
473    right: bool,
474    cells: Vec<Option<String>>,
475    width: usize,
476}
477
478fn grid_columns(
479    head: &DataFrame,
480    rows: usize,
481    limit: usize,
482    ctx: &RenderContext,
483) -> Vec<GridColumn> {
484    let g = crate::glyphs::get();
485    let mut scratch = String::new();
486    head.columns()
487        .iter()
488        .take(limit)
489        .map(|column| {
490            let name = column.name().to_string();
491            let dtype = column.dtype();
492            let fmt = ctx.number_format.formatter_for(&name, dtype);
493            let cells: Vec<Option<String>> = (0..rows.min(column.len()))
494                .map(|i| match column.get(i) {
495                    Ok(AnyValue::Null) | Err(_) => None,
496                    Ok(value) => Some(crate::exact::cell_preview(
497                        &numfmt::format_any_value(&fmt, &value, &mut scratch),
498                        g,
499                    )),
500                })
501                .collect();
502            let type_label = crate::widgets::datatable::dtype_label(dtype);
503            let widest = cells
504                .iter()
505                .map(|c| c.as_deref().map_or(1, crate::glyphs::cell_width))
506                .chain([
507                    crate::glyphs::cell_width(&name),
508                    if ctx.dtype_row {
509                        type_label.chars().count()
510                    } else {
511                        0
512                    },
513                ])
514                .max()
515                .unwrap_or(1);
516            GridColumn {
517                color: ctx.type_color(dtype),
518                right: ctx.number_format.align_numeric_right
519                    && numfmt::is_right_aligned_dtype(dtype),
520                name,
521                dtype: type_label,
522                cells,
523                width: widest.clamp(1, MAX_CELL),
524            }
525        })
526        .collect()
527}
528
529/// The preview's rows: header names in their type's color, the type row when the
530/// table shows one, then the cells, nulls as the null glyph. Columns that do not
531/// fit are counted at the end of the header.
532fn render_grid(area: Rect, buf: &mut Buffer, head: &DataFrame, stale: bool, ctx: &RenderContext) {
533    let g = crate::glyphs::get();
534    let header_rows = if ctx.dtype_row { 2 } else { 1 };
535    let body_rows = (area.height as usize).saturating_sub(header_rows);
536    let total = area.width as usize;
537    // Each column takes a cell and a gap at least: only those that could fit are
538    // formatted.
539    let columns = grid_columns(head, body_rows, total / 3 + 1, ctx);
540
541    // Which columns fit, keeping room to say how many do not.
542    let mut shown = 0;
543    let mut used = 0;
544    for (i, column) in columns.iter().enumerate() {
545        let left = head.width() - i - 1;
546        let more = if left > 0 {
547            format!("  +{left}").len()
548        } else {
549            0
550        };
551        let needed = used + if i > 0 { 2 } else { 0 } + column.width;
552        if needed + more > total && i > 0 {
553            break;
554        }
555        used = needed.min(total);
556        shown += 1;
557    }
558    let hidden = head.width() - shown;
559
560    let style_for = |color| {
561        if stale {
562            Style::default().fg(ctx.dimmed)
563        } else {
564            Style::default().fg(color)
565        }
566    };
567    let header_style = Style::default().bg(ctx.table_header_bg);
568    for row in 0..header_rows.min(area.height as usize) {
569        buf.set_style(
570            Rect {
571                y: area.y + row as u16,
572                height: 1,
573                ..area
574            },
575            header_style,
576        );
577    }
578    let put = |buf: &mut Buffer, x: usize, y: u16, w: usize, text: &str, right: bool, style| {
579        let text = crate::glyphs::fit_cells(text, w, g.ellipsis);
580        let pad = w.saturating_sub(crate::glyphs::cell_width(&text));
581        let x = area.x as usize + x + if right { pad } else { 0 };
582        buf.set_stringn(x as u16, y, text.as_ref(), w, style);
583    };
584    let mut x = 0;
585    for column in columns.iter().take(shown) {
586        let w = column.width.min(total.saturating_sub(x));
587        put(
588            buf,
589            x,
590            area.y,
591            w,
592            &column.name,
593            column.right,
594            style_for(column.color)
595                .bg(ctx.table_header_bg)
596                .add_modifier(Modifier::BOLD),
597        );
598        if ctx.dtype_row && area.height > 1 {
599            put(
600                buf,
601                x,
602                area.y + 1,
603                w,
604                &column.dtype,
605                column.right,
606                Style::default().fg(ctx.dimmed).bg(ctx.table_header_bg),
607            );
608        }
609        for (r, cell) in column.cells.iter().enumerate() {
610            let y = area.y + (header_rows + r) as u16;
611            if y >= area.y + area.height {
612                break;
613            }
614            let (text, style) = match cell {
615                Some(text) => (text.as_str(), style_for(ctx.text_primary)),
616                None => (g.null, Style::default().fg(ctx.dimmed)),
617            };
618            let style = match ctx.alternate_row_color {
619                Some(bg) if r % 2 == 1 => style.bg(bg),
620                _ => style,
621            };
622            put(buf, x, y, w, text, column.right, style);
623        }
624        x += column.width + 2;
625    }
626    if hidden > 0 {
627        let more = format!("+{hidden}");
628        let at = (used + 2).min(total.saturating_sub(more.len()));
629        put(
630            buf,
631            at,
632            area.y,
633            more.len().min(total),
634            &more,
635            false,
636            Style::default().fg(ctx.dimmed).bg(ctx.table_header_bg),
637        );
638    }
639    // Alternate rows tint the whole line, as the table's do.
640    if let Some(bg) = ctx.alternate_row_color {
641        for r in (1..body_rows.min(head.height())).step_by(2) {
642            let y = area.y + (header_rows + r) as u16;
643            if y < area.y + area.height {
644                buf.set_style(
645                    Rect {
646                        y,
647                        height: 1,
648                        ..area
649                    },
650                    Style::default().bg(bg),
651                );
652            }
653        }
654    }
655}
656
657#[cfg(test)]
658mod tests {
659    use super::*;
660    use crate::pivot_melt_modal::{
661        PivotAggregation, PivotSpec, PreviewInput, PreviewSpec, run_preview,
662    };
663    use polars::prelude::*;
664
665    fn modal_with_columns(columns: &[&str]) -> PivotMeltModal {
666        let mut m = PivotMeltModal::new();
667        m.available_columns = columns.iter().map(|s| s.to_string()).collect();
668        let config = crate::config::AppConfig::default();
669        let theme = crate::config::Theme::from_config(&config.theme).unwrap();
670        m.open(1000, &theme);
671        m
672    }
673
674    fn render_rows(modal: &mut PivotMeltModal, width: u16, height: u16) -> Vec<String> {
675        let ctx = RenderContext::for_test();
676        let area = Rect::new(0, 0, width, height);
677        let mut buf = Buffer::empty(area);
678        render(area, &mut buf, modal, &ctx);
679        (0..height)
680            .map(|y| {
681                (0..width)
682                    .map(|x| buf[(x, y)].symbol().to_string())
683                    .collect::<String>()
684            })
685            .collect()
686    }
687
688    fn long() -> DataFrame {
689        df!(
690            "dept" => ["a", "a", "b", "b"],
691            "job" => ["x", "y", "x", "y"],
692            "salary" => [1i64, 2, 3, 4],
693        )
694        .unwrap()
695    }
696
697    fn pivot_spec() -> PivotSpec {
698        PivotSpec {
699            index: vec!["dept".to_string()],
700            pivot_column: "job".to_string(),
701            value_column: "salary".to_string(),
702            aggregation: PivotAggregation::Avg,
703            sort_columns: None,
704        }
705    }
706
707    /// A modal staged for `pivot_spec`, with the preview answered over `input`.
708    fn previewed(input: DataFrame, whole: bool, view_rows: Option<usize>) -> PivotMeltModal {
709        let mut m = modal_with_columns(&["dept", "job", "salary"]);
710        m.index_columns = vec!["dept".to_string()];
711        m.pivot_column = Some("job".to_string());
712        m.value_column = Some("salary".to_string());
713        m.aggregation = PivotAggregation::Avg;
714        let spec = PreviewSpec::Pivot(pivot_spec());
715        let result = run_preview(&input, &spec);
716        m.preview.input = Some(PreviewInput {
717            rows: std::sync::Arc::new(input),
718            whole,
719        });
720        m.preview.view_rows = view_rows;
721        m.preview.wanted = Some(spec.clone());
722        m.preview.shown = Some((spec, result));
723        m
724    }
725
726    /// Wide: one border, the form on the left with each choice echoed at the
727    /// shared column, and the preview on the right with the shape and the rows.
728    #[test]
729    fn the_wide_builder_puts_the_preview_beside_the_form() {
730        let g = crate::glyphs::get();
731        let mut m = previewed(long(), true, Some(4));
732        let rows = render_rows(&mut m, 120, 24);
733
734        assert!(rows[0].contains("Pivot & Melt"), "title on the frame");
735        for row in &rows[1..23] {
736            assert!(
737                !row.contains('╭') && !row.contains('╰'),
738                "a second border inside the surface: {row:?}"
739            );
740        }
741        assert!(rows[1].contains("Pivot") && rows[1].contains("Melt"));
742        assert!(rows[3].contains("Index:") && rows[3].contains("dept"));
743        assert!(rows[4].contains("Columns:") && rows[4].contains("job"));
744        assert!(rows[5].contains("Values:") && rows[5].contains("salary"));
745        assert!(rows[6].contains("Aggregate:") && rows[6].contains("avg"));
746        let char_col = |s: &str, needle: &str| s.find(needle).map(|b| s[..b].chars().count());
747        for (row, value) in [(3, "dept"), (4, "job"), (5, "salary"), (6, "avg")] {
748            assert_eq!(
749                char_col(&rows[row], value),
750                Some(2 + 1 + LABEL_WIDTH as usize),
751                "row {row} value out of column: {:?}",
752                rows[row]
753            );
754        }
755        let spec = format!("dept {} job {} avg(salary)", g.times, g.arrow_right);
756        assert!(rows[22].contains(&spec), "the spec line: {:?}", rows[22]);
757
758        // The preview starts past the form.
759        let at = 2 + FORM_WIDTH as usize + GAP as usize;
760        assert_eq!(char_col(&rows[1], "Preview"), Some(at), "{:?}", rows[1]);
761        assert!(rows[2].contains("Input") && rows[2].contains("all 4 rows"));
762        assert!(
763            rows[3].contains(&format!("2 rows {} 3 columns", g.times)),
764            "the exact shape: {:?}",
765            rows[3]
766        );
767        // Names, types, then the rows.
768        assert!(rows[6].contains("dept") && rows[6].contains('x') && rows[6].contains('y'));
769        assert!(rows[7].contains("str") && rows[7].contains("f64"));
770        assert!(rows[8].contains('a') && rows[8].contains("1.0") && rows[8].contains("2.0"));
771        assert!(rows[9].contains('b') && rows[9].contains("3.0") && rows[9].contains("4.0"));
772    }
773
774    /// Narrow: the form on top, the preview under it, nothing cut off the side.
775    #[test]
776    fn a_narrow_builder_stacks_the_preview_under_the_form() {
777        let g = crate::glyphs::get();
778        let mut m = previewed(long(), true, Some(4));
779        let rows = render_rows(&mut m, 70, 30);
780        let find = |needle: &str| rows.iter().position(|r| r.contains(needle));
781        let spec = find(&format!("dept {} job", g.times)).expect("spec line");
782        let preview = find("Preview").expect("preview rule");
783        assert!(preview > spec, "the preview is under the form: {rows:#?}");
784        assert!(find("Aggregate:").unwrap() < spec);
785        assert!(rows[preview + 2].contains(&format!("2 rows {} 3 columns", g.times)));
786    }
787
788    /// Only part of the view read: the rows are unknown and a pivot's columns
789    /// a floor; the input line says how much was read.
790    #[test]
791    fn a_head_says_what_it_cannot_know() {
792        let g = crate::glyphs::get();
793        let m = previewed(long(), false, Some(1_939_184));
794        let (_, Ok(frame)) = m.preview.shown.as_ref().unwrap() else {
795            panic!("the pivot runs");
796        };
797        assert_eq!(
798            shape_line(&m.preview, frame),
799            format!("? rows {} 3+ columns", g.times)
800        );
801        assert_eq!(input_line(&m.preview), "first 1,000 rows of 1,939,184");
802        let mut m = m;
803        m.preview.sorted = true;
804        assert_eq!(
805            input_line(&m.preview),
806            "first 1,000 rows of 1,939,184, unsorted"
807        );
808    }
809
810    /// Many new columns: the callout says so, before anything is applied.
811    #[test]
812    fn a_wide_pivot_is_called_out() {
813        let n = 150;
814        let input = df!(
815            "dept" => vec!["a"; n],
816            "job" => (0..n).map(|i| format!("j{i:03}")).collect::<Vec<_>>(),
817            "salary" => (0..n as i64).collect::<Vec<_>>(),
818        )
819        .unwrap();
820        let mut m = previewed(input, false, None);
821        let rows = render_rows(&mut m, 120, 24);
822        let body = rows.join("\n");
823        assert!(
824            body.contains("150 new columns from 150 rows"),
825            "the callout: {body}"
826        );
827        assert!(
828            body.contains("+1"),
829            "columns that do not fit are counted: {body}"
830        );
831    }
832
833    /// A failed reshape says why in the preview, not in a modal.
834    #[test]
835    fn a_failed_preview_says_why_in_the_pane() {
836        let mut m = previewed(long(), true, None);
837        let spec = m.preview.wanted.clone().unwrap();
838        m.preview.shown = Some((spec, Err("Something is wrong".to_string())));
839        let body = render_rows(&mut m, 120, 24).join("\n");
840        assert!(body.contains("Something is wrong"), "{body}");
841    }
842
843    /// Before anything is chosen the rows say "none", the spec line names the
844    /// first gap, and the preview shows no result.
845    #[test]
846    fn an_empty_form_shows_placeholders_and_the_gap() {
847        let mut m = modal_with_columns(&["a", "b"]);
848        let rows = render_rows(&mut m, 120, 24);
849        assert!(rows[3].contains("none"), "empty index: {:?}", rows[3]);
850        assert!(
851            rows[22].contains("Select at least one index column."),
852            "the gap is named: {:?}",
853            rows[22]
854        );
855        let result = rows[3].find("Result").expect("the result line");
856        assert!(
857            rows[3][result + "Result".len()..]
858                .trim_start()
859                .starts_with('-'),
860            "no result yet: {:?}",
861            rows[3]
862        );
863    }
864
865    /// The open Picker drops in below the rows, scoped to the focused row,
866    /// with a checkbox per item on a toggle row.
867    #[test]
868    fn the_index_picker_shows_toggles() {
869        let g = crate::glyphs::get();
870        let mut m = modal_with_columns(&["dept", "region", "salary"]);
871        m.focus = PivotMeltFocus::PivotIndex;
872        m.open_picker();
873        m.picker_toggle(); // dept in
874        let rows = render_rows(&mut m, 120, 24);
875        let body = rows.join("\n");
876        assert_eq!(
877            body.matches(g.rail).count(),
878            1,
879            "the picker's line has the one rail, not its row too: {body}"
880        );
881        assert!(body.contains(&format!("{} dept", g.checkbox_on)), "{body}");
882        assert!(
883            body.contains(&format!("{} region", g.checkbox_off)),
884            "{body}"
885        );
886        let keys: Vec<(String, String)> = hints(&m)
887            .into_iter()
888            .map(|h| (h.key.into_owned(), h.label.into_owned()))
889            .collect();
890        assert_eq!(
891            keys[0],
892            ("Space".to_string(), "Toggle".to_string()),
893            "the footer says Space toggles: {keys:?}"
894        );
895    }
896
897    /// An open Picker owns the clicks even on a terminal too short to draw it, so a
898    /// click cannot move focus off the row it belongs to.
899    #[test]
900    fn an_open_picker_with_no_room_still_takes_the_clicks() {
901        let mut m = modal_with_columns(&["dept", "region", "salary"]);
902        m.focus = PivotMeltFocus::PivotIndex;
903        m.open_picker();
904        let hits = crate::pointer::recording(|| {
905            render_rows(&mut m, 120, 9);
906        });
907        assert!(hits.iter().any(|(_, h)| *h == Hit::Picker), "{hits:?}");
908        assert!(
909            !hits
910                .iter()
911                .any(|(_, h)| matches!(h, Hit::PickerItem { .. }))
912        );
913    }
914
915    /// The melt form swaps its strategy row's dependents in place.
916    #[test]
917    fn the_melt_form_follows_the_strategy() {
918        let mut m = modal_with_columns(&["id", "q1", "q2"]);
919        m.switch_tab();
920        m.melt_value_strategy = crate::pivot_melt_modal::MeltValueStrategy::ByPattern;
921        let rows = render_rows(&mut m, 120, 24);
922        assert!(rows[5].contains("Pattern:"), "got {:?}", rows[5]);
923        assert!(rows[6].contains("Variable name:"), "got {:?}", rows[6]);
924        assert!(rows[7].contains("Value name:") && rows[7].contains("value"));
925    }
926
927    /// Every hint the footer shows has its label in the key registry, at every
928    /// kind of field and in either picker; never more than three.
929    #[test]
930    fn every_footer_hint_has_a_registry_label() {
931        let mut m = modal_with_columns(&["id", "q1", "q2"]);
932        let check = |m: &PivotMeltModal| {
933            let keys = hints(m);
934            assert!((2..=3).contains(&keys.len()), "{:?}", m.focus);
935            for hint in keys {
936                assert!(!hint.label.is_empty(), "{:?} {}", m.focus, hint.key);
937            }
938        };
939        for focus in [
940            PivotMeltFocus::TabBar,
941            PivotMeltFocus::PivotIndex,
942            PivotMeltFocus::PivotColumn,
943            PivotMeltFocus::PivotAggregation,
944        ] {
945            m.focus = focus;
946            check(&m);
947            m.open_picker();
948            check(&m);
949            m.picker = None;
950        }
951        m.switch_tab();
952        m.focus = PivotMeltFocus::MeltVariable;
953        check(&m);
954        assert!(question_types(&m), "a text field types ?");
955    }
956
957    #[test]
958    fn a_tiny_area_never_panics() {
959        for (w, h) in [(0, 0), (3, 2), (10, 4), (20, 6), (50, 8), (60, 20), (97, 9)] {
960            let mut m = previewed(long(), false, None);
961            m.focus = PivotMeltFocus::PivotIndex;
962            m.open_picker();
963            let _ = render_rows(&mut m, w, h);
964        }
965    }
966}