Skip to main content

teksilo_data/
data_change.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `DataChange` — change notifications for flat collections.
5//!
6//! Describes the mutations that [`crate::ListModel`] (and [`crate::ListDataSource`]
7//! implementors) emit to their subscribers. Consumers such as `ListView`,
8//! `TableView`, and `SortFilterListModel` receive a `DataChange` through their
9//! observer and update their internal state (measured row heights, selection
10//! indices, sort projections) incrementally rather than rebuilding from scratch.
11//!
12//! Most variants carry index ranges so that observers can perform O(affected)
13//! work. `Reset` is the fallback when the change cannot be expressed
14//! incrementally; consumers must discard all cached state and re-query the source.
15//!
16//! Also provided: [`map_index_after_move`], a pure function that maps a single
17//! index through an `ItemsMoved` operation — used by [`crate::CheckedModel`] and
18//! [`crate::SelectionModel`] to keep index-based state in sync after reorders.
19//!
20//! ```rust
21//! # use teksilo_data::data_change::{DataChange, map_index_after_move};
22//! // An insertion at row 2 shifts index 5 to 6.
23//! let change = DataChange::ItemsInserted { range: 2..3 };
24//! // map_index_after_move: move row 0 to position 2 (post-removal index).
25//! let new_idx = map_index_after_move(0, 0, 2, 1);
26//! assert_eq!(new_idx, 2);
27//! ```
28
29use std::ops::Range;
30
31/// Describes a mutation to a flat list. Emitted by [`crate::ListModel`] automatically
32/// and by [`crate::ListDataSource`] implementors manually.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum DataChange {
35    /// Rows were inserted; `range` holds the indices of the newly inserted items.
36    ItemsInserted { range: Range<usize> },
37
38    /// Rows were removed; `range` holds the indices they occupied *before* removal.
39    ItemsRemoved { range: Range<usize> },
40
41    /// A contiguous block of `count` rows moved from `from` to `to` (post-removal index).
42    ItemsMoved {
43        from: usize,
44        to: usize,
45        count: usize,
46    },
47
48    /// A single row's data changed in place without any structural shift.
49    ItemUpdated { index: usize },
50
51    /// A window of previously-`Loading` rows became `Ready` (lazy / windowed
52    /// sources). Semantically like `ItemsInserted` for a row-height cache
53    /// (divergence = `range.start`), but no rows were added — the count was
54    /// already declared — so a `SelectionModel` must NOT index-shift for it.
55    WindowLoaded { range: Range<usize> },
56
57    /// The entire list was replaced; consumers must discard all cached state and rebuild.
58    Reset,
59}
60
61/// Map an index through a `DataChange::ItemsMoved { from, to, count }`.
62///
63/// Mirrors `ListModel::move_item`: the contiguous block `from..from+count` is
64/// removed, then reinserted so its first item lands at `to` (a *post-removal*
65/// index). Returns where `idx` ends up after the move. Used by index-based
66/// state (selection, checked-set) to follow items across a reorder.
67pub fn map_index_after_move(idx: usize, from: usize, to: usize, count: usize) -> usize {
68    // Items inside the moved block travel with it, preserving their offset.
69    if idx >= from && idx < from + count {
70        return to + (idx - from);
71    }
72    // Everyone else: apply the removal of the block, then its reinsertion.
73    let after_remove = if idx >= from + count {
74        idx - count
75    } else {
76        idx
77    };
78    if after_remove >= to {
79        after_remove + count
80    } else {
81        after_remove
82    }
83}
84
85/// Map a **single** index anchor (not a selection set) through a
86/// [`DataChange`], or `None` if the row the anchor pointed at no longer
87/// exists (it was removed, or the whole list was reset).
88///
89/// This is the same shift semantics as [`map_index_after_move`] /
90/// `SelectionModel::adjust_for_*` / `CheckedModel::adjust_for_*`, specialized
91/// for a bare `Option<usize>` anchor that has no "membership" to prune —
92/// e.g. `ListView`'s keyboard-focus index. Used so a single-anchor consumer
93/// doesn't have to re-derive insert/remove/move shift logic by hand.
94///
95/// - `ItemsInserted`: the anchor shifts up by the inserted count if it sat
96///   at or after the insertion point, otherwise it's untouched.
97/// - `ItemsRemoved`: the anchor shifts down past the removed range; if the
98///   anchor itself pointed *into* the removed range, it is dropped (`None`)
99///   — the row it followed is gone.
100/// - `ItemsMoved`: delegates to [`map_index_after_move`] (the anchor follows
101///   its row, or shifts around the moved block like everyone else).
102/// - `ItemUpdated` / `WindowLoaded`: no structural shift — the anchor is
103///   unchanged.
104/// - `Reset`: the anchor is dropped (`None`) — nothing about the old
105///   indexing survives a wholesale replacement.
106pub fn adjust_single_index_for_change(idx: usize, change: &DataChange) -> Option<usize> {
107    match change {
108        DataChange::ItemsInserted { range } => Some(if idx >= range.start {
109            idx + (range.end - range.start)
110        } else {
111            idx
112        }),
113        DataChange::ItemsRemoved { range } => {
114            if idx < range.start {
115                Some(idx)
116            } else if idx >= range.end {
117                Some(idx - (range.end - range.start))
118            } else {
119                None
120            }
121        }
122        DataChange::ItemsMoved { from, to, count } => {
123            Some(map_index_after_move(idx, *from, *to, *count))
124        }
125        DataChange::ItemUpdated { .. } | DataChange::WindowLoaded { .. } => Some(idx),
126        DataChange::Reset => None,
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    #[test]
135    fn adjust_single_index_inserted_shifts_at_or_after_start() {
136        let change = DataChange::ItemsInserted { range: 2..4 };
137        assert_eq!(adjust_single_index_for_change(0, &change), Some(0));
138        assert_eq!(adjust_single_index_for_change(1, &change), Some(1));
139        assert_eq!(adjust_single_index_for_change(2, &change), Some(4));
140        assert_eq!(adjust_single_index_for_change(5, &change), Some(7));
141    }
142
143    #[test]
144    fn adjust_single_index_removed_drops_within_range_shifts_after() {
145        let change = DataChange::ItemsRemoved { range: 2..4 };
146        assert_eq!(adjust_single_index_for_change(0, &change), Some(0));
147        assert_eq!(adjust_single_index_for_change(1, &change), Some(1));
148        assert_eq!(adjust_single_index_for_change(2, &change), None);
149        assert_eq!(adjust_single_index_for_change(3, &change), None);
150        assert_eq!(adjust_single_index_for_change(4, &change), Some(2));
151        assert_eq!(adjust_single_index_for_change(10, &change), Some(8));
152    }
153
154    #[test]
155    fn adjust_single_index_moved_delegates_to_map_index_after_move() {
156        let change = DataChange::ItemsMoved {
157            from: 1,
158            to: 4,
159            count: 2,
160        };
161        for idx in 0..8 {
162            assert_eq!(
163                adjust_single_index_for_change(idx, &change),
164                Some(map_index_after_move(idx, 1, 4, 2))
165            );
166        }
167    }
168
169    #[test]
170    fn adjust_single_index_updated_and_window_loaded_are_no_shift() {
171        assert_eq!(
172            adjust_single_index_for_change(3, &DataChange::ItemUpdated { index: 3 }),
173            Some(3)
174        );
175        assert_eq!(
176            adjust_single_index_for_change(3, &DataChange::WindowLoaded { range: 0..10 }),
177            Some(3)
178        );
179    }
180
181    #[test]
182    fn adjust_single_index_reset_always_drops() {
183        assert_eq!(adjust_single_index_for_change(0, &DataChange::Reset), None);
184        assert_eq!(adjust_single_index_for_change(99, &DataChange::Reset), None);
185    }
186}