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}