Skip to main content

teksilo_data/
checked_model.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `CheckedModel` — per-row checkbox state for flat collection widgets.
5//!
6//! Tracks which rows in a list view are marked (checked), independently of
7//! which row is selected. Selection (cursor position) and checked-ness
8//! (persistent marks) are orthogonal axes — the Outlook / Files-app
9//! convention where you can check many items and then act on them all.
10//!
11//! The model issues one writable `Signal<bool>` per row index via
12//! [`CheckedModel::signal_for`]; repeated calls for the same index return the
13//! same cached handle. A `Checkbox` widget writes to that signal on click;
14//! the model observes every per-index signal and keeps a central
15//! `Signal<BTreeSet<usize>>` in sync so consumers can react to the complete
16//! checked set without subscribing to each row individually.
17//!
18//! `CheckedModel` is a share-by-clone handle (`Rc<RefCell<…>>` internally);
19//! cloning produces a second handle to the same state. When rows are inserted,
20//! removed, or reordered, call the corresponding `adjust_for_*` method so that
21//! checked state follows the moved items rather than sticking to stale indices.
22//!
23//! For hierarchical lists with descendant→ancestor tristate aggregation, see
24//! [`crate::TreeCheckedModel`] instead.
25//!
26//! ```rust
27//! # use teksilo_data::CheckedModel;
28//! let model = CheckedModel::new();
29//! model.check(1);
30//! model.check(3);
31//! assert!(model.is_checked(1));
32//! assert_eq!(model.checked_count(), 2);
33//! model.toggle(1);
34//! assert!(!model.is_checked(1));
35//! ```
36
37use std::cell::RefCell;
38use std::collections::{BTreeSet, HashMap};
39use std::rc::Rc;
40
41use teksilo_core::signal::{ObserverHandle, Signal};
42
43struct Inner {
44    per_index: HashMap<usize, Signal<bool>>,
45    /// Observer handles keep the per-index → central propagation
46    /// alive for the lifetime of the model.
47    observers: HashMap<usize, ObserverHandle>,
48}
49
50/// Per-row checkbox state for a flat list, with a reactive aggregate checked-set.
51pub struct CheckedModel {
52    /// Aggregate set, derived from per-index signals via observers.
53    /// Read-only externally; the model updates it whenever a per-index
54    /// signal flips.
55    checked: Signal<BTreeSet<usize>>,
56    inner: Rc<RefCell<Inner>>,
57}
58
59impl CheckedModel {
60    /// Creates a new, empty `CheckedModel` with no rows checked.
61    pub fn new() -> Self {
62        Self {
63            checked: Signal::new(BTreeSet::new()),
64            inner: Rc::new(RefCell::new(Inner {
65                per_index: HashMap::new(),
66                observers: HashMap::new(),
67            })),
68        }
69    }
70
71    /// Reactive view of the full checked-set.
72    pub fn checked_signal(&self) -> Signal<BTreeSet<usize>> {
73        self.checked.clone()
74    }
75
76    /// Writable per-index signal. Repeat calls cache the same handle —
77    /// any consumer (the model itself, the Checkbox widget, an external
78    /// observer) writing through it propagates to the central
79    /// `checked_signal()`.
80    pub fn signal_for(&self, index: usize) -> Signal<bool> {
81        // Fast path — already cached.
82        if let Some(sig) = self.inner.borrow().per_index.get(&index) {
83            return sig.clone();
84        }
85        // Slow path — create + observe.
86        let sig = Signal::new(false);
87        let mut inner = self.inner.borrow_mut();
88        Self::install_signal(&mut inner, &self.checked, index, sig.clone());
89        sig
90    }
91
92    /// Register `sig` in `per_index` at `index`, wiring an observer that keeps
93    /// the central `checked` set in sync. The observer captures `index` by
94    /// value, so re-keying after an insert/remove/move must re-install the
95    /// signal at its new index (replacing the stale-index observer) — see the
96    /// `adjust_for_*` methods.
97    fn install_signal(
98        inner: &mut Inner,
99        central: &Signal<BTreeSet<usize>>,
100        index: usize,
101        sig: Signal<bool>,
102    ) {
103        let central = central.clone();
104        let handle = sig.observe(move |checked| {
105            let mut set = central.get();
106            let changed = if *checked {
107                set.insert(index)
108            } else {
109                set.remove(&index)
110            };
111            if changed {
112                central.set(set);
113            }
114        });
115        inner.per_index.insert(index, sig);
116        inner.observers.insert(index, handle);
117    }
118
119    /// Re-key every per-index signal through `map`, dropping any whose `map`
120    /// returns `None` (removed rows). Observers are rebuilt so each captures
121    /// its new index. The central set is recomputed to match. Shared spine of
122    /// `adjust_for_insert` / `adjust_for_remove` / `adjust_for_move`.
123    fn rekey(&self, map: impl Fn(usize) -> Option<usize>) {
124        let entries: Vec<(usize, Signal<bool>)> = {
125            let inner = self.inner.borrow();
126            inner
127                .per_index
128                .iter()
129                .map(|(&i, s)| (i, s.clone()))
130                .collect()
131        };
132        {
133            let mut inner = self.inner.borrow_mut();
134            // Dropping the old observers here detaches their stale-index
135            // callbacks before the rebuilt ones are installed.
136            inner.per_index.clear();
137            inner.observers.clear();
138            for (idx, sig) in entries {
139                if let Some(new_idx) = map(idx) {
140                    Self::install_signal(&mut inner, &self.checked, new_idx, sig);
141                }
142            }
143        }
144        let old = self.checked.get();
145        let new: BTreeSet<usize> = old.iter().filter_map(|&i| map(i)).collect();
146        if new != old {
147            self.checked.set(new);
148        }
149    }
150
151    /// Shift checked-state after `count` rows are inserted at `start`.
152    /// Indices `>= start` move up by `count`.
153    pub fn adjust_for_insert(&self, start: usize, count: usize) {
154        if count == 0 {
155            return;
156        }
157        self.rekey(|i| Some(if i >= start { i + count } else { i }));
158    }
159
160    /// Shift checked-state after `count` rows starting at `start` are removed.
161    /// Checked rows in `start..start+count` are dropped; later rows shift down.
162    pub fn adjust_for_remove(&self, start: usize, count: usize) {
163        if count == 0 {
164            return;
165        }
166        let end = start + count;
167        self.rekey(|i| {
168            if i < start {
169                Some(i)
170            } else if i >= end {
171                Some(i - count)
172            } else {
173                None
174            }
175        });
176    }
177
178    /// Shift checked-state after a block of `count` rows moved from `from` to
179    /// `to` (a post-removal index, matching `ListModel::move_item`). Checked
180    /// rows follow their items.
181    pub fn adjust_for_move(&self, from: usize, to: usize, count: usize) {
182        if from == to || count == 0 {
183            return;
184        }
185        self.rekey(|i| Some(crate::map_index_after_move(i, from, to, count)));
186    }
187
188    /// Returns `true` if the row at `index` is currently checked.
189    pub fn is_checked(&self, index: usize) -> bool {
190        self.inner
191            .borrow()
192            .per_index
193            .get(&index)
194            .map(|s| s.get())
195            .unwrap_or(false)
196    }
197
198    /// Returns a sorted `Vec` of every currently checked row index.
199    pub fn checked_indices(&self) -> Vec<usize> {
200        self.checked.get().into_iter().collect()
201    }
202
203    /// Returns the number of currently checked rows.
204    pub fn checked_count(&self) -> usize {
205        self.checked.get().len()
206    }
207
208    /// Marks the row at `index` as checked; notifies observers if the state changed.
209    pub fn check(&self, index: usize) {
210        let sig = self.signal_for(index);
211        if !sig.get() {
212            sig.set(true);
213        }
214    }
215
216    /// Marks the row at `index` as unchecked; notifies observers if the state changed.
217    pub fn uncheck(&self, index: usize) {
218        let sig = self.signal_for(index);
219        if sig.get() {
220            sig.set(false);
221        }
222    }
223
224    /// Flips the checked state of the row at `index`; notifies observers.
225    pub fn toggle(&self, index: usize) {
226        let sig = self.signal_for(index);
227        sig.set(!sig.get());
228    }
229
230    /// Checks every row in `0..count`; notifies observers for each row that was unchecked.
231    pub fn check_all(&self, count: usize) {
232        for i in 0..count {
233            self.check(i);
234        }
235    }
236
237    /// Unchecks every currently checked row; notifies observers for each change.
238    pub fn clear(&self) {
239        // Snapshot keys to avoid borrow-during-iteration when set()
240        // recurses into the observer.
241        let keys: Vec<usize> = self.inner.borrow().per_index.keys().copied().collect();
242        for i in keys {
243            self.uncheck(i);
244        }
245    }
246}
247
248impl Default for CheckedModel {
249    fn default() -> Self {
250        Self::new()
251    }
252}
253
254impl Clone for CheckedModel {
255    fn clone(&self) -> Self {
256        Self {
257            checked: self.checked.clone(),
258            inner: self.inner.clone(),
259        }
260    }
261}
262
263impl std::fmt::Debug for CheckedModel {
264    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
265        f.debug_struct("CheckedModel")
266            .field("checked_count", &self.checked.get().len())
267            .finish()
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    #[test]
276    fn signal_for_returns_same_root_per_index() {
277        let m = CheckedModel::new();
278        let a = m.signal_for(2);
279        let b = m.signal_for(2);
280        assert_eq!(a.get(), b.get());
281        a.set(true);
282        assert!(b.get(), "cached signal handles share the same root");
283    }
284
285    #[test]
286    fn writing_per_index_signal_updates_central() {
287        let m = CheckedModel::new();
288        let s = m.signal_for(7);
289        s.set(true);
290        assert_eq!(m.checked_indices(), vec![7]);
291        s.set(false);
292        assert_eq!(m.checked_indices(), Vec::<usize>::new());
293    }
294
295    #[test]
296    fn check_uncheck_toggle_round_trip() {
297        let m = CheckedModel::new();
298        assert!(!m.is_checked(3));
299        m.check(3);
300        assert!(m.is_checked(3));
301        m.toggle(3);
302        assert!(!m.is_checked(3));
303        m.toggle(3);
304        assert!(m.is_checked(3));
305        m.uncheck(3);
306        assert!(!m.is_checked(3));
307    }
308
309    #[test]
310    fn check_all_then_clear() {
311        let m = CheckedModel::new();
312        m.check_all(5);
313        assert_eq!(m.checked_indices(), vec![0, 1, 2, 3, 4]);
314        m.clear();
315        assert_eq!(m.checked_count(), 0);
316    }
317
318    #[test]
319    fn signal_updates_propagate() {
320        let m = CheckedModel::new();
321        let s = m.signal_for(7);
322        assert!(!s.get());
323        m.check(7);
324        assert!(s.get());
325        m.uncheck(7);
326        assert!(!s.get());
327    }
328
329    #[test]
330    fn unrelated_index_does_not_flip_signal() {
331        let m = CheckedModel::new();
332        let s = m.signal_for(1);
333        m.check(2);
334        assert!(!s.get());
335    }
336
337    #[test]
338    fn adjust_for_insert_shifts_checked_rows() {
339        let m = CheckedModel::new();
340        m.check(2);
341        m.check(4);
342        m.adjust_for_insert(3, 2);
343        // 2 stays, 4 -> 6.
344        assert_eq!(m.checked_indices(), vec![2, 6]);
345        assert!(m.is_checked(2));
346        assert!(m.is_checked(6));
347        assert!(!m.is_checked(4));
348    }
349
350    #[test]
351    fn adjust_for_remove_drops_in_range_and_shifts() {
352        let m = CheckedModel::new();
353        m.check(1);
354        m.check(3);
355        m.check(5);
356        m.adjust_for_remove(2, 2); // remove rows 2,3
357        // 1 stays, 3 dropped, 5 -> 3.
358        assert_eq!(m.checked_indices(), vec![1, 3]);
359        assert!(m.is_checked(3), "row that shifted in is checked");
360    }
361
362    #[test]
363    fn adjust_for_move_follows_checked_row() {
364        let m = CheckedModel::new();
365        m.check(0); // row A checked
366        m.adjust_for_move(0, 2, 1); // [B,C,A] — A now at 2
367        assert_eq!(m.checked_indices(), vec![2]);
368        assert!(m.is_checked(2));
369    }
370
371    #[test]
372    fn rekey_rewires_observer_so_later_clicks_target_the_new_index() {
373        // After a shift, the per-index signal handle must drive the *new*
374        // central index when toggled, not the stale captured one.
375        let m = CheckedModel::new();
376        let s = m.signal_for(2);
377        s.set(true);
378        assert_eq!(m.checked_indices(), vec![2]);
379        m.adjust_for_insert(0, 1); // row 2 -> 3, same Signal handle
380        assert_eq!(m.checked_indices(), vec![3]);
381        // The handle the widget kept now flips index 3 in the central set.
382        s.set(false);
383        assert_eq!(m.checked_indices(), Vec::<usize>::new());
384        s.set(true);
385        assert_eq!(m.checked_indices(), vec![3], "observer re-keyed to 3");
386    }
387
388    #[test]
389    fn adjust_is_noop_on_empty_and_zero_count() {
390        let m = CheckedModel::new();
391        m.check(1);
392        m.adjust_for_insert(0, 0);
393        m.adjust_for_remove(5, 0);
394        m.adjust_for_move(2, 2, 1);
395        assert_eq!(m.checked_indices(), vec![1]);
396    }
397}