1use crate::clipboard::CopyFormat;
7use crate::widgets::ui::PickerState;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
11pub enum CopyScope {
12 Cell,
13 #[default]
14 Row,
15 View,
16 Table,
17 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#[derive(Debug, Clone, Copy, Default)]
47pub struct CopyContext {
48 pub row_number: usize,
50 pub view_rows: usize,
52 pub view_cols: usize,
53 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 pub header_row: bool,
66 pub header_view: bool,
67 pub header_table: bool,
68 pub column: Option<String>,
70 pub available_columns: Vec<String>,
71 pub picker: Option<PickerState>,
73 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 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 pub const MOST_ROWS: u16 = 3;
151
152 pub fn row_order(&self) -> Vec<CopyFocus> {
156 match self.scope {
157 CopyScope::Cell => vec![CopyFocus::Scope, CopyFocus::Column],
158 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 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 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 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 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 pub fn is_picker_row(&self, focus: CopyFocus) -> bool {
227 focus == CopyFocus::Column
228 }
229
230 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 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 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 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 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
311pub 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 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 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}