Skip to main content

ironcalc_base/user_model/
clipboard.rs

1#![deny(missing_docs)]
2
3use std::{
4    collections::{HashMap, HashSet},
5    io::Cursor,
6};
7
8use csv::{ReaderBuilder, WriterBuilder};
9use serde::{Deserialize, Serialize};
10
11use crate::{
12    cf_types::ConditionalFormatting,
13    expressions::types::{Area, CellReferenceIndex},
14    model::CellStructure,
15    types::{ArrayKind, Cell, Style},
16    UserModel,
17};
18
19use crate::user_model::history::Diff;
20
21/// Data for the clipboard
22pub type ClipboardData = HashMap<i32, HashMap<i32, ClipboardCell>>;
23
24pub type ClipboardTuple = (i32, i32, i32, i32);
25
26#[derive(Serialize, Deserialize)]
27pub struct ClipboardCell {
28    text: String,
29    is_spill: bool,
30    style: Style,
31}
32
33#[derive(Serialize, Deserialize)]
34pub struct Clipboard {
35    pub(crate) csv: String,
36    pub(crate) data: ClipboardData,
37    pub(crate) sheet: u32,
38    pub(crate) range: (i32, i32, i32, i32),
39}
40
41impl<'a> UserModel<'a> {
42    /// Returns a copy of the selected area
43    pub fn copy_to_clipboard(&self) -> Result<Clipboard, String> {
44        let selected_area = self.get_selected_view();
45        let sheet = selected_area.sheet;
46        let mut wtr = WriterBuilder::new().delimiter(b'\t').from_writer(vec![]);
47
48        let mut data = HashMap::new();
49        let [row_start, column_start, row_end, column_end] = selected_area.range;
50        let dimension = self.model.workbook.worksheet(sheet)?.dimension();
51        let row_end = row_end.min(dimension.max_row).max(row_start);
52        let column_end = column_end.min(dimension.max_column).max(column_start);
53        for row in row_start..=row_end {
54            let mut data_row = HashMap::new();
55            let mut text_row = Vec::new();
56            for column in column_start..=column_end {
57                let text = self.get_formatted_cell_value(sheet, row, column)?;
58                let content = self.get_cell_content(sheet, row, column)?;
59                let style = self.model.get_style_for_cell(sheet, row, column)?;
60                let is_spill = matches!(
61                    self.model.get_cell_structure(sheet, row, column)?,
62                    CellStructure::SpillArray { .. } | CellStructure::SpillDynamic { .. }
63                );
64                data_row.insert(
65                    column,
66                    ClipboardCell {
67                        text: content,
68                        is_spill,
69                        style,
70                    },
71                );
72                text_row.push(text);
73            }
74            wtr.write_record(text_row)
75                .map_err(|e| format!("Error while processing csv: {e}"))?;
76            data.insert(row, data_row);
77        }
78
79        let csv = String::from_utf8(
80            wtr.into_inner()
81                .map_err(|e| format!("Processing error: '{e}'"))?,
82        )
83        .map_err(|e| format!("Error converting from utf8: '{e}'"))?;
84
85        Ok(Clipboard {
86            csv: csv.trim().to_string(),
87            data,
88            sheet,
89            range: (row_start, column_start, row_end, column_end),
90        })
91    }
92
93    /// Paste text that we copied
94    pub fn paste_from_clipboard(
95        &mut self,
96        source_sheet: u32,
97        source_range: ClipboardTuple,
98        clipboard: &ClipboardData,
99        is_cut: bool,
100    ) -> Result<(), String> {
101        let mut diff_list = Vec::new();
102        let view = self.get_selected_view();
103        let (source_first_row, source_first_column, source_last_row, source_last_column) =
104            source_range;
105        let sheet = view.sheet;
106        let [selected_row, selected_column, _, _] = view.range;
107        let mut max_row = selected_row;
108        let mut max_column = selected_column;
109        let area = &Area {
110            sheet,
111            row: source_first_row,
112            column: source_first_column,
113            width: source_last_column - source_first_column + 1,
114            height: source_last_row - source_first_row + 1,
115        };
116        let target_area = &Area {
117            sheet,
118            row: selected_row,
119            column: selected_column,
120            width: source_last_column - source_first_column + 1,
121            height: source_last_row - source_first_row + 1,
122        };
123
124        let mut seen_cells = HashSet::new();
125        // Compute all changes
126        let mut changes = Vec::new();
127        for (source_row, data_row) in clipboard {
128            let delta_row = source_row - source_first_row;
129            let target_row = selected_row + delta_row;
130            max_row = max_row.max(target_row);
131            for (source_column, value) in data_row {
132                let delta_column = source_column - source_first_column;
133                let target_column = selected_column + delta_column;
134                max_column = max_column.max(target_column);
135
136                if value.is_spill {
137                    // Spill cells carry no formula/value, but their style should still be copied.
138                    let old_style =
139                        self.model
140                            .get_cell_style_or_none(sheet, target_row, target_column)?;
141                    changes.push((
142                        target_row,
143                        target_column,
144                        None,
145                        old_style,
146                        None,
147                        value.style.clone(),
148                    ));
149                    seen_cells.insert((target_row, target_column));
150                    continue;
151                }
152
153                // We are copying the value in
154                // (source_row, source_column) to (target_row , target_column)
155                // References in formulas are displaced
156
157                // remain in the copied area
158                let source = &CellReferenceIndex {
159                    sheet,
160                    column: *source_column,
161                    row: *source_row,
162                };
163                let target = &CellReferenceIndex {
164                    sheet,
165                    column: target_column,
166                    row: target_row,
167                };
168                let new_value = if is_cut {
169                    self.model
170                        .move_cell_value_to_area(&value.text, source, target, area)?
171                } else {
172                    self.model
173                        .extend_copied_value(&value.text, source, target)?
174                };
175
176                let old_value = self
177                    .model
178                    .workbook
179                    .worksheet(sheet)?
180                    .cell(target_row, target_column)
181                    .cloned();
182
183                let old_style =
184                    self.model
185                        .get_cell_style_or_none(sheet, target_row, target_column)?;
186                changes.push((
187                    target_row,
188                    target_column,
189                    old_value.clone(),
190                    old_style.clone(),
191                    Some(new_value.clone()),
192                    value.style.clone(),
193                ));
194                seen_cells.insert((target_row, target_column));
195            }
196        }
197        // clear the whole area (this resets array formulas)
198        self.model.range_clear_contents(target_area)?;
199        // set the new values and styles
200        for (target_row, target_column, old_value, old_style, new_value, style) in changes {
201            if let Some(ref v) = new_value {
202                self.model
203                    .set_user_input(sheet, target_row, target_column, v.clone())?;
204                diff_list.push(Diff::SetCellValue {
205                    sheet,
206                    row: target_row,
207                    column: target_column,
208                    new_value: v.clone(),
209                    old_value: Box::new(old_value),
210                });
211            }
212            self.model
213                .set_cell_style(sheet, target_row, target_column, &style)?;
214
215            diff_list.push(Diff::SetCellStyle {
216                sheet,
217                row: target_row,
218                column: target_column,
219                old_value: Box::new(old_style),
220                new_value: Box::new(style),
221            });
222        }
223        if is_cut {
224            for row in source_first_row..=source_last_row {
225                for column in source_first_column..=source_last_column {
226                    if (source_sheet == sheet) && seen_cells.contains(&(row, column)) {
227                        continue;
228                    }
229                    let old_value = self
230                        .model
231                        .workbook
232                        .worksheet(source_sheet)?
233                        .cell(row, column)
234                        .cloned();
235
236                    diff_list.push(Diff::RangeClearContents {
237                        sheet: source_sheet,
238                        row,
239                        column,
240                        width: 1,
241                        height: 1,
242                        old_value: vec![vec![old_value.clone()]],
243                    });
244
245                    // If the source is a dynamic formula anchor, range_clear_contents
246                    // would erase its entire spill — including cells that were just
247                    // written to by this paste.  Clear the anchor and its spill cells
248                    // individually instead, skipping any paste-target cells.
249                    let spill_dims = match &old_value {
250                        Some(Cell::ArrayFormula {
251                            kind: ArrayKind::Dynamic,
252                            r,
253                            ..
254                        }) => Some(*r),
255                        _ => None,
256                    };
257                    if let Some((spill_w, spill_h)) = spill_dims {
258                        let ws = self.model.workbook.worksheet_mut(source_sheet)?;
259                        for sr in row..row + spill_h {
260                            for sc in column..column + spill_w {
261                                if (source_sheet == sheet) && seen_cells.contains(&(sr, sc)) {
262                                    continue;
263                                }
264                                let _ = ws.cell_clear_contents(sr, sc);
265                            }
266                        }
267                    } else {
268                        let area = Area {
269                            sheet: source_sheet,
270                            row,
271                            column,
272                            width: 1,
273                            height: 1,
274                        };
275                        self.model.range_clear_contents(&area)?;
276                    }
277                    let old_style = self
278                        .model
279                        .get_cell_style_or_none(source_sheet, row, column)?;
280                    let default_style = Style::default();
281                    self.model
282                        .set_cell_style(source_sheet, row, column, &default_style)?;
283                    diff_list.push(Diff::SetCellStyle {
284                        sheet: source_sheet,
285                        row,
286                        column,
287                        old_value: Box::new(old_style),
288                        new_value: Box::new(default_style),
289                    });
290                }
291            }
292            // Update external formulas that reference cells in the moved area.
293            // source_sheet is used here (not `sheet`) so cross-sheet paste works.
294            let ext_area = Area {
295                sheet: source_sheet,
296                row: source_first_row,
297                column: source_first_column,
298                width: source_last_column - source_first_column + 1,
299                height: source_last_row - source_first_row + 1,
300            };
301            let ext_updates = self.model.get_external_formula_updates_for_cut(
302                &ext_area,
303                selected_row,
304                selected_column,
305            )?;
306            for (ext_sheet, ext_row, ext_col, new_formula) in ext_updates {
307                let old_cell = self
308                    .model
309                    .workbook
310                    .worksheet(ext_sheet)?
311                    .cell(ext_row, ext_col)
312                    .cloned();
313                self.model
314                    .set_user_input(ext_sheet, ext_row, ext_col, new_formula.clone())?;
315                diff_list.push(Diff::SetCellValue {
316                    sheet: ext_sheet,
317                    row: ext_row,
318                    column: ext_col,
319                    new_value: new_formula,
320                    old_value: Box::new(old_cell),
321                });
322            }
323            // Update defined names whose references land inside the moved area.
324            let dn_updates = self.model.get_defined_name_updates_for_cut(
325                &ext_area,
326                selected_row,
327                selected_column,
328            );
329            for (dn_name, dn_scope, old_formula, new_formula) in dn_updates {
330                diff_list.push(Diff::UpdateDefinedName {
331                    name: dn_name.clone(),
332                    scope: dn_scope,
333                    old_formula: old_formula.clone(),
334                    new_name: dn_name.clone(),
335                    new_scope: dn_scope,
336                    new_formula: new_formula.clone(),
337                });
338                self.model.update_defined_name(
339                    &dn_name,
340                    dn_scope,
341                    &dn_name,
342                    dn_scope,
343                    &new_formula,
344                )?;
345            }
346            // Update conditional formatting ranges and formula references.
347            let cf_updates = self.model.get_conditional_formatting_updates_for_cut(
348                &ext_area,
349                selected_row,
350                selected_column,
351            )?;
352            for (cf_sheet, cf_idx, new_range, new_rule) in cf_updates {
353                let old_cf = self
354                    .model
355                    .workbook
356                    .worksheet(cf_sheet)?
357                    .conditional_formatting
358                    .get(cf_idx)
359                    .ok_or_else(|| format!("CF index {cf_idx} not found"))?
360                    .clone();
361                {
362                    let ws = self.model.workbook.worksheet_mut(cf_sheet)?;
363                    ws.conditional_formatting[cf_idx].range = new_range.clone();
364                    ws.conditional_formatting[cf_idx].cf_rule = new_rule.clone();
365                }
366                diff_list.push(Diff::UpdateConditionalFormatting {
367                    sheet: cf_sheet,
368                    index: cf_idx as u32,
369                    old_range: old_cf.range,
370                    old_rule: Box::new(old_cf.cf_rule),
371                    old_priority: old_cf.priority,
372                    new_range,
373                    new_rule: Box::new(new_rule),
374                });
375            }
376        } else {
377            // Copy-paste: duplicate CF rules from the source area to the target.
378            let cf_copies = self.model.get_cf_rules_to_copy(
379                source_sheet,
380                source_first_row,
381                source_first_column,
382                source_last_row,
383                source_last_column,
384                selected_row,
385                selected_column,
386            );
387            for (new_range, new_rule) in cf_copies {
388                let priority = self
389                    .model
390                    .workbook
391                    .worksheet(sheet)?
392                    .conditional_formatting
393                    .iter()
394                    .map(|cf| cf.priority)
395                    .max()
396                    .map(|m| m + 1)
397                    .unwrap_or(1);
398                self.model
399                    .workbook
400                    .worksheet_mut(sheet)?
401                    .conditional_formatting
402                    .push(ConditionalFormatting {
403                        range: new_range.clone(),
404                        cf_rule: new_rule.clone(),
405                        priority,
406                    });
407                diff_list.push(Diff::AddConditionalFormatting {
408                    sheet,
409                    range: new_range,
410                    rule: Box::new(new_rule),
411                    priority,
412                });
413            }
414        }
415        self.push_diff_list(diff_list);
416        // select the pasted area
417        self.set_selected_range(selected_row, selected_column, max_row, max_column)?;
418        self.evaluate_if_not_paused();
419        Ok(())
420    }
421
422    /// Paste a csv-string into the model
423    pub fn paste_csv_string(&mut self, area: &Area, csv: &str) -> Result<(), String> {
424        let sheet = area.sheet;
425
426        // First pass: parse all records so we know the full extent before touching any cells.
427        let mut records: Vec<Vec<String>> = Vec::new();
428        let mut max_width: i32 = 0;
429        let csv_reader = Cursor::new(csv);
430        let mut reader = ReaderBuilder::new()
431            .delimiter(b'\t')
432            .has_headers(false)
433            .from_reader(csv_reader);
434        for r in reader.records().flatten() {
435            let row_data: Vec<String> = r.iter().map(|v| v.to_string()).collect();
436            max_width = max_width.max(row_data.len() as i32);
437            records.push(row_data);
438        }
439        if records.is_empty() {
440            return Ok(());
441        }
442
443        // Check whether any static array formula would be partially overwritten.
444        let paste_area = Area {
445            sheet,
446            row: area.row,
447            column: area.column,
448            width: max_width,
449            height: records.len() as i32,
450        };
451
452        // Capture old values BEFORE clearing so undo can restore them correctly.
453        let mut old_values: HashMap<(i32, i32), Option<Cell>> = HashMap::new();
454        {
455            let ws = self.model.workbook.worksheet(sheet)?;
456            for r in area.row..area.row + records.len() as i32 {
457                for c in area.column..area.column + max_width {
458                    old_values.insert((r, c), ws.cell(r, c).cloned());
459                }
460            }
461        }
462
463        self.model.range_clear_contents(&paste_area)?;
464
465        // Second pass: write values and build diff list.
466        let mut diff_list = Vec::new();
467        let mut row = area.row;
468        let mut last_column = area.column;
469        for row_data in &records {
470            let mut column = area.column;
471            for value in row_data {
472                let old_value = old_values.remove(&(row, column)).unwrap_or(None);
473                self.model
474                    .set_user_input(sheet, row, column, value.to_string())?;
475                diff_list.push(Diff::SetCellValue {
476                    sheet,
477                    row,
478                    column,
479                    new_value: value.to_string(),
480                    old_value: Box::new(old_value),
481                });
482                column += 1;
483            }
484            last_column = last_column.max(column - 1);
485            row += 1;
486        }
487        self.push_diff_list(diff_list);
488        // select the pasted area
489        self.set_selected_range(area.row, area.column, row - 1, last_column)?;
490        self.evaluate_if_not_paused();
491        Ok(())
492    }
493}