Skip to main content

datui_lib/
copy_modal.rs

1//! Copy dialog state: scope × format, a column for the cell scope, and the
2//! header toggle, with the last choices kept for the session so a repeat copy
3//! is `y` `Enter`. The Python scope copies the view's pipeline as code rather
4//! than its rows.
5
6use crate::clipboard::CopyFormat;
7use crate::widgets::ui::PickerState;
8
9/// How much of the view one copy takes.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
11pub enum CopyScope {
12    Cell,
13    #[default]
14    Row,
15    View,
16    Table,
17    /// The view's pipeline as a Python Polars script.
18    Python,
19}
20
21impl CopyScope {
22    pub const ALL: [Self; 5] = [Self::Cell, Self::Row, Self::View, Self::Table, Self::Python];
23
24    pub fn as_str(self) -> &'static str {
25        match self {
26            Self::Cell => "Cell",
27            Self::Row => "Row",
28            Self::View => "View",
29            Self::Table => "Table",
30            Self::Python => "Python (Polars)",
31        }
32    }
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
36pub enum CopyFocus {
37    #[default]
38    Scope,
39    Column,
40    Format,
41    Header,
42}
43
44/// What the dialog needs to say what Enter will do; taken from the table when
45/// the dialog opens, while the table cannot move under it.
46#[derive(Debug, Clone, Copy, Default)]
47pub struct CopyContext {
48    /// The current row, as the row-numbers column would show it.
49    pub row_number: usize,
50    /// Rows and columns a view copy carries.
51    pub view_rows: usize,
52    pub view_cols: usize,
53    /// Total rows, once the count has run.
54    pub total_rows: Option<usize>,
55}
56
57pub struct CopyModal {
58    pub active: bool,
59    pub focus: CopyFocus,
60    pub scope: CopyScope,
61    pub format: CopyFormat,
62    /// Header per tabular scope. A row pasted mid-sheet rarely wants one; a
63    /// view or table pasted whole usually does. Each remembers its last
64    /// setting for the session.
65    pub header_row: bool,
66    pub header_view: bool,
67    pub header_table: bool,
68    /// The cell scope's column: the column cursor's when the dialog opens.
69    pub column: Option<String>,
70    pub available_columns: Vec<String>,
71    /// The one Picker, open for the focused row; None while the form has the keys.
72    pub picker: Option<PickerState>,
73    /// Enter on an incomplete form re-accents the spec line instead of raising
74    /// a modal; any other key clears it.
75    pub attention: bool,
76    pub context: CopyContext,
77}
78
79impl crate::form::Form for CopyModal {
80    type Field = CopyFocus;
81
82    fn fields(&self) -> Vec<(CopyFocus, crate::form::FieldKind)> {
83        use crate::form::FieldKind;
84        self.row_order()
85            .into_iter()
86            .map(|row| {
87                let kind = match row {
88                    CopyFocus::Scope | CopyFocus::Format => FieldKind::Choice,
89                    CopyFocus::Column => FieldKind::Picker { multi: false },
90                    CopyFocus::Header => FieldKind::Checkbox,
91                };
92                (row, kind)
93            })
94            .collect()
95    }
96
97    fn focused(&self) -> CopyFocus {
98        self.focus
99    }
100
101    fn set_focused(&mut self, field: CopyFocus) {
102        self.focus = field;
103    }
104}
105
106impl Default for CopyModal {
107    fn default() -> Self {
108        Self {
109            active: false,
110            focus: CopyFocus::Scope,
111            scope: CopyScope::default(),
112            format: CopyFormat::default(),
113            header_row: false,
114            header_view: true,
115            header_table: true,
116            column: None,
117            available_columns: Vec::new(),
118            picker: None,
119            attention: false,
120            context: CopyContext::default(),
121        }
122    }
123}
124
125impl CopyModal {
126    pub fn new() -> Self {
127        Self::default()
128    }
129
130    /// Open over the current table. Scope, format and headers are sticky; the
131    /// cell scope's column is the column cursor's, `current`.
132    pub fn open(&mut self, columns: Vec<String>, current: Option<&str>, context: CopyContext) {
133        self.active = true;
134        self.focus = CopyFocus::Scope;
135        self.picker = None;
136        self.attention = false;
137        self.column = current
138            .filter(|current| columns.iter().any(|c| c == current))
139            .map(str::to_string);
140        self.available_columns = columns;
141        self.context = context;
142    }
143
144    pub fn close(&mut self) {
145        self.active = false;
146        self.picker = None;
147    }
148
149    /// The most rows any scope offers: the dialog's height, whatever the scope.
150    pub const MOST_ROWS: u16 = 3;
151
152    /// The rows the dialog offers, in Tab order. The cell scope trades the
153    /// format and header rows for the column row; a Markdown table without its
154    /// header row is not a table, so Markdown drops the header row too.
155    pub fn row_order(&self) -> Vec<CopyFocus> {
156        match self.scope {
157            CopyScope::Cell => vec![CopyFocus::Scope, CopyFocus::Column],
158            // Code has no format or header to choose.
159            CopyScope::Python => vec![CopyFocus::Scope],
160            _ => {
161                let mut order = vec![CopyFocus::Scope, CopyFocus::Format];
162                if self.format != CopyFormat::Markdown {
163                    order.push(CopyFocus::Header);
164                }
165                order
166            }
167        }
168    }
169
170    /// Step the scope. The scope decides which rows exist, so a focus the new scope
171    /// does not offer goes back to the scope row.
172    pub fn step_scope(&mut self, delta: i8) {
173        self.scope = crate::form::step_value(&CopyScope::ALL, self.scope, delta);
174        if !self.row_order().contains(&self.focus) {
175            self.focus = CopyFocus::Scope;
176        }
177    }
178
179    /// Step the format. Markdown has no header row, so focus there moves back.
180    pub fn step_format(&mut self, delta: i8) {
181        self.format = crate::form::step_value(&CopyFormat::ALL, self.format, delta);
182        if !self.row_order().contains(&self.focus) {
183            self.focus = CopyFocus::Format;
184        }
185    }
186
187    /// ←/→ on the column row: the next or previous column.
188    pub fn step_column(&mut self, delta: i8) {
189        let columns = &self.available_columns;
190        if columns.is_empty() {
191            return;
192        }
193        let at = self
194            .column
195            .as_ref()
196            .and_then(|c| columns.iter().position(|name| name == c));
197        let next = match at {
198            Some(at) => crate::form::step_index(at, columns.len(), delta),
199            None if delta < 0 => columns.len() - 1,
200            None => 0,
201        };
202        self.column = Some(columns[next].clone());
203    }
204
205    /// The header setting the chosen scope carries.
206    pub fn header(&self) -> bool {
207        match self.scope {
208            CopyScope::Cell | CopyScope::Python => false,
209            CopyScope::Row => self.header_row,
210            CopyScope::View => self.header_view,
211            CopyScope::Table => self.header_table,
212        }
213    }
214
215    pub fn toggle_header(&mut self) {
216        match self.scope {
217            CopyScope::Cell | CopyScope::Python => {}
218            CopyScope::Row => self.header_row = !self.header_row,
219            CopyScope::View => self.header_view = !self.header_view,
220            CopyScope::Table => self.header_table = !self.header_table,
221        }
222    }
223
224    /// Rows edited through the Picker: the column, a list too long to step. Scope
225    /// and format step; the header row is a toggle.
226    pub fn is_picker_row(&self, focus: CopyFocus) -> bool {
227        focus == CopyFocus::Column
228    }
229
230    /// What the focused row's Picker offers.
231    pub fn picker_items(&self) -> Vec<String> {
232        match self.focus {
233            CopyFocus::Column => self.available_columns.clone(),
234            CopyFocus::Scope | CopyFocus::Format | CopyFocus::Header => Vec::new(),
235        }
236    }
237
238    /// Open the Picker for the focused row, cursor on the current choice.
239    pub fn open_picker(&mut self) {
240        if !self.is_picker_row(self.focus) {
241            return;
242        }
243        let items = self.picker_items();
244        let current = self.column.as_deref();
245        let mut state = PickerState::new(items.clone());
246        if let Some(current) = current
247            && let Some(i) = items.iter().position(|item| item == current)
248        {
249            state.select_original(i);
250        }
251        self.picker = Some(state);
252    }
253
254    /// Enter in the column Picker takes the cursor's item and closes it.
255    pub fn picker_choose(&mut self) {
256        let Some(state) = self.picker.take() else {
257            return;
258        };
259        let Some(i) = state.selected_original() else {
260            return;
261        };
262        if self.focus == CopyFocus::Column {
263            self.column = self.available_columns.get(i).cloned();
264        }
265    }
266
267    /// Why Enter cannot copy yet, or None when it can.
268    pub fn validation_error(&self) -> Option<&'static str> {
269        match self.scope {
270            CopyScope::Cell if self.column.is_none() => Some("pick a column to copy"),
271            _ => None,
272        }
273    }
274
275    /// What Enter will do, echoed above the footer, or the gap that stops it.
276    pub fn spec_line(&self) -> Result<String, String> {
277        let ctx = &self.context;
278        match self.scope {
279            CopyScope::Cell => match &self.column {
280                Some(column) => Ok(format!(
281                    "Copy cell {column} of row {}",
282                    thousands(ctx.row_number)
283                )),
284                None => Err("pick a column to copy".to_string()),
285            },
286            CopyScope::Row => Ok(format!(
287                "Copy row {} as {}",
288                thousands(ctx.row_number),
289                self.format.as_str()
290            )),
291            CopyScope::View => Ok(format!(
292                "Copy the {} {} {} view as {}",
293                thousands(ctx.view_rows),
294                crate::glyphs::get().times,
295                thousands(ctx.view_cols),
296                self.format.as_str()
297            )),
298            CopyScope::Table => match ctx.total_rows {
299                Some(total) => Ok(format!(
300                    "Copy all {} rows as {}",
301                    thousands(total),
302                    self.format.as_str()
303                )),
304                None => Ok(format!("Copy every row as {}", self.format.as_str())),
305            },
306            CopyScope::Python => Ok("Copy the view as a Python (Polars) script".to_string()),
307        }
308    }
309}
310
311/// `1234567` as `1,234,567`, for the spec line.
312pub fn thousands(n: usize) -> String {
313    let digits = n.to_string();
314    let mut out = String::with_capacity(digits.len() + digits.len() / 3);
315    for (i, ch) in digits.chars().enumerate() {
316        if i > 0 && (digits.len() - i).is_multiple_of(3) {
317            out.push(',');
318        }
319        out.push(ch);
320    }
321    out
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327
328    #[test]
329    fn the_scope_decides_which_rows_exist() {
330        let mut modal = CopyModal::new();
331        modal.scope = CopyScope::Cell;
332        assert_eq!(modal.row_order(), vec![CopyFocus::Scope, CopyFocus::Column]);
333        modal.scope = CopyScope::View;
334        assert_eq!(
335            modal.row_order(),
336            vec![CopyFocus::Scope, CopyFocus::Format, CopyFocus::Header]
337        );
338        // Markdown always carries its header, so the toggle goes away.
339        modal.format = CopyFormat::Markdown;
340        assert_eq!(modal.row_order(), vec![CopyFocus::Scope, CopyFocus::Format]);
341    }
342
343    #[test]
344    fn choices_are_sticky_across_opens_and_the_column_is_the_cursors() {
345        let mut modal = CopyModal::new();
346        let ab = || vec!["a".to_string(), "b".to_string()];
347        modal.open(ab(), Some("a"), CopyContext::default());
348        assert_eq!(modal.column.as_deref(), Some("a"));
349        modal.scope = CopyScope::View;
350        modal.format = CopyFormat::Markdown;
351        modal.column = Some("a".into());
352        modal.close();
353        modal.open(ab(), Some("b"), CopyContext::default());
354        assert_eq!(modal.scope, CopyScope::View);
355        assert_eq!(modal.format, CopyFormat::Markdown);
356        assert_eq!(modal.column.as_deref(), Some("b"), "the cursor moved to b");
357        modal.close();
358        modal.open(vec!["a".into()], None, CopyContext::default());
359        assert_eq!(modal.column, None);
360    }
361
362    #[test]
363    fn headers_remember_per_scope() {
364        let mut modal = CopyModal::new();
365        modal.scope = CopyScope::Row;
366        assert!(!modal.header(), "a row pasted mid-sheet wants no header");
367        modal.toggle_header();
368        assert!(modal.header());
369        modal.scope = CopyScope::View;
370        assert!(modal.header(), "a view pasted whole wants one");
371        modal.scope = CopyScope::Row;
372        assert!(modal.header(), "the row's own setting survived the visit");
373    }
374
375    #[test]
376    fn a_cell_copy_needs_a_column_and_the_spec_says_so() {
377        let mut modal = CopyModal::new();
378        modal.scope = CopyScope::Cell;
379        assert!(modal.validation_error().is_some());
380        assert!(modal.spec_line().is_err());
381        modal.column = Some("city".into());
382        modal.context.row_number = 1235;
383        assert_eq!(modal.spec_line().unwrap(), "Copy cell city of row 1,235");
384    }
385
386    #[test]
387    fn stepping_to_a_scope_that_hides_the_focused_row_moves_focus_home() {
388        let mut modal = CopyModal::new();
389        modal.available_columns = vec!["a".into()];
390        modal.scope = CopyScope::View;
391        modal.focus = CopyFocus::Header;
392        // View steps back twice to Cell, which has no header row.
393        modal.step_scope(-1);
394        modal.step_scope(-1);
395        assert_eq!(modal.scope, CopyScope::Cell);
396        assert_eq!(modal.focus, CopyFocus::Scope);
397        modal.step_scope(-1);
398        assert_eq!(modal.scope, CopyScope::Python, "the scope wraps");
399    }
400
401    #[test]
402    fn markdown_takes_focus_off_the_header_row() {
403        let mut modal = CopyModal::new();
404        modal.scope = CopyScope::View;
405        modal.format = CopyFormat::Csv;
406        modal.focus = CopyFocus::Header;
407        modal.step_format(1);
408        assert_eq!(modal.format, CopyFormat::Markdown);
409        assert_eq!(modal.focus, CopyFocus::Format);
410    }
411
412    #[test]
413    fn the_column_row_steps_through_the_columns() {
414        let mut modal = CopyModal::new();
415        modal.available_columns = vec!["a".into(), "b".into()];
416        modal.step_column(1);
417        assert_eq!(
418            modal.column.as_deref(),
419            Some("a"),
420            "unset starts at the first"
421        );
422        modal.step_column(1);
423        assert_eq!(modal.column.as_deref(), Some("b"));
424        modal.step_column(1);
425        assert_eq!(modal.column.as_deref(), Some("a"), "and wraps");
426    }
427
428    #[test]
429    fn the_python_scope_has_no_format_or_header() {
430        let mut modal = CopyModal::new();
431        modal.scope = CopyScope::Python;
432        assert_eq!(modal.row_order(), vec![CopyFocus::Scope]);
433        assert!(!modal.header());
434        assert_eq!(
435            modal.spec_line().unwrap(),
436            "Copy the view as a Python (Polars) script"
437        );
438        modal.focus = CopyFocus::Scope;
439        crate::form::Form::focus_next(&mut modal);
440        assert_eq!(modal.focus, CopyFocus::Scope, "Tab has nowhere else to go");
441    }
442
443    #[test]
444    fn thousands_groups_digits() {
445        assert_eq!(thousands(0), "0");
446        assert_eq!(thousands(999), "999");
447        assert_eq!(thousands(1000), "1,000");
448        assert_eq!(thousands(1234567), "1,234,567");
449    }
450}