facet_diff_core/layout/
attrs.rs

1//! Attribute types and grouping algorithms.
2
3use std::borrow::Cow;
4
5use super::Span;
6
7/// Type of a formatted value for color selection.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
9pub enum ValueType {
10    /// String type (green-based colors)
11    String,
12    /// Numeric type (orange-based colors)
13    Number,
14    /// Boolean type (orange-based colors)
15    Boolean,
16    /// Null/None type (cyan-based colors)
17    Null,
18    /// Other/unknown types (use accent color)
19    #[default]
20    Other,
21}
22
23/// A pre-formatted value with its measurements.
24#[derive(Copy, Clone, Debug, Default)]
25pub struct FormattedValue {
26    /// Span into the FormatArena (the formatted, escaped value)
27    pub span: Span,
28    /// Display width of the value (unicode-aware)
29    pub width: usize,
30    /// Type of the value for color selection
31    pub value_type: ValueType,
32}
33
34impl FormattedValue {
35    /// Create a new formatted value with unknown type.
36    pub fn new(span: Span, width: usize) -> Self {
37        Self {
38            span,
39            width,
40            value_type: ValueType::Other,
41        }
42    }
43
44    /// Create a new formatted value with a specific type.
45    pub fn with_type(span: Span, width: usize, value_type: ValueType) -> Self {
46        Self {
47            span,
48            width,
49            value_type,
50        }
51    }
52}
53
54/// The change status of an attribute.
55#[derive(Clone, Debug)]
56pub enum AttrStatus {
57    /// Unchanged (shown dimmed)
58    Unchanged { value: FormattedValue },
59    /// Changed (shown as -/+ line pair)
60    Changed {
61        old: FormattedValue,
62        new: FormattedValue,
63    },
64    /// Deleted (only in old, shown with - prefix)
65    Deleted { value: FormattedValue },
66    /// Inserted (only in new, shown with + prefix)
67    Inserted { value: FormattedValue },
68}
69
70/// A single attribute with its formatting info.
71#[derive(Clone, Debug)]
72pub struct Attr {
73    /// Attribute name (can be borrowed for static struct fields or owned for dynamic values)
74    pub name: Cow<'static, str>,
75    /// Display width of the name
76    pub name_width: usize,
77    /// Change status with formatted values
78    pub status: AttrStatus,
79}
80
81impl Attr {
82    /// Create an unchanged attribute.
83    pub fn unchanged(
84        name: impl Into<Cow<'static, str>>,
85        name_width: usize,
86        value: FormattedValue,
87    ) -> Self {
88        Self {
89            name: name.into(),
90            name_width,
91            status: AttrStatus::Unchanged { value },
92        }
93    }
94
95    /// Create a changed attribute.
96    pub fn changed(
97        name: impl Into<Cow<'static, str>>,
98        name_width: usize,
99        old: FormattedValue,
100        new: FormattedValue,
101    ) -> Self {
102        Self {
103            name: name.into(),
104            name_width,
105            status: AttrStatus::Changed { old, new },
106        }
107    }
108
109    /// Create a deleted attribute.
110    pub fn deleted(
111        name: impl Into<Cow<'static, str>>,
112        name_width: usize,
113        value: FormattedValue,
114    ) -> Self {
115        Self {
116            name: name.into(),
117            name_width,
118            status: AttrStatus::Deleted { value },
119        }
120    }
121
122    /// Create an inserted attribute.
123    pub fn inserted(
124        name: impl Into<Cow<'static, str>>,
125        name_width: usize,
126        value: FormattedValue,
127    ) -> Self {
128        Self {
129            name: name.into(),
130            name_width,
131            status: AttrStatus::Inserted { value },
132        }
133    }
134
135    /// Check if this attribute is changed (needs -/+ lines).
136    pub fn is_changed(&self) -> bool {
137        matches!(self.status, AttrStatus::Changed { .. })
138    }
139
140    /// Get the width this attribute takes on a line.
141    /// Format: `name="value"` -> name_width + 2 (for =") + value_width + 1 (closing ")
142    pub fn line_width(&self) -> usize {
143        let value_width = match &self.status {
144            AttrStatus::Unchanged { value } => value.width,
145            AttrStatus::Changed { old, new } => old.width.max(new.width),
146            AttrStatus::Deleted { value } => value.width,
147            AttrStatus::Inserted { value } => value.width,
148        };
149        // name="value"
150        // ^^^^^       = name_width
151        //      ^^     = ="
152        //        ^^^^^ = value_width
153        //             ^ = "
154        self.name_width + 2 + value_width + 1
155    }
156}
157
158/// A group of changed attributes that fit on one -/+ line pair.
159///
160/// Attributes in a group are aligned vertically:
161/// ```text
162/// - fill="red"   x="10"
163/// + fill="blue"  x="20"
164/// ```
165#[derive(Clone, Debug, Default)]
166pub struct ChangedGroup {
167    /// Indices into the parent's attrs vec
168    pub attr_indices: Vec<usize>,
169    /// Max name width in this group (for alignment)
170    pub max_name_width: usize,
171    /// Max old value width in this group (for alignment)
172    pub max_old_width: usize,
173    /// Max new value width in this group (for alignment)
174    pub max_new_width: usize,
175}
176
177impl ChangedGroup {
178    /// Create a new empty group.
179    pub fn new() -> Self {
180        Self::default()
181    }
182
183    /// Add an attribute to this group, updating max widths.
184    pub fn add(&mut self, index: usize, attr: &Attr) {
185        self.attr_indices.push(index);
186        self.max_name_width = self.max_name_width.max(attr.name_width);
187
188        if let AttrStatus::Changed { old, new } = &attr.status {
189            self.max_old_width = self.max_old_width.max(old.width);
190            self.max_new_width = self.max_new_width.max(new.width);
191        }
192    }
193
194    /// Check if this group is empty.
195    pub fn is_empty(&self) -> bool {
196        self.attr_indices.is_empty()
197    }
198
199    /// Calculate the line width for this group's - line.
200    /// Does not include the "- " prefix or leading indent.
201    pub fn minus_line_width(&self, attrs: &[Attr]) -> usize {
202        if self.attr_indices.is_empty() {
203            return 0;
204        }
205
206        let mut width = 0;
207        for (i, &idx) in self.attr_indices.iter().enumerate() {
208            if i > 0 {
209                width += 1; // space between attrs
210            }
211            let attr = &attrs[idx];
212            if let AttrStatus::Changed { .. } = &attr.status {
213                // name="value" with padding
214                // Pad name to max_name_width, pad value to max_old_width
215                width += self.max_name_width + 2 + self.max_old_width + 1;
216            }
217        }
218        width
219    }
220
221    /// Calculate the line width for this group's + line.
222    pub fn plus_line_width(&self, attrs: &[Attr]) -> usize {
223        if self.attr_indices.is_empty() {
224            return 0;
225        }
226
227        let mut width = 0;
228        for (i, &idx) in self.attr_indices.iter().enumerate() {
229            if i > 0 {
230                width += 1; // space between attrs
231            }
232            let attr = &attrs[idx];
233            if let AttrStatus::Changed { .. } = &attr.status {
234                // Pad name to max_name_width, pad value to max_new_width
235                width += self.max_name_width + 2 + self.max_new_width + 1;
236            }
237        }
238        width
239    }
240}
241
242/// Group changed attributes into lines that fit within max line width.
243///
244/// Uses greedy bin-packing: add attributes to the current group until
245/// the next one would exceed the width limit, then start a new group.
246pub fn group_changed_attrs(
247    attrs: &[Attr],
248    max_line_width: usize,
249    indent_width: usize,
250) -> Vec<ChangedGroup> {
251    let available_width = max_line_width.saturating_sub(indent_width + 2); // "- " prefix
252
253    let mut groups = Vec::new();
254    let mut current = ChangedGroup::new();
255    let mut current_width = 0usize;
256
257    for (i, attr) in attrs.iter().enumerate() {
258        if !attr.is_changed() {
259            continue;
260        }
261
262        let attr_width = attr.line_width();
263        let needed = if current.is_empty() {
264            attr_width
265        } else {
266            attr_width + 1 // space before
267        };
268
269        if current_width + needed > available_width && !current.is_empty() {
270            // Start new group
271            groups.push(std::mem::take(&mut current));
272            current_width = 0;
273        }
274
275        let needed = if current.is_empty() {
276            attr_width
277        } else {
278            attr_width + 1
279        };
280        current_width += needed;
281        current.add(i, attr);
282    }
283
284    if !current.is_empty() {
285        groups.push(current);
286    }
287
288    groups
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    fn make_changed_attr(name: &'static str, old_width: usize, new_width: usize) -> Attr {
296        Attr::changed(
297            name,
298            name.len(),
299            FormattedValue::new(Span::default(), old_width),
300            FormattedValue::new(Span::default(), new_width),
301        )
302    }
303
304    #[test]
305    fn test_attr_line_width() {
306        // fill="red" -> 4 + 2 + 3 + 1 = 10
307        let attr = make_changed_attr("fill", 3, 4);
308        assert_eq!(attr.line_width(), 4 + 2 + 4 + 1); // uses max(old, new)
309    }
310
311    #[test]
312    fn test_group_single_attr() {
313        let attrs = vec![make_changed_attr("fill", 3, 4)];
314        let groups = group_changed_attrs(&attrs, 80, 0);
315
316        assert_eq!(groups.len(), 1);
317        assert_eq!(groups[0].attr_indices, vec![0]);
318    }
319
320    #[test]
321    fn test_group_multiple_fit_one_line() {
322        // fill="red" x="10" -> fits in 80 chars
323        let attrs = vec![
324            make_changed_attr("fill", 3, 4),
325            make_changed_attr("x", 2, 2),
326        ];
327        let groups = group_changed_attrs(&attrs, 80, 0);
328
329        assert_eq!(groups.len(), 1);
330        assert_eq!(groups[0].attr_indices, vec![0, 1]);
331    }
332
333    #[test]
334    fn test_group_overflow_to_second_line() {
335        // Very narrow width forces split
336        let attrs = vec![
337            make_changed_attr("fill", 3, 4),
338            make_changed_attr("x", 2, 2),
339        ];
340        // fill="xxxx" = 4 + 2 + 4 + 1 = 11
341        // With "- " prefix = 13
342        // x="xx" = 1 + 2 + 2 + 1 = 6
343        // Total = 19 + 1 (space) = 20
344        let groups = group_changed_attrs(&attrs, 15, 0);
345
346        assert_eq!(groups.len(), 2);
347        assert_eq!(groups[0].attr_indices, vec![0]);
348        assert_eq!(groups[1].attr_indices, vec![1]);
349    }
350
351    #[test]
352    fn test_group_skips_unchanged() {
353        let attrs = vec![
354            make_changed_attr("fill", 3, 4),
355            Attr::unchanged("x", 1, FormattedValue::new(Span::default(), 2)),
356            make_changed_attr("y", 2, 2),
357        ];
358        let groups = group_changed_attrs(&attrs, 80, 0);
359
360        assert_eq!(groups.len(), 1);
361        assert_eq!(groups[0].attr_indices, vec![0, 2]); // skipped index 1
362    }
363
364    #[test]
365    fn test_group_max_widths() {
366        let attrs = vec![
367            make_changed_attr("fill", 3, 4),   // old=3, new=4
368            make_changed_attr("stroke", 5, 3), // old=5, new=3
369        ];
370        let groups = group_changed_attrs(&attrs, 80, 0);
371
372        assert_eq!(groups.len(), 1);
373        assert_eq!(groups[0].max_name_width, 6); // "stroke"
374        assert_eq!(groups[0].max_old_width, 5);
375        assert_eq!(groups[0].max_new_width, 4);
376    }
377}