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