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::app::modals::pivot_melt_modal::{
8    PREVIEW_INPUT_ROWS, PREVIEW_WIDE_PIVOT, PivotMeltFocus, PivotMeltModal, PivotMeltTab,
9    PreviewFrame, ReshapePreview,
10};
11use crate::app::pointer::{FieldId, Hit};
12use crate::numfmt;
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            vec![form("Enter"), form("← / →"), form("Esc")]
74        }
75        None => vec![form("Enter"), form("Esc")],
76    }
77}
78
79/// Whether `?` types in the builder right now (a picker narrows, a text field
80/// types), so help is F1.
81pub fn question_types(modal: &PivotMeltModal) -> bool {
82    modal.picker.is_some() || modal.is_text_row(modal.focus)
83}
84
85/// Draw the builder over `area`, the whole screen above the status footer, which
86/// carries its keys ([`hints`]).
87pub fn render(area: Rect, buf: &mut Buffer, modal: &mut PivotMeltModal, ctx: &RenderContext) {
88    let content = Surface::new("Pivot & Melt").render(area, buf, ctx);
89    if content.height < 4 || content.width < 10 {
90        return;
91    }
92    let (form, preview) = split(content, modal);
93    render_form(form, buf, modal, ctx);
94    if let Some(preview) = preview {
95        render_preview(preview, buf, modal, ctx);
96    }
97}
98
99/// The form's area and the preview's: side by side when there is room, else the
100/// form on top at the height it needs and the preview under it.
101pub fn split(content: Rect, modal: &PivotMeltModal) -> (Rect, Option<Rect>) {
102    if content.width >= SIDE_BY_SIDE {
103        let form = Rect {
104            width: FORM_WIDTH,
105            ..content
106        };
107        let preview = Rect {
108            x: content.x + FORM_WIDTH + GAP,
109            width: content.width - FORM_WIDTH - GAP,
110            ..content
111        };
112        return (form, Some(preview));
113    }
114    let form_height = form_height(modal).min(content.height);
115    let form = Rect {
116        height: form_height,
117        ..content
118    };
119    // A blank row between them; the preview only where it has room for its lines.
120    let rest = content.height.saturating_sub(form_height + 1);
121    let preview = (rest >= 3).then(|| Rect {
122        y: content.y + form_height + 1,
123        height: rest,
124        ..content
125    });
126    (form, preview)
127}
128
129/// Rows the form takes when stacked: the tab line and a blank, a row per field,
130/// the open Picker under them, then a blank and the spec line.
131fn form_height(modal: &PivotMeltModal) -> u16 {
132    let rows = modal.row_order().len() as u16;
133    let picker = modal
134        .picker
135        .as_ref()
136        .map_or(0, |state| 1 + (state.filtered().len() as u16).clamp(1, 8));
137    2 + rows + picker + 2
138}
139
140fn render_form(area: Rect, buf: &mut Buffer, modal: &mut PivotMeltModal, ctx: &RenderContext) {
141    if area.height < 3 || area.width < 10 {
142        return;
143    }
144    // Tab line: the active tab carries the accent, and the rail sits beside
145    // its name while the tab bar holds focus — never beside a tab the
146    // surface is not on. The slot is reserved either way, so nothing moves.
147    let g = crate::glyphs::get();
148    let pivot_tab = modal.active_tab == PivotMeltTab::Pivot;
149    let on_tab_bar = modal.focus == PivotMeltFocus::TabBar;
150    let mark = |active: bool| {
151        if on_tab_bar && active { g.rail } else { " " }
152    };
153    let tab_style = |active: bool| {
154        if active {
155            Style::default().fg(ctx.accent).add_modifier(Modifier::BOLD)
156        } else {
157            Style::default().fg(ctx.text_secondary)
158        }
159    };
160    let tab_line = Line::from(vec![
161        Span::styled(mark(pivot_tab), Style::default().fg(ctx.accent)),
162        Span::styled("Pivot", tab_style(pivot_tab)),
163        Span::styled(format!(" {}", g.rule), Style::default().fg(ctx.dimmed)),
164        Span::styled(mark(!pivot_tab), Style::default().fg(ctx.accent)),
165        Span::styled("Melt", tab_style(!pivot_tab)),
166    ]);
167    let tab_area = Rect { height: 1, ..area };
168    let field = Some(FieldId::of::<PivotMeltModal>(PivotMeltFocus::TabBar));
169    let current = usize::from(!pivot_tab);
170    crate::app::pointer::record_spans(
171        tab_area,
172        &tab_line,
173        [1, 4]
174            .into_iter()
175            .enumerate()
176            .map(|(index, span)| {
177                let hit = Hit::Option {
178                    field: field.clone(),
179                    index,
180                    current,
181                };
182                (span, hit)
183            })
184            .collect(),
185    );
186    Paragraph::new(tab_line).render(tab_area, buf);
187
188    // The spec line sits on the form's last row.
189    let spec_y = area.y + area.height - 1;
190
191    modal
192        .melt_pattern_input
193        .set_focused(modal.focus == PivotMeltFocus::MeltPattern);
194    modal
195        .melt_variable_input
196        .set_focused(modal.focus == PivotMeltFocus::MeltVariable);
197    modal
198        .melt_value_input
199        .set_focused(modal.focus == PivotMeltFocus::MeltValue);
200
201    // One FormRow per field; the chosen value is always echoed on the row, so
202    // nothing is ambiguous when focus is elsewhere.
203    let rows = modal.row_order();
204    let mut y = area.y + 2;
205    for &row in rows {
206        if y >= spec_y {
207            break;
208        }
209        let echo;
210        let value = match row {
211            PivotMeltFocus::PivotIndex => {
212                echo = modal.index_columns.join(", ");
213                echo_or_placeholder(&echo, "none")
214            }
215            PivotMeltFocus::PivotColumn => {
216                echo_or_placeholder(modal.pivot_column.as_deref().unwrap_or(""), "none")
217            }
218            PivotMeltFocus::PivotValue => {
219                echo_or_placeholder(modal.value_column.as_deref().unwrap_or(""), "none")
220            }
221            PivotMeltFocus::PivotAggregation => FormValue::Choice(modal.aggregation.as_str()),
222            PivotMeltFocus::MeltIndex => {
223                echo = modal.melt_index_columns.join(", ");
224                echo_or_placeholder(&echo, "none")
225            }
226            PivotMeltFocus::MeltStrategy => FormValue::Choice(modal.melt_value_strategy.as_str()),
227            PivotMeltFocus::MeltPattern => FormValue::Input(&modal.melt_pattern_input),
228            PivotMeltFocus::MeltType => FormValue::Choice(modal.melt_type_filter.as_str()),
229            PivotMeltFocus::MeltColumns => {
230                echo = modal.melt_explicit_list.join(", ");
231                echo_or_placeholder(&echo, "none")
232            }
233            PivotMeltFocus::MeltVariable => FormValue::Input(&modal.melt_variable_input),
234            PivotMeltFocus::MeltValue => FormValue::Input(&modal.melt_value_input),
235            PivotMeltFocus::TabBar => continue,
236        };
237        let row_area = Rect {
238            y,
239            height: 1,
240            ..area
241        };
242        FormRow {
243            label: row_label(row),
244            value,
245            focused: modal.focus == row,
246            label_width: LABEL_WIDTH,
247        }
248        .render_picking(row_area, buf, ctx, modal.picker.is_some());
249        crate::app::pointer::record_field::<PivotMeltModal>(row_area, row);
250        y += 1;
251    }
252
253    // The focused row's Picker drops in below the rows and reaches down to the
254    // spec line; the selection carries the rail while the list is up.
255    if let Some(state) = &modal.picker {
256        // It owns the keys even with no room to draw: the rows take no clicks.
257        crate::app::pointer::record(area, Hit::Picker);
258        let picker_y = y + 1;
259        if picker_y < spec_y {
260            let picker_area = Rect {
261                x: area.x + 2,
262                y: picker_y,
263                width: area.width.saturating_sub(2),
264                height: spec_y - picker_y,
265            };
266            let mut picker = Picker::from_state(state, true);
267            if modal.is_multi_row(modal.focus) {
268                let marks = state
269                    .filtered()
270                    .into_iter()
271                    .map(|(_, item)| modal.is_marked(item))
272                    .collect();
273                picker = picker.marks(marks);
274            }
275            picker.render(picker_area, buf, ctx);
276        }
277    }
278
279    // The full spec, echoed: a mis-aimed aggregation is catchable here, before
280    // it runs. While the spec is incomplete, the line says what is missing.
281    let spec = match modal.active_tab {
282        PivotMeltTab::Pivot => modal.pivot_spec_line(g),
283        PivotMeltTab::Melt => modal.melt_spec_line(),
284    };
285    let (text, style) = match spec {
286        Ok(line) => (line, Style::default().fg(ctx.text_primary)),
287        // Enter on the incomplete form lit the line up; edits dim it again.
288        Err(gap) if modal.attention => (gap, Style::default().fg(ctx.warning)),
289        Err(gap) => (gap, Style::default().fg(ctx.dimmed)),
290    };
291    let text = crate::glyphs::fit_cells(&text, area.width as usize, g.ellipsis).into_owned();
292    Paragraph::new(text).style(style).render(
293        Rect {
294            y: spec_y,
295            height: 1,
296            ..area
297        },
298        buf,
299    );
300}
301
302fn echo_or_placeholder<'a>(value: &'a str, placeholder: &'a str) -> FormValue<'a> {
303    if value.is_empty() {
304        FormValue::Placeholder(placeholder)
305    } else {
306        FormValue::Choice(value)
307    }
308}
309
310/// What the preview runs over: all of a small view, or its first rows.
311pub fn input_line(preview: &ReshapePreview) -> String {
312    let n = numfmt::group_chrome;
313    let whole = match &preview.input {
314        Some(input) => input.whole.then(|| input.rows.height()),
315        None => preview.view_rows.filter(|rows| *rows <= PREVIEW_INPUT_ROWS),
316    };
317    if let Some(rows) = whole {
318        return format!("all {} rows", n(rows));
319    }
320    let of = preview
321        .view_rows
322        .map_or(String::new(), |rows| format!(" of {}", n(rows)));
323    let unsorted = if preview.sorted { ", unsorted" } else { "" };
324    format!("first {} rows{of}{unsorted}", n(PREVIEW_INPUT_ROWS))
325}
326
327/// The result's shape, `rows × columns`: exact when the head is the whole view;
328/// otherwise `?` for what the head cannot say, and a pivot's columns as a floor.
329pub fn shape_line(preview: &ReshapePreview, frame: &PreviewFrame) -> String {
330    let g = crate::glyphs::get();
331    let n = numfmt::group_chrome;
332    let whole = preview.input.as_ref().is_some_and(|input| input.whole);
333    let (rows, columns) = if whole {
334        (n(frame.rows), n(frame.columns))
335    } else if frame.new_columns.is_some() {
336        ("?".to_string(), format!("{}+", n(frame.columns)))
337    } else {
338        // Each row of the view becomes one per value column.
339        let rows = match (preview.view_rows, frame.melted_columns) {
340            (Some(rows), Some(each)) => n(rows.saturating_mul(each)),
341            _ => "?".to_string(),
342        };
343        (rows, n(frame.columns))
344    };
345    format!("{rows} rows {} {columns} columns", g.times)
346}
347
348/// A callout for a surprise: a pivot that makes many columns.
349pub fn wide_pivot_callout(preview: &ReshapePreview, frame: &PreviewFrame) -> Option<String> {
350    let new = frame.new_columns?;
351    if new < PREVIEW_WIDE_PIVOT {
352        return None;
353    }
354    let n = numfmt::group_chrome;
355    let whole = preview.input.as_ref().is_some_and(|input| input.whole);
356    let input_rows = preview
357        .input
358        .as_ref()
359        .map_or(0, |input| input.rows.height());
360    Some(if whole {
361        format!("{} new columns", n(new))
362    } else {
363        format!(
364            "{} new columns from {} rows; the whole view may make more",
365            n(new),
366            n(input_rows)
367        )
368    })
369}
370
371fn render_preview(area: Rect, buf: &mut Buffer, modal: &PivotMeltModal, ctx: &RenderContext) {
372    if area.height == 0 || area.width < 10 {
373        return;
374    }
375    let g = crate::glyphs::get();
376    let preview = &modal.preview;
377    SectionRule {
378        title: "Preview",
379        chip: None,
380    }
381    .render(Rect { height: 1, ..area }, buf, ctx);
382
383    let fresh = preview
384        .shown
385        .as_ref()
386        .filter(|(spec, _)| preview.wanted.as_ref() == Some(spec));
387    let computing = preview.wanted.is_some() && fresh.is_none();
388    let (result, callout): (String, Option<(String, Style)>) = match fresh {
389        _ if preview.wanted.is_none() => ("-".to_string(), None),
390        None => ("computing...".to_string(), None),
391        Some((_, Ok(frame))) => (
392            shape_line(preview, frame),
393            wide_pivot_callout(preview, frame).map(|text| (text, Style::default().fg(ctx.warning))),
394        ),
395        // One line: a message's own line breaks become spaces.
396        Some((_, Err(message))) => (
397            "-".to_string(),
398            Some((
399                message.split_whitespace().collect::<Vec<_>>().join(" "),
400                Style::default().fg(ctx.warning),
401            )),
402        ),
403    };
404
405    let width = area.width as usize;
406    let pane_line = |label: &str, value: &str| {
407        let value = crate::glyphs::fit_cells(value, width.saturating_sub(PANE_LABEL), g.ellipsis)
408            .into_owned();
409        Line::from(vec![
410            Span::styled(
411                format!("{label:<PANE_LABEL$}"),
412                Style::default().fg(ctx.label),
413            ),
414            Span::styled(value, Style::default().fg(ctx.text_primary)),
415        ])
416    };
417    let mut y = area.y + 1;
418    let bottom = area.y + area.height;
419    let mut line = |line: Line, y: &mut u16| {
420        if *y < bottom {
421            Paragraph::new(line).render(
422                Rect {
423                    y: *y,
424                    height: 1,
425                    ..area
426                },
427                buf,
428            );
429        }
430        *y += 1;
431    };
432    line(pane_line("Input", &input_line(preview)), &mut y);
433    line(pane_line("Result", &result), &mut y);
434    // The callout's row is kept whether or not there is one, so the grid holds
435    // still as answers arrive.
436    let callout = callout.map(|(text, style)| {
437        let text = format!("{} {text}", g.warning);
438        Line::styled(
439            crate::glyphs::fit_cells(&text, width, g.ellipsis).into_owned(),
440            style,
441        )
442    });
443    line(callout.unwrap_or_default(), &mut y);
444    y += 1;
445
446    // The last rows shown stay up, dimmed, while the next are computed.
447    let grid = match &preview.shown {
448        Some((_, Ok(frame))) if preview.wanted.is_some() => Some(&frame.head),
449        _ => None,
450    };
451    if let Some(head) = grid
452        && y < bottom
453    {
454        let grid_area = Rect {
455            y,
456            height: bottom - y,
457            ..area
458        };
459        render_grid(grid_area, buf, head, computing, ctx);
460    }
461}
462
463/// One column of the grid as drawn: name, type and cells, and its width.
464struct GridColumn {
465    name: String,
466    dtype: String,
467    color: ratatui::style::Color,
468    right: bool,
469    cells: Vec<Option<String>>,
470    width: usize,
471}
472
473fn grid_columns(
474    head: &DataFrame,
475    rows: usize,
476    limit: usize,
477    ctx: &RenderContext,
478) -> Vec<GridColumn> {
479    let g = crate::glyphs::get();
480    let mut scratch = String::new();
481    head.columns()
482        .iter()
483        .take(limit)
484        .map(|column| {
485            let name = column.name().to_string();
486            let dtype = column.dtype();
487            let fmt = ctx.number_format.formatter_for(&name, dtype);
488            let cells: Vec<Option<String>> = (0..rows.min(column.len()))
489                .map(|i| match column.get(i) {
490                    Ok(AnyValue::Null) | Err(_) => None,
491                    Ok(value) => Some(crate::exact::cell_preview(
492                        &numfmt::format_any_value(&fmt, &value, &mut scratch),
493                        g,
494                    )),
495                })
496                .collect();
497            let type_label = crate::formats::column_types::dtype_label(dtype);
498            let widest = cells
499                .iter()
500                .map(|c| c.as_deref().map_or(1, crate::glyphs::cell_width))
501                .chain([
502                    crate::glyphs::cell_width(&name),
503                    if ctx.dtype_row {
504                        crate::glyphs::display_width(&type_label)
505                    } else {
506                        0
507                    },
508                ])
509                .max()
510                .unwrap_or(1);
511            GridColumn {
512                color: ctx.type_color(dtype),
513                right: ctx.number_format.align_numeric_right
514                    && numfmt::is_right_aligned_dtype(dtype),
515                name,
516                dtype: type_label,
517                cells,
518                width: widest.clamp(1, MAX_CELL),
519            }
520        })
521        .collect()
522}
523
524/// The preview's rows: header names in their type's color, the type row when the
525/// table shows one, then the cells, nulls as the null glyph. Columns that do not
526/// fit are counted at the end of the header.
527fn render_grid(area: Rect, buf: &mut Buffer, head: &DataFrame, stale: bool, ctx: &RenderContext) {
528    let g = crate::glyphs::get();
529    let header_rows = if ctx.dtype_row { 2 } else { 1 };
530    let body_rows = (area.height as usize).saturating_sub(header_rows);
531    let total = area.width as usize;
532    // Each column takes a cell and a gap at least: only those that could fit are
533    // formatted.
534    let columns = grid_columns(head, body_rows, total / 3 + 1, ctx);
535
536    // Which columns fit, keeping room to say how many do not.
537    let mut shown = 0;
538    let mut used = 0;
539    for (i, column) in columns.iter().enumerate() {
540        let left = head.width() - i - 1;
541        let more = if left > 0 {
542            format!("  +{left}").len()
543        } else {
544            0
545        };
546        let needed = used + if i > 0 { 2 } else { 0 } + column.width;
547        if needed + more > total && i > 0 {
548            break;
549        }
550        used = needed.min(total);
551        shown += 1;
552    }
553    let hidden = head.width() - shown;
554
555    let style_for = |color| {
556        if stale {
557            Style::default().fg(ctx.dimmed)
558        } else {
559            Style::default().fg(color)
560        }
561    };
562    let header_style = Style::default().bg(ctx.table_header_bg);
563    for row in 0..header_rows.min(area.height as usize) {
564        buf.set_style(
565            Rect {
566                y: area.y + row as u16,
567                height: 1,
568                ..area
569            },
570            header_style,
571        );
572    }
573    let put = |buf: &mut Buffer, x: usize, y: u16, w: usize, text: &str, right: bool, style| {
574        let text = crate::glyphs::fit_cells(text, w, g.ellipsis);
575        let pad = w.saturating_sub(crate::glyphs::cell_width(&text));
576        let x = area.x as usize + x + if right { pad } else { 0 };
577        buf.set_stringn(x as u16, y, text.as_ref(), w, style);
578    };
579    let mut x = 0;
580    for column in columns.iter().take(shown) {
581        let w = column.width.min(total.saturating_sub(x));
582        put(
583            buf,
584            x,
585            area.y,
586            w,
587            &column.name,
588            column.right,
589            style_for(column.color)
590                .bg(ctx.table_header_bg)
591                .add_modifier(Modifier::BOLD),
592        );
593        if ctx.dtype_row && area.height > 1 {
594            put(
595                buf,
596                x,
597                area.y + 1,
598                w,
599                &column.dtype,
600                column.right,
601                Style::default().fg(ctx.dimmed).bg(ctx.table_header_bg),
602            );
603        }
604        for (r, cell) in column.cells.iter().enumerate() {
605            let y = area.y + (header_rows + r) as u16;
606            if y >= area.y + area.height {
607                break;
608            }
609            let (text, style) = match cell {
610                Some(text) => (text.as_str(), style_for(ctx.text_primary)),
611                None => (g.null, Style::default().fg(ctx.dimmed)),
612            };
613            let style = match ctx.alternate_row_color {
614                Some(bg) if r % 2 == 1 => style.bg(bg),
615                _ => style,
616            };
617            put(buf, x, y, w, text, column.right, style);
618        }
619        x += column.width + 2;
620    }
621    if hidden > 0 {
622        let more = format!("+{hidden}");
623        let at = (used + 2).min(total.saturating_sub(more.len()));
624        put(
625            buf,
626            at,
627            area.y,
628            more.len().min(total),
629            &more,
630            false,
631            Style::default().fg(ctx.dimmed).bg(ctx.table_header_bg),
632        );
633    }
634    // Alternate rows tint the whole line, as the table's do.
635    if let Some(bg) = ctx.alternate_row_color {
636        for r in (1..body_rows.min(head.height())).step_by(2) {
637            let y = area.y + (header_rows + r) as u16;
638            if y < area.y + area.height {
639                buf.set_style(
640                    Rect {
641                        y,
642                        height: 1,
643                        ..area
644                    },
645                    Style::default().bg(bg),
646                );
647            }
648        }
649    }
650}
651
652#[cfg(test)]
653mod tests;