Skip to main content

teksilo_widgets/
data_views.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Shared substrate for the data views' source-owned drag-and-drop + lazy
5//! loading.
6//!
7//! Centralizes the vocabulary the four data views (`ListView` / `TreeView` /
8//! `TableView` / `TreeTableView`) share, so DnD validation (`can_accept`) and
9//! the lazy placeholder are wired one way everywhere:
10//!
11//! - [`RowDragData`] — the **public, generic** intra-app drag payload a row (or
12//!   a whole selected set) emits. The receiving source distinguishes its OWN
13//!   reorder (matching [`ViewId`]) from a foreign drop, and translates the
14//!   origin's `rows` → its own key via `key_at`, so the source's `Key` type
15//!   never leaks into the view. When the origin opted into export it also
16//!   carries `items` (clones of the dragged `T`), so a foreign `DropTarget`,
17//!   a different data view, or the OS can consume the drag.
18//! - [`DropIndicator`] — what `paint` renders; `allowed == false` is the
19//!   pre-commit forbidden affordance.
20//! - [`flat_insertion_target`] — maps a flat insertion index to the
21//!   `(target, position)` pair `can_accept` / `accept_drop` expect.
22//! - [`default_placeholder`] — the skeleton for a `Loading` row.
23
24use std::cell::{Cell, RefCell};
25use std::rc::Rc;
26use std::sync::atomic::{AtomicUsize, Ordering};
27
28use teksilo_core::ObserverHandle;
29use teksilo_core::drag_payload::{DragPayload, DropOutcome};
30use teksilo_core::widget::{EventContext, Widget};
31use teksilo_core::widget_builder::HandlerSet;
32use teksilo_data::{
33    DataChange, DropPosition, ItemKey, KeyedSelectionModel, SelectionMode, SelectionModel,
34};
35
36/// How a data-view row/tile is *activated* (opened/committed) by pointer —
37/// distinct from *selection*, which also moves on arrow-key navigation. Mirrors
38/// the platform split other toolkits expose (Qt
39/// `SH_ItemView_ActivateItemOnSingleClick`, GTK `activate-on-single-click`).
40/// Enter/Space always activates regardless of this mode.
41///
42/// Pass to `ListView::activate_on`, `TreeView::activate_on`, etc.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
44pub enum ActivateOn {
45    /// One primary click activates the row (KDE / web / Scrivener convention).
46    /// Selection and activation happen on the same click.
47    SingleClick,
48    /// A double primary click activates the row; the first click only selects
49    /// it (Finder / Explorer / Qt and GTK default). This is the [`Default`].
50    #[default]
51    DoubleClick,
52}
53
54/// Which kind of data view minted a [`ViewId`]. Folded into the id so two
55/// different widget kinds that happen to draw the same value from the shared
56/// process counter can never be mistaken for one another — the reason a bare
57/// `usize` id was a latent cross-widget hazard.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
59pub(crate) enum ViewKind {
60    List,
61    Tree,
62    Table,
63    TreeTable,
64    Grid,
65}
66
67/// Opaque, kind-tagged, process-unique identity of a drag-capable data-view
68/// instance. Used to tell a view's OWN reorder (`SameView`) from a foreign drop
69/// on the receive side. Apps only ever compare two `ViewId`s for equality (e.g.
70/// out of a received [`RowDragData`]); there is no public constructor, and the
71/// value is stable for a view instance's lifetime, so it is safe to compare
72/// even across windows (each mint is globally unique).
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
74pub struct ViewId(ViewKind, usize);
75
76impl ViewId {
77    /// Mint a fresh, globally-unique id for a view of the given kind.
78    pub(crate) fn next(kind: ViewKind) -> Self {
79        Self(kind, next_view_id())
80    }
81}
82
83/// What the *origin* view does to its own rows once a drag is accepted by a
84/// **foreign** target (a different `DropTarget` / view / the OS). Purely an
85/// origin-side cleanup choice — the receiver is unaffected. A same-view reorder
86/// is never a transfer, so this never applies to it.
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
88pub enum DragTransferMode {
89    /// Leave the origin rows in place (the dragged data is duplicated).
90    Copy,
91    /// Remove the dragged rows from the origin once accepted elsewhere
92    /// (or exported as an OS move). This is the [`Default`].
93    #[default]
94    Move,
95}
96
97/// The public, generic drag payload every data-view row (or selected set)
98/// emits. It occupies the single typed slot of a
99/// [`teksilo_core::drag_payload::DragPayload`] and serves both audiences:
100///
101/// - the origin view's own erased classifier reads [`source`](Self::source) +
102///   [`rows`](Self::rows) to recognise a same-view reorder;
103/// - a **foreign** consumer (another view's custom `ListDataSource`, a
104///   `DropTarget::accept_typed::<RowDragData<T>>()`, or `on_rows_received`)
105///   reads [`items`](Self::items).
106///
107/// `items` is `Some` only when the origin view opted into export via
108/// `.exportable(..)` (which requires `T: Clone`); a plain `.reorderable(true)`
109/// drag carries `items == None` (nothing outside the origin could use it
110/// anyway), so a reorder-only view is never accidentally droppable elsewhere.
111#[derive(Debug)]
112pub struct RowDragData<T: 'static> {
113    /// Identity of the view that started the drag.
114    pub source: ViewId,
115    /// The dragged rows as the origin view's flat visible indices at
116    /// drag-start, ascending. Informational (row count, app callbacks): the
117    /// origin's accept path resolves the dragged rows' **stable keys** at
118    /// drag-start and never re-reads these indices at hover/drop time — they
119    /// go stale the moment the source reflows mid-drag (a spring-load
120    /// auto-expand, a peer write). A foreign consumer should read
121    /// [`items`](Self::items) instead.
122    pub rows: Vec<usize>,
123    /// Clones of the dragged items, `rows`-ordered. `None` for a reorder-only
124    /// (non-exportable) drag.
125    pub items: Option<Vec<T>>,
126}
127
128impl<T: 'static> RowDragData<T> {
129    /// The dragged items, if this is an export drag (`.exportable(..)` was set
130    /// on the origin). `None` for a reorder-only drag.
131    pub fn items(&self) -> Option<&[T]> {
132        self.items.as_deref()
133    }
134
135    /// Consume the payload for its items (avoids cloning on the receive side).
136    pub fn into_items(self) -> Option<Vec<T>> {
137        self.items
138    }
139
140    /// Whether this drag carries exportable items — i.e. the origin opted into
141    /// `.exportable(..)`. A foreign receiver should gate on this (a reorder-only
142    /// payload has the same Rust type but carries nothing usable).
143    pub fn is_export(&self) -> bool {
144        self.items.is_some()
145    }
146
147    /// Number of dragged rows.
148    pub fn len(&self) -> usize {
149        self.rows.len()
150    }
151
152    /// Whether no rows are carried (never true for a real drag).
153    pub fn is_empty(&self) -> bool {
154        self.rows.is_empty()
155    }
156}
157
158/// A drop indicator the data views' `paint` renders. `allowed == false` paints a
159/// muted line where an accepted-drop line would be — the pre-commit "you can't
160/// drop here" affordance.
161#[derive(Debug, Clone, Copy, PartialEq)]
162pub(crate) struct DropIndicator {
163    pub(crate) y: f32,
164    pub(crate) width: f32,
165    pub(crate) allowed: bool,
166}
167
168/// A process-unique id distinguishing data-view instances (for SameView drop
169/// detection when several views share one source).
170pub(crate) fn next_view_id() -> usize {
171    static NEXT: AtomicUsize = AtomicUsize::new(1);
172    NEXT.fetch_add(1, Ordering::Relaxed)
173}
174
175/// Map a flat insertion index (`0..=len`) to the `(target_index, position)` pair
176/// a `ListDataSource::can_accept` / `accept_drop` understands. `None` for an
177/// empty list. Insertion *before* row `i` is `(i, Before)`; insertion past the
178/// end is `(len-1, After)`.
179pub(crate) fn flat_insertion_target(insertion: usize, len: usize) -> Option<(usize, DropPosition)> {
180    if len == 0 {
181        None
182    } else if insertion >= len {
183        Some((len - 1, DropPosition::After))
184    } else {
185        Some((insertion, DropPosition::Before))
186    }
187}
188
189/// The default skeleton for a `Loading` row — a muted inset bar. The row's
190/// placement sizes it to the row's height and width.
191/// One row's tooltip, already resolved from its item and awaiting a
192/// `BuildContext` to attach it with.
193pub(crate) enum ResolvedRowTooltip {
194    Plain(teksilo_i18n::LocalizedString),
195    Rich(crate::tooltip::RichTooltipSource),
196    Composite(Box<dyn Widget>),
197}
198
199/// Per-row tooltip resolvers, shared by every data view that builds rows from
200/// a delegate.
201///
202/// A data view's rows are not authored by the app as widgets it can hang a
203/// `.tooltip(...)` on — they come out of a delegate, and the view owns the
204/// resulting `WidgetId`. So the view takes the *resolvers* instead and does the
205/// attaching itself, against the row it just built. Same shape as
206/// [`TabDelegate`](crate::tab_widget::TabDelegate)'s per-tab tooltip callbacks,
207/// and the same last-setter-wins matrix as the per-widget setters: each `set_*`
208/// clears the other two, so a row can never mature two tips at once.
209///
210/// Placement is [`Side`](crate::tooltip::TooltipPlacement::Side) for every
211/// view here — rows stack vertically, and a `Below` tip would cover the next
212/// row, which is the one the user is most likely reading next.
213///
214/// Cost: the body is resolved and built for each **realized** row, i.e. the
215/// virtualization window (visible + buffer), not the whole model — and again
216/// whenever those rows rebuild. Keep resolvers cheap; defer anything expensive
217/// (a backend read, a subtree walk) to the body's own first paint, which only
218/// happens if the tip is actually shown.
219pub(crate) struct RowTooltips<T: 'static> {
220    plain: Option<Rc<dyn Fn(usize, &T) -> Option<teksilo_i18n::LocalizedString>>>,
221    rich: Option<Rc<dyn Fn(usize, &T) -> Option<crate::tooltip::RichTooltipSource>>>,
222    composite: Option<Rc<dyn Fn(usize, &T) -> Option<Box<dyn Widget>>>>,
223    /// Whether a composite row tip offers dwell-to-sticky promotion.
224    composite_sticky: bool,
225}
226
227impl<T: 'static> Default for RowTooltips<T> {
228    fn default() -> Self {
229        Self {
230            plain: None,
231            rich: None,
232            composite: None,
233            composite_sticky: true,
234        }
235    }
236}
237
238impl<T: 'static> Clone for RowTooltips<T> {
239    fn clone(&self) -> Self {
240        Self {
241            plain: self.plain.clone(),
242            rich: self.rich.clone(),
243            composite: self.composite.clone(),
244            composite_sticky: self.composite_sticky,
245        }
246    }
247}
248
249impl<T: 'static> std::fmt::Debug for RowTooltips<T> {
250    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
251        f.debug_struct("RowTooltips")
252            .field("plain", &self.plain.is_some())
253            .field("rich", &self.rich.is_some())
254            .field("composite", &self.composite.is_some())
255            .finish()
256    }
257}
258
259impl<T: 'static> RowTooltips<T> {
260    /// Whether any resolver is set — lets a view skip the per-row work.
261    pub(crate) fn is_set(&self) -> bool {
262        self.plain.is_some() || self.rich.is_some() || self.composite.is_some()
263    }
264
265    pub(crate) fn set_plain(
266        &mut self,
267        f: impl Fn(usize, &T) -> Option<teksilo_i18n::LocalizedString> + 'static,
268    ) {
269        *self = Self {
270            plain: Some(Rc::new(f)),
271            rich: None,
272            composite: None,
273            composite_sticky: self.composite_sticky,
274        };
275    }
276
277    pub(crate) fn set_rich(
278        &mut self,
279        f: impl Fn(usize, &T) -> Option<crate::tooltip::RichTooltipSource> + 'static,
280    ) {
281        *self = Self {
282            plain: None,
283            rich: Some(Rc::new(f)),
284            composite: None,
285            composite_sticky: self.composite_sticky,
286        };
287    }
288
289    pub(crate) fn set_composite(
290        &mut self,
291        f: impl Fn(usize, &T) -> Option<Box<dyn Widget>> + 'static,
292    ) {
293        *self = Self {
294            plain: None,
295            rich: None,
296            composite: Some(Rc::new(f)),
297            composite_sticky: self.composite_sticky,
298        };
299    }
300
301    /// Whether a composite row tip offers dwell promotion. Off suits a
302    /// read-only card: nothing to reach into, so nothing to pin.
303    pub(crate) fn set_composite_sticky(&mut self, on: bool) {
304        self.composite_sticky = on;
305    }
306
307    /// Resolve this row's tooltip, if any.
308    ///
309    /// Split from [`attach_resolved`](Self::attach_resolved) because a view can
310    /// only reach the item from inside the same borrow that builds the row
311    /// widget, while attaching needs the `BuildContext` and the resulting
312    /// `WidgetId` — which only exist after that borrow ends.
313    pub(crate) fn resolve(&self, index: usize, item: &T) -> Option<ResolvedRowTooltip> {
314        if let Some(f) = &self.composite {
315            f(index, item).map(ResolvedRowTooltip::Composite)
316        } else if let Some(f) = &self.rich {
317            f(index, item).map(ResolvedRowTooltip::Rich)
318        } else if let Some(f) = &self.plain {
319            f(index, item).map(ResolvedRowTooltip::Plain)
320        } else {
321            None
322        }
323    }
324
325    /// Attach a resolved tooltip to the row widget the view just built.
326    pub(crate) fn attach_resolved(
327        &self,
328        ctx: &mut teksilo_core::build_context::BuildContext,
329        row_id: teksilo_core::widget_id::WidgetId,
330        resolved: ResolvedRowTooltip,
331    ) {
332        // Rows stack vertically, so a `Below` tip would cover the next row —
333        // the one the user is most likely reading next.
334        let placement = crate::tooltip::TooltipPlacement::Side;
335        match resolved {
336            ResolvedRowTooltip::Composite(body) => {
337                let delay = ctx.theme().motion.tooltip_delay_heavy;
338                crate::tooltip::attach_composite_tooltip_widget_with_placement(
339                    ctx,
340                    row_id,
341                    crate::tooltip::CompositeTooltipWidget::new()
342                        .content_boxed(body)
343                        .sticky(self.composite_sticky),
344                    delay,
345                    placement,
346                );
347            }
348            ResolvedRowTooltip::Rich(source) => {
349                let delay = ctx.theme().motion.tooltip_delay;
350                crate::tooltip::attach_rich_tooltip_source_with_placement(
351                    ctx, row_id, source, delay, placement,
352                );
353            }
354            ResolvedRowTooltip::Plain(text) => {
355                let delay = ctx.theme().motion.tooltip_delay;
356                crate::tooltip::attach_plain_tooltip_with_placement(
357                    ctx, row_id, text, delay, placement,
358                );
359            }
360        }
361    }
362}
363
364pub(crate) fn default_placeholder() -> Box<dyn Widget> {
365    use crate::primitives::{Padding, RectWidget};
366    Box::new(
367        Padding::uniform(6.0).child(
368            RectWidget::new()
369                .background(teksilo_tokens::SurfaceRole::Hover)
370                .corner_radius(teksilo_tokens::CornerRadius::uniform(4.0)),
371        ),
372    )
373}
374
375/// Index-facing row-selection facade backing the four data views.
376///
377/// An app installs *either* the index-based [`SelectionModel`] (positions) or a
378/// [`KeyedSelectionModel<K>`] (stable identities that survive reorder / filter /
379/// window-slide / multi-view). The views' click / keyboard / rebuild / paint
380/// paths all work in **indices**, so this facade erases the difference: the
381/// keyed variant carries the view's index↔key mapping (`key_at` / `len` /
382/// `contains_key`) and translates internally. The method surface deliberately
383/// mirrors `SelectionModel` so call sites read identically (`rs.select(i)`,
384/// `rs.is_selected(i)`, …).
385#[derive(Clone)]
386pub(crate) struct RowSelection {
387    mode: SelectionMode,
388    is_selected: Rc<dyn Fn(usize) -> bool>,
389    select_fn: Rc<dyn Fn(usize)>,
390    toggle_fn: Rc<dyn Fn(usize)>,
391    extend_fn: Rc<dyn Fn(usize)>,
392    /// Ctrl+Shift range extension: keeps whatever the previous gesture
393    /// selected instead of replacing it, so a second disjoint range can be
394    /// built without losing the first.
395    extend_additive_fn: Rc<dyn Fn(usize)>,
396    select_all_fn: Rc<dyn Fn(usize)>,
397    selected_indices_fn: Rc<dyn Fn() -> Vec<usize>>,
398    /// Cheap (O(selected count), never O(visible)) emptiness check for the
399    /// container-focus-ring gate — paint runs every frame and only needs to
400    /// know "is anything selected", not the set itself.
401    has_selection_fn: Rc<dyn Fn() -> bool>,
402    clear_fn: Rc<dyn Fn()>,
403    observe_fn: Rc<dyn Fn(Box<dyn Fn()>) -> ObserverHandle>,
404    on_change_fn: Rc<dyn Fn(&DataChange)>,
405    /// Unconditional prune for the version-signal-driven tree views (which
406    /// don't emit a `DataChange`): drop orphaned keys (keyed) or no-op (index).
407    prune_fn: Rc<dyn Fn()>,
408    /// Drop selected indices that no longer fit `0..count`, for the
409    /// version-signal-driven tree views' index-selection path: an index has
410    /// no identity to follow a moved row by (that's `focused_index`'s job,
411    /// via `RowAnchor`), so a structural change can only clamp it — never
412    /// re-land it on the row it used to point at. A no-op for the keyed
413    /// model, which is already fully reconciled by `prune_fn`.
414    prune_range_fn: Rc<dyn Fn(usize)>,
415}
416
417impl RowSelection {
418    /// Back the facade with the index-based [`SelectionModel`]. Index ops pass
419    /// straight through; `on_data_change` index-shifts (insert / remove) or
420    /// clears (reset) the selection, matching the legacy inline behaviour.
421    pub(crate) fn from_index(sel: SelectionModel) -> Self {
422        let (s_is, s_sel, s_tog, s_ext, s_all, s_idx, s_has, s_clr, s_obs, s_chg, s_range) = (
423            sel.clone(),
424            sel.clone(),
425            sel.clone(),
426            sel.clone(),
427            sel.clone(),
428            sel.clone(),
429            sel.clone(),
430            sel.clone(),
431            sel.clone(),
432            sel.clone(),
433            sel.clone(),
434        );
435        Self {
436            mode: sel.mode(),
437            is_selected: Rc::new(move |i| s_is.is_selected(i)),
438            select_fn: Rc::new(move |i| s_sel.select(i)),
439            toggle_fn: Rc::new(move |i| s_tog.toggle(i)),
440            extend_fn: Rc::new(move |i| s_ext.extend_to(i)),
441            extend_additive_fn: {
442                let s = sel.clone();
443                Rc::new(move |i| s.extend_to_additive(i))
444            },
445            select_all_fn: Rc::new(move |count| s_all.select_all(count)),
446            selected_indices_fn: Rc::new(move || s_idx.selected_indices()),
447            has_selection_fn: Rc::new(move || s_has.count() > 0),
448            clear_fn: Rc::new(move || s_clr.clear()),
449            observe_fn: Rc::new(move |cb| s_obs.selection_signal().observe(move |_| cb())),
450            on_change_fn: Rc::new(move |change| match change {
451                DataChange::ItemsInserted { range } => {
452                    s_chg.adjust_for_insert(range.start, range.end - range.start);
453                }
454                DataChange::ItemsRemoved { range } => {
455                    s_chg.adjust_for_remove(range.start, range.end - range.start);
456                }
457                DataChange::ItemsMoved { from, to, count } => {
458                    s_chg.adjust_for_move(*from, *to, *count);
459                }
460                DataChange::Reset => s_chg.clear(),
461                _ => {}
462            }),
463            // The index model has no stable identity to prune against on a
464            // bare version bump — tree structural adjustments stay no-ops here
465            // (the legacy behaviour).
466            prune_fn: Rc::new(|| {}),
467            prune_range_fn: Rc::new(move |count| {
468                let kept: Vec<usize> = s_range
469                    .selected_indices()
470                    .into_iter()
471                    .filter(|&i| i < count)
472                    .collect();
473                if kept.len() != s_range.count() {
474                    s_range.select_indices(kept, false);
475                }
476            }),
477        }
478    }
479
480    /// Back the facade with a [`KeyedSelectionModel<K>`] plus the view's
481    /// index↔key mapping. `key_at(i)` is the key at visible index `i`, `len()`
482    /// the visible count (for Shift-range ordering and `selected_indices`), and
483    /// `contains_key(&k)` whether the *source* still holds the key (for
484    /// prune-on-remove — a collapsed-but-present tree node must NOT be pruned,
485    /// so this is supplied by the view, not derived from the visible window).
486    pub(crate) fn from_keyed<K: ItemKey>(
487        keyed: KeyedSelectionModel<K>,
488        key_at: Rc<dyn Fn(usize) -> Option<K>>,
489        len: Rc<dyn Fn() -> usize>,
490        contains_key: Rc<dyn Fn(&K) -> bool>,
491    ) -> Self {
492        let mode = keyed.mode();
493        Self {
494            mode,
495            is_selected: {
496                let (k, ka) = (keyed.clone(), key_at.clone());
497                Rc::new(move |i| ka(i).map(|key| k.is_selected(&key)).unwrap_or(false))
498            },
499            select_fn: {
500                let (k, ka) = (keyed.clone(), key_at.clone());
501                Rc::new(move |i| {
502                    if let Some(key) = ka(i) {
503                        k.select(key);
504                    }
505                })
506            },
507            toggle_fn: {
508                let (k, ka) = (keyed.clone(), key_at.clone());
509                Rc::new(move |i| {
510                    if let Some(key) = ka(i) {
511                        k.toggle(key);
512                    }
513                })
514            },
515            extend_fn: {
516                // O(visible count) per Shift-click / Shift-arrow gesture:
517                // builds the full visible key order every call rather than
518                // just the `[anchor_index..=target_index]` span
519                // `KeyedSelectionModel::extend_to` actually inserts.
520                //
521                // Narrowing this to the sub-range was considered and
522                // rejected as not cleanly possible without touching
523                // `teksilo-data`: `extend_to`'s "anchor scrolled out of
524                // view / evicted" fallback (single-select `target`) is
525                // detected by NOT finding `anchor` in the `ordered_keys`
526                // slice it's given, and the anchor is a private field with
527                // no public accessor (`KeyedSelectionModel::anchor` isn't
528                // exposed, and there's no `index_of_key` on the view's
529                // key↔index mapping this facade carries either). Without
530                // that, this closure has no way to know the anchor's
531                // current index — or whether it still HAS one — to bound a
532                // sub-range with, and a shadow copy of the anchor tracked
533                // here would drift from `KeyedSelectionModel`'s own
534                // whenever something else drives `select`/`toggle`
535                // (clearing or moving the anchor) — a duplicated-state
536                // correctness risk for a micro-optimization on a
537                // human-triggered, once-per-gesture path (not a hot loop).
538                let (k, ka, l) = (keyed.clone(), key_at.clone(), len.clone());
539                Rc::new(move |i| {
540                    if let Some(target) = ka(i) {
541                        let ordered: Vec<K> = (0..l()).filter_map(|j| ka(j)).collect();
542                        k.extend_to(target, &ordered);
543                    }
544                })
545            },
546            extend_additive_fn: {
547                // Same visible-order rebuild as `extend_fn` above, and the same
548                // reasoning for why it is not narrowed to the anchor's span.
549                let (k, ka, l) = (keyed.clone(), key_at.clone(), len.clone());
550                Rc::new(move |i| {
551                    if let Some(target) = ka(i) {
552                        let ordered: Vec<K> = (0..l()).filter_map(|j| ka(j)).collect();
553                        k.extend_to_additive(target, &ordered);
554                    }
555                })
556            },
557            select_all_fn: {
558                let (k, ka) = (keyed.clone(), key_at.clone());
559                Rc::new(move |count| {
560                    let keys: Vec<K> = (0..count).filter_map(|i| ka(i)).collect();
561                    k.select_keys(keys, false);
562                })
563            },
564            selected_indices_fn: {
565                let (k, ka, l) = (keyed.clone(), key_at.clone(), len.clone());
566                Rc::new(move || {
567                    (0..l())
568                        .filter(|&i| ka(i).map(|key| k.is_selected(&key)).unwrap_or(false))
569                        .collect()
570                })
571            },
572            has_selection_fn: {
573                let k = keyed.clone();
574                Rc::new(move || k.count() > 0)
575            },
576            clear_fn: {
577                let k = keyed.clone();
578                Rc::new(move || k.clear())
579            },
580            observe_fn: {
581                let k = keyed.clone();
582                Rc::new(move |cb| k.selection_signal().observe(move |_| cb()))
583            },
584            on_change_fn: {
585                let (k, c) = (keyed.clone(), contains_key.clone());
586                Rc::new(move |change| match change {
587                    // Keys are stable across inserts / moves; only removals and
588                    // resets can orphan a selected key.
589                    DataChange::ItemsRemoved { .. } | DataChange::Reset => {
590                        k.prune_missing(|key| c(key));
591                    }
592                    _ => {}
593                })
594            },
595            prune_fn: {
596                let (k, c) = (keyed, contains_key);
597                Rc::new(move || k.prune_missing(|key| c(key)))
598            },
599            // Keys already survive a version bump via `prune_fn` above —
600            // there is no separate index range to clamp.
601            prune_range_fn: Rc::new(|_count: usize| {}),
602        }
603    }
604
605    pub(crate) fn mode(&self) -> SelectionMode {
606        self.mode
607    }
608    pub(crate) fn is_selected(&self, index: usize) -> bool {
609        (self.is_selected)(index)
610    }
611    pub(crate) fn select(&self, index: usize) {
612        (self.select_fn)(index)
613    }
614    pub(crate) fn toggle(&self, index: usize) {
615        (self.toggle_fn)(index)
616    }
617    pub(crate) fn extend_to(&self, index: usize) {
618        (self.extend_fn)(index)
619    }
620    pub(crate) fn extend_to_additive(&self, index: usize) {
621        (self.extend_additive_fn)(index)
622    }
623    pub(crate) fn select_all(&self, count: usize) {
624        (self.select_all_fn)(count)
625    }
626    pub(crate) fn selected_indices(&self) -> Vec<usize> {
627        (self.selected_indices_fn)()
628    }
629    /// Whether anything is selected. Prefer this over
630    /// `!selected_indices().is_empty()` when only the emptiness matters (e.g.
631    /// a per-frame paint gate) — it costs O(selected count), never
632    /// O(visible), for both the index and keyed backings.
633    pub(crate) fn has_selection(&self) -> bool {
634        (self.has_selection_fn)()
635    }
636    pub(crate) fn clear(&self) {
637        (self.clear_fn)()
638    }
639    /// Subscribe to selection changes (drives the view's rebuild). Owns the
640    /// returned handle for the subscription's lifetime.
641    pub(crate) fn observe_for_rebuild(&self, cb: impl Fn() + 'static) -> ObserverHandle {
642        (self.observe_fn)(Box::new(cb))
643    }
644    /// React to a source data change (index-shift for the index model, prune
645    /// for the keyed model).
646    pub(crate) fn on_data_change(&self, change: &DataChange) {
647        (self.on_change_fn)(change)
648    }
649    /// Prune orphaned keys (keyed model) — used by the tree views, which drive
650    /// off a version signal rather than a `DataChange`. No-op for the index
651    /// model.
652    pub(crate) fn prune(&self) {
653        (self.prune_fn)()
654    }
655    /// Drop selected indices `>= count` (index model) after a structural
656    /// change with no delta to shift them by — a version-signal-driven tree
657    /// view's only defence against a selection left pointing past the
658    /// shrunk end (it cannot re-land on the row it used to point at; only
659    /// `focused_index`'s `RowAnchor` tracks identity). No-op for the keyed
660    /// model, already fully reconciled by `prune`.
661    pub(crate) fn prune_out_of_range(&self, count: usize) {
662        (self.prune_range_fn)(count)
663    }
664}
665
666/// Resolves a set of the origin view's flat indices to a **removal thunk** at
667/// drag-start. Invoked at completion, the thunk removes exactly those rows from
668/// the source. Resolving eagerly (rather than re-reading flat indices at
669/// completion) keeps a Move correct even if the origin's flat indices reshuffle
670/// mid-drag — e.g. a `TreeView` spring-load auto-expand — since the stable keys
671/// were already captured. The source erasure supplies it.
672pub(crate) type SnapshotOutFn = Rc<dyn Fn(&[usize]) -> Box<dyn Fn()>>;
673
674/// Active drag-drop feedback a tree data view paints itself: a between-rows
675/// insertion line (Before/After) or a highlighted row (an into-container drop).
676///
677/// Shared by `TreeView` and `TreeTableView` so both render the same affordance
678/// for the same source verdict.
679/// A stable handle to a row in a data view.
680///
681/// Per-row event handlers (a chevron toggle, a click, an activation) are built
682/// once and then live as long as the row widget does, so capturing the flat
683/// index they were built at is fragile: expanding a branch above, applying a
684/// filter, or sorting shifts every index below, and the stale handler would act
685/// on whatever row moved into that slot.
686///
687/// A `RowAnchor` closes over the row's **source-owned identity** instead and
688/// resolves the row's *current* position on demand. The key never surfaces in
689/// the anchor's type — it is captured inside the resolver, so views stay
690/// key-agnostic ([`TreeSource`](crate::tree_source::TreeSource) and
691/// [`ListSource`](crate::list_source::ListSource) both erase it).
692///
693/// Sources without identity (a bare `ListModel`, or any source that leaves
694/// `key_at` at its `None` default) get a fixed anchor that always reports the
695/// index it was built with — no worse than capturing the index directly.
696///
697/// A bare `ListModel` has no identity to offer (a `Vec` row *is* its position),
698/// so anchors over one are fixed. `SortFilterListModel` keys rows by their
699/// **source index**, which no sort/filter reprojection renumbers — so anchors
700/// over a projection do track their row across a filter change, which is the
701/// flat fragility in practice. They can still mis-resolve inside the window
702/// between an *upstream* insert/remove and the rebuild it schedules, since that
703/// does renumber source indices; no worse than the captured index they replace.
704/// The tree sources all carry real identity.
705///
706/// **Precondition: keys must be unique.** Resolution falls back to a lookup by
707/// key, which returns the *first* match, so a source handing out duplicate keys
708/// would silently redirect an anchor onto a different row — the very failure
709/// this type exists to prevent.
710#[derive(Clone)]
711pub struct RowAnchor {
712    resolve: Rc<dyn Fn() -> Option<usize>>,
713}
714
715impl RowAnchor {
716    /// Build an identity-backed anchor from a resolver.
717    pub(crate) fn new(resolve: Rc<dyn Fn() -> Option<usize>>) -> Self {
718        Self { resolve }
719    }
720
721    /// An anchor for a source with no identity: always reports `index`.
722    pub(crate) fn fixed(index: usize) -> Self {
723        Self {
724            resolve: Rc::new(move || Some(index)),
725        }
726    }
727
728    /// The row's current flat index, or `None` if it no longer exists in the
729    /// source (it was deleted, or filtered away).
730    pub fn index(&self) -> Option<usize> {
731        (self.resolve)()
732    }
733
734    /// Whether the row still exists.
735    pub fn is_live(&self) -> bool {
736        self.index().is_some()
737    }
738}
739
740impl std::fmt::Debug for RowAnchor {
741    /// Deliberately does NOT resolve: the resolver reads the source's
742    /// interior-mutable state, so a `{:?}` from inside code already holding a
743    /// borrow (a `set_source` closure, a reorder callback, a debugger's
744    /// pretty-printer) would panic on a `RefCell` conflict.
745    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
746        f.write_str("RowAnchor(..)")
747    }
748}
749
750/// Keep an open cell editor pointing at the row it was opened on.
751///
752/// `editing_cell` is a `(row, col)` pair that outlives rebuilds, so rows
753/// appearing or vanishing above an open editor would slide it onto a different
754/// row. The anchor is captured the first time an open editor is seen and
755/// re-resolved on every later rebuild: the row index is rewritten when it moved,
756/// and the editor closes outright when its row is gone — better than silently
757/// editing whoever took the slot.
758///
759/// Called from each body pane's `build`, which is the only place that sees both
760/// an editing change and a data change. It can therefore write `editing_cell`
761/// while that pane is building; the write is idempotent and converges in one
762/// extra pass (the next reconcile finds `cur == row` and writes nothing), which
763/// `an_editing_reconcile_converges_in_one_pass` pins.
764pub(crate) fn reconcile_editing_row(
765    editing_cell: &teksilo_core::signal::Signal<Option<(usize, usize)>>,
766    slot: &Rc<std::cell::RefCell<Option<RowAnchor>>>,
767    anchor_of: &dyn Fn(usize) -> RowAnchor,
768) {
769    let Some((row, col)) = editing_cell.get() else {
770        *slot.borrow_mut() = None;
771        return;
772    };
773    let existing = slot.borrow().clone();
774    match existing {
775        None => *slot.borrow_mut() = Some(anchor_of(row)),
776        Some(anchor) => match anchor.index() {
777            Some(cur) if cur != row => editing_cell.set(Some((cur, col))),
778            Some(_) => {}
779            None => {
780                editing_cell.set(None);
781                *slot.borrow_mut() = None;
782            }
783        },
784    }
785}
786
787/// Tint for the "drop into this container" row highlight. Defined once so the
788/// `DropFeedback` handed to the framework and the widget's own paint cannot
789/// drift into two different colors on the same row.
790pub(crate) fn drop_into_tint() -> teksilo_tokens::Color {
791    teksilo_tokens::Color::from_rgba(0.25, 0.47, 0.85, 0.25)
792}
793
794#[derive(Clone, Copy, PartialEq, Debug)]
795pub(crate) enum DropViz {
796    /// Horizontal insertion line at `y`, spanning `width`, indented by
797    /// `depth` tree levels — the level the dropped row lands at.
798    Line { y: f32, width: f32, depth: usize },
799    /// Highlighted target row `[top, top + height]`, spanning `width`,
800    /// indented by the target's own `depth` — the "drop into this folder"
801    /// affordance.
802    Rect {
803        top: f32,
804        height: f32,
805        width: f32,
806        depth: usize,
807    },
808}
809
810/// The reusable export / foreign-drop machinery shared by all five data views:
811/// the config fields, the drag-start payload build (selection set already
812/// resolved by the caller), the foreign-receive sugar, and the `on_drag_ended`
813/// move-out completion. Each view holds ONE of these instead of duplicating the
814/// logic five ways (the drift that a code review caught). See
815/// [docs/drag-and-drop.md §12](https://github.com/ferntech-eu/teksilo/blob/main/docs/drag-and-drop.md).
816pub(crate) struct RowExport<T: 'static> {
817    /// `Some` once `.exportable(..)` was called; the transfer mode also drives
818    /// the move-out completion.
819    pub(crate) mode: Option<DragTransferMode>,
820    /// Clones `&T` → `T` for the payload (set by `.exportable`/`.export_external`,
821    /// each `where T: Clone`, so the view constructor stays unconstrained).
822    #[allow(clippy::type_complexity)]
823    pub(crate) clone_item_fn: Option<Rc<dyn Fn(&T) -> T>>,
824    /// Builds MIME reps of the dragged items for OS / `DropZone` export.
825    #[allow(clippy::type_complexity)]
826    pub(crate) export_mime_fn: Option<Rc<dyn Fn(&[T]) -> Vec<(String, Vec<u8>)>>>,
827    /// App override for removing rows moved out to a foreign target.
828    #[allow(clippy::type_complexity)]
829    pub(crate) on_rows_transferred_out: Option<Rc<dyn Fn(&[usize], &mut EventContext)>>,
830    /// Accept exported rows from a different view/source (zero-custom-source).
831    pub(crate) accept_foreign_rows: bool,
832    /// Handler for rows accepted via `accept_foreign_rows`.
833    #[allow(clippy::type_complexity)]
834    pub(crate) on_rows_received: Option<Rc<dyn Fn(Vec<T>, usize, &mut EventContext)>>,
835    /// Set by the view's own `on_drop` when it applied a same-view reorder, so
836    /// the completion skips the move-out (already applied). The TabBar pattern.
837    pub(crate) self_reorder_flag: Rc<Cell<bool>>,
838    /// The rows carried by the in-flight drag (for the app move-out callback).
839    dragged_rows: Rc<RefCell<Vec<usize>>>,
840    /// Stable-key removal thunk for the default move-out, resolved at drag-start.
841    #[allow(clippy::type_complexity)]
842    removal: Rc<RefCell<Option<Box<dyn Fn()>>>>,
843}
844
845impl<T: 'static> Clone for RowExport<T> {
846    // Hand-written (not derived) so cloning does NOT require `T: Clone` — every
847    // field is an `Rc` / `Copy`, so a clone shares the same drag stash + flags,
848    // which is exactly what the per-row drag closure needs.
849    fn clone(&self) -> Self {
850        Self {
851            mode: self.mode,
852            clone_item_fn: self.clone_item_fn.clone(),
853            export_mime_fn: self.export_mime_fn.clone(),
854            on_rows_transferred_out: self.on_rows_transferred_out.clone(),
855            accept_foreign_rows: self.accept_foreign_rows,
856            on_rows_received: self.on_rows_received.clone(),
857            self_reorder_flag: self.self_reorder_flag.clone(),
858            dragged_rows: self.dragged_rows.clone(),
859            removal: self.removal.clone(),
860        }
861    }
862}
863
864impl<T: 'static> Default for RowExport<T> {
865    fn default() -> Self {
866        Self {
867            mode: None,
868            clone_item_fn: None,
869            export_mime_fn: None,
870            on_rows_transferred_out: None,
871            accept_foreign_rows: false,
872            on_rows_received: None,
873            self_reorder_flag: Rc::new(Cell::new(false)),
874            dragged_rows: Rc::new(RefCell::new(Vec::new())),
875            removal: Rc::new(RefCell::new(None)),
876        }
877    }
878}
879
880impl<T: 'static> RowExport<T> {
881    /// `.exportable(mode)` — carry item clones; `where T: Clone` at the call.
882    pub(crate) fn set_exportable(&mut self, mode: DragTransferMode)
883    where
884        T: Clone,
885    {
886        self.mode = Some(mode);
887        if self.clone_item_fn.is_none() {
888            self.clone_item_fn = Some(Rc::new(|t: &T| t.clone()));
889        }
890    }
891
892    /// `.export_external(f)` — attach MIME; implies exportable.
893    pub(crate) fn set_export_external(
894        &mut self,
895        f: impl Fn(&[T]) -> Vec<(String, Vec<u8>)> + 'static,
896    ) where
897        T: Clone,
898    {
899        if self.clone_item_fn.is_none() {
900            self.clone_item_fn = Some(Rc::new(|t: &T| t.clone()));
901        }
902        if self.mode.is_none() {
903            self.mode = Some(DragTransferMode::default());
904        }
905        self.export_mime_fn = Some(Rc::new(f));
906    }
907
908    pub(crate) fn set_on_rows_transferred_out(
909        &mut self,
910        f: impl Fn(&[usize], &mut EventContext) + 'static,
911    ) {
912        self.on_rows_transferred_out = Some(Rc::new(f));
913    }
914
915    pub(crate) fn set_on_rows_received(
916        &mut self,
917        f: impl Fn(Vec<T>, usize, &mut EventContext) + 'static,
918    ) {
919        self.on_rows_received = Some(Rc::new(f));
920    }
921
922    /// Rows are a drag source when the view reorders OR exports.
923    pub(crate) fn is_drag_source(&self, reorderable: bool) -> bool {
924        reorderable || self.mode.is_some()
925    }
926
927    /// The view is a drop target when it reorders OR accepts foreign rows.
928    pub(crate) fn is_drop_target(&self, reorderable: bool) -> bool {
929        reorderable || self.accept_foreign_rows
930    }
931
932    /// Build the drag payload for the (already selection-resolved) `rows`. Drops
933    /// any non-resident row (a lazy `Loading` row `read` can't serve) so `rows`
934    /// and `items` stay index-aligned and a Move never deletes a row whose data
935    /// wasn't transferred. Attaches MIME, and stashes the rows + a stable-key
936    /// removal thunk for the completion.
937    ///
938    /// `None` when no row survives the residency filter (an all-`Loading`
939    /// selection): the caller must refuse the drag rather than float an empty
940    /// payload nothing can accept.
941    pub(crate) fn build_payload(
942        &self,
943        source: ViewId,
944        mut rows: Vec<usize>,
945        read: &dyn Fn(usize, &mut dyn FnMut(&T)) -> bool,
946        snapshot_out: &SnapshotOutFn,
947    ) -> Option<DragPayload> {
948        let items: Option<Vec<T>> = if let Some(cf) = self.clone_item_fn.as_ref() {
949            let mut out = Vec::with_capacity(rows.len());
950            rows.retain(|&r| {
951                let mut got = None;
952                read(r, &mut |t| got = Some(cf(t)));
953                match got {
954                    Some(v) => {
955                        out.push(v);
956                        true
957                    }
958                    None => false,
959                }
960            });
961            Some(out)
962        } else {
963            None
964        };
965        if rows.is_empty() {
966            return None;
967        }
968        let mime_pairs: Vec<(String, Vec<u8>)> =
969            match (self.export_mime_fn.as_ref(), items.as_ref()) {
970                (Some(mf), Some(its)) => mf(its),
971                _ => Vec::new(),
972            };
973        let mut payload = DragPayload::typed(RowDragData::<T> {
974            source,
975            rows: rows.clone(),
976            items,
977        });
978        let has_mime = !mime_pairs.is_empty();
979        for (mime, bytes) in mime_pairs {
980            payload = payload.with_mime(&mime, bytes);
981        }
982        if has_mime {
983            payload.enrich_external_from_mime();
984        }
985        *self.removal.borrow_mut() = Some((snapshot_out)(&rows));
986        *self.dragged_rows.borrow_mut() = rows;
987        Some(payload)
988    }
989
990    /// Whether a **foreign** exported payload would be accepted here — for the
991    /// hover affordance. (Same-view / reorder-only payloads return `false`.)
992    pub(crate) fn accepts_foreign_export(&self, payload: &DragPayload, source: ViewId) -> bool {
993        self.accept_foreign_rows
994            && self.on_rows_received.is_some()
995            && payload
996                .get_typed::<RowDragData<T>>()
997                .is_some_and(|rd| rd.source != source && rd.is_export())
998    }
999
1000    /// Foreign-receive sugar for a view's `on_drop`. Peeks before taking, so a
1001    /// non-matching payload is left intact for any further fallback.
1002    pub(crate) fn foreign_receive(
1003        &self,
1004        payload: &mut DragPayload,
1005        source: ViewId,
1006        insertion: usize,
1007        ctx: &mut EventContext,
1008    ) -> bool {
1009        if self.accepts_foreign_export(payload, source)
1010            && let Some(cb) = self.on_rows_received.as_ref()
1011            && let Some(rd) = payload.take_typed::<RowDragData<T>>()
1012            && let Some(items) = rd.items
1013        {
1014            cb(items, insertion, ctx);
1015            return true;
1016        }
1017        false
1018    }
1019
1020    /// The view's own `on_drop` calls this after applying a genuine SAME-VIEW
1021    /// reorder, so the completion knows the change was already applied.
1022    pub(crate) fn note_self_reorder(&self) {
1023        self.self_reorder_flag.set(true);
1024    }
1025
1026    /// Install the `on_drag_ended` move-out completion. A same-view reorder set
1027    /// `self_reorder_flag` (skipped here); on `Move` + accepted-elsewhere the
1028    /// origin rows are removed via the app override (delivered **descending** so
1029    /// index-by-index removal stays valid) or the stable-key removal thunk.
1030    pub(crate) fn install_completion(&self, handlers: HandlerSet) -> HandlerSet {
1031        let Some(mode) = self.mode else {
1032            return handlers;
1033        };
1034        let flag = self.self_reorder_flag.clone();
1035        let dragged = self.dragged_rows.clone();
1036        let removal = self.removal.clone();
1037        let on_out = self.on_rows_transferred_out.clone();
1038        handlers.on_drag_ended(move |outcome, ctx| {
1039            let handled_by_us = flag.replace(false);
1040            let rows = std::mem::take(&mut *dragged.borrow_mut());
1041            let thunk = removal.borrow_mut().take();
1042            if handled_by_us {
1043                return;
1044            }
1045            let accepted_elsewhere = matches!(
1046                outcome,
1047                DropOutcome::InApp { accepted: true } | DropOutcome::OsMove
1048            );
1049            if mode != DragTransferMode::Move || !accepted_elsewhere || rows.is_empty() {
1050                return;
1051            }
1052            if let Some(cb) = on_out.as_ref() {
1053                let mut desc = rows;
1054                desc.sort_unstable();
1055                desc.reverse();
1056                cb(&desc, ctx);
1057            } else if let Some(thunk) = thunk {
1058                thunk();
1059            }
1060        })
1061    }
1062}
1063
1064/// Shared deferred-selection press logic for a data-view row (the drift a code
1065/// review caught: the `press_claimed` guard was missing in one view). Pressing
1066/// an already-selected row DEFERS the collapse-to-single to a release WITHOUT a
1067/// drag (an active drag consumes `PointerUp`), so grabbing a multi-selection
1068/// drags the whole set. `pending` is a per-row cell shared by the two calls.
1069pub(crate) mod deferred_select {
1070    use std::cell::Cell;
1071    use std::rc::Rc;
1072
1073    use teksilo_core::event::Modifiers;
1074    use teksilo_core::widget::EventContext;
1075
1076    use super::RowSelection;
1077
1078    /// Handle a primary `PointerDown` on row `index`. Returns without selecting
1079    /// if the press was claimed by an interactive child (also clearing a stale
1080    /// `pending`). Ctrl/Shift select immediately; a plain press on an
1081    /// already-selected row defers; otherwise selects.
1082    pub(crate) fn on_down(
1083        sel: &RowSelection,
1084        index: usize,
1085        modifiers: Modifiers,
1086        pending: &Rc<Cell<bool>>,
1087        ctx: &mut EventContext,
1088    ) -> bool {
1089        if ctx.press_claimed_by_interactive_child() {
1090            pending.set(false);
1091            return false;
1092        }
1093        // The accelerator-click that adds one row to a discontiguous selection:
1094        // Ctrl+click on Windows and Linux, ⌘-click on macOS — where ⌃-click is
1095        // the secondary click and would open a context menu instead.
1096        if modifiers.command() {
1097            sel.toggle(index);
1098            pending.set(false);
1099        } else if modifiers.shift() {
1100            sel.extend_to(index);
1101            pending.set(false);
1102        } else if sel.is_selected(index) {
1103            pending.set(true);
1104        } else {
1105            sel.select(index);
1106            pending.set(false);
1107        }
1108        true
1109    }
1110
1111    /// Handle a primary `PointerUp` on row `index` — reached only on a click
1112    /// WITHOUT a drag. Collapses the deferred multi-selection, unless the
1113    /// release belongs to an interactive child.
1114    pub(crate) fn on_up(
1115        sel: &RowSelection,
1116        index: usize,
1117        pending: &Rc<Cell<bool>>,
1118        ctx: &mut EventContext,
1119    ) {
1120        if ctx.press_claimed_by_interactive_child() {
1121            return;
1122        }
1123        if pending.replace(false) {
1124            sel.select(index);
1125        }
1126    }
1127}
1128
1129#[cfg(test)]
1130mod payload_tests {
1131    use super::*;
1132
1133    fn noop_snapshot() -> SnapshotOutFn {
1134        Rc::new(|_: &[usize]| Box::new(|| {}) as Box<dyn Fn()>)
1135    }
1136
1137    #[test]
1138    fn an_all_unresident_selection_refuses_the_drag() {
1139        // Every dragged row is still `Loading`: the residency filter empties
1140        // the set, and the drag must be refused outright — a floating payload
1141        // with no rows and no items would remove nothing on a Move and offer
1142        // nothing to a receiver.
1143        let mut export = RowExport::<u64>::default();
1144        export.set_exportable(DragTransferMode::Move);
1145        let read = |_: usize, _: &mut dyn FnMut(&u64)| false;
1146        let payload = export.build_payload(
1147            ViewId::next(ViewKind::List),
1148            vec![0, 1, 2],
1149            &read,
1150            &noop_snapshot(),
1151        );
1152        assert!(payload.is_none());
1153    }
1154
1155    #[test]
1156    fn a_partially_resident_selection_carries_only_the_resident_rows() {
1157        let mut export = RowExport::<u64>::default();
1158        export.set_exportable(DragTransferMode::Copy);
1159        // Row 1 is unresident; rows 0 and 2 resolve.
1160        let read = |i: usize, f: &mut dyn FnMut(&u64)| {
1161            if i == 1 {
1162                return false;
1163            }
1164            f(&(i as u64 * 10));
1165            true
1166        };
1167        let payload = export
1168            .build_payload(
1169                ViewId::next(ViewKind::List),
1170                vec![0, 1, 2],
1171                &read,
1172                &noop_snapshot(),
1173            )
1174            .expect("two rows are resident");
1175        let rd = payload.get_typed::<RowDragData<u64>>().unwrap();
1176        assert_eq!(rd.rows, vec![0, 2]);
1177        assert_eq!(rd.items.as_deref(), Some(&[0, 20][..]));
1178    }
1179}