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::{
8 CellChangeKind, CellDiff, SheetChange, Severity, WorkbookDiff,
9};
10
11// ---------------------------------------------------------------------------
12// Filtering
13// ---------------------------------------------------------------------------
14
15/// Controls which change categories are visible in a `DiffView`.
16#[derive(Clone, Debug)]
17pub struct ViewFilter {
18 pub include_values: bool,
19 pub include_formulas: bool,
20 /// Formatting diffs (always false until RFC-022 is implemented).
21 pub include_formatting: bool,
22 pub include_info_diagnostics: bool,
23 /// If `Some`, only include changes from the listed sheet indices (0-based).
24 pub sheets: Option<Vec<usize>>,
25}
26
27impl Default for ViewFilter {
28 fn default() -> Self {
29 Self {
30 include_values: true,
31 include_formulas: true,
32 include_formatting: false,
33 include_info_diagnostics: false,
34 sheets: None,
35 }
36 }
37}
38
39// ---------------------------------------------------------------------------
40// Stable change anchor (for virtualized tables / navigation)
41// ---------------------------------------------------------------------------
42
43/// A stable, deterministic identifier for a single change row.
44///
45/// Ordering matches the canonical `(sheet_index, row, col)` sort.
46#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug)]
47pub struct ChangeAnchor {
48 pub sheet_index: usize,
49 pub row: u32,
50 pub col: u32,
51}
52
53// ---------------------------------------------------------------------------
54// Flat change row (one entry per visible cell change)
55// ---------------------------------------------------------------------------
56
57/// A single row in the flat change list presented to a GUI table.
58pub struct CellChangeRow<'a> {
59 /// Stable anchor for navigation and virtualized table positioning.
60 pub anchor: ChangeAnchor,
61 pub sheet_name: &'a str,
62 pub address: &'a CellAddress,
63 /// Combined change kind derived from sub-fields.
64 pub change_kind: CellChangeKind,
65 /// Display string for the old value (empty if Added).
66 pub old_display: String,
67 /// Display string for the new value (empty if Removed).
68 pub new_display: String,
69 /// Whether a formula also changed on this cell.
70 pub formula_changed: bool,
71 /// Highest diagnostic severity attached to this cell.
72 pub max_severity: Option<Severity>,
73}
74
75// ---------------------------------------------------------------------------
76// Sheet summary row
77// ---------------------------------------------------------------------------
78
79/// Summary line for one sheet in the sheet-tree view.
80pub struct SheetSummaryRow<'a> {
81 pub sheet_index: usize,
82 pub name: &'a str,
83 pub change: &'a SheetChange,
84 pub cells_changed: usize,
85 pub has_diagnostics: bool,
86}
87
88// ---------------------------------------------------------------------------
89// DiffView — main adapter
90// ---------------------------------------------------------------------------
91
92/// Borrowed view over a `WorkbookDiff`, providing filtered iteration and
93/// deterministic navigation for GUI applications.
94pub struct DiffView<'a> {
95 pub workbook: &'a WorkbookDiff,
96}
97
98impl<'a> DiffView<'a> {
99 pub fn new(workbook: &'a WorkbookDiff) -> Self {
100 Self { workbook }
101 }
102
103 // ------------------------------------------------------------------
104 // Sheet tree
105 // ------------------------------------------------------------------
106
107 /// Iterate sheet summary rows in workbook display order.
108 pub fn sheets(&self) -> impl Iterator<Item = SheetSummaryRow<'a>> {
109 self.workbook.sheets.iter().enumerate().map(|(i, sd)| {
110 SheetSummaryRow {
111 sheet_index: i,
112 name: sd.new_sheet.as_ref().or(sd.old_sheet.as_ref())
113 .map(|s| s.name.as_str()).unwrap_or("?"),
114 change: &sd.change,
115 cells_changed: sd.summary.cells_changed,
116 has_diagnostics: !sd.diagnostics.is_empty(),
117 }
118 })
119 }
120
121 // ------------------------------------------------------------------
122 // Flat change list
123 // ------------------------------------------------------------------
124
125 /// Collect all visible cell-change rows into a `Vec`, respecting the filter.
126 ///
127 /// Order is deterministic: sheet order → (row, col).
128 pub fn rows(&'a self, filter: &ViewFilter) -> Vec<CellChangeRow<'a>> {
129 let mut out = Vec::new();
130 for (sheet_idx, sd) in self.workbook.sheets.iter().enumerate() {
131 if let Some(ref allowed) = filter.sheets {
132 if !allowed.contains(&sheet_idx) { continue; }
133 }
134 let sheet_name = sd.new_sheet.as_ref().or(sd.old_sheet.as_ref())
135 .map(|s| s.name.as_str()).unwrap_or("?");
136 for cd in &sd.cell_diffs {
137 if let Some(row) = cell_to_row(cd, sheet_idx, sheet_name, filter) {
138 out.push(row);
139 }
140 }
141 }
142 out
143 }
144
145 /// Total number of visible change rows (may iterate; not O(1)).
146 pub fn row_count(&self, filter: &ViewFilter) -> usize {
147 self.rows(filter).len()
148 }
149
150 // ------------------------------------------------------------------
151 // Navigation
152 // ------------------------------------------------------------------
153
154 /// Return the first change anchor in the view, or `None` if empty.
155 pub fn first(&self, filter: &ViewFilter) -> Option<ChangeAnchor> {
156 self.rows(filter).into_iter().next().map(|r| r.anchor)
157 }
158
159 /// Return the anchor immediately after `current`, or `None` if at end.
160 pub fn next_after(&self, current: &ChangeAnchor, filter: &ViewFilter) -> Option<ChangeAnchor> {
161 let mut past = false;
162 for row in self.rows(filter).into_iter() {
163 if past {
164 return Some(row.anchor);
165 }
166 if &row.anchor == current {
167 past = true;
168 }
169 }
170 None
171 }
172
173 /// Return the anchor immediately before `current`, or `None` if at start.
174 pub fn previous_before(&self, current: &ChangeAnchor, filter: &ViewFilter) -> Option<ChangeAnchor> {
175 let mut prev: Option<ChangeAnchor> = None;
176 for row in self.rows(filter).into_iter() {
177 if &row.anchor == current {
178 return prev;
179 }
180 prev = Some(row.anchor.clone());
181 }
182 None
183 }
184
185 // ------------------------------------------------------------------
186 // Per-sheet slice
187 // ------------------------------------------------------------------
188
189 /// All cell-change rows for one sheet (by 0-based sheet index).
190 pub fn sheet_rows(
191 &'a self,
192 sheet_index: usize,
193 filter: &ViewFilter,
194 ) -> Vec<CellChangeRow<'a>> {
195 let mut f = filter.clone();
196 f.sheets = Some(vec![sheet_index]);
197 self.rows(&f)
198 }
199}
200
201// ---------------------------------------------------------------------------
202// Helper: CellDiff → CellChangeRow
203// ---------------------------------------------------------------------------
204
205fn cell_to_row<'a>(
206 cd: &'a CellDiff,
207 sheet_index: usize,
208 sheet_name: &'a str,
209 filter: &ViewFilter,
210) -> Option<CellChangeRow<'a>> {
211 let has_value = cd.value.is_some() && filter.include_values;
212 let has_formula = cd.formula.is_some() && filter.include_formulas;
213
214 if !has_value && !has_formula {
215 return None;
216 }
217
218 let old_display = cd.value.as_ref()
219 .map(|vc| vc.old.display_string())
220 .unwrap_or_default();
221 let new_display = cd.value.as_ref()
222 .map(|vc| vc.new.display_string())
223 .unwrap_or_default();
224
225 let max_severity = cd.diagnostics.iter()
226 .map(|d| d.severity)
227 .max();
228
229 Some(CellChangeRow {
230 anchor: ChangeAnchor {
231 sheet_index,
232 row: cd.address.row,
233 col: cd.address.col,
234 },
235 sheet_name,
236 address: &cd.address,
237 change_kind: cd.change_kind(),
238 old_display,
239 new_display,
240 formula_changed: cd.formula.is_some(),
241 max_severity,
242 })
243}