Skip to main content

datui_lib/app/modals/
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 focus: CopyFocus,
59    pub scope: CopyScope,
60    pub format: CopyFormat,
61    /// Header per tabular scope. A row pasted mid-sheet rarely wants one; a
62    /// view or table pasted whole usually does. Each remembers its last
63    /// setting for the session.
64    pub header_row: bool,
65    pub header_view: bool,
66    pub header_table: bool,
67    /// The cell scope's column: the column cursor's when the dialog opens.
68    pub column: Option<String>,
69    pub available_columns: Vec<String>,
70    /// The one Picker, open for the focused row; None while the form has the keys.
71    pub picker: Option<PickerState>,
72    /// Enter on an incomplete form re-accents the spec line instead of raising
73    /// a modal; any other key clears it.
74    pub attention: bool,
75    pub context: CopyContext,
76}
77
78impl crate::app::form::Form for CopyModal {
79    type Field = CopyFocus;
80
81    fn shown_picker(&mut self) -> Option<(&mut crate::widgets::ui::PickerState, bool)> {
82        self.picker.as_mut().map(|p| (p, false))
83    }
84
85    fn dismiss_picker(&mut self) {
86        self.picker = None;
87    }
88
89    fn pick(&mut self, _toggle: bool) {
90        self.picker_choose();
91    }
92
93    fn fields(&self) -> Vec<(CopyFocus, crate::app::form::FieldKind)> {
94        use crate::app::form::FieldKind;
95        self.row_order()
96            .into_iter()
97            .map(|row| {
98                let kind = match row {
99                    CopyFocus::Scope | CopyFocus::Format => FieldKind::Choice,
100                    CopyFocus::Column => FieldKind::Picker { multi: false },
101                    CopyFocus::Header => FieldKind::Checkbox,
102                };
103                (row, kind)
104            })
105            .collect()
106    }
107
108    fn focused(&self) -> CopyFocus {
109        self.focus
110    }
111
112    fn set_focused(&mut self, field: CopyFocus) {
113        self.focus = field;
114    }
115}
116
117impl Default for CopyModal {
118    fn default() -> Self {
119        Self {
120            focus: CopyFocus::Scope,
121            scope: CopyScope::default(),
122            format: CopyFormat::default(),
123            header_row: false,
124            header_view: true,
125            header_table: true,
126            column: None,
127            available_columns: Vec::new(),
128            picker: None,
129            attention: false,
130            context: CopyContext::default(),
131        }
132    }
133}
134
135impl CopyModal {
136    pub fn new() -> Self {
137        Self::default()
138    }
139
140    /// Open over the current table. Scope, format and headers are sticky; the
141    /// cell scope's column is the column cursor's, `current`.
142    pub fn open(&mut self, columns: Vec<String>, current: Option<&str>, context: CopyContext) {
143        self.focus = CopyFocus::Scope;
144        self.picker = None;
145        self.attention = false;
146        self.column = current
147            .filter(|current| columns.iter().any(|c| c == current))
148            .map(str::to_string);
149        self.available_columns = columns;
150        self.context = context;
151    }
152
153    pub fn close(&mut self) {
154        self.picker = None;
155    }
156
157    /// The most rows any scope offers: the dialog's height, whatever the scope.
158    pub const MOST_ROWS: u16 = 3;
159
160    /// The rows the dialog offers, in Tab order. The cell scope trades the
161    /// format and header rows for the column row; a Markdown table without its
162    /// header row is not a table, so Markdown drops the header row too.
163    pub fn row_order(&self) -> Vec<CopyFocus> {
164        match self.scope {
165            CopyScope::Cell => vec![CopyFocus::Scope, CopyFocus::Column],
166            // Code has no format or header to choose.
167            CopyScope::Python => vec![CopyFocus::Scope],
168            _ => {
169                let mut order = vec![CopyFocus::Scope, CopyFocus::Format];
170                if self.format != CopyFormat::Markdown {
171                    order.push(CopyFocus::Header);
172                }
173                order
174            }
175        }
176    }
177
178    /// Step the scope. The scope decides which rows exist, so a focus the new scope
179    /// does not offer goes back to the scope row.
180    pub fn step_scope(&mut self, delta: i8) {
181        self.scope = crate::app::form::step_value(&CopyScope::ALL, self.scope, delta);
182        if !self.row_order().contains(&self.focus) {
183            self.focus = CopyFocus::Scope;
184        }
185    }
186
187    /// Step the format. Markdown has no header row, so focus there moves back.
188    pub fn step_format(&mut self, delta: i8) {
189        self.format = crate::app::form::step_value(&CopyFormat::ALL, self.format, delta);
190        if !self.row_order().contains(&self.focus) {
191            self.focus = CopyFocus::Format;
192        }
193    }
194
195    /// ←/→ on the column row: the next or previous column.
196    pub fn step_column(&mut self, delta: i8) {
197        let columns = &self.available_columns;
198        if columns.is_empty() {
199            return;
200        }
201        let at = self
202            .column
203            .as_ref()
204            .and_then(|c| columns.iter().position(|name| name == c));
205        let next = match at {
206            Some(at) => crate::app::form::step_index(at, columns.len(), delta),
207            None if delta < 0 => columns.len() - 1,
208            None => 0,
209        };
210        self.column = Some(columns[next].clone());
211    }
212
213    /// The header setting the chosen scope carries.
214    pub fn header(&self) -> bool {
215        match self.scope {
216            CopyScope::Cell | CopyScope::Python => false,
217            CopyScope::Row => self.header_row,
218            CopyScope::View => self.header_view,
219            CopyScope::Table => self.header_table,
220        }
221    }
222
223    pub fn toggle_header(&mut self) {
224        match self.scope {
225            CopyScope::Cell | CopyScope::Python => {}
226            CopyScope::Row => self.header_row = !self.header_row,
227            CopyScope::View => self.header_view = !self.header_view,
228            CopyScope::Table => self.header_table = !self.header_table,
229        }
230    }
231
232    /// Rows edited through the Picker: the column, a list too long to step. Scope
233    /// and format step; the header row is a toggle.
234    pub fn is_picker_row(&self, focus: CopyFocus) -> bool {
235        focus == CopyFocus::Column
236    }
237
238    /// What the focused row's Picker offers.
239    pub fn picker_items(&self) -> Vec<String> {
240        match self.focus {
241            CopyFocus::Column => self.available_columns.clone(),
242            CopyFocus::Scope | CopyFocus::Format | CopyFocus::Header => Vec::new(),
243        }
244    }
245
246    /// Open the Picker for the focused row, cursor on the current choice.
247    pub fn open_picker(&mut self) {
248        if !self.is_picker_row(self.focus) {
249            return;
250        }
251        let items = self.picker_items();
252        let current = self.column.as_deref();
253        let mut state = PickerState::new(items.clone());
254        if let Some(current) = current
255            && let Some(i) = items.iter().position(|item| item == current)
256        {
257            state.select_original(i);
258        }
259        self.picker = Some(state);
260    }
261
262    /// Enter in the column Picker takes the cursor's item and closes it.
263    pub fn picker_choose(&mut self) {
264        let Some(state) = self.picker.take() else {
265            return;
266        };
267        let Some(i) = state.selected_original() else {
268            return;
269        };
270        if self.focus == CopyFocus::Column {
271            self.column = self.available_columns.get(i).cloned();
272        }
273    }
274
275    /// Why Enter cannot copy yet, or None when it can.
276    pub fn validation_error(&self) -> Option<&'static str> {
277        match self.scope {
278            CopyScope::Cell if self.column.is_none() => Some("pick a column to copy"),
279            _ => None,
280        }
281    }
282
283    /// What Enter will do, echoed above the footer, or the gap that stops it.
284    pub fn spec_line(&self) -> Result<String, String> {
285        let ctx = &self.context;
286        match self.scope {
287            CopyScope::Cell => match &self.column {
288                Some(column) => Ok(format!(
289                    "Copy cell {column} of row {}",
290                    thousands(ctx.row_number)
291                )),
292                None => Err("pick a column to copy".to_string()),
293            },
294            CopyScope::Row => Ok(format!(
295                "Copy row {} as {}",
296                thousands(ctx.row_number),
297                self.format.as_str()
298            )),
299            CopyScope::View => Ok(format!(
300                "Copy the {} {} {} view as {}",
301                thousands(ctx.view_rows),
302                crate::glyphs::get().times,
303                thousands(ctx.view_cols),
304                self.format.as_str()
305            )),
306            CopyScope::Table => match ctx.total_rows {
307                Some(total) => Ok(format!(
308                    "Copy all {} rows as {}",
309                    thousands(total),
310                    self.format.as_str()
311                )),
312                None => Ok(format!("Copy every row as {}", self.format.as_str())),
313            },
314            CopyScope::Python => Ok("Copy the view as a Python (Polars) script".to_string()),
315        }
316    }
317}
318
319/// `1234567` as `1,234,567`, for the spec line.
320pub fn thousands(n: usize) -> String {
321    let digits = n.to_string();
322    let mut out = String::with_capacity(digits.len() + digits.len() / 3);
323    for (i, ch) in digits.chars().enumerate() {
324        if i > 0 && (digits.len() - i).is_multiple_of(3) {
325            out.push(',');
326        }
327        out.push(ch);
328    }
329    out
330}
331
332#[cfg(test)]
333mod tests;