teksilo-widgets 0.9.0

Widget library for Teksilo — over a hundred widgets and layout primitives, from Button to TreeTableView.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
// SPDX-License-Identifier: MPL-2.0
// SPDX-FileCopyrightText: 2026 FernTech

//! Shared substrate for the data views' source-owned drag-and-drop + lazy
//! loading.
//!
//! Centralizes the vocabulary the four data views (`ListView` / `TreeView` /
//! `TableView` / `TreeTableView`) share, so DnD validation (`can_accept`) and
//! the lazy placeholder are wired one way everywhere:
//!
//! - [`RowDragData`] — the **public, generic** intra-app drag payload a row (or
//!   a whole selected set) emits. The receiving source distinguishes its OWN
//!   reorder (matching [`ViewId`]) from a foreign drop, and translates the
//!   origin's `rows` → its own key via `key_at`, so the source's `Key` type
//!   never leaks into the view. When the origin opted into export it also
//!   carries `items` (clones of the dragged `T`), so a foreign `DropTarget`,
//!   a different data view, or the OS can consume the drag.
//! - [`DropIndicator`] — what `paint` renders; `allowed == false` is the
//!   pre-commit forbidden affordance.
//! - [`flat_insertion_target`] — maps a flat insertion index to the
//!   `(target, position)` pair `can_accept` / `accept_drop` expect.
//! - [`default_placeholder`] — the skeleton for a `Loading` row.

use std::cell::{Cell, RefCell};
use std::rc::Rc;
use std::sync::atomic::{AtomicUsize, Ordering};

use teksilo_core::ObserverHandle;
use teksilo_core::drag_payload::{DragPayload, DropOutcome};
use teksilo_core::widget::{EventContext, Widget};
use teksilo_core::widget_builder::HandlerSet;
use teksilo_data::{
    DataChange, DropPosition, ItemKey, KeyedSelectionModel, SelectionMode, SelectionModel,
};

/// How a data-view row/tile is *activated* (opened/committed) by pointer —
/// distinct from *selection*, which also moves on arrow-key navigation. Mirrors
/// the platform split other toolkits expose (Qt
/// `SH_ItemView_ActivateItemOnSingleClick`, GTK `activate-on-single-click`).
/// Enter/Space always activates regardless of this mode.
///
/// Pass to `ListView::activate_on`, `TreeView::activate_on`, etc.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ActivateOn {
    /// One primary click activates the row (KDE / web / Scrivener convention).
    /// Selection and activation happen on the same click.
    SingleClick,
    /// A double primary click activates the row; the first click only selects
    /// it (Finder / Explorer / Qt and GTK default). This is the [`Default`].
    #[default]
    DoubleClick,
}

/// Which kind of data view minted a [`ViewId`]. Folded into the id so two
/// different widget kinds that happen to draw the same value from the shared
/// process counter can never be mistaken for one another — the reason a bare
/// `usize` id was a latent cross-widget hazard.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) enum ViewKind {
    List,
    Tree,
    Table,
    TreeTable,
    Grid,
}

/// Opaque, kind-tagged, process-unique identity of a drag-capable data-view
/// instance. Used to tell a view's OWN reorder (`SameView`) from a foreign drop
/// on the receive side. Apps only ever compare two `ViewId`s for equality (e.g.
/// out of a received [`RowDragData`]); there is no public constructor, and the
/// value is stable for a view instance's lifetime, so it is safe to compare
/// even across windows (each mint is globally unique).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ViewId(ViewKind, usize);

impl ViewId {
    /// Mint a fresh, globally-unique id for a view of the given kind.
    pub(crate) fn next(kind: ViewKind) -> Self {
        Self(kind, next_view_id())
    }
}

/// What the *origin* view does to its own rows once a drag is accepted by a
/// **foreign** target (a different `DropTarget` / view / the OS). Purely an
/// origin-side cleanup choice — the receiver is unaffected. A same-view reorder
/// is never a transfer, so this never applies to it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DragTransferMode {
    /// Leave the origin rows in place (the dragged data is duplicated).
    Copy,
    /// Remove the dragged rows from the origin once accepted elsewhere
    /// (or exported as an OS move). This is the [`Default`].
    #[default]
    Move,
}

/// The public, generic drag payload every data-view row (or selected set)
/// emits. It occupies the single typed slot of a
/// [`teksilo_core::drag_payload::DragPayload`] and serves both audiences:
///
/// - the origin view's own erased classifier reads [`source`](Self::source) +
///   [`rows`](Self::rows) to recognise a same-view reorder;
/// - a **foreign** consumer (another view's custom `ListDataSource`, a
///   `DropTarget::accept_typed::<RowDragData<T>>()`, or `on_rows_received`)
///   reads [`items`](Self::items).
///
/// `items` is `Some` only when the origin view opted into export via
/// `.exportable(..)` (which requires `T: Clone`); a plain `.reorderable(true)`
/// drag carries `items == None` (nothing outside the origin could use it
/// anyway), so a reorder-only view is never accidentally droppable elsewhere.
#[derive(Debug)]
pub struct RowDragData<T: 'static> {
    /// Identity of the view that started the drag.
    pub source: ViewId,
    /// The dragged rows as the origin view's flat visible indices at
    /// drag-start, ascending. Informational (row count, app callbacks): the
    /// origin's accept path resolves the dragged rows' **stable keys** at
    /// drag-start and never re-reads these indices at hover/drop time — they
    /// go stale the moment the source reflows mid-drag (a spring-load
    /// auto-expand, a peer write). A foreign consumer should read
    /// [`items`](Self::items) instead.
    pub rows: Vec<usize>,
    /// Clones of the dragged items, `rows`-ordered. `None` for a reorder-only
    /// (non-exportable) drag.
    pub items: Option<Vec<T>>,
}

impl<T: 'static> RowDragData<T> {
    /// The dragged items, if this is an export drag (`.exportable(..)` was set
    /// on the origin). `None` for a reorder-only drag.
    pub fn items(&self) -> Option<&[T]> {
        self.items.as_deref()
    }

    /// Consume the payload for its items (avoids cloning on the receive side).
    pub fn into_items(self) -> Option<Vec<T>> {
        self.items
    }

    /// Whether this drag carries exportable items — i.e. the origin opted into
    /// `.exportable(..)`. A foreign receiver should gate on this (a reorder-only
    /// payload has the same Rust type but carries nothing usable).
    pub fn is_export(&self) -> bool {
        self.items.is_some()
    }

    /// Number of dragged rows.
    pub fn len(&self) -> usize {
        self.rows.len()
    }

    /// Whether no rows are carried (never true for a real drag).
    pub fn is_empty(&self) -> bool {
        self.rows.is_empty()
    }
}

/// A drop indicator the data views' `paint` renders. `allowed == false` paints a
/// muted line where an accepted-drop line would be — the pre-commit "you can't
/// drop here" affordance.
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct DropIndicator {
    pub(crate) y: f32,
    pub(crate) width: f32,
    pub(crate) allowed: bool,
}

/// A process-unique id distinguishing data-view instances (for SameView drop
/// detection when several views share one source).
pub(crate) fn next_view_id() -> usize {
    static NEXT: AtomicUsize = AtomicUsize::new(1);
    NEXT.fetch_add(1, Ordering::Relaxed)
}

/// Map a flat insertion index (`0..=len`) to the `(target_index, position)` pair
/// a `ListDataSource::can_accept` / `accept_drop` understands. `None` for an
/// empty list. Insertion *before* row `i` is `(i, Before)`; insertion past the
/// end is `(len-1, After)`.
pub(crate) fn flat_insertion_target(insertion: usize, len: usize) -> Option<(usize, DropPosition)> {
    if len == 0 {
        None
    } else if insertion >= len {
        Some((len - 1, DropPosition::After))
    } else {
        Some((insertion, DropPosition::Before))
    }
}

/// The default skeleton for a `Loading` row — a muted inset bar. The row's
/// placement sizes it to the row's height and width.
/// One row's tooltip, already resolved from its item and awaiting a
/// `BuildContext` to attach it with.
pub(crate) enum ResolvedRowTooltip {
    Plain(teksilo_i18n::LocalizedString),
    Rich(crate::tooltip::RichTooltipSource),
    Composite(Box<dyn Widget>),
}

/// Per-row tooltip resolvers, shared by every data view that builds rows from
/// a delegate.
///
/// A data view's rows are not authored by the app as widgets it can hang a
/// `.tooltip(...)` on — they come out of a delegate, and the view owns the
/// resulting `WidgetId`. So the view takes the *resolvers* instead and does the
/// attaching itself, against the row it just built. Same shape as
/// [`TabDelegate`](crate::tab_widget::TabDelegate)'s per-tab tooltip callbacks,
/// and the same last-setter-wins matrix as the per-widget setters: each `set_*`
/// clears the other two, so a row can never mature two tips at once.
///
/// Placement is [`Side`](crate::tooltip::TooltipPlacement::Side) for every
/// view here — rows stack vertically, and a `Below` tip would cover the next
/// row, which is the one the user is most likely reading next.
///
/// Cost: the body is resolved and built for each **realized** row, i.e. the
/// virtualization window (visible + buffer), not the whole model — and again
/// whenever those rows rebuild. Keep resolvers cheap; defer anything expensive
/// (a backend read, a subtree walk) to the body's own first paint, which only
/// happens if the tip is actually shown.
pub(crate) struct RowTooltips<T: 'static> {
    plain: Option<Rc<dyn Fn(usize, &T) -> Option<teksilo_i18n::LocalizedString>>>,
    rich: Option<Rc<dyn Fn(usize, &T) -> Option<crate::tooltip::RichTooltipSource>>>,
    composite: Option<Rc<dyn Fn(usize, &T) -> Option<Box<dyn Widget>>>>,
    /// Whether a composite row tip offers dwell-to-sticky promotion.
    composite_sticky: bool,
}

impl<T: 'static> Default for RowTooltips<T> {
    fn default() -> Self {
        Self {
            plain: None,
            rich: None,
            composite: None,
            composite_sticky: true,
        }
    }
}

impl<T: 'static> Clone for RowTooltips<T> {
    fn clone(&self) -> Self {
        Self {
            plain: self.plain.clone(),
            rich: self.rich.clone(),
            composite: self.composite.clone(),
            composite_sticky: self.composite_sticky,
        }
    }
}

impl<T: 'static> std::fmt::Debug for RowTooltips<T> {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("RowTooltips")
            .field("plain", &self.plain.is_some())
            .field("rich", &self.rich.is_some())
            .field("composite", &self.composite.is_some())
            .finish()
    }
}

impl<T: 'static> RowTooltips<T> {
    /// Whether any resolver is set — lets a view skip the per-row work.
    pub(crate) fn is_set(&self) -> bool {
        self.plain.is_some() || self.rich.is_some() || self.composite.is_some()
    }

    pub(crate) fn set_plain(
        &mut self,
        f: impl Fn(usize, &T) -> Option<teksilo_i18n::LocalizedString> + 'static,
    ) {
        *self = Self {
            plain: Some(Rc::new(f)),
            rich: None,
            composite: None,
            composite_sticky: self.composite_sticky,
        };
    }

    pub(crate) fn set_rich(
        &mut self,
        f: impl Fn(usize, &T) -> Option<crate::tooltip::RichTooltipSource> + 'static,
    ) {
        *self = Self {
            plain: None,
            rich: Some(Rc::new(f)),
            composite: None,
            composite_sticky: self.composite_sticky,
        };
    }

    pub(crate) fn set_composite(
        &mut self,
        f: impl Fn(usize, &T) -> Option<Box<dyn Widget>> + 'static,
    ) {
        *self = Self {
            plain: None,
            rich: None,
            composite: Some(Rc::new(f)),
            composite_sticky: self.composite_sticky,
        };
    }

    /// Whether a composite row tip offers dwell promotion. Off suits a
    /// read-only card: nothing to reach into, so nothing to pin.
    pub(crate) fn set_composite_sticky(&mut self, on: bool) {
        self.composite_sticky = on;
    }

    /// Resolve this row's tooltip, if any.
    ///
    /// Split from [`attach_resolved`](Self::attach_resolved) because a view can
    /// only reach the item from inside the same borrow that builds the row
    /// widget, while attaching needs the `BuildContext` and the resulting
    /// `WidgetId` — which only exist after that borrow ends.
    pub(crate) fn resolve(&self, index: usize, item: &T) -> Option<ResolvedRowTooltip> {
        if let Some(f) = &self.composite {
            f(index, item).map(ResolvedRowTooltip::Composite)
        } else if let Some(f) = &self.rich {
            f(index, item).map(ResolvedRowTooltip::Rich)
        } else if let Some(f) = &self.plain {
            f(index, item).map(ResolvedRowTooltip::Plain)
        } else {
            None
        }
    }

    /// Attach a resolved tooltip to the row widget the view just built.
    pub(crate) fn attach_resolved(
        &self,
        ctx: &mut teksilo_core::build_context::BuildContext,
        row_id: teksilo_core::widget_id::WidgetId,
        resolved: ResolvedRowTooltip,
    ) {
        // Rows stack vertically, so a `Below` tip would cover the next row —
        // the one the user is most likely reading next.
        let placement = crate::tooltip::TooltipPlacement::Side;
        match resolved {
            ResolvedRowTooltip::Composite(body) => {
                let delay = ctx.theme().motion.tooltip_delay_heavy;
                crate::tooltip::attach_composite_tooltip_widget_with_placement(
                    ctx,
                    row_id,
                    crate::tooltip::CompositeTooltipWidget::new()
                        .content_boxed(body)
                        .sticky(self.composite_sticky),
                    delay,
                    placement,
                );
            }
            ResolvedRowTooltip::Rich(source) => {
                let delay = ctx.theme().motion.tooltip_delay;
                crate::tooltip::attach_rich_tooltip_source_with_placement(
                    ctx, row_id, source, delay, placement,
                );
            }
            ResolvedRowTooltip::Plain(text) => {
                let delay = ctx.theme().motion.tooltip_delay;
                crate::tooltip::attach_plain_tooltip_with_placement(
                    ctx, row_id, text, delay, placement,
                );
            }
        }
    }
}

pub(crate) fn default_placeholder() -> Box<dyn Widget> {
    use crate::primitives::{Padding, RectWidget};
    Box::new(
        Padding::uniform(6.0).child(
            RectWidget::new()
                .background(teksilo_tokens::SurfaceRole::Hover)
                .corner_radius(teksilo_tokens::CornerRadius::uniform(4.0)),
        ),
    )
}

/// Index-facing row-selection facade backing the four data views.
///
/// An app installs *either* the index-based [`SelectionModel`] (positions) or a
/// [`KeyedSelectionModel<K>`] (stable identities that survive reorder / filter /
/// window-slide / multi-view). The views' click / keyboard / rebuild / paint
/// paths all work in **indices**, so this facade erases the difference: the
/// keyed variant carries the view's index↔key mapping (`key_at` / `len` /
/// `contains_key`) and translates internally. The method surface deliberately
/// mirrors `SelectionModel` so call sites read identically (`rs.select(i)`,
/// `rs.is_selected(i)`, …).
#[derive(Clone)]
pub(crate) struct RowSelection {
    mode: SelectionMode,
    is_selected: Rc<dyn Fn(usize) -> bool>,
    select_fn: Rc<dyn Fn(usize)>,
    toggle_fn: Rc<dyn Fn(usize)>,
    extend_fn: Rc<dyn Fn(usize)>,
    select_all_fn: Rc<dyn Fn(usize)>,
    selected_indices_fn: Rc<dyn Fn() -> Vec<usize>>,
    /// Cheap (O(selected count), never O(visible)) emptiness check for the
    /// container-focus-ring gate — paint runs every frame and only needs to
    /// know "is anything selected", not the set itself.
    has_selection_fn: Rc<dyn Fn() -> bool>,
    clear_fn: Rc<dyn Fn()>,
    observe_fn: Rc<dyn Fn(Box<dyn Fn()>) -> ObserverHandle>,
    on_change_fn: Rc<dyn Fn(&DataChange)>,
    /// Unconditional prune for the version-signal-driven tree views (which
    /// don't emit a `DataChange`): drop orphaned keys (keyed) or no-op (index).
    prune_fn: Rc<dyn Fn()>,
    /// Drop selected indices that no longer fit `0..count`, for the
    /// version-signal-driven tree views' index-selection path: an index has
    /// no identity to follow a moved row by (that's `focused_index`'s job,
    /// via `RowAnchor`), so a structural change can only clamp it — never
    /// re-land it on the row it used to point at. A no-op for the keyed
    /// model, which is already fully reconciled by `prune_fn`.
    prune_range_fn: Rc<dyn Fn(usize)>,
}

impl RowSelection {
    /// Back the facade with the index-based [`SelectionModel`]. Index ops pass
    /// straight through; `on_data_change` index-shifts (insert / remove) or
    /// clears (reset) the selection, matching the legacy inline behaviour.
    pub(crate) fn from_index(sel: SelectionModel) -> Self {
        let (s_is, s_sel, s_tog, s_ext, s_all, s_idx, s_has, s_clr, s_obs, s_chg, s_range) = (
            sel.clone(),
            sel.clone(),
            sel.clone(),
            sel.clone(),
            sel.clone(),
            sel.clone(),
            sel.clone(),
            sel.clone(),
            sel.clone(),
            sel.clone(),
            sel.clone(),
        );
        Self {
            mode: sel.mode(),
            is_selected: Rc::new(move |i| s_is.is_selected(i)),
            select_fn: Rc::new(move |i| s_sel.select(i)),
            toggle_fn: Rc::new(move |i| s_tog.toggle(i)),
            extend_fn: Rc::new(move |i| s_ext.extend_to(i)),
            select_all_fn: Rc::new(move |count| s_all.select_all(count)),
            selected_indices_fn: Rc::new(move || s_idx.selected_indices()),
            has_selection_fn: Rc::new(move || s_has.count() > 0),
            clear_fn: Rc::new(move || s_clr.clear()),
            observe_fn: Rc::new(move |cb| s_obs.selection_signal().observe(move |_| cb())),
            on_change_fn: Rc::new(move |change| match change {
                DataChange::ItemsInserted { range } => {
                    s_chg.adjust_for_insert(range.start, range.end - range.start);
                }
                DataChange::ItemsRemoved { range } => {
                    s_chg.adjust_for_remove(range.start, range.end - range.start);
                }
                DataChange::ItemsMoved { from, to, count } => {
                    s_chg.adjust_for_move(*from, *to, *count);
                }
                DataChange::Reset => s_chg.clear(),
                _ => {}
            }),
            // The index model has no stable identity to prune against on a
            // bare version bump — tree structural adjustments stay no-ops here
            // (the legacy behaviour).
            prune_fn: Rc::new(|| {}),
            prune_range_fn: Rc::new(move |count| {
                let kept: Vec<usize> = s_range
                    .selected_indices()
                    .into_iter()
                    .filter(|&i| i < count)
                    .collect();
                if kept.len() != s_range.count() {
                    s_range.select_indices(kept, false);
                }
            }),
        }
    }

    /// Back the facade with a [`KeyedSelectionModel<K>`] plus the view's
    /// index↔key mapping. `key_at(i)` is the key at visible index `i`, `len()`
    /// the visible count (for Shift-range ordering and `selected_indices`), and
    /// `contains_key(&k)` whether the *source* still holds the key (for
    /// prune-on-remove — a collapsed-but-present tree node must NOT be pruned,
    /// so this is supplied by the view, not derived from the visible window).
    pub(crate) fn from_keyed<K: ItemKey>(
        keyed: KeyedSelectionModel<K>,
        key_at: Rc<dyn Fn(usize) -> Option<K>>,
        len: Rc<dyn Fn() -> usize>,
        contains_key: Rc<dyn Fn(&K) -> bool>,
    ) -> Self {
        let mode = keyed.mode();
        Self {
            mode,
            is_selected: {
                let (k, ka) = (keyed.clone(), key_at.clone());
                Rc::new(move |i| ka(i).map(|key| k.is_selected(&key)).unwrap_or(false))
            },
            select_fn: {
                let (k, ka) = (keyed.clone(), key_at.clone());
                Rc::new(move |i| {
                    if let Some(key) = ka(i) {
                        k.select(key);
                    }
                })
            },
            toggle_fn: {
                let (k, ka) = (keyed.clone(), key_at.clone());
                Rc::new(move |i| {
                    if let Some(key) = ka(i) {
                        k.toggle(key);
                    }
                })
            },
            extend_fn: {
                // O(visible count) per Shift-click / Shift-arrow gesture:
                // builds the full visible key order every call rather than
                // just the `[anchor_index..=target_index]` span
                // `KeyedSelectionModel::extend_to` actually inserts.
                //
                // Narrowing this to the sub-range was considered and
                // rejected as not cleanly possible without touching
                // `teksilo-data`: `extend_to`'s "anchor scrolled out of
                // view / evicted" fallback (single-select `target`) is
                // detected by NOT finding `anchor` in the `ordered_keys`
                // slice it's given, and the anchor is a private field with
                // no public accessor (`KeyedSelectionModel::anchor` isn't
                // exposed, and there's no `index_of_key` on the view's
                // key↔index mapping this facade carries either). Without
                // that, this closure has no way to know the anchor's
                // current index — or whether it still HAS one — to bound a
                // sub-range with, and a shadow copy of the anchor tracked
                // here would drift from `KeyedSelectionModel`'s own
                // whenever something else drives `select`/`toggle`
                // (clearing or moving the anchor) — a duplicated-state
                // correctness risk for a micro-optimization on a
                // human-triggered, once-per-gesture path (not a hot loop).
                let (k, ka, l) = (keyed.clone(), key_at.clone(), len.clone());
                Rc::new(move |i| {
                    if let Some(target) = ka(i) {
                        let ordered: Vec<K> = (0..l()).filter_map(|j| ka(j)).collect();
                        k.extend_to(target, &ordered);
                    }
                })
            },
            select_all_fn: {
                let (k, ka) = (keyed.clone(), key_at.clone());
                Rc::new(move |count| {
                    let keys: Vec<K> = (0..count).filter_map(|i| ka(i)).collect();
                    k.select_keys(keys, false);
                })
            },
            selected_indices_fn: {
                let (k, ka, l) = (keyed.clone(), key_at.clone(), len.clone());
                Rc::new(move || {
                    (0..l())
                        .filter(|&i| ka(i).map(|key| k.is_selected(&key)).unwrap_or(false))
                        .collect()
                })
            },
            has_selection_fn: {
                let k = keyed.clone();
                Rc::new(move || k.count() > 0)
            },
            clear_fn: {
                let k = keyed.clone();
                Rc::new(move || k.clear())
            },
            observe_fn: {
                let k = keyed.clone();
                Rc::new(move |cb| k.selection_signal().observe(move |_| cb()))
            },
            on_change_fn: {
                let (k, c) = (keyed.clone(), contains_key.clone());
                Rc::new(move |change| match change {
                    // Keys are stable across inserts / moves; only removals and
                    // resets can orphan a selected key.
                    DataChange::ItemsRemoved { .. } | DataChange::Reset => {
                        k.prune_missing(|key| c(key));
                    }
                    _ => {}
                })
            },
            prune_fn: {
                let (k, c) = (keyed, contains_key);
                Rc::new(move || k.prune_missing(|key| c(key)))
            },
            // Keys already survive a version bump via `prune_fn` above —
            // there is no separate index range to clamp.
            prune_range_fn: Rc::new(|_count: usize| {}),
        }
    }

    pub(crate) fn mode(&self) -> SelectionMode {
        self.mode
    }
    pub(crate) fn is_selected(&self, index: usize) -> bool {
        (self.is_selected)(index)
    }
    pub(crate) fn select(&self, index: usize) {
        (self.select_fn)(index)
    }
    pub(crate) fn toggle(&self, index: usize) {
        (self.toggle_fn)(index)
    }
    pub(crate) fn extend_to(&self, index: usize) {
        (self.extend_fn)(index)
    }
    pub(crate) fn select_all(&self, count: usize) {
        (self.select_all_fn)(count)
    }
    pub(crate) fn selected_indices(&self) -> Vec<usize> {
        (self.selected_indices_fn)()
    }
    /// Whether anything is selected. Prefer this over
    /// `!selected_indices().is_empty()` when only the emptiness matters (e.g.
    /// a per-frame paint gate) — it costs O(selected count), never
    /// O(visible), for both the index and keyed backings.
    pub(crate) fn has_selection(&self) -> bool {
        (self.has_selection_fn)()
    }
    pub(crate) fn clear(&self) {
        (self.clear_fn)()
    }
    /// Subscribe to selection changes (drives the view's rebuild). Owns the
    /// returned handle for the subscription's lifetime.
    pub(crate) fn observe_for_rebuild(&self, cb: impl Fn() + 'static) -> ObserverHandle {
        (self.observe_fn)(Box::new(cb))
    }
    /// React to a source data change (index-shift for the index model, prune
    /// for the keyed model).
    pub(crate) fn on_data_change(&self, change: &DataChange) {
        (self.on_change_fn)(change)
    }
    /// Prune orphaned keys (keyed model) — used by the tree views, which drive
    /// off a version signal rather than a `DataChange`. No-op for the index
    /// model.
    pub(crate) fn prune(&self) {
        (self.prune_fn)()
    }
    /// Drop selected indices `>= count` (index model) after a structural
    /// change with no delta to shift them by — a version-signal-driven tree
    /// view's only defence against a selection left pointing past the
    /// shrunk end (it cannot re-land on the row it used to point at; only
    /// `focused_index`'s `RowAnchor` tracks identity). No-op for the keyed
    /// model, already fully reconciled by `prune`.
    pub(crate) fn prune_out_of_range(&self, count: usize) {
        (self.prune_range_fn)(count)
    }
}

/// Resolves a set of the origin view's flat indices to a **removal thunk** at
/// drag-start. Invoked at completion, the thunk removes exactly those rows from
/// the source. Resolving eagerly (rather than re-reading flat indices at
/// completion) keeps a Move correct even if the origin's flat indices reshuffle
/// mid-drag — e.g. a `TreeView` spring-load auto-expand — since the stable keys
/// were already captured. The source erasure supplies it.
pub(crate) type SnapshotOutFn = Rc<dyn Fn(&[usize]) -> Box<dyn Fn()>>;

/// Active drag-drop feedback a tree data view paints itself: a between-rows
/// insertion line (Before/After) or a highlighted row (an into-container drop).
///
/// Shared by `TreeView` and `TreeTableView` so both render the same affordance
/// for the same source verdict.
/// A stable handle to a row in a data view.
///
/// Per-row event handlers (a chevron toggle, a click, an activation) are built
/// once and then live as long as the row widget does, so capturing the flat
/// index they were built at is fragile: expanding a branch above, applying a
/// filter, or sorting shifts every index below, and the stale handler would act
/// on whatever row moved into that slot.
///
/// A `RowAnchor` closes over the row's **source-owned identity** instead and
/// resolves the row's *current* position on demand. The key never surfaces in
/// the anchor's type — it is captured inside the resolver, so views stay
/// key-agnostic ([`TreeSource`](crate::tree_source::TreeSource) and
/// [`ListSource`](crate::list_source::ListSource) both erase it).
///
/// Sources without identity (a bare `ListModel`, or any source that leaves
/// `key_at` at its `None` default) get a fixed anchor that always reports the
/// index it was built with — no worse than capturing the index directly.
///
/// A bare `ListModel` has no identity to offer (a `Vec` row *is* its position),
/// so anchors over one are fixed. `SortFilterListModel` keys rows by their
/// **source index**, which no sort/filter reprojection renumbers — so anchors
/// over a projection do track their row across a filter change, which is the
/// flat fragility in practice. They can still mis-resolve inside the window
/// between an *upstream* insert/remove and the rebuild it schedules, since that
/// does renumber source indices; no worse than the captured index they replace.
/// The tree sources all carry real identity.
///
/// **Precondition: keys must be unique.** Resolution falls back to a lookup by
/// key, which returns the *first* match, so a source handing out duplicate keys
/// would silently redirect an anchor onto a different row — the very failure
/// this type exists to prevent.
#[derive(Clone)]
pub struct RowAnchor {
    resolve: Rc<dyn Fn() -> Option<usize>>,
}

impl RowAnchor {
    /// Build an identity-backed anchor from a resolver.
    pub(crate) fn new(resolve: Rc<dyn Fn() -> Option<usize>>) -> Self {
        Self { resolve }
    }

    /// An anchor for a source with no identity: always reports `index`.
    pub(crate) fn fixed(index: usize) -> Self {
        Self {
            resolve: Rc::new(move || Some(index)),
        }
    }

    /// The row's current flat index, or `None` if it no longer exists in the
    /// source (it was deleted, or filtered away).
    pub fn index(&self) -> Option<usize> {
        (self.resolve)()
    }

    /// Whether the row still exists.
    pub fn is_live(&self) -> bool {
        self.index().is_some()
    }
}

impl std::fmt::Debug for RowAnchor {
    /// Deliberately does NOT resolve: the resolver reads the source's
    /// interior-mutable state, so a `{:?}` from inside code already holding a
    /// borrow (a `set_source` closure, a reorder callback, a debugger's
    /// pretty-printer) would panic on a `RefCell` conflict.
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str("RowAnchor(..)")
    }
}

/// Keep an open cell editor pointing at the row it was opened on.
///
/// `editing_cell` is a `(row, col)` pair that outlives rebuilds, so rows
/// appearing or vanishing above an open editor would slide it onto a different
/// row. The anchor is captured the first time an open editor is seen and
/// re-resolved on every later rebuild: the row index is rewritten when it moved,
/// and the editor closes outright when its row is gone — better than silently
/// editing whoever took the slot.
///
/// Called from each body pane's `build`, which is the only place that sees both
/// an editing change and a data change. It can therefore write `editing_cell`
/// while that pane is building; the write is idempotent and converges in one
/// extra pass (the next reconcile finds `cur == row` and writes nothing), which
/// `an_editing_reconcile_converges_in_one_pass` pins.
pub(crate) fn reconcile_editing_row(
    editing_cell: &teksilo_core::signal::Signal<Option<(usize, usize)>>,
    slot: &Rc<std::cell::RefCell<Option<RowAnchor>>>,
    anchor_of: &dyn Fn(usize) -> RowAnchor,
) {
    let Some((row, col)) = editing_cell.get() else {
        *slot.borrow_mut() = None;
        return;
    };
    let existing = slot.borrow().clone();
    match existing {
        None => *slot.borrow_mut() = Some(anchor_of(row)),
        Some(anchor) => match anchor.index() {
            Some(cur) if cur != row => editing_cell.set(Some((cur, col))),
            Some(_) => {}
            None => {
                editing_cell.set(None);
                *slot.borrow_mut() = None;
            }
        },
    }
}

/// Tint for the "drop into this container" row highlight. Defined once so the
/// `DropFeedback` handed to the framework and the widget's own paint cannot
/// drift into two different colors on the same row.
pub(crate) fn drop_into_tint() -> teksilo_tokens::Color {
    teksilo_tokens::Color::from_rgba(0.25, 0.47, 0.85, 0.25)
}

#[derive(Clone, Copy, PartialEq, Debug)]
pub(crate) enum DropViz {
    /// Horizontal insertion line at `y`, spanning `width`, indented by
    /// `depth` tree levels — the level the dropped row lands at.
    Line { y: f32, width: f32, depth: usize },
    /// Highlighted target row `[top, top + height]`, spanning `width`,
    /// indented by the target's own `depth` — the "drop into this folder"
    /// affordance.
    Rect {
        top: f32,
        height: f32,
        width: f32,
        depth: usize,
    },
}

/// The reusable export / foreign-drop machinery shared by all five data views:
/// the config fields, the drag-start payload build (selection set already
/// resolved by the caller), the foreign-receive sugar, and the `on_drag_ended`
/// move-out completion. Each view holds ONE of these instead of duplicating the
/// logic five ways (the drift that a code review caught). See
/// [docs/drag-and-drop.md §12](https://github.com/ferntech-eu/teksilo/blob/main/docs/drag-and-drop.md).
pub(crate) struct RowExport<T: 'static> {
    /// `Some` once `.exportable(..)` was called; the transfer mode also drives
    /// the move-out completion.
    pub(crate) mode: Option<DragTransferMode>,
    /// Clones `&T` → `T` for the payload (set by `.exportable`/`.export_external`,
    /// each `where T: Clone`, so the view constructor stays unconstrained).
    #[allow(clippy::type_complexity)]
    pub(crate) clone_item_fn: Option<Rc<dyn Fn(&T) -> T>>,
    /// Builds MIME reps of the dragged items for OS / `DropZone` export.
    #[allow(clippy::type_complexity)]
    pub(crate) export_mime_fn: Option<Rc<dyn Fn(&[T]) -> Vec<(String, Vec<u8>)>>>,
    /// App override for removing rows moved out to a foreign target.
    #[allow(clippy::type_complexity)]
    pub(crate) on_rows_transferred_out: Option<Rc<dyn Fn(&[usize], &mut EventContext)>>,
    /// Accept exported rows from a different view/source (zero-custom-source).
    pub(crate) accept_foreign_rows: bool,
    /// Handler for rows accepted via `accept_foreign_rows`.
    #[allow(clippy::type_complexity)]
    pub(crate) on_rows_received: Option<Rc<dyn Fn(Vec<T>, usize, &mut EventContext)>>,
    /// Set by the view's own `on_drop` when it applied a same-view reorder, so
    /// the completion skips the move-out (already applied). The TabBar pattern.
    pub(crate) self_reorder_flag: Rc<Cell<bool>>,
    /// The rows carried by the in-flight drag (for the app move-out callback).
    dragged_rows: Rc<RefCell<Vec<usize>>>,
    /// Stable-key removal thunk for the default move-out, resolved at drag-start.
    #[allow(clippy::type_complexity)]
    removal: Rc<RefCell<Option<Box<dyn Fn()>>>>,
}

impl<T: 'static> Clone for RowExport<T> {
    // Hand-written (not derived) so cloning does NOT require `T: Clone` — every
    // field is an `Rc` / `Copy`, so a clone shares the same drag stash + flags,
    // which is exactly what the per-row drag closure needs.
    fn clone(&self) -> Self {
        Self {
            mode: self.mode,
            clone_item_fn: self.clone_item_fn.clone(),
            export_mime_fn: self.export_mime_fn.clone(),
            on_rows_transferred_out: self.on_rows_transferred_out.clone(),
            accept_foreign_rows: self.accept_foreign_rows,
            on_rows_received: self.on_rows_received.clone(),
            self_reorder_flag: self.self_reorder_flag.clone(),
            dragged_rows: self.dragged_rows.clone(),
            removal: self.removal.clone(),
        }
    }
}

impl<T: 'static> Default for RowExport<T> {
    fn default() -> Self {
        Self {
            mode: None,
            clone_item_fn: None,
            export_mime_fn: None,
            on_rows_transferred_out: None,
            accept_foreign_rows: false,
            on_rows_received: None,
            self_reorder_flag: Rc::new(Cell::new(false)),
            dragged_rows: Rc::new(RefCell::new(Vec::new())),
            removal: Rc::new(RefCell::new(None)),
        }
    }
}

impl<T: 'static> RowExport<T> {
    /// `.exportable(mode)` — carry item clones; `where T: Clone` at the call.
    pub(crate) fn set_exportable(&mut self, mode: DragTransferMode)
    where
        T: Clone,
    {
        self.mode = Some(mode);
        if self.clone_item_fn.is_none() {
            self.clone_item_fn = Some(Rc::new(|t: &T| t.clone()));
        }
    }

    /// `.export_external(f)` — attach MIME; implies exportable.
    pub(crate) fn set_export_external(
        &mut self,
        f: impl Fn(&[T]) -> Vec<(String, Vec<u8>)> + 'static,
    ) where
        T: Clone,
    {
        if self.clone_item_fn.is_none() {
            self.clone_item_fn = Some(Rc::new(|t: &T| t.clone()));
        }
        if self.mode.is_none() {
            self.mode = Some(DragTransferMode::default());
        }
        self.export_mime_fn = Some(Rc::new(f));
    }

    pub(crate) fn set_on_rows_transferred_out(
        &mut self,
        f: impl Fn(&[usize], &mut EventContext) + 'static,
    ) {
        self.on_rows_transferred_out = Some(Rc::new(f));
    }

    pub(crate) fn set_on_rows_received(
        &mut self,
        f: impl Fn(Vec<T>, usize, &mut EventContext) + 'static,
    ) {
        self.on_rows_received = Some(Rc::new(f));
    }

    /// Rows are a drag source when the view reorders OR exports.
    pub(crate) fn is_drag_source(&self, reorderable: bool) -> bool {
        reorderable || self.mode.is_some()
    }

    /// The view is a drop target when it reorders OR accepts foreign rows.
    pub(crate) fn is_drop_target(&self, reorderable: bool) -> bool {
        reorderable || self.accept_foreign_rows
    }

    /// Build the drag payload for the (already selection-resolved) `rows`. Drops
    /// any non-resident row (a lazy `Loading` row `read` can't serve) so `rows`
    /// and `items` stay index-aligned and a Move never deletes a row whose data
    /// wasn't transferred. Attaches MIME, and stashes the rows + a stable-key
    /// removal thunk for the completion.
    ///
    /// `None` when no row survives the residency filter (an all-`Loading`
    /// selection): the caller must refuse the drag rather than float an empty
    /// payload nothing can accept.
    pub(crate) fn build_payload(
        &self,
        source: ViewId,
        mut rows: Vec<usize>,
        read: &dyn Fn(usize, &mut dyn FnMut(&T)) -> bool,
        snapshot_out: &SnapshotOutFn,
    ) -> Option<DragPayload> {
        let items: Option<Vec<T>> = if let Some(cf) = self.clone_item_fn.as_ref() {
            let mut out = Vec::with_capacity(rows.len());
            rows.retain(|&r| {
                let mut got = None;
                read(r, &mut |t| got = Some(cf(t)));
                match got {
                    Some(v) => {
                        out.push(v);
                        true
                    }
                    None => false,
                }
            });
            Some(out)
        } else {
            None
        };
        if rows.is_empty() {
            return None;
        }
        let mime_pairs: Vec<(String, Vec<u8>)> =
            match (self.export_mime_fn.as_ref(), items.as_ref()) {
                (Some(mf), Some(its)) => mf(its),
                _ => Vec::new(),
            };
        let mut payload = DragPayload::typed(RowDragData::<T> {
            source,
            rows: rows.clone(),
            items,
        });
        let has_mime = !mime_pairs.is_empty();
        for (mime, bytes) in mime_pairs {
            payload = payload.with_mime(&mime, bytes);
        }
        if has_mime {
            payload.enrich_external_from_mime();
        }
        *self.removal.borrow_mut() = Some((snapshot_out)(&rows));
        *self.dragged_rows.borrow_mut() = rows;
        Some(payload)
    }

    /// Whether a **foreign** exported payload would be accepted here — for the
    /// hover affordance. (Same-view / reorder-only payloads return `false`.)
    pub(crate) fn accepts_foreign_export(&self, payload: &DragPayload, source: ViewId) -> bool {
        self.accept_foreign_rows
            && self.on_rows_received.is_some()
            && payload
                .get_typed::<RowDragData<T>>()
                .is_some_and(|rd| rd.source != source && rd.is_export())
    }

    /// Foreign-receive sugar for a view's `on_drop`. Peeks before taking, so a
    /// non-matching payload is left intact for any further fallback.
    pub(crate) fn foreign_receive(
        &self,
        payload: &mut DragPayload,
        source: ViewId,
        insertion: usize,
        ctx: &mut EventContext,
    ) -> bool {
        if self.accepts_foreign_export(payload, source)
            && let Some(cb) = self.on_rows_received.as_ref()
            && let Some(rd) = payload.take_typed::<RowDragData<T>>()
            && let Some(items) = rd.items
        {
            cb(items, insertion, ctx);
            return true;
        }
        false
    }

    /// The view's own `on_drop` calls this after applying a genuine SAME-VIEW
    /// reorder, so the completion knows the change was already applied.
    pub(crate) fn note_self_reorder(&self) {
        self.self_reorder_flag.set(true);
    }

    /// Install the `on_drag_ended` move-out completion. A same-view reorder set
    /// `self_reorder_flag` (skipped here); on `Move` + accepted-elsewhere the
    /// origin rows are removed via the app override (delivered **descending** so
    /// index-by-index removal stays valid) or the stable-key removal thunk.
    pub(crate) fn install_completion(&self, handlers: HandlerSet) -> HandlerSet {
        let Some(mode) = self.mode else {
            return handlers;
        };
        let flag = self.self_reorder_flag.clone();
        let dragged = self.dragged_rows.clone();
        let removal = self.removal.clone();
        let on_out = self.on_rows_transferred_out.clone();
        handlers.on_drag_ended(move |outcome, ctx| {
            let handled_by_us = flag.replace(false);
            let rows = std::mem::take(&mut *dragged.borrow_mut());
            let thunk = removal.borrow_mut().take();
            if handled_by_us {
                return;
            }
            let accepted_elsewhere = matches!(
                outcome,
                DropOutcome::InApp { accepted: true } | DropOutcome::OsMove
            );
            if mode != DragTransferMode::Move || !accepted_elsewhere || rows.is_empty() {
                return;
            }
            if let Some(cb) = on_out.as_ref() {
                let mut desc = rows;
                desc.sort_unstable();
                desc.reverse();
                cb(&desc, ctx);
            } else if let Some(thunk) = thunk {
                thunk();
            }
        })
    }
}

/// Shared deferred-selection press logic for a data-view row (the drift a code
/// review caught: the `press_claimed` guard was missing in one view). Pressing
/// an already-selected row DEFERS the collapse-to-single to a release WITHOUT a
/// drag (an active drag consumes `PointerUp`), so grabbing a multi-selection
/// drags the whole set. `pending` is a per-row cell shared by the two calls.
pub(crate) mod deferred_select {
    use std::cell::Cell;
    use std::rc::Rc;

    use teksilo_core::event::Modifiers;
    use teksilo_core::widget::EventContext;

    use super::RowSelection;

    /// Handle a primary `PointerDown` on row `index`. Returns without selecting
    /// if the press was claimed by an interactive child (also clearing a stale
    /// `pending`). Ctrl/Shift select immediately; a plain press on an
    /// already-selected row defers; otherwise selects.
    pub(crate) fn on_down(
        sel: &RowSelection,
        index: usize,
        modifiers: Modifiers,
        pending: &Rc<Cell<bool>>,
        ctx: &mut EventContext,
    ) -> bool {
        if ctx.press_claimed_by_interactive_child() {
            pending.set(false);
            return false;
        }
        // The accelerator-click that adds one row to a discontiguous selection:
        // Ctrl+click on Windows and Linux, ⌘-click on macOS — where ⌃-click is
        // the secondary click and would open a context menu instead.
        if modifiers.command() {
            sel.toggle(index);
            pending.set(false);
        } else if modifiers.shift() {
            sel.extend_to(index);
            pending.set(false);
        } else if sel.is_selected(index) {
            pending.set(true);
        } else {
            sel.select(index);
            pending.set(false);
        }
        true
    }

    /// Handle a primary `PointerUp` on row `index` — reached only on a click
    /// WITHOUT a drag. Collapses the deferred multi-selection, unless the
    /// release belongs to an interactive child.
    pub(crate) fn on_up(
        sel: &RowSelection,
        index: usize,
        pending: &Rc<Cell<bool>>,
        ctx: &mut EventContext,
    ) {
        if ctx.press_claimed_by_interactive_child() {
            return;
        }
        if pending.replace(false) {
            sel.select(index);
        }
    }
}

#[cfg(test)]
mod payload_tests {
    use super::*;

    fn noop_snapshot() -> SnapshotOutFn {
        Rc::new(|_: &[usize]| Box::new(|| {}) as Box<dyn Fn()>)
    }

    #[test]
    fn an_all_unresident_selection_refuses_the_drag() {
        // Every dragged row is still `Loading`: the residency filter empties
        // the set, and the drag must be refused outright — a floating payload
        // with no rows and no items would remove nothing on a Move and offer
        // nothing to a receiver.
        let mut export = RowExport::<u64>::default();
        export.set_exportable(DragTransferMode::Move);
        let read = |_: usize, _: &mut dyn FnMut(&u64)| false;
        let payload = export.build_payload(
            ViewId::next(ViewKind::List),
            vec![0, 1, 2],
            &read,
            &noop_snapshot(),
        );
        assert!(payload.is_none());
    }

    #[test]
    fn a_partially_resident_selection_carries_only_the_resident_rows() {
        let mut export = RowExport::<u64>::default();
        export.set_exportable(DragTransferMode::Copy);
        // Row 1 is unresident; rows 0 and 2 resolve.
        let read = |i: usize, f: &mut dyn FnMut(&u64)| {
            if i == 1 {
                return false;
            }
            f(&(i as u64 * 10));
            true
        };
        let payload = export
            .build_payload(
                ViewId::next(ViewKind::List),
                vec![0, 1, 2],
                &read,
                &noop_snapshot(),
            )
            .expect("two rows are resident");
        let rd = payload.get_typed::<RowDragData<u64>>().unwrap();
        assert_eq!(rd.rows, vec![0, 2]);
        assert_eq!(rd.items.as_deref(), Some(&[0, 20][..]));
    }
}