1use 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
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 vec![form("Enter"), form("← / →"), form("Esc")]
74 }
75 None => vec![form("Enter"), form("Esc")],
76 }
77}
78
79pub fn question_types(modal: &PivotMeltModal) -> bool {
82 modal.picker.is_some() || modal.is_text_row(modal.focus)
83}
84
85pub 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
99pub 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 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
129fn 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 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 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 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 if let Some(state) = &modal.picker {
256 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 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 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
310pub 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
327pub 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 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
348pub 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 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 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 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
463struct 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
524fn 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 let columns = grid_columns(head, body_rows, total / 3 + 1, ctx);
535
536 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 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;