Skip to main content

teksilo_data/
chart_selection.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `ChartSelection` — point-level selection state for chart widgets.
5//!
6//! [`ChartSelection`] manages which `(series, point index)` pairs are
7//! selected across a [`crate::ChartModel`] — the chart counterpart of
8//! [`crate::SelectionModel`] (flat lists) and
9//! [`crate::KeyedSelectionModel`] (keyed collections). It is a
10//! share-by-clone handle: pass a clone to each chart that should share
11//! selection state. The current selection is exposed as a reactive
12//! `Signal<HashSet<(SeriesId, usize)>>` so widgets can bind to it without
13//! polling.
14//!
15//! `HashSet` (not `BTreeSet`) is used because [`SeriesId`] is intentionally
16//! **not** `Ord` (it's an opaque SlotMap key, mirroring [`crate::NodeId`]) —
17//! there is no natural ordering across series, only within one series'
18//! point indices. This is the same rationale as
19//! [`crate::KeyedSelectionModel`], which uses `HashSet<K>` for the same
20//! reason.
21//!
22//! Three selection behaviours are available via
23//! [`SelectionMode`]: `None`, `Single`, and `Multi`
24//! (toggle + anchor-based range extension). [`ChartSelection::extend_to`]
25//! only extends within the anchor's own series — a cross-series "range" has
26//! no natural order, so it falls back to a single-point select.
27//! [`ChartSelection::adjust`] keeps selected points consistent as the
28//! source model mutates (series removed, points inserted/removed) — call it
29//! from your own model observer, or skip the wiring entirely with
30//! [`ChartSelection::attached`] (equivalently, [`ChartSelection::attach`] on
31//! an existing selection), which subscribes internally and calls `adjust`
32//! for you, the same way [`crate::ChartWindow`]/[`crate::ChartAggregate`]
33//! self-wire in their own constructors. Forgetting to wire `adjust` up
34//! manually otherwise leaves the selection silently stale after a mutation.
35//!
36//! ```rust
37//! # use teksilo_data::{ChartModel, ChartSelection, SelectionMode};
38//! let model: ChartModel<i32> = ChartModel::new();
39//! let s = model.add_series("s");
40//! for i in 0..5 {
41//!     model.push_point(s, i, i as f32);
42//! }
43//!
44//! let sel = ChartSelection::attached(SelectionMode::Multi, &model);
45//! sel.select_point(s, 1);
46//! sel.extend_to(s, 3);
47//! assert_eq!(sel.count(), 3); // (s,1), (s,2), (s,3)
48//!
49//! model.remove_point(s, 0); // upstream mutation — no manual adjust() call
50//! assert_eq!(sel.count(), 3); // (s,0), (s,1), (s,2) — shifted down
51//!
52//! sel.clear();
53//! assert_eq!(sel.count(), 0);
54//! ```
55
56use std::cell::RefCell;
57use std::collections::HashSet;
58use std::rc::Rc;
59
60use teksilo_core::ObserverHandle;
61use teksilo_core::signal::Signal;
62
63use crate::chart_change::{ChartChange, SeriesId};
64use crate::chart_model::ChartModel;
65use crate::selection_model::SelectionMode;
66
67/// Point-level selection state for a chart, keyed by `(series, point
68/// index)`. See module documentation for semantics.
69pub struct ChartSelection {
70    mode: SelectionMode,
71    selection: Signal<HashSet<(SeriesId, usize)>>,
72    /// Anchor point for range extension. Shared via `Rc` so clones see the
73    /// same anchor state.
74    anchor: Rc<RefCell<Option<(SeriesId, usize)>>>,
75    /// Holder for an [`attach`](Self::attach)ed model subscription. Shared
76    /// across clones (like `anchor`) so the subscription stays alive as
77    /// long as any handle to this selection does, and re-attaching (or
78    /// every clone dropping) tears down the previous one.
79    attach_handle: Rc<RefCell<Option<ObserverHandle>>>,
80    /// Strong holder for the debug-registry adapter. Shared across clones;
81    /// once all `ChartSelection` handles drop, the holder `Rc` reaches
82    /// zero and the adapter is freed, marking the registry entry dead.
83    /// `None` until `.debug_named()` is called. Compiled out in release.
84    #[cfg(debug_assertions)]
85    debug_adapter_holder: Rc<RefCell<Option<Rc<dyn crate::debug_registry::ModelDebug>>>>,
86}
87
88impl ChartSelection {
89    /// Create a new chart selection with the given mode.
90    pub fn new(mode: SelectionMode) -> Self {
91        Self {
92            mode,
93            selection: Signal::new(HashSet::new()),
94            anchor: Rc::new(RefCell::new(None)),
95            attach_handle: Rc::new(RefCell::new(None)),
96            #[cfg(debug_assertions)]
97            debug_adapter_holder: Rc::new(RefCell::new(None)),
98        }
99    }
100
101    /// Create a selection that self-wires to `model`: every [`ChartChange`]
102    /// the model emits is automatically routed through [`Self::adjust`], so
103    /// a point removed or shifted upstream never leaves a stale selected
104    /// index behind. Equivalent to `ChartSelection::new(mode)` plus
105    /// `model.observe_changes(|c| sel.adjust(c))`, minus the easy-to-forget
106    /// wiring — mirrors how [`crate::ChartWindow`] and
107    /// [`crate::ChartAggregate`] self-wire in their own constructors. The
108    /// manual [`Self::adjust`] path still works — call it yourself instead
109    /// if you'd rather relay through a custom change pipeline.
110    pub fn attached<T: 'static>(mode: SelectionMode, model: &ChartModel<T>) -> Self {
111        let sel = Self::new(mode);
112        sel.attach(model);
113        sel
114    }
115
116    /// Subscribe this selection to `model`'s changes, applying
117    /// [`Self::adjust`] on every [`ChartChange`]. The subscription is held
118    /// internally (shared across clones — see [`Clone`]), so it stays alive
119    /// as long as any handle to this selection does; calling `attach`
120    /// again (on this handle or any clone) drops the previous subscription
121    /// and installs the new one.
122    ///
123    /// The subscription closure captures only `selection` + `anchor`, not a
124    /// full `Self` — capturing `Self` would pull in `attach_handle` too,
125    /// which holds this very `ObserverHandle`, forming an `Rc` cycle that
126    /// would leak the subscription instead of tearing down when every
127    /// `ChartSelection` handle drops.
128    pub fn attach<T: 'static>(&self, model: &ChartModel<T>) {
129        let selection = self.selection.clone();
130        let anchor = self.anchor.clone();
131        let handle = model.observe_changes(move |change| {
132            Self::adjust_state(&selection, &anchor, change);
133        });
134        *self.attach_handle.borrow_mut() = Some(handle);
135    }
136
137    /// The selection mode.
138    pub fn mode(&self) -> SelectionMode {
139        self.mode
140    }
141
142    /// A clone of the selection signal for reactive binding.
143    pub fn selection_signal(&self) -> Signal<HashSet<(SeriesId, usize)>> {
144        self.selection.clone()
145    }
146
147    /// Whether `(series, index)` is currently selected.
148    pub fn is_selected(&self, series: SeriesId, index: usize) -> bool {
149        self.selection.get().contains(&(series, index))
150    }
151
152    /// The currently selected points (unordered snapshot).
153    pub fn selected_points(&self) -> Vec<(SeriesId, usize)> {
154        self.selection.get().into_iter().collect()
155    }
156
157    /// Number of selected points.
158    pub fn count(&self) -> usize {
159        self.selection.get().len()
160    }
161
162    /// Select a single point, clearing the previous selection and setting
163    /// the anchor.
164    pub fn select_point(&self, series: SeriesId, index: usize) {
165        if self.mode == SelectionMode::None {
166            return;
167        }
168        let mut set = HashSet::new();
169        set.insert((series, index));
170        self.selection.set(set);
171        *self.anchor.borrow_mut() = Some((series, index));
172    }
173
174    /// Toggle a point (Ctrl+click in Multi mode; acts as `select_point` in
175    /// Single mode).
176    pub fn toggle_point(&self, series: SeriesId, index: usize) {
177        match self.mode {
178            SelectionMode::None => {}
179            SelectionMode::Single => self.select_point(series, index),
180            SelectionMode::Multi => {
181                let key = (series, index);
182                let mut set = self.selection.get();
183                if set.contains(&key) {
184                    set.remove(&key);
185                } else {
186                    set.insert(key);
187                }
188                self.selection.set(set);
189                *self.anchor.borrow_mut() = Some(key);
190            }
191        }
192    }
193
194    /// Extend the selection from the anchor to `(series, target)` (for
195    /// Shift+click). Only extends **within the anchor's own series** — if
196    /// the anchor is unset or belongs to a different series, falls back to
197    /// a single-point select of `(series, target)`.
198    pub fn extend_to(&self, series: SeriesId, target: usize) {
199        match self.mode {
200            SelectionMode::None => {}
201            SelectionMode::Single => self.select_point(series, target),
202            SelectionMode::Multi => {
203                let anchor = *self.anchor.borrow();
204                let Some((a_series, a_index)) = anchor else {
205                    self.select_point(series, target);
206                    return;
207                };
208                if a_series != series {
209                    self.select_point(series, target);
210                    return;
211                }
212                let start = a_index.min(target);
213                let end = a_index.max(target);
214                let mut set = self.selection.get();
215                for i in start..=end {
216                    set.insert((series, i));
217                }
218                self.selection.set(set);
219                // Anchor stays put.
220            }
221        }
222    }
223
224    /// Replace the selection with `points` (or, when `additive`, union
225    /// them into the current selection). Used by rubber-band / marquee
226    /// selection. In `Single` mode an arbitrary one wins; `None` mode is a
227    /// no-op.
228    pub fn select_points(
229        &self,
230        points: impl IntoIterator<Item = (SeriesId, usize)>,
231        additive: bool,
232    ) {
233        if self.mode == SelectionMode::None {
234            return;
235        }
236        let mut set = if additive {
237            self.selection.get()
238        } else {
239            HashSet::new()
240        };
241        set.extend(points);
242        if self.mode == SelectionMode::Single && set.len() > 1 {
243            let keep = set.iter().next().copied();
244            set = keep.into_iter().collect();
245        }
246        self.selection.set(set);
247    }
248
249    /// Clear the selection and anchor.
250    pub fn clear(&self) {
251        self.selection.set(HashSet::new());
252        *self.anchor.borrow_mut() = None;
253    }
254
255    /// React to an upstream [`ChartChange`], keeping selection consistent
256    /// with the model: a removed or wholesale-replaced series drops its
257    /// selected points (and the anchor, if it pointed there); point
258    /// insertions/removals shift or drop indices within their series.
259    /// Series metadata changes (rename/recolor/visibility/move/insert) and
260    /// in-place point updates never affect which points are selected.
261    pub fn adjust(&self, change: &ChartChange) {
262        Self::adjust_state(&self.selection, &self.anchor, change);
263    }
264
265    /// The body of [`Self::adjust`], taking `selection`/`anchor` directly
266    /// rather than `&self` — used by [`Self::attach`]'s subscription
267    /// closure, which must **not** capture a full `Self` (that would
268    /// capture `attach_handle` too, which holds the very `ObserverHandle`
269    /// the closure lives inside: an `Rc` cycle that would leak the
270    /// subscription forever instead of tearing down when every
271    /// `ChartSelection` handle drops).
272    fn adjust_state(
273        selection: &Signal<HashSet<(SeriesId, usize)>>,
274        anchor: &Rc<RefCell<Option<(SeriesId, usize)>>>,
275        change: &ChartChange,
276    ) {
277        match change {
278            ChartChange::SeriesRemoved { series } | ChartChange::SeriesDataReplaced { series } => {
279                let series = *series;
280                let old = selection.get();
281                let new: HashSet<(SeriesId, usize)> =
282                    old.iter().filter(|(s, _)| *s != series).copied().collect();
283                if new.len() != old.len() {
284                    selection.set(new);
285                }
286                let drop_anchor = anchor.borrow().as_ref().is_some_and(|(s, _)| *s == series);
287                if drop_anchor {
288                    *anchor.borrow_mut() = None;
289                }
290            }
291            ChartChange::PointsInserted { series, range } => {
292                let series = *series;
293                let start = range.start;
294                let count = range.end - range.start;
295                let old = selection.get();
296                let new: HashSet<(SeriesId, usize)> = old
297                    .iter()
298                    .map(|&(s, i)| {
299                        if s == series && i >= start {
300                            (s, i + count)
301                        } else {
302                            (s, i)
303                        }
304                    })
305                    .collect();
306                if new != old {
307                    selection.set(new);
308                }
309                let mut anchor = anchor.borrow_mut();
310                if let Some((s, i)) = *anchor
311                    && s == series
312                    && i >= start
313                {
314                    *anchor = Some((s, i + count));
315                }
316            }
317            ChartChange::PointsRemoved { series, range } => {
318                let series = *series;
319                let start = range.start;
320                let end = range.end;
321                let count = range.end - range.start;
322                let old = selection.get();
323                let new: HashSet<(SeriesId, usize)> = old
324                    .iter()
325                    .filter_map(|&(s, i)| {
326                        if s != series {
327                            return Some((s, i));
328                        }
329                        if i < start {
330                            Some((s, i))
331                        } else if i >= end {
332                            Some((s, i - count))
333                        } else {
334                            None
335                        }
336                    })
337                    .collect();
338                if new != old {
339                    selection.set(new);
340                }
341                let mut anchor = anchor.borrow_mut();
342                if let Some((s, i)) = *anchor
343                    && s == series
344                {
345                    if i >= end {
346                        *anchor = Some((s, i - count));
347                    } else if i >= start {
348                        *anchor = None;
349                    }
350                }
351            }
352            ChartChange::Reset => {
353                selection.set(HashSet::new());
354                *anchor.borrow_mut() = None;
355            }
356            // Series metadata and in-place point updates don't change
357            // which points are selected.
358            ChartChange::SeriesInserted { .. }
359            | ChartChange::SeriesMoved { .. }
360            | ChartChange::SeriesRenamed { .. }
361            | ChartChange::SeriesColorChanged { .. }
362            | ChartChange::SeriesPatternChanged { .. }
363            | ChartChange::SeriesVisibilityChanged { .. }
364            | ChartChange::PointUpdated { .. } => {}
365        }
366    }
367
368    /// Drop any selected point for which `exists` returns false.
369    pub fn prune(&self, exists: impl Fn(SeriesId, usize) -> bool) {
370        let old = self.selection.get();
371        let new: HashSet<(SeriesId, usize)> = old
372            .iter()
373            .filter(|(s, i)| exists(*s, *i))
374            .copied()
375            .collect();
376        if new.len() != old.len() {
377            self.selection.set(new);
378        }
379        let drop_anchor = self
380            .anchor
381            .borrow()
382            .as_ref()
383            .is_some_and(|(s, i)| !exists(*s, *i));
384        if drop_anchor {
385            *self.anchor.borrow_mut() = None;
386        }
387    }
388}
389
390impl Clone for ChartSelection {
391    fn clone(&self) -> Self {
392        Self {
393            mode: self.mode,
394            selection: self.selection.clone(),
395            anchor: self.anchor.clone(),
396            attach_handle: self.attach_handle.clone(),
397            #[cfg(debug_assertions)]
398            debug_adapter_holder: self.debug_adapter_holder.clone(),
399        }
400    }
401}
402
403impl ChartSelection {
404    /// Register this selection with the debug inspector under `name`. In
405    /// release builds (`!cfg(debug_assertions)`) this is a no-op
406    /// pass-through so call sites stay free of `#[cfg]` lines.
407    ///
408    /// Idempotent on repeated calls — the latest registration wins. The
409    /// registration drops automatically when the last `ChartSelection`
410    /// handle is freed (the strong adapter `Rc` lives inside a shared
411    /// holder; the registry holds only a `Weak`).
412    pub fn debug_named(self, _name: impl Into<String>) -> Self {
413        #[cfg(debug_assertions)]
414        {
415            let adapter: Rc<dyn crate::debug_registry::ModelDebug> = Rc::new(ChartSelectionDebug {
416                selection: self.selection.clone(),
417                mode: self.mode,
418            });
419            crate::debug_registry::register(_name.into(), Rc::downgrade(&adapter));
420            *self.debug_adapter_holder.borrow_mut() = Some(adapter);
421        }
422        self
423    }
424}
425
426#[cfg(debug_assertions)]
427struct ChartSelectionDebug {
428    selection: Signal<HashSet<(SeriesId, usize)>>,
429    mode: SelectionMode,
430}
431
432#[cfg(debug_assertions)]
433impl crate::debug_registry::ModelDebug for ChartSelectionDebug {
434    fn kind(&self) -> &'static str {
435        "ChartSelection"
436    }
437    fn len(&self) -> usize {
438        self.selection.get().len()
439    }
440    fn debug_dump(&self, out: &mut dyn std::fmt::Write) {
441        let _ = writeln!(out, "mode = {:?}", self.mode);
442        let sel = self.selection.get();
443        if sel.is_empty() {
444            let _ = writeln!(out, "(empty)");
445            return;
446        }
447        for (s, i) in sel.iter() {
448            let _ = writeln!(out, "{:?}[{}]", s, i);
449        }
450    }
451}
452
453impl std::fmt::Debug for ChartSelection {
454    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
455        f.debug_struct("ChartSelection")
456            .field("mode", &self.mode)
457            .field("selected_count", &self.selection.get().len())
458            .finish()
459    }
460}
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465
466    fn two_series() -> (SeriesId, SeriesId) {
467        let model: ChartModel<i32> = ChartModel::new();
468        let a = model.add_series("a");
469        let b = model.add_series("b");
470        (a, b)
471    }
472
473    fn set(points: impl IntoIterator<Item = (SeriesId, usize)>) -> HashSet<(SeriesId, usize)> {
474        points.into_iter().collect()
475    }
476
477    #[test]
478    fn select_toggle_extend_within_series() {
479        let (a, _b) = two_series();
480        let sel = ChartSelection::new(SelectionMode::Multi);
481        sel.select_point(a, 2);
482        sel.extend_to(a, 5);
483        assert_eq!(
484            sel.selection_signal().get(),
485            set([(a, 2), (a, 3), (a, 4), (a, 5)])
486        );
487    }
488
489    #[test]
490    fn toggle_deselects() {
491        let (a, _b) = two_series();
492        let sel = ChartSelection::new(SelectionMode::Multi);
493        sel.toggle_point(a, 1);
494        assert!(sel.is_selected(a, 1));
495        sel.toggle_point(a, 1);
496        assert!(!sel.is_selected(a, 1));
497    }
498
499    #[test]
500    fn extend_backwards_within_series() {
501        let (a, _b) = two_series();
502        let sel = ChartSelection::new(SelectionMode::Multi);
503        sel.select_point(a, 5);
504        sel.extend_to(a, 2);
505        assert_eq!(
506            sel.selection_signal().get(),
507            set([(a, 2), (a, 3), (a, 4), (a, 5)])
508        );
509    }
510
511    #[test]
512    fn cross_series_extend_falls_back_to_single_select() {
513        let (a, b) = two_series();
514        let sel = ChartSelection::new(SelectionMode::Multi);
515        sel.select_point(a, 2); // anchor (a, 2)
516        sel.extend_to(b, 3);
517        assert_eq!(sel.selection_signal().get(), set([(b, 3)]));
518    }
519
520    #[test]
521    fn select_points_additive_and_replace() {
522        let (a, b) = two_series();
523        let sel = ChartSelection::new(SelectionMode::Multi);
524        sel.select_points([(a, 0), (a, 1)], false);
525        assert_eq!(sel.count(), 2);
526        sel.select_points([(b, 0)], true);
527        assert_eq!(sel.selection_signal().get(), set([(a, 0), (a, 1), (b, 0)]));
528        sel.select_points([(b, 1)], false);
529        assert_eq!(sel.selection_signal().get(), set([(b, 1)]));
530    }
531
532    #[test]
533    fn none_mode_ignores_all() {
534        let (a, _b) = two_series();
535        let sel = ChartSelection::new(SelectionMode::None);
536        sel.select_point(a, 0);
537        sel.toggle_point(a, 1);
538        sel.select_points([(a, 2)], false);
539        assert_eq!(sel.count(), 0);
540    }
541
542    #[test]
543    fn single_mode_extend_acts_as_select() {
544        let (a, b) = two_series();
545        let sel = ChartSelection::new(SelectionMode::Single);
546        sel.select_point(a, 1);
547        sel.extend_to(b, 5);
548        assert_eq!(sel.selection_signal().get(), set([(b, 5)]));
549    }
550
551    #[test]
552    fn adjust_drops_on_series_removed() {
553        let (a, b) = two_series();
554        let sel = ChartSelection::new(SelectionMode::Multi);
555        sel.select_points([(a, 0), (a, 1), (b, 0)], false);
556        sel.adjust(&ChartChange::SeriesRemoved { series: a });
557        assert_eq!(sel.selection_signal().get(), set([(b, 0)]));
558    }
559
560    #[test]
561    fn adjust_drops_anchor_on_series_removed() {
562        let (a, b) = two_series();
563        let sel = ChartSelection::new(SelectionMode::Multi);
564        sel.select_point(a, 0); // anchor = (a, 0)
565        sel.adjust(&ChartChange::SeriesRemoved { series: a });
566        // Anchor was dropped; extend_to now falls back to a single select.
567        sel.extend_to(b, 3);
568        assert_eq!(sel.selection_signal().get(), set([(b, 3)]));
569    }
570
571    #[test]
572    fn adjust_drops_on_series_data_replaced() {
573        let (a, b) = two_series();
574        let sel = ChartSelection::new(SelectionMode::Multi);
575        sel.select_points([(a, 0), (b, 0)], false);
576        sel.adjust(&ChartChange::SeriesDataReplaced { series: a });
577        assert_eq!(sel.selection_signal().get(), set([(b, 0)]));
578    }
579
580    #[test]
581    fn adjust_clears_on_reset() {
582        let (a, _b) = two_series();
583        let sel = ChartSelection::new(SelectionMode::Multi);
584        sel.select_point(a, 0);
585        sel.adjust(&ChartChange::Reset);
586        assert_eq!(sel.count(), 0);
587    }
588
589    #[test]
590    fn adjust_shifts_on_points_inserted() {
591        let (a, b) = two_series();
592        let sel = ChartSelection::new(SelectionMode::Multi);
593        sel.select_points([(a, 1), (a, 3), (b, 1)], false);
594        sel.adjust(&ChartChange::PointsInserted {
595            series: a,
596            range: 2..4,
597        });
598        assert_eq!(sel.selection_signal().get(), set([(a, 1), (a, 5), (b, 1)]));
599    }
600
601    #[test]
602    fn adjust_shifts_and_drops_on_points_removed() {
603        let (a, _b) = two_series();
604        let sel = ChartSelection::new(SelectionMode::Multi);
605        sel.select_points([(a, 1), (a, 3), (a, 5)], false);
606        sel.adjust(&ChartChange::PointsRemoved {
607            series: a,
608            range: 2..4,
609        });
610        // index 1 stays; index 3 dropped (inside the removed range); index 5 shifts to 3.
611        assert_eq!(sel.selection_signal().get(), set([(a, 1), (a, 3)]));
612    }
613
614    #[test]
615    fn adjust_ignores_metadata_and_point_updated() {
616        let (a, _b) = two_series();
617        let sel = ChartSelection::new(SelectionMode::Multi);
618        sel.select_point(a, 2);
619        sel.adjust(&ChartChange::SeriesInserted {
620            index: 0,
621            series: a,
622        });
623        sel.adjust(&ChartChange::SeriesMoved {
624            series: a,
625            from: 0,
626            to: 1,
627        });
628        sel.adjust(&ChartChange::SeriesRenamed { series: a });
629        sel.adjust(&ChartChange::SeriesColorChanged { series: a });
630        sel.adjust(&ChartChange::SeriesVisibilityChanged { series: a });
631        sel.adjust(&ChartChange::PointUpdated {
632            series: a,
633            index: 2,
634        });
635        assert_eq!(sel.selection_signal().get(), set([(a, 2)]));
636    }
637
638    #[test]
639    fn prune_drops_missing_points() {
640        let (a, b) = two_series();
641        let sel = ChartSelection::new(SelectionMode::Multi);
642        sel.select_points([(a, 0), (a, 1), (b, 0)], false);
643        sel.prune(|s, i| !(s == a && i == 1));
644        assert_eq!(sel.selection_signal().get(), set([(a, 0), (b, 0)]));
645    }
646
647    #[test]
648    fn prune_drops_anchor_when_missing() {
649        let (a, _b) = two_series();
650        let sel = ChartSelection::new(SelectionMode::Multi);
651        sel.select_point(a, 3); // anchor = (a, 3)
652        sel.prune(|_, _| false);
653        sel.extend_to(a, 9);
654        // Anchor was pruned -> extend_to falls back to single-select.
655        assert_eq!(sel.selection_signal().get(), set([(a, 9)]));
656    }
657
658    #[test]
659    fn signal_reactivity() {
660        use std::cell::Cell;
661        let (a, _b) = two_series();
662        let sel = ChartSelection::new(SelectionMode::Single);
663        let signal = sel.selection_signal();
664        let changed = Rc::new(Cell::new(false));
665        let c = changed.clone();
666        let _handle = signal.observe(move |_| c.set(true));
667        sel.select_point(a, 0);
668        assert!(changed.get());
669    }
670
671    #[test]
672    fn clone_shares_selection_and_anchor() {
673        let (a, b) = two_series();
674        let sel = ChartSelection::new(SelectionMode::Multi);
675        let clone = sel.clone();
676        sel.select_point(a, 1);
677        assert!(clone.is_selected(a, 1));
678        clone.extend_to(a, 3); // uses the shared anchor from `sel`
679        assert_eq!(sel.selection_signal().get(), set([(a, 1), (a, 2), (a, 3)]));
680        let _ = b;
681    }
682
683    // ── attach / attached ───────────────────────────────────────────────
684
685    #[test]
686    fn attached_shifts_selection_on_point_removed_before_it() {
687        let model: ChartModel<i32> = ChartModel::new();
688        let s = model.add_series("s");
689        for i in 0..5 {
690            model.push_point(s, i, i as f32);
691        }
692        let sel = ChartSelection::attached(SelectionMode::Multi, &model);
693        sel.select_point(s, 3);
694        assert!(sel.is_selected(s, 3));
695
696        // Remove the two points before index 3 — it must auto-shift to 1,
697        // with no manual `sel.adjust(...)` call from the test.
698        model.remove_point(s, 0);
699        model.remove_point(s, 0);
700
701        assert!(!sel.is_selected(s, 3));
702        assert!(sel.is_selected(s, 1));
703    }
704
705    #[test]
706    fn attach_on_a_manually_constructed_selection_wires_it_up() {
707        let model: ChartModel<i32> = ChartModel::new();
708        let s = model.add_series("s");
709        model.push_point(s, 0, 0.0);
710        model.push_point(s, 1, 1.0);
711
712        let sel = ChartSelection::new(SelectionMode::Single);
713        sel.select_point(s, 1);
714        sel.attach(&model); // not attached at construction time
715
716        model.remove_point(s, 0);
717        assert!(sel.is_selected(s, 0), "index 1 shifted down to 0");
718    }
719
720    #[test]
721    fn manual_adjust_still_works_without_attach() {
722        // The pre-existing manual wiring path keeps working — attach is
723        // additive sugar, not a replacement.
724        let (a, _b) = two_series();
725        let sel = ChartSelection::new(SelectionMode::Multi);
726        sel.select_point(a, 3);
727        sel.adjust(&ChartChange::PointsRemoved {
728            series: a,
729            range: 0..2,
730        });
731        assert!(sel.is_selected(a, 1));
732    }
733
734    #[test]
735    fn attach_does_not_leak_the_subscription_via_a_reference_cycle() {
736        let model: ChartModel<i32> = ChartModel::new();
737        let sel = ChartSelection::attached(SelectionMode::Multi, &model);
738        let weak_anchor = Rc::downgrade(&sel.anchor);
739        let weak_attach_handle = Rc::downgrade(&sel.attach_handle);
740
741        drop(sel);
742
743        assert!(
744            weak_anchor.upgrade().is_none(),
745            "anchor must not be kept alive by the subscription closure"
746        );
747        assert!(
748            weak_attach_handle.upgrade().is_none(),
749            "attach_handle must not be kept alive by its own subscription"
750        );
751    }
752}