sheets_diff/output/view.rs
1//! Framework-neutral GUI view adapters over `WorkbookDiff` (RFC-029).
2//!
3//! These types borrow from `WorkbookDiff` and allocate display strings only
4//! on demand. No GUI framework dependency is introduced.
5
6use crate::address::CellAddress;
7use crate::model::{CellChangeKind, CellDiff, Severity, SheetChange, WorkbookDiff};
8
9// ---------------------------------------------------------------------------
10// Filtering
11// ---------------------------------------------------------------------------
12
13/// Controls which change categories are visible in a `DiffView`.
14#[derive(Clone, Debug)]
15pub struct ViewFilter {
16 pub include_values: bool,
17 pub include_formulas: bool,
18 /// Formatting diffs (always false until RFC-022 is implemented).
19 pub include_formatting: bool,
20 pub include_info_diagnostics: bool,
21 /// If `Some`, only include changes from the listed sheet indices (0-based).
22 pub sheets: Option<Vec<usize>>,
23}
24
25impl Default for ViewFilter {
26 fn default() -> Self {
27 Self {
28 include_values: true,
29 include_formulas: true,
30 include_formatting: false,
31 include_info_diagnostics: false,
32 sheets: None,
33 }
34 }
35}
36
37// ---------------------------------------------------------------------------
38// Stable change anchor (for virtualized tables / navigation)
39// ---------------------------------------------------------------------------
40
41/// A stable, deterministic identifier for a single change row.
42///
43/// Ordering matches the canonical `(sheet_index, row, col)` sort.
44#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug)]
45#[cfg_attr(feature = "serde", derive(serde::Serialize))]
46pub struct ChangeAnchor {
47 pub sheet_index: usize,
48 pub row: u32,
49 pub col: u32,
50}
51
52// ---------------------------------------------------------------------------
53// Flat change row (one entry per visible cell change)
54// ---------------------------------------------------------------------------
55
56/// A single row in the flat change list presented to a GUI table.
57pub struct CellChangeRow<'a> {
58 /// Stable anchor for navigation and virtualized table positioning.
59 pub anchor: ChangeAnchor,
60 pub sheet_name: &'a str,
61 pub address: &'a CellAddress,
62 /// Combined change kind derived from sub-fields.
63 pub change_kind: CellChangeKind,
64 /// Display string for the old value (empty if Added).
65 pub old_display: String,
66 /// Display string for the new value (empty if Removed).
67 pub new_display: String,
68 /// Whether a formula also changed on this cell.
69 pub formula_changed: bool,
70 /// Old formula text, if a formula change is present (Q2: borrowed from the
71 /// underlying `CellDiff`, so GUI consumers need not reach into the raw model).
72 pub old_formula: Option<&'a str>,
73 /// New formula text, if a formula change is present.
74 pub new_formula: Option<&'a str>,
75 /// Highest diagnostic severity attached to this cell.
76 pub max_severity: Option<Severity>,
77}
78
79impl<'a> CellChangeRow<'a> {
80 /// Convert this borrowed row into a fully owned [`OwnedCellChangeRow`]
81 /// (Q3: convenience for consumers whose model outlives the `WorkbookDiff`).
82 pub fn to_owned_row(&self) -> OwnedCellChangeRow {
83 OwnedCellChangeRow {
84 anchor: self.anchor.clone(),
85 sheet_name: self.sheet_name.to_owned(),
86 address: self.address.clone(),
87 change_kind: self.change_kind,
88 old_display: self.old_display.clone(),
89 new_display: self.new_display.clone(),
90 formula_changed: self.formula_changed,
91 old_formula: self.old_formula.map(|s| s.to_owned()),
92 new_formula: self.new_formula.map(|s| s.to_owned()),
93 max_severity: self.max_severity,
94 }
95 }
96}
97
98/// Fully owned counterpart to [`CellChangeRow`] (Q3).
99///
100/// All borrowed fields become owned (`String`, `CellAddress`), so the row can
101/// outlive the `WorkbookDiff` it was derived from. Produced by
102/// [`CellChangeRow::to_owned_row`].
103#[derive(Clone, Debug)]
104#[cfg_attr(feature = "serde", derive(serde::Serialize))]
105pub struct OwnedCellChangeRow {
106 pub anchor: ChangeAnchor,
107 pub sheet_name: String,
108 pub address: CellAddress,
109 pub change_kind: CellChangeKind,
110 pub old_display: String,
111 pub new_display: String,
112 pub formula_changed: bool,
113 pub old_formula: Option<String>,
114 pub new_formula: Option<String>,
115 pub max_severity: Option<Severity>,
116}
117
118// ---------------------------------------------------------------------------
119// Sheet summary row
120// ---------------------------------------------------------------------------
121
122/// Summary line for one sheet in the sheet-tree view.
123pub struct SheetSummaryRow<'a> {
124 pub sheet_index: usize,
125 pub name: &'a str,
126 pub change: &'a SheetChange,
127 pub cells_changed: usize,
128 pub has_diagnostics: bool,
129}
130
131// ---------------------------------------------------------------------------
132// DiffView — main adapter
133// ---------------------------------------------------------------------------
134
135/// Borrowed view over a `WorkbookDiff`, providing filtered iteration and
136/// deterministic navigation for GUI applications.
137pub struct DiffView<'a> {
138 pub workbook: &'a WorkbookDiff,
139}
140
141impl<'a> DiffView<'a> {
142 pub fn new(workbook: &'a WorkbookDiff) -> Self {
143 Self { workbook }
144 }
145
146 // ------------------------------------------------------------------
147 // Sheet tree
148 // ------------------------------------------------------------------
149
150 /// Iterate sheet summary rows in workbook display order.
151 pub fn sheets(&self) -> impl Iterator<Item = SheetSummaryRow<'a>> {
152 self.workbook
153 .sheets
154 .iter()
155 .enumerate()
156 .map(|(i, sd)| SheetSummaryRow {
157 sheet_index: i,
158 name: sd
159 .new_sheet
160 .as_ref()
161 .or(sd.old_sheet.as_ref())
162 .map(|s| s.name.as_str())
163 .unwrap_or("?"),
164 change: &sd.change,
165 cells_changed: sd.summary.cells_changed,
166 has_diagnostics: !sd.diagnostics.is_empty(),
167 })
168 }
169
170 // ------------------------------------------------------------------
171 // Flat change list
172 // ------------------------------------------------------------------
173
174 /// Collect all visible cell-change rows into a `Vec`, respecting the filter.
175 ///
176 /// Order is deterministic: sheet order → (row, col).
177 pub fn rows(&'a self, filter: &ViewFilter) -> Vec<CellChangeRow<'a>> {
178 let mut out = Vec::new();
179 for (sheet_idx, sd) in self.workbook.sheets.iter().enumerate() {
180 if let Some(ref allowed) = filter.sheets
181 && !allowed.contains(&sheet_idx)
182 {
183 continue;
184 }
185 let sheet_name = sd
186 .new_sheet
187 .as_ref()
188 .or(sd.old_sheet.as_ref())
189 .map(|s| s.name.as_str())
190 .unwrap_or("?");
191 for cd in &sd.cell_diffs {
192 if let Some(row) = cell_to_row(cd, sheet_idx, sheet_name, filter) {
193 out.push(row);
194 }
195 }
196 }
197 out
198 }
199
200 /// Total number of visible change rows (may iterate; not O(1)).
201 pub fn row_count(&self, filter: &ViewFilter) -> usize {
202 self.rows(filter).len()
203 }
204
205 // ------------------------------------------------------------------
206 // Navigation
207 // ------------------------------------------------------------------
208
209 /// Return the first change anchor in the view, or `None` if empty.
210 pub fn first(&self, filter: &ViewFilter) -> Option<ChangeAnchor> {
211 self.rows(filter).into_iter().next().map(|r| r.anchor)
212 }
213
214 /// Return the anchor immediately after `current`, or `None` if at end.
215 pub fn next_after(&self, current: &ChangeAnchor, filter: &ViewFilter) -> Option<ChangeAnchor> {
216 let mut past = false;
217 for row in self.rows(filter).into_iter() {
218 if past {
219 return Some(row.anchor);
220 }
221 if &row.anchor == current {
222 past = true;
223 }
224 }
225 None
226 }
227
228 /// Return the anchor immediately before `current`, or `None` if at start.
229 pub fn previous_before(
230 &self,
231 current: &ChangeAnchor,
232 filter: &ViewFilter,
233 ) -> Option<ChangeAnchor> {
234 let mut prev: Option<ChangeAnchor> = None;
235 for row in self.rows(filter).into_iter() {
236 if &row.anchor == current {
237 return prev;
238 }
239 prev = Some(row.anchor.clone());
240 }
241 None
242 }
243
244 // ------------------------------------------------------------------
245 // Per-sheet slice
246 // ------------------------------------------------------------------
247
248 /// All cell-change rows for one sheet (by 0-based sheet index).
249 pub fn sheet_rows(&'a self, sheet_index: usize, filter: &ViewFilter) -> Vec<CellChangeRow<'a>> {
250 let mut f = filter.clone();
251 f.sheets = Some(vec![sheet_index]);
252 self.rows(&f)
253 }
254}
255
256// ---------------------------------------------------------------------------
257// Helper: CellDiff → CellChangeRow
258// ---------------------------------------------------------------------------
259
260fn cell_to_row<'a>(
261 cd: &'a CellDiff,
262 sheet_index: usize,
263 sheet_name: &'a str,
264 filter: &ViewFilter,
265) -> Option<CellChangeRow<'a>> {
266 let has_value = cd.value.is_some() && filter.include_values;
267 let has_formula = cd.formula.is_some() && filter.include_formulas;
268
269 if !has_value && !has_formula {
270 return None;
271 }
272
273 let old_display = cd
274 .value
275 .as_ref()
276 .map(|vc| vc.old.display_string())
277 .unwrap_or_default();
278 let new_display = cd
279 .value
280 .as_ref()
281 .map(|vc| vc.new.display_string())
282 .unwrap_or_default();
283
284 let max_severity = cd.diagnostics.iter().map(|d| d.severity).max();
285
286 // Borrow formula text from the underlying change, if present (Q2).
287 let (old_formula, new_formula) = match &cd.formula {
288 Some(fc) => (
289 fc.old.as_ref().map(|t| t.raw.as_str()),
290 fc.new.as_ref().map(|t| t.raw.as_str()),
291 ),
292 None => (None, None),
293 };
294
295 Some(CellChangeRow {
296 anchor: ChangeAnchor {
297 sheet_index,
298 row: cd.address.row,
299 col: cd.address.col,
300 },
301 sheet_name,
302 address: &cd.address,
303 change_kind: cd.change_kind(),
304 old_display,
305 new_display,
306 formula_changed: cd.formula.is_some(),
307 old_formula,
308 new_formula,
309 max_severity,
310 })
311}