Skip to main content

datui_lib/widgets/
pivot_melt.rs

1//! Pivot / Melt modal rendering.
2//!
3//! Phase 4: Pivot tab UI. Phase 5: Melt tab UI.
4
5use crate::pivot_melt_modal::{PivotMeltFocus, PivotMeltModal, PivotMeltTab};
6use ratatui::layout::{Constraint, Direction, Layout, Rect};
7use ratatui::style::{Color, Modifier, Style};
8use ratatui::text::{Line, Span};
9use ratatui::widgets::{
10    Block, BorderType, Borders, Cell, Clear, Paragraph, Row, StatefulWidget, Table, Tabs, Widget,
11};
12
13/// Render the Pivot and Melt modal: tab bar, tab-specific body, footer.
14/// Uses `border_color` for default borders and `active_color` for focused elements.
15/// `text_primary` and `text_inverse` are used for text-input cursor (same as query prompt).
16pub fn render_shell(
17    area: Rect,
18    buf: &mut ratatui::buffer::Buffer,
19    modal: &mut PivotMeltModal,
20    border_color: Color,
21    active_color: Color,
22    text_primary: Color,
23    text_inverse: Color,
24) {
25    Clear.render(area, buf);
26    let block = Block::default()
27        .borders(Borders::ALL)
28        .border_type(BorderType::Rounded)
29        .border_style(Style::default().fg(border_color));
30    let inner = block.inner(area);
31    block.render(area, buf);
32
33    let chunks = Layout::default()
34        .direction(Direction::Vertical)
35        .constraints([
36            Constraint::Length(2),
37            Constraint::Min(10),
38            Constraint::Length(3),
39        ])
40        .split(inner);
41
42    // Tab bar (Ratatui Tabs widget): no frame/title, no divider, line below
43    let tab_line_chunks = Layout::default()
44        .direction(Direction::Vertical)
45        .constraints([Constraint::Length(1), Constraint::Length(1)])
46        .split(chunks[0]);
47    let selected = match modal.active_tab {
48        PivotMeltTab::Pivot => 0,
49        PivotMeltTab::Melt => 1,
50    };
51    let tabs = Tabs::new(vec!["Pivot", "Melt"])
52        .style(Style::default().fg(border_color))
53        .highlight_style(
54            Style::default()
55                .fg(active_color)
56                .add_modifier(Modifier::REVERSED),
57        )
58        .select(selected);
59    tabs.render(tab_line_chunks[0], buf);
60    let line_style = if modal.focus == PivotMeltFocus::TabBar {
61        Style::default().fg(active_color)
62    } else {
63        Style::default().fg(border_color)
64    };
65    Block::default()
66        .borders(Borders::BOTTOM)
67        .border_type(BorderType::Rounded)
68        .border_style(line_style)
69        .render(tab_line_chunks[1], buf);
70
71    // Body
72    match modal.active_tab {
73        PivotMeltTab::Pivot => render_pivot_body(
74            chunks[1],
75            buf,
76            modal,
77            border_color,
78            active_color,
79            text_primary,
80            text_inverse,
81        ),
82        PivotMeltTab::Melt => render_melt_body(
83            chunks[1],
84            buf,
85            modal,
86            border_color,
87            active_color,
88            text_primary,
89            text_inverse,
90        ),
91    }
92
93    // Footer (expand to fill horizontal space, like filter/sort dialogs)
94    let footer_chunks = Layout::default()
95        .direction(Direction::Horizontal)
96        .constraints([
97            Constraint::Percentage(33),
98            Constraint::Percentage(33),
99            Constraint::Percentage(34),
100        ])
101        .split(chunks[2]);
102
103    let apply_style = if modal.focus == PivotMeltFocus::Apply {
104        Style::default().fg(active_color)
105    } else {
106        Style::default().fg(border_color)
107    };
108    let cancel_style = if modal.focus == PivotMeltFocus::Cancel {
109        Style::default().fg(active_color)
110    } else {
111        Style::default().fg(border_color)
112    };
113    let clear_style = if modal.focus == PivotMeltFocus::Clear {
114        Style::default().fg(active_color)
115    } else {
116        Style::default().fg(border_color)
117    };
118
119    Paragraph::new("Apply")
120        .block(
121            Block::default()
122                .borders(Borders::ALL)
123                .border_type(BorderType::Rounded)
124                .border_style(apply_style),
125        )
126        .centered()
127        .render(footer_chunks[0], buf);
128    Paragraph::new("Cancel")
129        .block(
130            Block::default()
131                .borders(Borders::ALL)
132                .border_type(BorderType::Rounded)
133                .border_style(cancel_style),
134        )
135        .centered()
136        .render(footer_chunks[1], buf);
137    Paragraph::new("Clear")
138        .block(
139            Block::default()
140                .borders(Borders::ALL)
141                .border_type(BorderType::Rounded)
142                .border_style(clear_style),
143        )
144        .centered()
145        .render(footer_chunks[2], buf);
146}
147
148fn render_pivot_body(
149    area: Rect,
150    buf: &mut ratatui::buffer::Buffer,
151    modal: &mut PivotMeltModal,
152    border_color: Color,
153    active_color: Color,
154    _text_primary: Color,
155    _text_inverse: Color,
156) {
157    let chunks = Layout::default()
158        .direction(Direction::Vertical)
159        .constraints([
160            Constraint::Length(3),
161            Constraint::Min(6),
162            Constraint::Length(5),
163            Constraint::Length(5),
164            Constraint::Length(4),
165            Constraint::Length(4),
166        ])
167        .split(area);
168
169    // Filter (1 line, no placeholder)
170    let filter_style = if modal.focus == PivotMeltFocus::PivotFilter {
171        Style::default().fg(active_color)
172    } else {
173        Style::default().fg(border_color)
174    };
175    let filter_block = Block::default()
176        .borders(Borders::ALL)
177        .border_type(BorderType::Rounded)
178        .title("Filter Index Columns")
179        .border_style(filter_style);
180    let filter_inner = filter_block.inner(chunks[0]);
181    filter_block.render(chunks[0], buf);
182
183    // Render filter input using TextInput widget
184    let is_focused = modal.focus == PivotMeltFocus::PivotFilter;
185    modal.pivot_filter_input.set_focused(is_focused);
186    (&modal.pivot_filter_input).render(filter_inner, buf);
187
188    // Index list
189    let list_style = if modal.focus == PivotMeltFocus::PivotIndexList {
190        Style::default().fg(active_color)
191    } else {
192        Style::default().fg(border_color)
193    };
194    let list_block = Block::default()
195        .borders(Borders::ALL)
196        .border_type(BorderType::Rounded)
197        .title("Index Columns")
198        .border_style(list_style);
199    let list_inner = list_block.inner(chunks[1]);
200    list_block.render(chunks[1], buf);
201
202    let filtered = modal.pivot_filtered_columns();
203    if !filtered.is_empty() && modal.pivot_index_table.selected().is_none() {
204        modal.pivot_index_table.select(Some(0));
205    }
206    let rows: Vec<Row> = filtered
207        .iter()
208        .map(|c| {
209            let check = if modal.index_columns.contains(c) {
210                "[x]"
211            } else {
212                "[ ]"
213            };
214            Row::new(vec![Cell::from(check), Cell::from(c.as_str())])
215        })
216        .collect();
217    let widths = [Constraint::Length(4), Constraint::Min(10)];
218    let table = Table::new(rows, widths)
219        .column_spacing(1)
220        .header(
221            Row::new(vec!["", "Column"])
222                .style(Style::default().add_modifier(Modifier::BOLD))
223                .bottom_margin(0),
224        )
225        .row_highlight_style(Style::default().add_modifier(Modifier::REVERSED));
226    StatefulWidget::render(table, list_inner, buf, &mut modal.pivot_index_table);
227
228    // Pivot / Value: small tables (single-select lists)
229    let pivot_style = if modal.focus == PivotMeltFocus::PivotPivotCol {
230        Style::default().fg(active_color)
231    } else {
232        Style::default().fg(border_color)
233    };
234    let value_style = if modal.focus == PivotMeltFocus::PivotValueCol {
235        Style::default().fg(active_color)
236    } else {
237        Style::default().fg(border_color)
238    };
239    let row_chunks = Layout::default()
240        .direction(Direction::Horizontal)
241        .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
242        .split(chunks[2]);
243
244    let pivot_pool = modal.pivot_pool();
245    if !pivot_pool.is_empty() {
246        let n = pivot_pool.len();
247        let idx = modal.pivot_pool_idx.min(n.saturating_sub(1));
248        if modal.pivot_pool_idx != idx {
249            modal.pivot_pool_idx = idx;
250            modal.pivot_column = pivot_pool.get(idx).cloned();
251        }
252        modal.pivot_pool_table.select(Some(idx));
253    }
254    let pivot_rows: Vec<Row> = pivot_pool
255        .iter()
256        .map(|c| Row::new(vec![Cell::from(c.as_str())]))
257        .collect();
258    let pivot_block = Block::default()
259        .borders(Borders::ALL)
260        .border_type(BorderType::Rounded)
261        .title("Pivot Column")
262        .border_style(pivot_style);
263    let pivot_inner = pivot_block.inner(row_chunks[0]);
264    pivot_block.render(row_chunks[0], buf);
265    if pivot_rows.is_empty() {
266        Paragraph::new("(none)").render(pivot_inner, buf);
267    } else {
268        let pt = Table::new(pivot_rows, [Constraint::Min(5)])
269            .row_highlight_style(Style::default().add_modifier(Modifier::REVERSED));
270        StatefulWidget::render(pt, pivot_inner, buf, &mut modal.pivot_pool_table);
271    }
272
273    let value_pool = modal.pivot_value_pool();
274    if !value_pool.is_empty() {
275        let n = value_pool.len();
276        let idx = modal.value_pool_idx.min(n.saturating_sub(1));
277        if modal.value_pool_idx != idx {
278            modal.value_pool_idx = idx;
279            modal.value_column = value_pool.get(idx).cloned();
280        }
281        modal.value_pool_table.select(Some(idx));
282    }
283    let value_rows: Vec<Row> = value_pool
284        .iter()
285        .map(|c| Row::new(vec![Cell::from(c.as_str())]))
286        .collect();
287    let value_block = Block::default()
288        .borders(Borders::ALL)
289        .border_type(BorderType::Rounded)
290        .title("Value Column")
291        .border_style(value_style);
292    let value_inner = value_block.inner(row_chunks[1]);
293    value_block.render(row_chunks[1], buf);
294    if value_rows.is_empty() {
295        Paragraph::new("(none)").render(value_inner, buf);
296    } else {
297        let vt = Table::new(value_rows, [Constraint::Min(5)])
298            .row_highlight_style(Style::default().add_modifier(Modifier::REVERSED));
299        StatefulWidget::render(vt, value_inner, buf, &mut modal.value_pool_table);
300    }
301
302    // Aggregation
303    let agg_style = if modal.focus == PivotMeltFocus::PivotAggregation {
304        Style::default().fg(active_color)
305    } else {
306        Style::default().fg(border_color)
307    };
308    let opts = modal.pivot_aggregation_options();
309    let agg_label = opts
310        .get(modal.aggregation_idx)
311        .map(|a| a.as_str())
312        .unwrap_or("last");
313    let agg_block = Block::default()
314        .borders(Borders::ALL)
315        .border_type(BorderType::Rounded)
316        .title("Aggregation")
317        .border_style(agg_style);
318    let agg_inner = agg_block.inner(chunks[3]);
319    agg_block.render(chunks[3], buf);
320    Paragraph::new(agg_label).render(agg_inner, buf);
321
322    // Sort toggle
323    let sort_style = if modal.focus == PivotMeltFocus::PivotSortToggle {
324        Style::default().fg(active_color)
325    } else {
326        Style::default().fg(border_color)
327    };
328    let sort_check = if modal.sort_new_columns { "[x]" } else { "[ ]" };
329    let sort_block = Block::default()
330        .borders(Borders::ALL)
331        .border_type(BorderType::Rounded)
332        .title("Sort New Columns")
333        .border_style(sort_style);
334    let sort_inner = sort_block.inner(chunks[4]);
335    sort_block.render(chunks[4], buf);
336    Paragraph::new(format!("{} Sort", sort_check)).render(sort_inner, buf);
337}
338
339fn render_melt_body(
340    area: Rect,
341    buf: &mut ratatui::buffer::Buffer,
342    modal: &mut PivotMeltModal,
343    border_color: Color,
344    active_color: Color,
345    text_primary: Color,
346    text_inverse: Color,
347) {
348    use crate::pivot_melt_modal::{MeltValueStrategy, PivotMeltFocus};
349
350    let chunks = Layout::default()
351        .direction(Direction::Vertical)
352        .constraints([
353            Constraint::Length(3),
354            Constraint::Min(6),
355            Constraint::Length(4),
356            Constraint::Length(5),
357            Constraint::Length(4),
358        ])
359        .split(area);
360
361    // Filter (1 line, no placeholder)
362    let filter_style = if modal.focus == PivotMeltFocus::MeltFilter {
363        Style::default().fg(active_color)
364    } else {
365        Style::default().fg(border_color)
366    };
367    let filter_block = Block::default()
368        .borders(Borders::ALL)
369        .border_type(BorderType::Rounded)
370        .title("Filter Index Columns")
371        .border_style(filter_style);
372    let filter_inner = filter_block.inner(chunks[0]);
373    filter_block.render(chunks[0], buf);
374
375    // Render filter input using TextInput widget
376    let is_focused = modal.focus == PivotMeltFocus::MeltFilter;
377    modal.melt_filter_input.set_focused(is_focused);
378    (&modal.melt_filter_input).render(filter_inner, buf);
379
380    // Index list
381    let list_style = if modal.focus == PivotMeltFocus::MeltIndexList {
382        Style::default().fg(active_color)
383    } else {
384        Style::default().fg(border_color)
385    };
386    let list_block = Block::default()
387        .borders(Borders::ALL)
388        .border_type(BorderType::Rounded)
389        .title("Index Columns")
390        .border_style(list_style);
391    let list_inner = list_block.inner(chunks[1]);
392    list_block.render(chunks[1], buf);
393
394    let filtered = modal.melt_filtered_columns();
395    if !filtered.is_empty() && modal.melt_index_table.selected().is_none() {
396        modal.melt_index_table.select(Some(0));
397    }
398    let rows: Vec<Row> = filtered
399        .iter()
400        .map(|c| {
401            let check = if modal.melt_index_columns.contains(c) {
402                "[x]"
403            } else {
404                "[ ]"
405            };
406            Row::new(vec![Cell::from(check), Cell::from(c.as_str())])
407        })
408        .collect();
409    let widths = [Constraint::Length(4), Constraint::Min(10)];
410    let table = Table::new(rows, widths)
411        .column_spacing(1)
412        .header(
413            Row::new(vec!["", "Column"])
414                .style(Style::default().add_modifier(Modifier::BOLD))
415                .bottom_margin(0),
416        )
417        .row_highlight_style(Style::default().add_modifier(Modifier::REVERSED));
418    StatefulWidget::render(table, list_inner, buf, &mut modal.melt_index_table);
419
420    // Strategy row
421    let strat_style = if modal.focus == PivotMeltFocus::MeltStrategy {
422        Style::default().fg(active_color)
423    } else {
424        Style::default().fg(border_color)
425    };
426    let strat_block = Block::default()
427        .borders(Borders::ALL)
428        .border_type(BorderType::Rounded)
429        .title("Strategy")
430        .border_style(strat_style);
431    let strat_inner = strat_block.inner(chunks[2]);
432    strat_block.render(chunks[2], buf);
433    Paragraph::new(modal.melt_value_strategy.as_str()).render(strat_inner, buf);
434
435    // Pattern / Type / Explicit
436    let opt_chunks = Layout::default()
437        .direction(Direction::Horizontal)
438        .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
439        .split(chunks[3]);
440
441    match modal.melt_value_strategy {
442        MeltValueStrategy::ByPattern => {
443            let pat_style = if modal.focus == PivotMeltFocus::MeltPattern {
444                Style::default().fg(active_color)
445            } else {
446                Style::default().fg(border_color)
447            };
448            let pat_block = Block::default()
449                .borders(Borders::ALL)
450                .border_type(BorderType::Rounded)
451                .title("Pattern (Regex)")
452                .border_style(pat_style);
453            let pat_inner = pat_block.inner(opt_chunks[0]);
454            pat_block.render(opt_chunks[0], buf);
455            let pt = modal.melt_pattern.as_str();
456            let pc = modal.melt_pattern_cursor.min(pt.chars().count());
457            let mut ch = pt.chars();
458            let b: String = ch.by_ref().take(pc).collect();
459            let a = ch
460                .next()
461                .map(|c| c.to_string())
462                .unwrap_or_else(|| " ".to_string());
463            let af: String = ch.collect();
464            let mut pl = Line::default();
465            pl.spans.push(Span::raw(b));
466            if modal.focus == PivotMeltFocus::MeltPattern {
467                pl.spans.push(Span::styled(
468                    a,
469                    Style::default().bg(text_inverse).fg(text_primary),
470                ));
471            } else {
472                pl.spans.push(Span::raw(a));
473            }
474            if !af.is_empty() {
475                pl.spans.push(Span::raw(af));
476            }
477            Paragraph::new(pl).render(pat_inner, buf);
478        }
479        MeltValueStrategy::ByType => {
480            let ty_style = if modal.focus == PivotMeltFocus::MeltType {
481                Style::default().fg(active_color)
482            } else {
483                Style::default().fg(border_color)
484            };
485            let ty_block = Block::default()
486                .borders(Borders::ALL)
487                .border_type(BorderType::Rounded)
488                .title("Type")
489                .border_style(ty_style);
490            let ty_inner = ty_block.inner(opt_chunks[0]);
491            ty_block.render(opt_chunks[0], buf);
492            Paragraph::new(modal.melt_type_filter.as_str()).render(ty_inner, buf);
493        }
494        MeltValueStrategy::ExplicitList => {
495            let ex_style = if modal.focus == PivotMeltFocus::MeltExplicitList {
496                Style::default().fg(active_color)
497            } else {
498                Style::default().fg(border_color)
499            };
500            let ex_block = Block::default()
501                .borders(Borders::ALL)
502                .border_type(BorderType::Rounded)
503                .title("Value Columns")
504                .border_style(ex_style);
505            let ex_inner = ex_block.inner(chunks[3]);
506            ex_block.render(chunks[3], buf);
507            let pool = modal.melt_explicit_pool();
508            if !pool.is_empty() && modal.melt_explicit_table.selected().is_none() {
509                modal.melt_explicit_table.select(Some(0));
510            }
511            let ex_rows: Vec<Row> = pool
512                .iter()
513                .map(|c| {
514                    let check = if modal.melt_explicit_list.contains(c) {
515                        "[x]"
516                    } else {
517                        "[ ]"
518                    };
519                    Row::new(vec![Cell::from(check), Cell::from(c.as_str())])
520                })
521                .collect();
522            let ew = [Constraint::Length(4), Constraint::Min(10)];
523            let ex_table = Table::new(ex_rows, ew)
524                .column_spacing(1)
525                .header(
526                    Row::new(vec!["", "Column"])
527                        .style(Style::default().add_modifier(Modifier::BOLD))
528                        .bottom_margin(0),
529                )
530                .row_highlight_style(Style::default().add_modifier(Modifier::REVERSED));
531            StatefulWidget::render(ex_table, ex_inner, buf, &mut modal.melt_explicit_table);
532        }
533        MeltValueStrategy::AllExceptIndex => {}
534    }
535
536    // Variable name / Value name with cursor
537    let var_style = if modal.focus == PivotMeltFocus::MeltVarName {
538        Style::default().fg(active_color)
539    } else {
540        Style::default().fg(border_color)
541    };
542    let val_style = if modal.focus == PivotMeltFocus::MeltValName {
543        Style::default().fg(active_color)
544    } else {
545        Style::default().fg(border_color)
546    };
547    let vchunks = Layout::default()
548        .direction(Direction::Horizontal)
549        .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
550        .split(chunks[4]);
551    let var_block = Block::default()
552        .borders(Borders::ALL)
553        .border_type(BorderType::Rounded)
554        .title("Variable Name")
555        .border_style(var_style);
556    let var_inner = var_block.inner(vchunks[0]);
557    var_block.render(vchunks[0], buf);
558    let vn = modal.melt_variable_name.as_str();
559    let vc = modal.melt_variable_cursor.min(vn.chars().count());
560    let mut ch = vn.chars();
561    let vb: String = ch.by_ref().take(vc).collect();
562    let va = ch
563        .next()
564        .map(|c| c.to_string())
565        .unwrap_or_else(|| " ".to_string());
566    let vaf: String = ch.collect();
567    let mut vl = Line::default();
568    vl.spans.push(Span::raw(vb));
569    if modal.focus == PivotMeltFocus::MeltVarName {
570        vl.spans.push(Span::styled(
571            va,
572            Style::default().bg(text_inverse).fg(text_primary),
573        ));
574    } else {
575        vl.spans.push(Span::raw(va));
576    }
577    if !vaf.is_empty() {
578        vl.spans.push(Span::raw(vaf));
579    }
580    Paragraph::new(vl).render(var_inner, buf);
581
582    let val_block = Block::default()
583        .borders(Borders::ALL)
584        .border_type(BorderType::Rounded)
585        .title("Value Name")
586        .border_style(val_style);
587    let val_inner = val_block.inner(vchunks[1]);
588    val_block.render(vchunks[1], buf);
589    let wn = modal.melt_value_name.as_str();
590    let wc = modal.melt_value_cursor.min(wn.chars().count());
591    let mut ch = wn.chars();
592    let wb: String = ch.by_ref().take(wc).collect();
593    let wa = ch
594        .next()
595        .map(|c| c.to_string())
596        .unwrap_or_else(|| " ".to_string());
597    let waf: String = ch.collect();
598    let mut wl = Line::default();
599    wl.spans.push(Span::raw(wb));
600    if modal.focus == PivotMeltFocus::MeltValName {
601        wl.spans.push(Span::styled(
602            wa,
603            Style::default().bg(text_inverse).fg(text_primary),
604        ));
605    } else {
606        wl.spans.push(Span::raw(wa));
607    }
608    if !waf.is_empty() {
609        wl.spans.push(Span::raw(waf));
610    }
611    Paragraph::new(wl).render(val_inner, buf);
612}