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