Skip to main content

ironcalc_base/user_model/
merged_cells.rs

1use crate::expressions::types::Area;
2use crate::merged_cells::{merge_across_ranges, merge_down_ranges};
3use crate::types::{Cell, MergedCell};
4use std::borrow::Cow;
5
6use super::{common::UserModel, history::Diff};
7
8impl UserModel<'_> {
9    /// Merges the cells of `range` into a single merged cell anchored at its
10    /// top-left corner. At most one cell of the range may have content: its
11    /// content, link and style move to the anchor and the style is copied to
12    /// the whole range (with its borders outlining the merged cell as a
13    /// whole); undoing restores everything.
14    ///
15    /// Fails if the range is invalid, a single cell, intersects an existing
16    /// merged cell or an array formula, or contains more than one cell with
17    /// content.
18    ///
19    /// See also:
20    /// * [Model::merge_cells](crate::Model::merge_cells)
21    pub fn merge_cells(&mut self, range: &Area) -> Result<(), String> {
22        self.merge_ranges(std::slice::from_ref(range), false)
23    }
24
25    /// Same as [UserModel::merge_cells] but the merged cell is also centered
26    /// horizontally ("merge & center"). Merging and centering form a single
27    /// undo step.
28    ///
29    /// See also:
30    /// * [Model::merge_cells_center](crate::Model::merge_cells_center)
31    pub fn merge_cells_center(&mut self, range: &Area) -> Result<(), String> {
32        self.merge_ranges(std::slice::from_ref(range), true)
33    }
34
35    /// Merges each row of `range` separately into a one-row-tall merged cell
36    /// ("merge across"), following the rules of [UserModel::merge_cells] row
37    /// by row. Every row is validated up front, so the operation is
38    /// all-or-nothing, and all the merges form a single undo step.
39    ///
40    /// See also:
41    /// * [Model::merge_cells_across](crate::Model::merge_cells_across)
42    pub fn merge_cells_across(&mut self, range: &Area) -> Result<(), String> {
43        self.merge_ranges(&merge_across_ranges(range)?, false)
44    }
45
46    /// Merges each column of `range` separately into a one-column-wide merged
47    /// cell ("merge down"), following the rules of [UserModel::merge_cells]
48    /// column by column. Every column is validated up front, so the operation
49    /// is all-or-nothing, and all the merges form a single undo step.
50    ///
51    /// See also:
52    /// * [Model::merge_cells_down](crate::Model::merge_cells_down)
53    pub fn merge_cells_down(&mut self, range: &Area) -> Result<(), String> {
54        self.merge_ranges(&merge_down_ranges(range)?, false)
55    }
56
57    // Merges every range of `ranges` (all on the same sheet), centering each
58    // horizontally when `center` is set, as a single undo step.
59    fn merge_ranges(&mut self, ranges: &[Area], center: bool) -> Result<(), String> {
60        let Some(first) = ranges.first() else {
61            return Ok(());
62        };
63        let sheet = first.sheet;
64        // Validate every range up front so a multi-range merge is
65        // all-or-nothing: if any range cannot be merged nothing changes.
66        for range in ranges {
67            self.model.check_merge(range)?;
68        }
69        let old_merged_cells = self.model.get_merged_cells(sheet)?.to_vec();
70
71        let mut diff_list = Vec::new();
72        for range in ranges {
73            self.merge_range_collect_diffs(range, center, &mut diff_list)?;
74        }
75        self.snap_selection_to_merged_area(ranges);
76
77        let new_merged_cells = self.model.get_merged_cells(sheet)?.to_vec();
78        diff_list.push(Diff::SetMergedCells {
79            sheet,
80            old_value: old_merged_cells,
81            new_value: new_merged_cells,
82        });
83        self.push_diff_list(diff_list);
84        self.evaluate_if_not_paused();
85        Ok(())
86    }
87
88    // Merges `range` (plus, with `center`, the horizontal centering) and
89    // appends the undo diffs for everything that changed: contents, links and
90    // styles, but not the merged-cell list itself.
91    fn merge_range_collect_diffs(
92        &mut self,
93        range: &Area,
94        center: bool,
95        diff_list: &mut Vec<Diff>,
96    ) -> Result<(), String> {
97        let sheet = range.sheet;
98        diff_list.extend(self.covered_cells_clear_diffs(range)?);
99
100        // Merging moves the content and link of the single covered cell with
101        // content to the anchor: capture the anchor's state before so the
102        // gained content can be recorded as diffs of its own (redo and
103        // external models replay the diffs, not merge_cells).
104        let worksheet = self.model.workbook.worksheet(sheet)?;
105        let anchor_old_value = worksheet.cell(range.row, range.column).map(Cow::into_owned);
106        let anchor_old_link = worksheet.links.get(&(range.row, range.column)).cloned();
107
108        // Merging stamps the anchor's style on the whole range: capture the
109        // explicit style of every cell before and after so undo restores them
110        let mut old_styles = Vec::new();
111        for row in range.row..range.row + range.height {
112            for column in range.column..range.column + range.width {
113                old_styles.push((
114                    row,
115                    column,
116                    self.model.get_cell_style_or_none(sheet, row, column)?,
117                ));
118            }
119        }
120
121        self.model.merge_cells(range)?;
122        if center {
123            self.model.center_merged_range(range)?;
124        }
125
126        let worksheet = self.model.workbook.worksheet(sheet)?;
127        let anchor_new_value = worksheet.cell(range.row, range.column).map(Cow::into_owned);
128        let anchor_had_content = !matches!(&anchor_old_value, None | Some(Cell::EmptyCell { .. }));
129        let anchor_has_content = !matches!(&anchor_new_value, None | Some(Cell::EmptyCell { .. }));
130        if !anchor_had_content && anchor_has_content {
131            let new_value =
132                self.model
133                    .get_localized_cell_content(sheet, range.row, range.column)?;
134            diff_list.push(Diff::SetCellValue {
135                sheet,
136                row: range.row,
137                column: range.column,
138                new_value,
139                old_value: Box::new(anchor_old_value),
140            });
141        }
142        let anchor_new_link = worksheet.links.get(&(range.row, range.column)).cloned();
143        if anchor_new_link != anchor_old_link {
144            diff_list.push(Diff::SetCellLink {
145                sheet,
146                row: range.row,
147                column: range.column,
148                old_value: Box::new(anchor_old_link),
149                new_value: Box::new(anchor_new_link),
150            });
151        }
152
153        for (row, column, old_style) in old_styles {
154            let new_style = self.model.get_cell_style_or_none(sheet, row, column)?;
155            if new_style != old_style {
156                if let Some(new_style) = new_style {
157                    diff_list.push(Diff::SetCellStyle {
158                        sheet,
159                        row,
160                        column,
161                        old_value: Box::new(old_style),
162                        new_value: Box::new(new_style),
163                    });
164                }
165            }
166        }
167        Ok(())
168    }
169
170    // A covered cell can never stay selected: if the selected cell is now
171    // inside the merged area, select the whole area — all the merged cells of
172    // a multi-range merge ("across", "down") — with the top-left anchor as
173    // the selected cell and the bottom-right cell as the focus (the canonical
174    // anchor/focus pair for that range). The scroll position and the
175    // selection are left alone on undo.
176    fn snap_selection_to_merged_area(&mut self, ranges: &[Area]) {
177        let Some(first) = ranges.first() else {
178            return;
179        };
180        let row = ranges.iter().map(|r| r.row).min().unwrap_or(first.row);
181        let column = ranges
182            .iter()
183            .map(|r| r.column)
184            .min()
185            .unwrap_or(first.column);
186        let last_row = ranges
187            .iter()
188            .map(|r| r.row + r.height - 1)
189            .max()
190            .unwrap_or(first.row);
191        let last_column = ranges
192            .iter()
193            .map(|r| r.column + r.width - 1)
194            .max()
195            .unwrap_or(first.column);
196        if let Ok(worksheet) = self.model.workbook.worksheet_mut(first.sheet) {
197            if let Some(view) = worksheet.views.get_mut(&self.model.view_id) {
198                if view.row >= row
199                    && view.row <= last_row
200                    && view.column >= column
201                    && view.column <= last_column
202                {
203                    view.row = row;
204                    view.column = column;
205                    view.range = [row, column, last_row, last_column];
206                    view.focus_row = last_row;
207                    view.focus_column = last_column;
208                }
209            }
210        }
211    }
212
213    /// Removes every merged cell that intersects `range`. The content of the
214    /// anchors is kept. Removing no merged cells at all is a no-op.
215    ///
216    /// See also:
217    /// * [Model::unmerge_cells](crate::Model::unmerge_cells)
218    pub fn unmerge_cells(&mut self, range: &Area) -> Result<(), String> {
219        let sheet = range.sheet;
220        let old_value = self.model.get_merged_cells(sheet)?.to_vec();
221        self.model.unmerge_cells(range)?;
222        let new_value = self.model.get_merged_cells(sheet)?.to_vec();
223        if old_value == new_value {
224            // no-op, don't pollute the undo history
225            return Ok(());
226        }
227        self.push_diff_list(vec![Diff::SetMergedCells {
228            sheet,
229            old_value,
230            new_value,
231        }]);
232        self.evaluate_if_not_paused();
233        Ok(())
234    }
235
236    /// Returns the list of merged cells of the worksheet.
237    pub fn get_merged_cells(&self, sheet: u32) -> Result<Vec<MergedCell>, String> {
238        Ok(self.model.get_merged_cells(sheet)?.to_vec())
239    }
240
241    // Captures the undo diffs for the content and links that merging `range`
242    // will clear: everything but the anchor.
243    pub(super) fn covered_cells_clear_diffs(&self, range: &Area) -> Result<Vec<Diff>, String> {
244        let sheet = range.sheet;
245        let mut diff_list: Vec<Diff> = Vec::new();
246        let worksheet = self.model.workbook.worksheet(sheet)?;
247        for row in range.row..range.row + range.height {
248            for column in range.column..range.column + range.width {
249                if row == range.row && column == range.column {
250                    continue;
251                }
252                if let Some(link) = worksheet.links.get(&(row, column)) {
253                    diff_list.push(Diff::SetCellLink {
254                        sheet,
255                        row,
256                        column,
257                        old_value: Box::new(Some(link.clone())),
258                        new_value: Box::new(None),
259                    });
260                }
261                let old_value = worksheet.cell(row, column).map(Cow::into_owned);
262                if matches!(&old_value, None | Some(Cell::EmptyCell { .. })) {
263                    continue;
264                }
265                diff_list.push(Diff::SetCellValue {
266                    sheet,
267                    row,
268                    column,
269                    new_value: "".to_string(),
270                    old_value: Box::new(old_value),
271                });
272            }
273        }
274        Ok(diff_list)
275    }
276}