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::build_context::BuildContext;
30use teksilo_core::drag_payload::{DragPayload, DropOutcome};
31use teksilo_core::widget::{EventContext, Widget};
32use teksilo_core::widget_builder::HandlerSet;
33use teksilo_core::widget_id::WidgetId;
34use teksilo_data::{
35    DataChange, DropPosition, ItemKey, KeyedSelectionModel, SelectionMode, SelectionModel,
36};
37
38/// How a data-view row/tile is *activated* (opened/committed) by pointer —
39/// distinct from *selection*, which also moves on arrow-key navigation. Mirrors
40/// the platform split other toolkits expose (Qt
41/// `SH_ItemView_ActivateItemOnSingleClick`, GTK `activate-on-single-click`).
42/// Enter/Space always activates regardless of this mode.
43///
44/// Pass to `ListView::activate_on`, `TreeView::activate_on`, etc.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
46pub enum ActivateOn {
47    /// One primary click activates the row (KDE / web / Scrivener convention).
48    /// Selection and activation happen on the same click.
49    SingleClick,
50    /// A double primary click activates the row; the first click only selects
51    /// it (Finder / Explorer / Qt and GTK default). This is the [`Default`].
52    #[default]
53    DoubleClick,
54}
55
56/// Which kind of data view minted a [`ViewId`]. Folded into the id so two
57/// different widget kinds that happen to draw the same value from the shared
58/// process counter can never be mistaken for one another — the reason a bare
59/// `usize` id was a latent cross-widget hazard.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
61pub(crate) enum ViewKind {
62    List,
63    Tree,
64    Table,
65    TreeTable,
66    Grid,
67}
68
69/// Opaque, kind-tagged, process-unique identity of a drag-capable data-view
70/// instance. Used to tell a view's OWN reorder (`SameView`) from a foreign drop
71/// on the receive side. Apps only ever compare two `ViewId`s for equality (e.g.
72/// out of a received [`RowDragData`]); there is no public constructor, and the
73/// value is stable for a view instance's lifetime, so it is safe to compare
74/// even across windows (each mint is globally unique).
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
76pub struct ViewId(ViewKind, usize);
77
78impl ViewId {
79    /// Mint a fresh, globally-unique id for a view of the given kind.
80    pub(crate) fn next(kind: ViewKind) -> Self {
81        Self(kind, next_view_id())
82    }
83}
84
85/// What the *origin* view does to its own rows once a drag is accepted by a
86/// **foreign** target (a different `DropTarget` / view / the OS). Purely an
87/// origin-side cleanup choice — the receiver is unaffected. A same-view reorder
88/// is never a transfer, so this never applies to it.
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
90pub enum DragTransferMode {
91    /// Leave the origin rows in place (the dragged data is duplicated).
92    Copy,
93    /// Remove the dragged rows from the origin once accepted elsewhere
94    /// (or exported as an OS move). This is the [`Default`].
95    #[default]
96    Move,
97}
98
99/// The public, generic drag payload every data-view row (or selected set)
100/// emits. It occupies the single typed slot of a
101/// [`teksilo_core::drag_payload::DragPayload`] and serves both audiences:
102///
103/// - the origin view's own erased classifier reads [`source`](Self::source) +
104///   [`rows`](Self::rows) to recognise a same-view reorder;
105/// - a **foreign** consumer (another view's custom `ListDataSource`, a
106///   `DropTarget::accept_typed::<RowDragData<T>>()`, or `on_rows_received`)
107///   reads [`items`](Self::items).
108///
109/// `items` is `Some` only when the origin view opted into export via
110/// `.exportable(..)` (which requires `T: Clone`); a plain `.reorderable(true)`
111/// drag carries `items == None` (nothing outside the origin could use it
112/// anyway), so a reorder-only view is never accidentally droppable elsewhere.
113#[derive(Debug)]
114pub struct RowDragData<T: 'static> {
115    /// Identity of the view that started the drag.
116    pub source: ViewId,
117    /// The dragged rows as the origin view's flat visible indices at
118    /// drag-start, ascending. Informational (row count, app callbacks): the
119    /// origin's accept path resolves the dragged rows' **stable keys** at
120    /// drag-start and never re-reads these indices at hover/drop time — they
121    /// go stale the moment the source reflows mid-drag (a spring-load
122    /// auto-expand, a peer write). A foreign consumer should read
123    /// [`items`](Self::items) instead.
124    pub rows: Vec<usize>,
125    /// Clones of the dragged items, `rows`-ordered. `None` for a reorder-only
126    /// (non-exportable) drag.
127    pub items: Option<Vec<T>>,
128}
129
130impl<T: 'static> RowDragData<T> {
131    /// The dragged items, if this is an export drag (`.exportable(..)` was set
132    /// on the origin). `None` for a reorder-only drag.
133    pub fn items(&self) -> Option<&[T]> {
134        self.items.as_deref()
135    }
136
137    /// Consume the payload for its items (avoids cloning on the receive side).
138    pub fn into_items(self) -> Option<Vec<T>> {
139        self.items
140    }
141
142    /// Whether this drag carries exportable items — i.e. the origin opted into
143    /// `.exportable(..)`. A foreign receiver should gate on this (a reorder-only
144    /// payload has the same Rust type but carries nothing usable).
145    pub fn is_export(&self) -> bool {
146        self.items.is_some()
147    }
148
149    /// Number of dragged rows.
150    pub fn len(&self) -> usize {
151        self.rows.len()
152    }
153
154    /// Whether no rows are carried (never true for a real drag).
155    pub fn is_empty(&self) -> bool {
156        self.rows.is_empty()
157    }
158}
159
160/// A drop indicator the data views' `paint` renders. `allowed == false` paints a
161/// muted line where an accepted-drop line would be — the pre-commit "you can't
162/// drop here" affordance.
163#[derive(Debug, Clone, Copy, PartialEq)]
164pub(crate) struct DropIndicator {
165    pub(crate) y: f32,
166    pub(crate) width: f32,
167    pub(crate) allowed: bool,
168}
169
170/// A process-unique id distinguishing data-view instances (for SameView drop
171/// detection when several views share one source).
172pub(crate) fn next_view_id() -> usize {
173    static NEXT: AtomicUsize = AtomicUsize::new(1);
174    NEXT.fetch_add(1, Ordering::Relaxed)
175}
176
177/// Map a flat insertion index (`0..=len`) to the `(target_index, position)` pair
178/// a `ListDataSource::can_accept` / `accept_drop` understands. `None` for an
179/// empty list. Insertion *before* row `i` is `(i, Before)`; insertion past the
180/// end is `(len-1, After)`.
181pub(crate) fn flat_insertion_target(insertion: usize, len: usize) -> Option<(usize, DropPosition)> {
182    if len == 0 {
183        None
184    } else if insertion >= len {
185        Some((len - 1, DropPosition::After))
186    } else {
187        Some((insertion, DropPosition::Before))
188    }
189}
190
191/// The default skeleton for a `Loading` row — a muted inset bar. The row's
192/// placement sizes it to the row's height and width.
193/// One row's tooltip, already resolved from its item and awaiting a
194/// `BuildContext` to attach it with.
195pub(crate) enum ResolvedRowTooltip {
196    Plain(teksilo_i18n::LocalizedString),
197    Rich(crate::tooltip::RichTooltipSource),
198    Composite(Box<dyn Widget>),
199}
200
201/// Per-row tooltip resolvers, shared by every data view that builds rows from
202/// a delegate.
203///
204/// A data view's rows are not authored by the app as widgets it can hang a
205/// `.tooltip(...)` on — they come out of a delegate, and the view owns the
206/// resulting `WidgetId`. So the view takes the *resolvers* instead and does the
207/// attaching itself, against the row it just built. Same shape as
208/// [`TabDelegate`](crate::tab_widget::TabDelegate)'s per-tab tooltip callbacks,
209/// and the same last-setter-wins matrix as the per-widget setters: each `set_*`
210/// clears the other two, so a row can never mature two tips at once.
211///
212/// Placement is [`Side`](crate::tooltip::TooltipPlacement::Side) for every
213/// view here — rows stack vertically, and a `Below` tip would cover the next
214/// row, which is the one the user is most likely reading next.
215///
216/// Cost: the body is resolved and built for each **realized** row, i.e. the
217/// virtualization window (visible + buffer), not the whole model — and again
218/// whenever those rows rebuild. Keep resolvers cheap; defer anything expensive
219/// (a backend read, a subtree walk) to the body's own first paint, which only
220/// happens if the tip is actually shown.
221pub(crate) struct RowTooltips<T: 'static> {
222    plain: Option<Rc<dyn Fn(usize, &T) -> Option<teksilo_i18n::LocalizedString>>>,
223    rich: Option<Rc<dyn Fn(usize, &T) -> Option<crate::tooltip::RichTooltipSource>>>,
224    composite: Option<Rc<dyn Fn(usize, &T) -> Option<Box<dyn Widget>>>>,
225    /// Whether a composite row tip offers dwell-to-sticky promotion.
226    composite_sticky: bool,
227}
228
229impl<T: 'static> Default for RowTooltips<T> {
230    fn default() -> Self {
231        Self {
232            plain: None,
233            rich: None,
234            composite: None,
235            composite_sticky: true,
236        }
237    }
238}
239
240impl<T: 'static> Clone for RowTooltips<T> {
241    fn clone(&self) -> Self {
242        Self {
243            plain: self.plain.clone(),
244            rich: self.rich.clone(),
245            composite: self.composite.clone(),
246            composite_sticky: self.composite_sticky,
247        }
248    }
249}
250
251impl<T: 'static> std::fmt::Debug for RowTooltips<T> {
252    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
253        f.debug_struct("RowTooltips")
254            .field("plain", &self.plain.is_some())
255            .field("rich", &self.rich.is_some())
256            .field("composite", &self.composite.is_some())
257            .finish()
258    }
259}
260
261impl<T: 'static> RowTooltips<T> {
262    /// Whether any resolver is set — lets a view skip the per-row work.
263    pub(crate) fn is_set(&self) -> bool {
264        self.plain.is_some() || self.rich.is_some() || self.composite.is_some()
265    }
266
267    pub(crate) fn set_plain(
268        &mut self,
269        f: impl Fn(usize, &T) -> Option<teksilo_i18n::LocalizedString> + 'static,
270    ) {
271        *self = Self {
272            plain: Some(Rc::new(f)),
273            rich: None,
274            composite: None,
275            composite_sticky: self.composite_sticky,
276        };
277    }
278
279    pub(crate) fn set_rich(
280        &mut self,
281        f: impl Fn(usize, &T) -> Option<crate::tooltip::RichTooltipSource> + 'static,
282    ) {
283        *self = Self {
284            plain: None,
285            rich: Some(Rc::new(f)),
286            composite: None,
287            composite_sticky: self.composite_sticky,
288        };
289    }
290
291    pub(crate) fn set_composite(
292        &mut self,
293        f: impl Fn(usize, &T) -> Option<Box<dyn Widget>> + 'static,
294    ) {
295        *self = Self {
296            plain: None,
297            rich: None,
298            composite: Some(Rc::new(f)),
299            composite_sticky: self.composite_sticky,
300        };
301    }
302
303    /// Whether a composite row tip offers dwell promotion. Off suits a
304    /// read-only card: nothing to reach into, so nothing to pin.
305    pub(crate) fn set_composite_sticky(&mut self, on: bool) {
306        self.composite_sticky = on;
307    }
308
309    /// Resolve this row's tooltip, if any.
310    ///
311    /// Split from [`attach_resolved`](Self::attach_resolved) because a view can
312    /// only reach the item from inside the same borrow that builds the row
313    /// widget, while attaching needs the `BuildContext` and the resulting
314    /// `WidgetId` — which only exist after that borrow ends.
315    pub(crate) fn resolve(&self, index: usize, item: &T) -> Option<ResolvedRowTooltip> {
316        if let Some(f) = &self.composite {
317            f(index, item).map(ResolvedRowTooltip::Composite)
318        } else if let Some(f) = &self.rich {
319            f(index, item).map(ResolvedRowTooltip::Rich)
320        } else if let Some(f) = &self.plain {
321            f(index, item).map(ResolvedRowTooltip::Plain)
322        } else {
323            None
324        }
325    }
326
327    /// Attach a resolved tooltip to the row widget the view just built.
328    pub(crate) fn attach_resolved(
329        &self,
330        ctx: &mut teksilo_core::build_context::BuildContext,
331        row_id: teksilo_core::widget_id::WidgetId,
332        resolved: ResolvedRowTooltip,
333    ) {
334        // Rows stack vertically, so a `Below` tip would cover the next row —
335        // the one the user is most likely reading next.
336        let placement = crate::tooltip::TooltipPlacement::Side;
337        match resolved {
338            ResolvedRowTooltip::Composite(body) => {
339                let delay = ctx.theme().motion.tooltip_delay_heavy;
340                crate::tooltip::attach_composite_tooltip_widget_with_placement(
341                    ctx,
342                    row_id,
343                    crate::tooltip::CompositeTooltipWidget::new()
344                        .content_boxed(body)
345                        .sticky(self.composite_sticky),
346                    delay,
347                    placement,
348                );
349            }
350            ResolvedRowTooltip::Rich(source) => {
351                let delay = ctx.theme().motion.tooltip_delay;
352                crate::tooltip::attach_rich_tooltip_source_with_placement(
353                    ctx, row_id, source, delay, placement,
354                );
355            }
356            ResolvedRowTooltip::Plain(text) => {
357                let delay = ctx.theme().motion.tooltip_delay;
358                crate::tooltip::attach_plain_tooltip_with_placement(
359                    ctx, row_id, text, delay, placement,
360                );
361            }
362        }
363    }
364}
365
366pub(crate) fn default_placeholder() -> Box<dyn Widget> {
367    use crate::primitives::{Padding, RectWidget};
368    Box::new(
369        Padding::uniform(6.0).child(
370            RectWidget::new()
371                .background(teksilo_tokens::SurfaceRole::Hover)
372                .corner_radius(teksilo_tokens::CornerRadius::uniform(4.0)),
373        ),
374    )
375}
376
377/// Index-facing row-selection facade backing the four data views.
378///
379/// An app installs *either* the index-based [`SelectionModel`] (positions) or a
380/// [`KeyedSelectionModel<K>`] (stable identities that survive reorder / filter /
381/// window-slide / multi-view). The views' click / keyboard / rebuild / paint
382/// paths all work in **indices**, so this facade erases the difference: the
383/// keyed variant carries the view's index↔key mapping (`key_at` / `len` /
384/// `contains_key`) and translates internally. The method surface deliberately
385/// mirrors `SelectionModel` so call sites read identically (`rs.select(i)`,
386/// `rs.is_selected(i)`, …).
387#[derive(Clone)]
388pub(crate) struct RowSelection {
389    mode: SelectionMode,
390    is_selected: Rc<dyn Fn(usize) -> bool>,
391    select_fn: Rc<dyn Fn(usize)>,
392    toggle_fn: Rc<dyn Fn(usize)>,
393    extend_fn: Rc<dyn Fn(usize)>,
394    /// Ctrl+Shift range extension: keeps whatever the previous gesture
395    /// selected instead of replacing it, so a second disjoint range can be
396    /// built without losing the first.
397    extend_additive_fn: Rc<dyn Fn(usize)>,
398    select_all_fn: Rc<dyn Fn(usize)>,
399    selected_indices_fn: Rc<dyn Fn() -> Vec<usize>>,
400    /// Cheap (O(selected count), never O(visible)) emptiness check for the
401    /// container-focus-ring gate — paint runs every frame and only needs to
402    /// know "is anything selected", not the set itself.
403    has_selection_fn: Rc<dyn Fn() -> bool>,
404    /// How many items are selected, keys this view does not show included.
405    count_fn: Rc<dyn Fn() -> usize>,
406    /// Replace the selection with these indices, or union them in when
407    /// `additive` — a rubber band.
408    select_indices_fn: Rc<dyn Fn(Vec<usize>, bool)>,
409    clear_fn: Rc<dyn Fn()>,
410    observe_fn: Rc<dyn Fn(Box<dyn Fn()>) -> ObserverHandle>,
411    on_change_fn: Rc<dyn Fn(&DataChange)>,
412    /// Unconditional prune for the version-signal-driven tree views (which
413    /// don't emit a `DataChange`): drop orphaned keys (keyed) or no-op (index).
414    prune_fn: Rc<dyn Fn()>,
415    /// Drop selected indices that no longer fit `0..count`, for the
416    /// version-signal-driven tree views' index-selection path: an index has
417    /// no identity to follow a moved row by (that's `focused_index`'s job,
418    /// via `RowAnchor`), so a structural change can only clamp it — never
419    /// re-land it on the row it used to point at. A no-op for the keyed
420    /// model, which is already fully reconciled by `prune_fn`.
421    prune_range_fn: Rc<dyn Fn(usize)>,
422}
423
424impl RowSelection {
425    /// Back the facade with the index-based [`SelectionModel`]. Index ops pass
426    /// straight through; `on_data_change` index-shifts (insert / remove) or
427    /// clears (reset) the selection, matching the legacy inline behaviour.
428    pub(crate) fn from_index(sel: SelectionModel) -> Self {
429        let (s_is, s_sel, s_tog, s_ext, s_all, s_idx, s_has, s_clr, s_obs, s_chg, s_range) = (
430            sel.clone(),
431            sel.clone(),
432            sel.clone(),
433            sel.clone(),
434            sel.clone(),
435            sel.clone(),
436            sel.clone(),
437            sel.clone(),
438            sel.clone(),
439            sel.clone(),
440            sel.clone(),
441        );
442        Self {
443            mode: sel.mode(),
444            is_selected: Rc::new(move |i| s_is.is_selected(i)),
445            select_fn: Rc::new(move |i| s_sel.select(i)),
446            toggle_fn: Rc::new(move |i| s_tog.toggle(i)),
447            extend_fn: Rc::new(move |i| s_ext.extend_to(i)),
448            extend_additive_fn: {
449                let s = sel.clone();
450                Rc::new(move |i| s.extend_to_additive(i))
451            },
452            select_all_fn: Rc::new(move |count| s_all.select_all(count)),
453            selected_indices_fn: Rc::new(move || s_idx.selected_indices()),
454            has_selection_fn: Rc::new(move || s_has.count() > 0),
455            count_fn: {
456                let s = sel.clone();
457                Rc::new(move || s.count())
458            },
459            select_indices_fn: {
460                let s = sel.clone();
461                Rc::new(move |indices, additive| s.select_indices(indices, additive))
462            },
463            clear_fn: Rc::new(move || s_clr.clear()),
464            observe_fn: Rc::new(move |cb| s_obs.selection_signal().observe(move |_| cb())),
465            on_change_fn: Rc::new(move |change| match change {
466                DataChange::ItemsInserted { range } => {
467                    s_chg.adjust_for_insert(range.start, range.end - range.start);
468                }
469                DataChange::ItemsRemoved { range } => {
470                    s_chg.adjust_for_remove(range.start, range.end - range.start);
471                }
472                DataChange::ItemsMoved { from, to, count } => {
473                    s_chg.adjust_for_move(*from, *to, *count);
474                }
475                DataChange::Reset => s_chg.clear(),
476                _ => {}
477            }),
478            // The index model has no stable identity to prune against on a
479            // bare version bump — tree structural adjustments stay no-ops here
480            // (the legacy behaviour).
481            prune_fn: Rc::new(|| {}),
482            prune_range_fn: Rc::new(move |count| {
483                let kept: Vec<usize> = s_range
484                    .selected_indices()
485                    .into_iter()
486                    .filter(|&i| i < count)
487                    .collect();
488                if kept.len() != s_range.count() {
489                    s_range.select_indices(kept, false);
490                }
491            }),
492        }
493    }
494
495    /// Back the facade with a [`KeyedSelectionModel<K>`] plus the view's
496    /// index↔key mapping. `key_at(i)` is the key at visible index `i`, `len()`
497    /// the visible count (for Shift-range ordering and `selected_indices`), and
498    /// `contains_key(&k)` whether the *source* still holds the key (for
499    /// prune-on-remove — a collapsed-but-present tree node must NOT be pruned,
500    /// so this is supplied by the view, not derived from the visible window).
501    pub(crate) fn from_keyed<K: ItemKey>(
502        keyed: KeyedSelectionModel<K>,
503        key_at: Rc<dyn Fn(usize) -> Option<K>>,
504        len: Rc<dyn Fn() -> usize>,
505        contains_key: Rc<dyn Fn(&K) -> bool>,
506    ) -> Self {
507        let prune: Rc<dyn Fn()> = {
508            let k = keyed.clone();
509            Rc::new(move || k.prune_missing(|key| contains_key(key)))
510        };
511        Self::keyed(keyed, key_at, len, prune)
512    }
513
514    /// [`from_keyed`](Self::from_keyed) for a flat source, whose keys are
515    /// exactly those `key_at` gives over `0..len()`: a key is still in the
516    /// source when some index maps to it.
517    ///
518    /// The prune after a removal or a reset gathers those keys into a set
519    /// once, the first time it is asked about one, rather than taking a
520    /// per-key `contains_key`. A per-key answer is a scan of the source, made
521    /// once for every selected key, which is the source's length times the
522    /// selection: a select-all over ten thousand tiles followed by one
523    /// removal was a hundred million key reads. A prune with nothing to ask
524    /// about (no selection, no anchor) reads no key at all.
525    pub(crate) fn from_keyed_flat<K: ItemKey>(
526        keyed: KeyedSelectionModel<K>,
527        key_at: Rc<dyn Fn(usize) -> Option<K>>,
528        len: Rc<dyn Fn() -> usize>,
529    ) -> Self {
530        let prune: Rc<dyn Fn()> = {
531            let (k, ka, l) = (keyed.clone(), key_at.clone(), len.clone());
532            Rc::new(move || {
533                let present: std::cell::OnceCell<std::collections::HashSet<K>> =
534                    std::cell::OnceCell::new();
535                k.prune_missing(|key| {
536                    present
537                        .get_or_init(|| (0..l()).filter_map(|i| ka(i)).collect())
538                        .contains(key)
539                });
540            })
541        };
542        Self::keyed(keyed, key_at, len, prune)
543    }
544
545    /// The keyed facade around `prune`, which drops the keys the source no
546    /// longer holds.
547    fn keyed<K: ItemKey>(
548        keyed: KeyedSelectionModel<K>,
549        key_at: Rc<dyn Fn(usize) -> Option<K>>,
550        len: Rc<dyn Fn() -> usize>,
551        prune: Rc<dyn Fn()>,
552    ) -> Self {
553        let mode = keyed.mode();
554        Self {
555            mode,
556            is_selected: {
557                let (k, ka) = (keyed.clone(), key_at.clone());
558                Rc::new(move |i| ka(i).map(|key| k.is_selected(&key)).unwrap_or(false))
559            },
560            select_fn: {
561                let (k, ka) = (keyed.clone(), key_at.clone());
562                Rc::new(move |i| {
563                    if let Some(key) = ka(i) {
564                        k.select(key);
565                    }
566                })
567            },
568            toggle_fn: {
569                let (k, ka) = (keyed.clone(), key_at.clone());
570                Rc::new(move |i| {
571                    if let Some(key) = ka(i) {
572                        k.toggle(key);
573                    }
574                })
575            },
576            extend_fn: {
577                // O(visible count) per Shift-click / Shift-arrow gesture:
578                // builds the full visible key order every call rather than
579                // just the `[anchor_index..=target_index]` span
580                // `KeyedSelectionModel::extend_to` actually inserts.
581                //
582                // Narrowing this to the sub-range was considered and
583                // rejected as not cleanly possible without touching
584                // `teksilo-data`: `extend_to`'s "anchor scrolled out of
585                // view / evicted" fallback (single-select `target`) is
586                // detected by NOT finding `anchor` in the `ordered_keys`
587                // slice it's given, and the anchor is a private field with
588                // no public accessor (`KeyedSelectionModel::anchor` isn't
589                // exposed, and there's no `index_of_key` on the view's
590                // key↔index mapping this facade carries either). Without
591                // that, this closure has no way to know the anchor's
592                // current index — or whether it still HAS one — to bound a
593                // sub-range with, and a shadow copy of the anchor tracked
594                // here would drift from `KeyedSelectionModel`'s own
595                // whenever something else drives `select`/`toggle`
596                // (clearing or moving the anchor) — a duplicated-state
597                // correctness risk for a micro-optimization on a
598                // human-triggered, once-per-gesture path (not a hot loop).
599                let (k, ka, l) = (keyed.clone(), key_at.clone(), len.clone());
600                Rc::new(move |i| {
601                    if let Some(target) = ka(i) {
602                        let ordered: Vec<K> = (0..l()).filter_map(|j| ka(j)).collect();
603                        k.extend_to(target, &ordered);
604                    }
605                })
606            },
607            extend_additive_fn: {
608                // Same visible-order rebuild as `extend_fn` above, and the same
609                // reasoning for why it is not narrowed to the anchor's span.
610                let (k, ka, l) = (keyed.clone(), key_at.clone(), len.clone());
611                Rc::new(move |i| {
612                    if let Some(target) = ka(i) {
613                        let ordered: Vec<K> = (0..l()).filter_map(|j| ka(j)).collect();
614                        k.extend_to_additive(target, &ordered);
615                    }
616                })
617            },
618            select_all_fn: {
619                let (k, ka) = (keyed.clone(), key_at.clone());
620                Rc::new(move |count| {
621                    let keys: Vec<K> = (0..count).filter_map(|i| ka(i)).collect();
622                    k.select_keys(keys, false);
623                })
624            },
625            selected_indices_fn: {
626                let (k, ka, l) = (keyed.clone(), key_at.clone(), len.clone());
627                Rc::new(move || {
628                    (0..l())
629                        .filter(|&i| ka(i).map(|key| k.is_selected(&key)).unwrap_or(false))
630                        .collect()
631                })
632            },
633            has_selection_fn: {
634                let k = keyed.clone();
635                Rc::new(move || k.count() > 0)
636            },
637            count_fn: {
638                let k = keyed.clone();
639                Rc::new(move || k.count())
640            },
641            select_indices_fn: {
642                let (k, ka) = (keyed.clone(), key_at.clone());
643                Rc::new(move |indices: Vec<usize>, additive| {
644                    k.select_keys(indices.into_iter().filter_map(|i| ka(i)), additive)
645                })
646            },
647            clear_fn: {
648                let k = keyed.clone();
649                Rc::new(move || k.clear())
650            },
651            observe_fn: {
652                let k = keyed.clone();
653                Rc::new(move |cb| k.selection_signal().observe(move |_| cb()))
654            },
655            on_change_fn: {
656                let prune = prune.clone();
657                Rc::new(move |change| match change {
658                    // Keys are stable across inserts / moves; only removals and
659                    // resets can orphan a selected key.
660                    DataChange::ItemsRemoved { .. } | DataChange::Reset => prune(),
661                    _ => {}
662                })
663            },
664            prune_fn: prune,
665            // Keys already survive a version bump via `prune_fn` above —
666            // there is no separate index range to clamp.
667            prune_range_fn: Rc::new(|_count: usize| {}),
668        }
669    }
670
671    pub(crate) fn mode(&self) -> SelectionMode {
672        self.mode
673    }
674    pub(crate) fn is_selected(&self, index: usize) -> bool {
675        (self.is_selected)(index)
676    }
677    pub(crate) fn select(&self, index: usize) {
678        (self.select_fn)(index)
679    }
680    pub(crate) fn toggle(&self, index: usize) {
681        (self.toggle_fn)(index)
682    }
683    pub(crate) fn extend_to(&self, index: usize) {
684        (self.extend_fn)(index)
685    }
686    pub(crate) fn extend_to_additive(&self, index: usize) {
687        (self.extend_additive_fn)(index)
688    }
689    pub(crate) fn select_all(&self, count: usize) {
690        (self.select_all_fn)(count)
691    }
692    pub(crate) fn selected_indices(&self) -> Vec<usize> {
693        (self.selected_indices_fn)()
694    }
695    /// Whether anything is selected. Prefer this over
696    /// `!selected_indices().is_empty()` when only the emptiness matters (e.g.
697    /// a per-frame paint gate) — it costs O(selected count), never
698    /// O(visible), for both the index and keyed backings.
699    pub(crate) fn has_selection(&self) -> bool {
700        (self.has_selection_fn)()
701    }
702    /// How many items are selected. A keyed selection counts its keys, so a
703    /// key another view of the same model shows, and this one does not, is
704    /// counted too.
705    pub(crate) fn count(&self) -> usize {
706        (self.count_fn)()
707    }
708    /// Replace the selection with `indices`, or add them to it when
709    /// `additive`. The keyed backing selects the keys at those indices.
710    pub(crate) fn select_indices(&self, indices: Vec<usize>, additive: bool) {
711        (self.select_indices_fn)(indices, additive)
712    }
713    pub(crate) fn clear(&self) {
714        (self.clear_fn)()
715    }
716    /// Subscribe to selection changes (drives the view's rebuild). Owns the
717    /// returned handle for the subscription's lifetime.
718    pub(crate) fn observe_for_rebuild(&self, cb: impl Fn() + 'static) -> ObserverHandle {
719        (self.observe_fn)(Box::new(cb))
720    }
721    /// React to a source data change (index-shift for the index model, prune
722    /// for the keyed model).
723    pub(crate) fn on_data_change(&self, change: &DataChange) {
724        (self.on_change_fn)(change)
725    }
726    /// Prune orphaned keys (keyed model) — used by the tree views, which drive
727    /// off a version signal rather than a `DataChange`. No-op for the index
728    /// model.
729    pub(crate) fn prune(&self) {
730        (self.prune_fn)()
731    }
732    /// Drop selected indices `>= count` (index model) after a structural
733    /// change with no delta to shift them by — a version-signal-driven tree
734    /// view's only defence against a selection left pointing past the
735    /// shrunk end (it cannot re-land on the row it used to point at; only
736    /// `focused_index`'s `RowAnchor` tracks identity). No-op for the keyed
737    /// model, already fully reconciled by `prune`.
738    pub(crate) fn prune_out_of_range(&self, count: usize) {
739        (self.prune_range_fn)(count)
740    }
741}
742
743/// Resolves a set of the origin view's flat indices to a **removal thunk** at
744/// drag-start. Invoked at completion, the thunk removes exactly those rows from
745/// the source. Resolving eagerly (rather than re-reading flat indices at
746/// completion) keeps a Move correct even if the origin's flat indices reshuffle
747/// mid-drag — e.g. a `TreeView` spring-load auto-expand — since the stable keys
748/// were already captured. The source erasure supplies it.
749pub(crate) type SnapshotOutFn = Rc<dyn Fn(&[usize]) -> Box<dyn Fn()>>;
750
751/// Active drag-drop feedback a tree data view paints itself: a between-rows
752/// insertion line (Before/After) or a highlighted row (an into-container drop).
753///
754/// Shared by `TreeView` and `TreeTableView` so both render the same affordance
755/// for the same source verdict.
756/// A stable handle to a row in a data view.
757///
758/// Per-row event handlers (a chevron toggle, a click, an activation) are built
759/// once and then live as long as the row widget does, so capturing the flat
760/// index they were built at is fragile: expanding a branch above, applying a
761/// filter, or sorting shifts every index below, and the stale handler would act
762/// on whatever row moved into that slot.
763///
764/// A `RowAnchor` closes over the row's **source-owned identity** instead and
765/// resolves the row's *current* position on demand. The key never surfaces in
766/// the anchor's type — it is captured inside the resolver, so views stay
767/// key-agnostic ([`TreeSource`](crate::tree_source::TreeSource) and
768/// [`ListSource`](crate::list_source::ListSource) both erase it).
769///
770/// Sources without identity (a bare `ListModel`, or any source that leaves
771/// `key_at` at its `None` default) get a fixed anchor that always reports the
772/// index it was built with — no worse than capturing the index directly.
773///
774/// A bare `ListModel` has no identity to offer (a `Vec` row *is* its position),
775/// so anchors over one are fixed. `SortFilterListModel` keys rows by their
776/// **source index**, which no sort/filter reprojection renumbers — so anchors
777/// over a projection do track their row across a filter change, which is the
778/// flat fragility in practice. They can still mis-resolve inside the window
779/// between an *upstream* insert/remove and the rebuild it schedules, since that
780/// does renumber source indices; no worse than the captured index they replace.
781/// The tree sources all carry real identity.
782///
783/// **Precondition: keys must be unique.** Resolution falls back to a lookup by
784/// key, which returns the *first* match, so a source handing out duplicate keys
785/// would silently redirect an anchor onto a different row — the very failure
786/// this type exists to prevent.
787#[derive(Clone)]
788pub struct RowAnchor {
789    resolve: Rc<dyn Fn() -> Option<usize>>,
790    /// `false` for a [`fixed`](Self::fixed) anchor, which knows only the
791    /// index it was built with.
792    identity: bool,
793}
794
795impl RowAnchor {
796    /// Build an identity-backed anchor from a resolver.
797    pub(crate) fn new(resolve: Rc<dyn Fn() -> Option<usize>>) -> Self {
798        Self {
799            resolve,
800            identity: true,
801        }
802    }
803
804    /// An anchor for a source with no identity: always reports `index`.
805    pub(crate) fn fixed(index: usize) -> Self {
806        Self {
807            resolve: Rc::new(move || Some(index)),
808            identity: false,
809        }
810    }
811
812    /// Whether the anchor follows its row by the source's key. A fixed one
813    /// reports its index whatever happened to the row, which after a reset
814    /// says nothing about where the row went.
815    pub(crate) fn tracks_identity(&self) -> bool {
816        self.identity
817    }
818
819    /// The row's current flat index, or `None` if it no longer exists in the
820    /// source (it was deleted, or filtered away).
821    pub fn index(&self) -> Option<usize> {
822        (self.resolve)()
823    }
824
825    /// Whether the row still exists.
826    pub fn is_live(&self) -> bool {
827        self.index().is_some()
828    }
829}
830
831impl std::fmt::Debug for RowAnchor {
832    /// Deliberately does NOT resolve: the resolver reads the source's
833    /// interior-mutable state, so a `{:?}` from inside code already holding a
834    /// borrow (a `set_source` closure, a reorder callback, a debugger's
835    /// pretty-printer) would panic on a `RefCell` conflict.
836    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
837        f.write_str("RowAnchor(..)")
838    }
839}
840
841/// Keep an open cell editor pointing at the row it was opened on.
842///
843/// `editing_cell` is a `(row, col)` pair that outlives rebuilds, so rows
844/// appearing or vanishing above an open editor would slide it onto a different
845/// row. The anchor is captured the first time an open editor is seen and
846/// re-resolved on every later rebuild: the row index is rewritten when it moved,
847/// and the editor closes outright when its row is gone — better than silently
848/// editing whoever took the slot.
849///
850/// Called from each body pane's `build`, which is the only place that sees both
851/// an editing change and a data change. It can therefore write `editing_cell`
852/// while that pane is building; the write is idempotent and converges in one
853/// extra pass (the next reconcile finds `cur == row` and writes nothing), which
854/// `an_editing_reconcile_converges_in_one_pass` pins.
855pub(crate) fn reconcile_editing_row(
856    editing_cell: &teksilo_core::signal::Signal<Option<(usize, usize)>>,
857    slot: &Rc<std::cell::RefCell<Option<RowAnchor>>>,
858    anchor_of: &dyn Fn(usize) -> RowAnchor,
859) {
860    let Some((row, col)) = editing_cell.get() else {
861        *slot.borrow_mut() = None;
862        return;
863    };
864    let existing = slot.borrow().clone();
865    match existing {
866        None => *slot.borrow_mut() = Some(anchor_of(row)),
867        Some(anchor) => match anchor.index() {
868            Some(cur) if cur != row => editing_cell.set(Some((cur, col))),
869            Some(_) => {}
870            None => {
871                editing_cell.set(None);
872                *slot.borrow_mut() = None;
873            }
874        },
875    }
876}
877
878/// Tint for the "drop into this container" row highlight. Defined once so the
879/// `DropFeedback` handed to the framework and the widget's own paint cannot
880/// drift into two different colors on the same row.
881pub(crate) fn drop_into_tint() -> teksilo_tokens::Color {
882    teksilo_tokens::Color::from_rgba(0.25, 0.47, 0.85, 0.25)
883}
884
885#[derive(Clone, Copy, PartialEq, Debug)]
886pub(crate) enum DropViz {
887    /// Horizontal insertion line at `y`, spanning `width`, indented by
888    /// `depth` tree levels — the level the dropped row lands at.
889    Line { y: f32, width: f32, depth: usize },
890    /// Highlighted target row `[top, top + height]`, spanning `width`,
891    /// indented by the target's own `depth` — the "drop into this folder"
892    /// affordance.
893    Rect {
894        top: f32,
895        height: f32,
896        width: f32,
897        depth: usize,
898    },
899}
900
901/// The reusable export / foreign-drop machinery shared by all five data views:
902/// the config fields, the drag-start payload build (selection set already
903/// resolved by the caller), the foreign-receive sugar, and the `on_drag_ended`
904/// move-out completion. Each view holds ONE of these instead of duplicating the
905/// logic five ways (the drift that a code review caught). See
906/// [docs/drag-and-drop.md §12](https://github.com/ferntech-eu/teksilo/blob/main/docs/drag-and-drop.md).
907pub(crate) struct RowExport<T: 'static> {
908    /// `Some` once `.exportable(..)` was called; the transfer mode also drives
909    /// the move-out completion.
910    pub(crate) mode: Option<DragTransferMode>,
911    /// Clones `&T` → `T` for the payload (set by `.exportable`/`.export_external`,
912    /// each `where T: Clone`, so the view constructor stays unconstrained).
913    #[allow(clippy::type_complexity)]
914    pub(crate) clone_item_fn: Option<Rc<dyn Fn(&T) -> T>>,
915    /// Builds MIME reps of the dragged items for OS / `DropZone` export.
916    #[allow(clippy::type_complexity)]
917    pub(crate) export_mime_fn: Option<Rc<dyn Fn(&[T]) -> Vec<(String, Vec<u8>)>>>,
918    /// App override for removing rows moved out to a foreign target.
919    #[allow(clippy::type_complexity)]
920    pub(crate) on_rows_transferred_out: Option<Rc<dyn Fn(&[usize], &mut EventContext)>>,
921    /// Accept exported rows from a different view/source (zero-custom-source).
922    pub(crate) accept_foreign_rows: bool,
923    /// Handler for rows accepted via `accept_foreign_rows`.
924    #[allow(clippy::type_complexity)]
925    pub(crate) on_rows_received: Option<Rc<dyn Fn(Vec<T>, usize, &mut EventContext)>>,
926    /// Set by the view's own `on_drop` when it applied a same-view reorder, so
927    /// the completion skips the move-out (already applied). The TabBar pattern.
928    pub(crate) self_reorder_flag: Rc<Cell<bool>>,
929    /// The rows carried by the in-flight drag (for the app move-out callback).
930    dragged_rows: Rc<RefCell<Vec<usize>>>,
931    /// Stable-key removal thunk for the default move-out, resolved at drag-start.
932    #[allow(clippy::type_complexity)]
933    removal: Rc<RefCell<Option<Box<dyn Fn()>>>>,
934}
935
936impl<T: 'static> Clone for RowExport<T> {
937    // Hand-written (not derived) so cloning does NOT require `T: Clone` — every
938    // field is an `Rc` / `Copy`, so a clone shares the same drag stash + flags,
939    // which is exactly what the per-row drag closure needs.
940    fn clone(&self) -> Self {
941        Self {
942            mode: self.mode,
943            clone_item_fn: self.clone_item_fn.clone(),
944            export_mime_fn: self.export_mime_fn.clone(),
945            on_rows_transferred_out: self.on_rows_transferred_out.clone(),
946            accept_foreign_rows: self.accept_foreign_rows,
947            on_rows_received: self.on_rows_received.clone(),
948            self_reorder_flag: self.self_reorder_flag.clone(),
949            dragged_rows: self.dragged_rows.clone(),
950            removal: self.removal.clone(),
951        }
952    }
953}
954
955impl<T: 'static> Default for RowExport<T> {
956    fn default() -> Self {
957        Self {
958            mode: None,
959            clone_item_fn: None,
960            export_mime_fn: None,
961            on_rows_transferred_out: None,
962            accept_foreign_rows: false,
963            on_rows_received: None,
964            self_reorder_flag: Rc::new(Cell::new(false)),
965            dragged_rows: Rc::new(RefCell::new(Vec::new())),
966            removal: Rc::new(RefCell::new(None)),
967        }
968    }
969}
970
971impl<T: 'static> RowExport<T> {
972    /// `.exportable(mode)` — carry item clones; `where T: Clone` at the call.
973    pub(crate) fn set_exportable(&mut self, mode: DragTransferMode)
974    where
975        T: Clone,
976    {
977        self.mode = Some(mode);
978        if self.clone_item_fn.is_none() {
979            self.clone_item_fn = Some(Rc::new(|t: &T| t.clone()));
980        }
981    }
982
983    /// `.export_external(f)` — attach MIME; implies exportable.
984    pub(crate) fn set_export_external(
985        &mut self,
986        f: impl Fn(&[T]) -> Vec<(String, Vec<u8>)> + 'static,
987    ) where
988        T: Clone,
989    {
990        if self.clone_item_fn.is_none() {
991            self.clone_item_fn = Some(Rc::new(|t: &T| t.clone()));
992        }
993        if self.mode.is_none() {
994            self.mode = Some(DragTransferMode::default());
995        }
996        self.export_mime_fn = Some(Rc::new(f));
997    }
998
999    pub(crate) fn set_on_rows_transferred_out(
1000        &mut self,
1001        f: impl Fn(&[usize], &mut EventContext) + 'static,
1002    ) {
1003        self.on_rows_transferred_out = Some(Rc::new(f));
1004    }
1005
1006    pub(crate) fn set_on_rows_received(
1007        &mut self,
1008        f: impl Fn(Vec<T>, usize, &mut EventContext) + 'static,
1009    ) {
1010        self.on_rows_received = Some(Rc::new(f));
1011    }
1012
1013    /// Rows are a drag source when the view reorders OR exports.
1014    pub(crate) fn is_drag_source(&self, reorderable: bool) -> bool {
1015        reorderable || self.mode.is_some()
1016    }
1017
1018    /// The view is a drop target when it reorders OR accepts foreign rows.
1019    pub(crate) fn is_drop_target(&self, reorderable: bool) -> bool {
1020        reorderable || self.accept_foreign_rows
1021    }
1022
1023    /// Build the drag payload for the (already selection-resolved) `rows`. Drops
1024    /// any non-resident row (a lazy `Loading` row `read` can't serve) so `rows`
1025    /// and `items` stay index-aligned and a Move never deletes a row whose data
1026    /// wasn't transferred. Attaches MIME, and stashes the rows + a stable-key
1027    /// removal thunk for the completion.
1028    ///
1029    /// `None` when no row survives the residency filter (an all-`Loading`
1030    /// selection): the caller must refuse the drag rather than float an empty
1031    /// payload nothing can accept.
1032    pub(crate) fn build_payload(
1033        &self,
1034        source: ViewId,
1035        mut rows: Vec<usize>,
1036        read: &dyn Fn(usize, &mut dyn FnMut(&T)) -> bool,
1037        snapshot_out: &SnapshotOutFn,
1038    ) -> Option<DragPayload> {
1039        let items: Option<Vec<T>> = if let Some(cf) = self.clone_item_fn.as_ref() {
1040            let mut out = Vec::with_capacity(rows.len());
1041            rows.retain(|&r| {
1042                let mut got = None;
1043                read(r, &mut |t| got = Some(cf(t)));
1044                match got {
1045                    Some(v) => {
1046                        out.push(v);
1047                        true
1048                    }
1049                    None => false,
1050                }
1051            });
1052            Some(out)
1053        } else {
1054            None
1055        };
1056        if rows.is_empty() {
1057            return None;
1058        }
1059        let mime_pairs: Vec<(String, Vec<u8>)> =
1060            match (self.export_mime_fn.as_ref(), items.as_ref()) {
1061                (Some(mf), Some(its)) => mf(its),
1062                _ => Vec::new(),
1063            };
1064        let mut payload = DragPayload::typed(RowDragData::<T> {
1065            source,
1066            rows: rows.clone(),
1067            items,
1068        });
1069        let has_mime = !mime_pairs.is_empty();
1070        for (mime, bytes) in mime_pairs {
1071            payload = payload.with_mime(&mime, bytes);
1072        }
1073        if has_mime {
1074            payload.enrich_external_from_mime();
1075        }
1076        *self.removal.borrow_mut() = Some((snapshot_out)(&rows));
1077        *self.dragged_rows.borrow_mut() = rows;
1078        Some(payload)
1079    }
1080
1081    /// Whether a **foreign** exported payload would be accepted here — for the
1082    /// hover affordance. (Same-view / reorder-only payloads return `false`.)
1083    pub(crate) fn accepts_foreign_export(&self, payload: &DragPayload, source: ViewId) -> bool {
1084        self.accept_foreign_rows
1085            && self.on_rows_received.is_some()
1086            && payload
1087                .get_typed::<RowDragData<T>>()
1088                .is_some_and(|rd| rd.source != source && rd.is_export())
1089    }
1090
1091    /// Foreign-receive sugar for a view's `on_drop`. Peeks before taking, so a
1092    /// non-matching payload is left intact for any further fallback.
1093    pub(crate) fn foreign_receive(
1094        &self,
1095        payload: &mut DragPayload,
1096        source: ViewId,
1097        insertion: usize,
1098        ctx: &mut EventContext,
1099    ) -> bool {
1100        if self.accepts_foreign_export(payload, source)
1101            && let Some(cb) = self.on_rows_received.as_ref()
1102            && let Some(rd) = payload.take_typed::<RowDragData<T>>()
1103            && let Some(items) = rd.items
1104        {
1105            cb(items, insertion, ctx);
1106            return true;
1107        }
1108        false
1109    }
1110
1111    /// The view's own `on_drop` calls this after applying a genuine SAME-VIEW
1112    /// reorder, so the completion knows the change was already applied.
1113    pub(crate) fn note_self_reorder(&self) {
1114        self.self_reorder_flag.set(true);
1115    }
1116
1117    /// Install the `on_drag_ended` move-out completion. A same-view reorder set
1118    /// `self_reorder_flag` (skipped here); on `Move` + accepted-elsewhere the
1119    /// origin rows are removed via the app override (delivered **descending** so
1120    /// index-by-index removal stays valid) or the stable-key removal thunk.
1121    pub(crate) fn install_completion(&self, handlers: HandlerSet) -> HandlerSet {
1122        let Some(mode) = self.mode else {
1123            return handlers;
1124        };
1125        let flag = self.self_reorder_flag.clone();
1126        let dragged = self.dragged_rows.clone();
1127        let removal = self.removal.clone();
1128        let on_out = self.on_rows_transferred_out.clone();
1129        handlers.on_drag_ended(move |outcome, ctx| {
1130            let handled_by_us = flag.replace(false);
1131            let rows = std::mem::take(&mut *dragged.borrow_mut());
1132            let thunk = removal.borrow_mut().take();
1133            if handled_by_us {
1134                return;
1135            }
1136            let accepted_elsewhere = matches!(
1137                outcome,
1138                DropOutcome::InApp { accepted: true } | DropOutcome::OsMove
1139            );
1140            if mode != DragTransferMode::Move || !accepted_elsewhere || rows.is_empty() {
1141                return;
1142            }
1143            if let Some(cb) = on_out.as_ref() {
1144                let mut desc = rows;
1145                desc.sort_unstable();
1146                desc.reverse();
1147                cb(&desc, ctx);
1148            } else if let Some(thunk) = thunk {
1149                thunk();
1150            }
1151        })
1152    }
1153}
1154
1155/// A layout- and accessibility-transparent wrapper whose only job is to own a
1156/// drag on a node that **strictly encloses** the node whose gesture arena takes
1157/// the press.
1158///
1159/// # Why the drag cannot live on the pressed node
1160///
1161/// [`DragActivation`](teksilo_tokens::DragActivation) — the policy that holds a
1162/// touch drag back until a long press so the scroll underneath can win first —
1163/// is read on two paths, and the node that *captures* the press is on neither
1164/// of them unless it is **already** the sequence's pan claimant:
1165///
1166/// * the **ancestor walk** of the tree's sequence enrolment, through
1167///   `PointerSequence::enrol_drag`;
1168/// * `PointerSequence::defer_own_drag`, the dual-role arm, which attaches the
1169///   activation to a member the node already holds — and which refuses anything
1170///   but a live `MemberRole::Pan`. A `SceneView` is such a node. A **row** is
1171///   not: the claim is the scrollable's, several levels out.
1172///
1173/// So a row that carries both its tap and its reorder `on_drag` is enrolled by
1174/// the captured branch's plain `enrol`, which consults no activation at all. It
1175/// latches that drag at `drag_slop` on the first sample past 18 dp, decides the
1176/// arbitration, and the scrollable's `PanClaim` — which needs 36 dp — is never
1177/// even evaluated. The view then neither scrolls nor reorders: the drag won and,
1178/// for a marquee that declines a press on a tile, did nothing. That is the
1179/// `list row · touch` row of `crates/teksilo-core/tests/arbitration_matrix.rs`,
1180/// read as a defect rather than as a rule.
1181///
1182/// Hanging the drag one level out fixes both halves at once, with no change to
1183/// the framework: the pressed node has no drag, so the walk reaches this
1184/// wrapper and enrols it *with* its activation. A precise pointer resolves
1185/// `Auto` to `Immediate` and latches at the same 5 dp it always did; a direct
1186/// pointer resolves it to `AfterLongPress` and the pan wins unless the contact
1187/// holds still.
1188///
1189/// # What must be true of the node inside
1190///
1191/// It must have a gesture arena of its own, or nothing captures the press and
1192/// the enrolment walk never starts — the drag would then compete by no path at
1193/// all. [`press_absorber`] is the no-op tap that guarantees one, for a row
1194/// whose application wired no activation. This is the `primitives::dead_zone`
1195/// precedent: a structural rule plus an absorber that makes the structure real.
1196///
1197/// Transparent in every other respect: it forwards the child's whole
1198/// [`LayoutResponse`](teksilo_core::widget::LayoutResponse) (grow weight,
1199/// shrink weight and compression floor, not
1200/// just the size), places the child at its own bounds, and emits no
1201/// accessibility properties, so the walker prunes it: a reorderable view's
1202/// accessibility tree has the same *shape* as the same view without the
1203/// wrapper, which is what
1204/// `a_reorderable_view_has_the_same_accessibility_shape` asserts.
1205pub(crate) struct DragSurface {
1206    child: Option<WidgetId>,
1207}
1208
1209impl DragSurface {
1210    /// Wrap a pre-registered widget by id.
1211    pub(crate) fn new(child: WidgetId) -> Self {
1212        Self { child: Some(child) }
1213    }
1214}
1215
1216impl std::fmt::Debug for DragSurface {
1217    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1218        f.debug_struct("DragSurface").finish()
1219    }
1220}
1221
1222impl Widget for DragSurface {
1223    fn build(&mut self, _ctx: &mut BuildContext) -> Vec<WidgetId> {
1224        self.child.into_iter().collect()
1225    }
1226
1227    fn layout_response(
1228        &self,
1229        proposal: teksilo_canvas::SizeProposal,
1230        ctx: &teksilo_core::widget::LayoutContext,
1231    ) -> teksilo_core::widget::LayoutResponse {
1232        self.child
1233            .and_then(|id| ctx.child_layout_response(id, proposal))
1234            .unwrap_or_else(|| proposal.resolve(0.0, 0.0).into())
1235    }
1236
1237    fn place_children(
1238        &self,
1239        bounds: teksilo_canvas::Rect,
1240        _proposal: teksilo_canvas::SizeProposal,
1241        children: &mut [teksilo_core::widget::WidgetPlacement],
1242        _ctx: &teksilo_core::widget::LayoutContext,
1243    ) {
1244        for child in children.iter_mut() {
1245            child.origin = bounds.origin();
1246            child.size = bounds.size();
1247        }
1248    }
1249
1250    fn children(&self) -> Vec<WidgetId> {
1251        self.child.into_iter().collect()
1252    }
1253}
1254
1255/// The `HandlerSet` a row's or tile's [`DragSurface`] carries, before its own
1256/// `on_drag` is chained onto it: the declaration that **the hold inside this
1257/// wrapper belongs to that drag**.
1258///
1259/// A19's ruling is that where a row has both a reorder and a context menu the
1260/// reorder wins the hold, and the menu moves to an overflow affordance plus
1261/// Secondary / `Shift+F10` / the AccessKit `ShowContextMenu` action. The
1262/// framework enforces "one hold, one meaning" implicitly, from the deferral on
1263/// the drag member — but that rule is keyed on the **node**, because a deferred
1264/// grab on some enclosing container is not a claim on the holds of everything
1265/// inside it (a `SceneView` that marquees after a hold must not thereby take the
1266/// touch context menu away from every widget placed in it; a finger has no
1267/// secondary button, so the hold is the only route those have).
1268///
1269/// A [`DragSurface`] is the one place that implicit rule reads one node too far
1270/// out. The wrapper is not an enclosing container: it is layout-, hit- and
1271/// accessibility-transparent, its subtree is exactly one row, and the drag it
1272/// carries is that row's own. So it says so, with the explicit subtree-wide
1273/// declaration [`LongPressRole::DragHandle`](teksilo_core::LongPressRole::DragHandle),
1274/// which `WidgetTree::long_press_is_a_grab` walks from the pressed node to the
1275/// root.
1276///
1277/// **It takes a hold only from a direct pointer**, because a hold is a
1278/// drag-start route only where a drag waits for one — which is the finger and
1279/// the pen. A mouse latches this same reorder on travel, at `drag_slop`, with no
1280/// deadline in it, so its hold is spent on nothing and stays the application's:
1281/// a row's own `on_long_press` still fires under a mouse however this wrapper is
1282/// declared. That gate lives in `long_press_is_a_grab`, not here, so every
1283/// `DragHandle` declaration gets it.
1284///
1285/// Deliberately **not** applied to the body-pane `DragSurface` that hosts
1286/// `GridView`'s marquee. That one *is* an enclosing container — its subtree is
1287/// every tile in the grid — and declaring it there would take the touch context
1288/// menu away from all of them, which is the defect this distinction exists to
1289/// avoid.
1290pub(crate) fn row_grab_surface() -> HandlerSet {
1291    HandlerSet::new().long_press_role(teksilo_core::LongPressRole::DragHandle)
1292}
1293
1294/// The no-op tap that guarantees a node its own gesture arena.
1295///
1296/// Merged onto every row, tile or body pane a [`DragSurface`] encloses. Without
1297/// an arena nothing captures the press, and the walk that enrols the surface's
1298/// drag never runs — so the drag would compete by no path at all.
1299///
1300/// `GridView` merges it onto every tile whether or not one encloses it, and for
1301/// a second reason: the grid's body pane carries an absorber too, so a tile with
1302/// no arena of its own leaves the pane holding the press — and a release
1303/// dispatched to the captor and bubbled target→root never reaches a tile
1304/// *beneath* it. A plain selectable grid lost both its finger tap and, on a
1305/// mouse, the release that collapses a multi-selection that way. The four row
1306/// views have no pane-level absorber, so a plain row there is merely arena-less,
1307/// which costs it nothing *so long as nothing above it competes for the press*.
1308/// An application that wraps such a view in anything tappable puts an arena
1309/// there itself, and the row loses its release the same way — see
1310/// `docs/data-view-touch.md`'s second known limit.
1311///
1312/// Applied unconditionally rather than only where the node would otherwise have
1313/// none: an extra no-op tap in an arena that already has one is inert (the
1314/// arena builds one `TapRecognizer` however many closures are installed), while
1315/// a condition enumerating the handlers a pane happens to attach today would
1316/// silently stop guaranteeing anything the next time one is added. It absorbs
1317/// nothing and answers nothing — the recognizer's existence is the whole point.
1318pub(crate) fn press_absorber() -> HandlerSet {
1319    HandlerSet::new().on_tap(|_tap, _ctx| {})
1320}
1321
1322/// Whether the primary release being dispatched still completes the press it
1323/// began — the question every **release-time commitment** in a data view's row
1324/// body has to ask before committing.
1325///
1326/// A row body commits two things on release: the deferred collapse of a
1327/// multi-selection ([`deferred_select::on_up`]), and — in `TreeView` — the
1328/// expansion toggle of a branch row. Both are correct for a click and wrong for
1329/// a finger that was scrolling, so both are gated on this.
1330///
1331/// It answers `false` in the two ways a row loses the press without a cancel it
1332/// can see:
1333///
1334/// * **a peer claimed the contact.** The scrollable above the row won the
1335///   arbitration with its `PanClaim` and the press is now a scroll. The row
1336///   cannot rely on being told: it is enrolled as a sequence member only when
1337///   it carries a drag (`reorderable` / `exportable`), and even then the
1338///   loser's `CancelReason::PeerClaimed` is *member*-level — deliberately
1339///   leaving the pointer alive so its winner can finish — so the `PointerUp`
1340///   still arrives here either way. `WidgetTree::update_press` ends the press
1341///   record on that claim, which is the fact this reads.
1342/// * **the pointer left the press's tap boundary.** The same predicate that
1343///   fails the tap and fires `cancel_taps`, so a release-time commit is
1344///   abandoned exactly when the activation is.
1345///
1346/// It stays `true` through a press-feedback delay — a finger that lands and
1347/// lifts before the delay elapses has still clicked — because the delay
1348/// withholds the *visual*, not the press.
1349///
1350/// The older reasoning at these sites, that "an active drag consumes
1351/// `PointerUp`", covers only a **drag**: a won pan claim is not an
1352/// `active_drag`, so the release is not routed to `handle_drag_drop` and does
1353/// reach the row.
1354pub(crate) fn release_completes_the_press(ctx: &teksilo_core::widget::EventContext) -> bool {
1355    ctx.press_is_inside()
1356}
1357
1358/// Shared selection-on-press/release logic for a data-view row, and the type
1359/// its two calls share.
1360///
1361/// # What a press decides, and when the decision is applied
1362///
1363/// A **precise** pointer (mouse) still selects on press, exactly as it always
1364/// has — that is the click convention on every desktop, and a press that never
1365/// becomes anything else is still a click. The one thing it defers is the
1366/// *collapse* of an existing multi-selection: pressing an already-selected row
1367/// keeps the whole set so it can be dragged, and collapses to the pressed row
1368/// only on a release that still belongs to it.
1369///
1370/// A **direct** pointer (a finger, a pen) defers the *whole* decision to the
1371/// release. A press from a finger is not yet a click: the same contact is the
1372/// opening sample of a scroll, and a scrolling finger must leave the selection
1373/// exactly as it found it. No release-time predicate can rescue a selection
1374/// already written on `PointerDown`, so the write itself moves to the release —
1375/// and a release the row has lost (a won `PanClaim`, a press that wandered out
1376/// of the tap boundary) is refused by
1377/// [`release_completes_the_press`], which is what makes a pan commit nothing.
1378///
1379/// The nav cursor travels with the selection rather than with the press, so the
1380/// arrow-key origin and the selection can never point at different rows: both
1381/// calls report whether the caller should move it *now*.
1382pub(crate) mod deferred_select {
1383    use std::cell::Cell;
1384    use std::rc::Rc;
1385
1386    use teksilo_core::event::Modifiers;
1387    use teksilo_core::widget::EventContext;
1388
1389    use super::RowSelection;
1390
1391    /// The selection change a press decided but has not applied yet.
1392    ///
1393    /// `Collapse` is the one a mouse defers; a direct pointer defers whichever
1394    /// of the three its modifiers chose.
1395    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1396    pub(crate) enum PendingSelect {
1397        /// Replace the selection with this row — a plain click, and the
1398        /// collapse of a multi-selection the press kept alive.
1399        Collapse,
1400        /// Accelerator-click: add or remove this row.
1401        Toggle,
1402        /// Shift-click: extend the range from the anchor.
1403        Extend,
1404    }
1405
1406    /// The per-row cell the two calls share. One per realized row.
1407    pub(crate) type Pending = Rc<Cell<Option<PendingSelect>>>;
1408
1409    /// A fresh, empty pending cell.
1410    pub(crate) fn pending_cell() -> Pending {
1411        Rc::new(Cell::new(None))
1412    }
1413
1414    fn apply(sel: &RowSelection, index: usize, what: PendingSelect) {
1415        match what {
1416            PendingSelect::Collapse => sel.select(index),
1417            PendingSelect::Toggle => sel.toggle(index),
1418            PendingSelect::Extend => sel.extend_to(index),
1419        }
1420    }
1421
1422    /// Handle a primary `PointerDown` on row `index`.
1423    ///
1424    /// Returns whether the caller should move its nav cursor to `index` now:
1425    /// `false` when an interactive child claimed the press (nothing happened
1426    /// here at all), and `false` for a direct pointer, whose decision — cursor
1427    /// included — belongs to the release.
1428    pub(crate) fn on_down(
1429        sel: &RowSelection,
1430        index: usize,
1431        modifiers: Modifiers,
1432        pending: &Pending,
1433        ctx: &mut EventContext,
1434    ) -> bool {
1435        if ctx.press_claimed_by_interactive_child() {
1436            pending.set(None);
1437            return false;
1438        }
1439        // The accelerator-click that adds one row to a discontiguous selection:
1440        // Ctrl+click on Windows and Linux, ⌘-click on macOS — where ⌃-click is
1441        // the secondary click and would open a context menu instead.
1442        let what = if modifiers.command() {
1443            PendingSelect::Toggle
1444        } else if modifiers.shift() {
1445            PendingSelect::Extend
1446        } else {
1447            PendingSelect::Collapse
1448        };
1449        if ctx.pointer_kind().is_direct() {
1450            // A finger's press is not yet a click — see the module header.
1451            pending.set(Some(what));
1452            return false;
1453        }
1454        if what == PendingSelect::Collapse && sel.is_selected(index) {
1455            // Grab-the-set-and-drag: keep the multi-selection alive for the
1456            // press, collapse it on a release without a drag.
1457            pending.set(Some(what));
1458        } else {
1459            apply(sel, index, what);
1460            pending.set(None);
1461        }
1462        true
1463    }
1464
1465    /// Handle a primary `PointerUp` on row `index`, applying whatever the press
1466    /// deferred.
1467    ///
1468    /// Returns whether the caller should move its nav cursor to `index` now —
1469    /// true exactly when a deferred decision was applied.
1470    ///
1471    /// Refuses when the release belongs to an interactive child, and when the
1472    /// press it would complete is no longer this row's
1473    /// ([`release_completes_the_press`](super::release_completes_the_press)).
1474    pub(crate) fn on_up(
1475        sel: &RowSelection,
1476        index: usize,
1477        pending: &Pending,
1478        ctx: &mut EventContext,
1479    ) -> bool {
1480        if ctx.press_claimed_by_interactive_child() {
1481            return false;
1482        }
1483        if !super::release_completes_the_press(ctx) {
1484            // The deferred decision is abandoned, not postponed: clear the flag
1485            // so it cannot fire on some later release this row does own.
1486            pending.set(None);
1487            return false;
1488        }
1489        match pending.replace(None) {
1490            Some(what) => {
1491                apply(sel, index, what);
1492                true
1493            }
1494            None => false,
1495        }
1496    }
1497}
1498
1499#[cfg(test)]
1500mod payload_tests {
1501    use super::*;
1502
1503    fn noop_snapshot() -> SnapshotOutFn {
1504        Rc::new(|_: &[usize]| Box::new(|| {}) as Box<dyn Fn()>)
1505    }
1506
1507    #[test]
1508    fn an_all_unresident_selection_refuses_the_drag() {
1509        // Every dragged row is still `Loading`: the residency filter empties
1510        // the set, and the drag must be refused outright — a floating payload
1511        // with no rows and no items would remove nothing on a Move and offer
1512        // nothing to a receiver.
1513        let mut export = RowExport::<u64>::default();
1514        export.set_exportable(DragTransferMode::Move);
1515        let read = |_: usize, _: &mut dyn FnMut(&u64)| false;
1516        let payload = export.build_payload(
1517            ViewId::next(ViewKind::List),
1518            vec![0, 1, 2],
1519            &read,
1520            &noop_snapshot(),
1521        );
1522        assert!(payload.is_none());
1523    }
1524
1525    #[test]
1526    fn a_partially_resident_selection_carries_only_the_resident_rows() {
1527        let mut export = RowExport::<u64>::default();
1528        export.set_exportable(DragTransferMode::Copy);
1529        // Row 1 is unresident; rows 0 and 2 resolve.
1530        let read = |i: usize, f: &mut dyn FnMut(&u64)| {
1531            if i == 1 {
1532                return false;
1533            }
1534            f(&(i as u64 * 10));
1535            true
1536        };
1537        let payload = export
1538            .build_payload(
1539                ViewId::next(ViewKind::List),
1540                vec![0, 1, 2],
1541                &read,
1542                &noop_snapshot(),
1543            )
1544            .expect("two rows are resident");
1545        let rd = payload.get_typed::<RowDragData<u64>>().unwrap();
1546        assert_eq!(rd.rows, vec![0, 2]);
1547        assert_eq!(rd.items.as_deref(), Some(&[0, 20][..]));
1548    }
1549}
1550
1551#[cfg(test)]
1552mod drag_surface_tests {
1553    use super::*;
1554    use crate::primitives::{FixedSize, HStack, Shrinkable};
1555    use teksilo_canvas::SizeProposal;
1556    use teksilo_core::widget_tree::WidgetTree;
1557
1558    /// The wrapper's own doc says it forwards the child's *whole*
1559    /// `LayoutResponse`, not just the size. Flattening it to a bare `Size` makes
1560    /// the surface rigid, which over-constrains any tight container it sits in —
1561    /// the failure `primitives::dead_zone` was written to avoid and
1562    /// `primitives::touch_target::tests::the_wrapper_forwards_its_child_shrink_weight`
1563    /// already pins for the other wrapper of this shape. Shrink is the
1564    /// discriminating field: a rigid surface refuses to compress at all, so its
1565    /// width stays at the child's ideal instead of yielding to the deficit.
1566    #[test]
1567    fn the_drag_surface_forwards_its_child_shrink_weight() {
1568        let mut tree = WidgetTree::new();
1569        let inner = tree.add(
1570            Shrinkable::new()
1571                .min_width(20.0)
1572                .child(FixedSize::new().width(100.0).height(20.0)),
1573        );
1574        let slot = tree.add(DragSurface::new(inner));
1575        let rigid = tree.add(FixedSize::new().width(100.0).height(20.0));
1576        tree.add(HStack::new().child(rigid).child(slot));
1577        tree.layout(SizeProposal::exact(120.0, 20.0));
1578        let w = tree.bounds(slot).width;
1579        assert!(
1580            w < 100.0,
1581            "the DragSurface must forward the child's shrink weight (width was {w})"
1582        );
1583    }
1584}