Skip to main content

datui_lib/widgets/
copy.rs

1//! Copy dialog rendering: one Surface, a FormRow per axis, the focused row's
2//! Picker below the rows, and the spec line saying what Enter will do.
3
4use crate::copy_modal::{CopyFocus, CopyModal};
5use crate::render::context::RenderContext;
6use crate::widgets::ui::{FormRow, FormValue, HintBar, Picker, Surface};
7use ratatui::buffer::Buffer;
8use ratatui::layout::Rect;
9use ratatui::style::Style;
10use ratatui::widgets::{Paragraph, Widget};
11
12/// Past the longest label, "Format:", plus air.
13const LABEL_WIDTH: u16 = 9;
14
15fn row_label(focus: CopyFocus) -> &'static str {
16    match focus {
17        CopyFocus::Scope => "Scope:",
18        CopyFocus::Column => "Column:",
19        CopyFocus::Format => "Format:",
20        CopyFocus::Header => "Header:",
21    }
22}
23
24pub fn render_copy_modal(area: Rect, buf: &mut Buffer, modal: &mut CopyModal, ctx: &RenderContext) {
25    let g = crate::glyphs::get();
26    let footer = match &modal.picker {
27        Some(_) => HintBar::from_ctx(ctx)
28            .hint_weighted("Enter", "Choose", 3)
29            .hint_weighted("type", "Narrow", 1)
30            .hint_weighted("Esc", "Back", 4),
31        None => {
32            let (key, label) = match modal.focus {
33                CopyFocus::Header => ("Space", "Toggle"),
34                CopyFocus::Column => ("Space", "Pick"),
35                CopyFocus::Scope | CopyFocus::Format => (g.updown_lr, "Change"),
36            };
37            HintBar::from_ctx(ctx)
38                .hint_weighted("Enter", "Copy", 3)
39                .hint_weighted(key, label, 2)
40                .hint_weighted("Tab", "Next", 1)
41                .hint_weighted("Esc", "Cancel", 4)
42        }
43    };
44    crate::pointer::record(area, crate::pointer::Hit::Modal);
45    let content = Surface::new("Copy").footer(&footer).render(area, buf, ctx);
46    if content.height < 3 || content.width < 10 {
47        return;
48    }
49
50    // The spec line sits on the last content row, directly above the footer.
51    let spec_y = content.y + content.height - 1;
52
53    let rows = modal.row_order();
54    let mut y = content.y;
55    for &row in &rows {
56        if y >= spec_y {
57            break;
58        }
59        let value = match row {
60            CopyFocus::Scope => FormValue::Choice(modal.scope.as_str()),
61            CopyFocus::Format => FormValue::Choice(modal.format.as_str()),
62            CopyFocus::Header => FormValue::Toggle(modal.header()),
63            CopyFocus::Column => match modal.column.as_deref() {
64                Some(column) => FormValue::Choice(column),
65                None => FormValue::Placeholder("none"),
66            },
67        };
68        let row_area = Rect {
69            y,
70            height: 1,
71            ..content
72        };
73        FormRow {
74            label: row_label(row),
75            value,
76            focused: modal.focus == row,
77            label_width: LABEL_WIDTH,
78        }
79        .render_picking(row_area, buf, ctx, modal.picker.is_some());
80        crate::pointer::record_field::<CopyModal>(row_area, row);
81        y += 1;
82    }
83
84    // The focused row's Picker drops in below the rows and reaches down to
85    // the spec line.
86    if let Some(state) = &modal.picker {
87        // It owns the keys even with no room to draw: the rows take no clicks.
88        crate::pointer::record(content, crate::pointer::Hit::Picker);
89        let picker_y = y + 1;
90        if picker_y < spec_y {
91            let picker_area = Rect {
92                x: content.x + 2,
93                y: picker_y,
94                width: content.width.saturating_sub(2),
95                height: spec_y - picker_y,
96            };
97            Picker::from_state(state, true).render(picker_area, buf, ctx);
98        }
99    }
100
101    // What Enter will do, echoed live; the gap re-accents when Enter hit it.
102    let (text, style) = match modal.spec_line() {
103        Ok(line) => (line, Style::default().fg(ctx.text_primary)),
104        Err(gap) if modal.attention => (gap, Style::default().fg(ctx.warning)),
105        Err(gap) => (gap, Style::default().fg(ctx.dimmed)),
106    };
107    Paragraph::new(text).style(style).render(
108        Rect {
109            y: spec_y,
110            height: 1,
111            ..content
112        },
113        buf,
114    );
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use crate::copy_modal::{CopyContext, CopyScope};
121
122    fn render_to_text(modal: &mut CopyModal) -> String {
123        let area = Rect::new(0, 0, 46, 14);
124        let mut buf = Buffer::empty(area);
125        let ctx = RenderContext::for_test();
126        render_copy_modal(area, &mut buf, modal, &ctx);
127        (0..area.height)
128            .map(|y| {
129                (0..area.width)
130                    .map(|x| buf[(x, y)].symbol().to_string())
131                    .collect::<String>()
132            })
133            .collect::<Vec<_>>()
134            .join("\n")
135    }
136
137    #[test]
138    fn the_dialog_echoes_every_choice_and_the_spec() {
139        let mut modal = CopyModal::new();
140        modal.open(
141            vec!["city".into(), "pop".into()],
142            None,
143            CopyContext {
144                row_number: 1235,
145                view_rows: 42,
146                view_cols: 8,
147                total_rows: Some(1_000_000),
148            },
149        );
150        let text = render_to_text(&mut modal);
151        assert!(text.contains("Scope:"), "{text}");
152        assert!(text.contains("Row"), "{text}");
153        assert!(text.contains("Format:"), "{text}");
154        assert!(text.contains("Copy row 1,235 as TSV"), "{text}");
155    }
156
157    #[test]
158    fn the_python_scope_shows_only_the_scope_row() {
159        let mut modal = CopyModal::new();
160        modal.open(vec!["city".into()], None, CopyContext::default());
161        modal.scope = CopyScope::Python;
162        let text = render_to_text(&mut modal);
163        assert!(text.contains("Python (Polars)"), "{text}");
164        assert!(!text.contains("Format:"), "{text}");
165        assert!(!text.contains("Header:"), "{text}");
166        assert!(
167            text.contains("Copy the view as a Python (Polars) script"),
168            "{text}"
169        );
170    }
171
172    #[test]
173    fn the_open_picker_lists_and_narrows() {
174        let mut modal = CopyModal::new();
175        modal.open(
176            vec!["city".into(), "population".into()],
177            None,
178            CopyContext::default(),
179        );
180        modal.scope = CopyScope::Cell;
181        modal.focus = CopyFocus::Column;
182        modal.open_picker();
183        let text = render_to_text(&mut modal);
184        assert!(text.contains("city"), "{text}");
185        assert!(text.contains("population"), "{text}");
186        modal
187            .picker
188            .as_mut()
189            .unwrap()
190            .filter_key('p', crossterm::event::KeyModifiers::NONE);
191        let text = render_to_text(&mut modal);
192        assert!(text.contains("population"), "{text}");
193        assert!(!text.contains("\n city"), "{text}");
194    }
195}