1use 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
24const LABEL_WIDTH: u16 = 16;
27
28pub const SIDE_BY_SIDE: u16 = 96;
30
31const FORM_WIDTH: u16 = 46;
33
34const GAP: u16 = 3;
36
37const MAX_CELL: usize = 24;
39
40const 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
58pub 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
84pub fn question_types(modal: &PivotMeltModal) -> bool {
87 modal.picker.is_some() || modal.is_text_row(modal.focus)
88}
89
90pub 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
104pub 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 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
134fn 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 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 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 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 if let Some(state) = &modal.picker {
261 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 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 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
315pub 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
332pub 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 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
353pub 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 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 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 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
468struct 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
529fn 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 let columns = grid_columns(head, body_rows, total / 3 + 1, ctx);
540
541 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 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 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 #[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 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 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 #[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 #[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 #[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 #[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 #[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 #[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(); 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 #[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 #[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 #[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}